Compare commits

..

29 Commits

Author SHA1 Message Date
Paulus Schoutsen
84e4a0f22e Allow finding relevant blueprints 2025-11-15 14:01:37 -05:00
David Rapan
bdca592219 Add Shelly event translation (#156162)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-15 20:52:13 +02:00
Michael
5c0c7b9ec3 Bump adguardhome to 0.8.0 (#156651) 2025-11-15 16:26:05 +01:00
TheDK
9717599fb9 Use SensorDeviceClass.PRESSURE in Withings (#156648)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-15 16:08:34 +01:00
MarkGodwin
4d7de2f814 Bump tplink-omada-api to 1.5.3 (#156645) 2025-11-15 15:39:56 +01:00
epenet
779590ce1c Migrate Tuya climate (humidity) to use wrapper class (#156575) 2025-11-15 15:05:52 +01:00
epenet
f3a185ff9c Migrate Tuya cover to use wrapper class (#156558)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:04:54 +01:00
epenet
5a5a106984 Migrate Tuya humidifier to use wrapper class (#156572)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-15 15:03:30 +01:00
epenet
796b421d99 Migrate Tuya vacuum to use wrapper class (#156569) 2025-11-15 15:02:49 +01:00
epenet
0c03e8dbe9 Migrate Tuya light (color_mode) to use wrapper class (#156582) 2025-11-15 15:02:40 +01:00
epenet
47cf4e3ffe Ensure Tuya scale and step are integers (#156555) 2025-11-15 14:30:44 +02:00
epenet
0ea0fc151d Use parametrize in tuya climate tests (#156577) 2025-11-15 14:28:57 +02:00
Åke Strandberg
b7e5afec9f Fix typing in miele tests (#156637) 2025-11-15 12:19:34 +01:00
cdnninja
7a2bb67e82 Refactor vesync test (#156625) 2025-11-15 09:24:48 +01:00
Manu
e0612bec07 Bump pythonkuma to v0.3.2 (#156626) 2025-11-15 03:43:16 +01:00
Denis Shulyaka
a06f4b6776 Anthropic model selection from list (#156261) 2025-11-14 21:16:52 -05:00
Denis Shulyaka
275670a526 Add support for gpt-5.1 (#156612) 2025-11-14 18:39:05 -05:00
Robert Resch
d0d62526dd Bump async-upnp-client to 0.46.0 (#156622) 2025-11-14 18:30:18 -05:00
Franck Nijhof
aefdf412b0 Extract device template functions into a devices Jinja2 extension (#156619) 2025-11-15 00:23:38 +01:00
Manu
56ab6b2512 Prevent sensor updates caused by fluctuating “last seen” timestamps in Xbox integration (#156419) 2025-11-14 22:07:13 +01:00
mettolen
d1dea85cf5 Add Saunum integration (#155099)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-14 19:55:55 +01:00
wollew
84b0d39763 clean up velux test fixtures (#156554) 2025-11-14 19:53:17 +01:00
tronikos
3aff225bc3 Add Google Weather integration (#147015)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-14 19:46:56 +01:00
Ludovic BOUÉ
04458e01be Fix spelling of 'Auto-relock time' in Matter integration strings (#156607) 2025-11-14 19:10:57 +01:00
Thomas55555
ae51cfb8c0 Fix model_id in Husqvarna Automower (#156608) 2025-11-14 19:10:16 +01:00
Simone Chemelli
c116a9c037 Add debounce to Alexa Devices coordinator (#156609) 2025-11-14 19:09:19 +01:00
Allen Porter
fb58758684 Add completed timestamp support in Google tasks (#156564) 2025-11-14 12:07:23 -05:00
Franck Nijhof
25fbcbc68c Extract floor template functions into a floors Jinja2 extension (#156589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-14 12:06:55 -05:00
Denis Shulyaka
a670286b45 Bump openai to 2.8.0 (#156602) 2025-11-14 12:06:21 -05:00
111 changed files with 5933 additions and 1697 deletions

View File

@@ -231,6 +231,7 @@ homeassistant.components.google_cloud.*
homeassistant.components.google_drive.*
homeassistant.components.google_photos.*
homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.*
homeassistant.components.gpsd.*
homeassistant.components.greeneye_monitor.*

4
CODEOWNERS generated
View File

@@ -607,6 +607,8 @@ build.json @home-assistant/supervisor
/tests/components/google_tasks/ @allenporter
/homeassistant/components/google_travel_time/ @eifinger
/tests/components/google_travel_time/ @eifinger
/homeassistant/components/google_weather/ @tronikos
/tests/components/google_weather/ @tronikos
/homeassistant/components/govee_ble/ @bdraco
/tests/components/govee_ble/ @bdraco
/homeassistant/components/govee_light_local/ @Galorhallen
@@ -1374,6 +1376,8 @@ build.json @home-assistant/supervisor
/tests/components/sanix/ @tomaszsluszniak
/homeassistant/components/satel_integra/ @Tommatheussen
/tests/components/satel_integra/ @Tommatheussen
/homeassistant/components/saunum/ @mettolen
/tests/components/saunum/ @mettolen
/homeassistant/components/scene/ @home-assistant/core
/tests/components/scene/ @home-assistant/core
/homeassistant/components/schedule/ @home-assistant/core

View File

@@ -15,6 +15,7 @@
"google_tasks",
"google_translate",
"google_travel_time",
"google_weather",
"google_wifi",
"google",
"nest",

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["adguardhome"],
"requirements": ["adguardhome==0.7.0"]
"requirements": ["adguardhome==0.8.0"]
}

View File

@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN
@@ -43,6 +44,9 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
name=entry.title,
config_entry=entry,
update_interval=timedelta(seconds=SCAN_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=30, immediate=False
),
)
self.api = AmazonEchoApi(
session,

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from functools import partial
import json
import logging
import re
from typing import Any
import anthropic
@@ -283,7 +284,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
vol.Optional(
CONF_CHAT_MODEL,
default=RECOMMENDED_CHAT_MODEL,
): str,
): SelectSelector(
SelectSelectorConfig(
options=await self._get_model_list(), custom_value=True
)
),
vol.Optional(
CONF_MAX_TOKENS,
default=RECOMMENDED_MAX_TOKENS,
@@ -394,6 +399,39 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
last_step=True,
)
async def _get_model_list(self) -> list[SelectOptionDict]:
"""Get list of available models."""
try:
client = await self.hass.async_add_executor_job(
partial(
anthropic.AsyncAnthropic,
api_key=self._get_entry().data[CONF_API_KEY],
)
)
models = (await client.models.list()).data
except anthropic.AnthropicError:
models = []
_LOGGER.debug("Available models: %s", models)
model_options: list[SelectOptionDict] = []
short_form = re.compile(r"[^\d]-\d$")
for model_info in models:
# Resolve alias from versioned model name:
model_alias = (
model_info.id[:-9]
if model_info.id
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
else model_info.id
)
if short_form.search(model_alias):
model_alias += "-0"
model_options.append(
SelectOptionDict(
label=model_info.display_name,
value=model_alias,
)
)
return model_options
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""
location_data: dict[str, str] = {}

View File

@@ -1,7 +1,13 @@
"""The blueprint integration."""
from typing import Any
import voluptuous as vol
from homeassistant.const import CONF_NAME, CONF_SELECTOR
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.selector import selector as create_selector
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -29,3 +35,61 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""
websocket_api.async_setup(hass)
return True
async def async_find_relevant_blueprints(
hass: HomeAssistant, device_id: str
) -> dict[str, list[dict[str, Any]]]:
"""Find all blueprints relevant to a specific device."""
results = {}
entities = [
entry
for entry in er.async_entries_for_device(er.async_get(hass), device_id)
if not entry.entity_category
]
async def all_blueprints_generator(hass: HomeAssistant):
"""Yield all blueprints from all domains."""
blueprint_domains: dict[str, DomainBlueprints] = hass.data[DOMAIN]
for blueprint_domain in blueprint_domains.values():
blueprints = await blueprint_domain.async_get_blueprints()
for blueprint in blueprints.values():
yield blueprint
async for blueprint in all_blueprints_generator(hass):
blueprint_input_matches: dict[str, list[str]] = {}
for info in blueprint.inputs.values():
if (
not info
or not (selector_conf := info.get(CONF_SELECTOR))
or "entity" not in selector_conf
):
continue
selector = create_selector(selector_conf)
matched = []
for entity in entities:
try:
entity.entity_id, selector(entity.entity_id)
except vol.Invalid:
continue
matched.append(entity.entity_id)
if matched:
blueprint_input_matches[info[CONF_NAME]] = matched
if not blueprint_input_matches:
continue
results.setdefault(blueprint.domain, []).append(
{
"blueprint": blueprint,
"matched_input": blueprint_input_matches,
}
)
return results

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.45.0"],
"requirements": ["async-upnp-client==0.46.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",

View File

@@ -53,6 +53,9 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
# Due dates are returned always in UTC so we only need to
# parse the date portion which will be interpreted as a a local date.
due = datetime.fromisoformat(due_str).date()
completed: datetime | None = None
if (completed_str := item.get("completed")) is not None:
completed = datetime.fromisoformat(completed_str)
return TodoItem(
summary=item["title"],
uid=item["id"],
@@ -61,6 +64,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
TodoItemStatus.NEEDS_ACTION,
),
due=due,
completed=completed,
description=item.get("notes"),
)

View File

@@ -0,0 +1,84 @@
"""The Google Weather integration."""
from __future__ import annotations
import asyncio
from google_weather_api import GoogleWeatherApi
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_REFERRER
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherRuntimeData,
GoogleWeatherSubEntryRuntimeData,
)
_PLATFORMS: list[Platform] = [Platform.WEATHER]
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Set up Google Weather from a config entry."""
api = GoogleWeatherApi(
session=async_get_clientsession(hass),
api_key=entry.data[CONF_API_KEY],
referrer=entry.data.get(CONF_REFERRER),
language_code=hass.config.language,
)
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData] = {}
for subentry in entry.subentries.values():
subentry_runtime_data = GoogleWeatherSubEntryRuntimeData(
coordinator_observation=GoogleWeatherCurrentConditionsCoordinator(
hass, entry, subentry, api
),
coordinator_daily_forecast=GoogleWeatherDailyForecastCoordinator(
hass, entry, subentry, api
),
coordinator_hourly_forecast=GoogleWeatherHourlyForecastCoordinator(
hass, entry, subentry, api
),
)
subentries_runtime_data[subentry.subentry_id] = subentry_runtime_data
tasks = [
coro
for subentry_runtime_data in subentries_runtime_data.values()
for coro in (
subentry_runtime_data.coordinator_observation.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_daily_forecast.async_config_entry_first_refresh(),
subentry_runtime_data.coordinator_hourly_forecast.async_config_entry_first_refresh(),
)
]
await asyncio.gather(*tasks)
entry.runtime_data = GoogleWeatherRuntimeData(
api=api,
subentries_runtime_data=subentries_runtime_data,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_update_options))
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
async def async_update_options(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> None:
"""Update options."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -0,0 +1,198 @@
"""Config flow for the Google Weather integration."""
from __future__ import annotations
import logging
from typing import Any
from google_weather_api import GoogleWeatherApi, GoogleWeatherApiError
import voluptuous as vol
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector, LocationSelectorConfig
from .const import CONF_REFERRER, DOMAIN, SECTION_API_KEY_OPTIONS
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Optional(SECTION_API_KEY_OPTIONS): section(
vol.Schema({vol.Optional(CONF_REFERRER): str}), {"collapsed": True}
),
}
)
async def _validate_input(
user_input: dict[str, Any],
api: GoogleWeatherApi,
errors: dict[str, str],
description_placeholders: dict[str, str],
) -> bool:
try:
await api.async_get_current_conditions(
latitude=user_input[CONF_LOCATION][CONF_LATITUDE],
longitude=user_input[CONF_LOCATION][CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
errors["base"] = "cannot_connect"
description_placeholders["error_message"] = str(err)
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return True
return False
def _get_location_schema(hass: HomeAssistant) -> vol.Schema:
"""Return the schema for a location with default values from the hass config."""
return vol.Schema(
{
vol.Required(CONF_NAME, default=hass.config.location_name): str,
vol.Required(
CONF_LOCATION,
default={
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
},
): LocationSelector(LocationSelectorConfig(radius=False)),
}
)
def _is_location_already_configured(
hass: HomeAssistant, new_data: dict[str, float], epsilon: float = 1e-4
) -> bool:
"""Check if the location is already configured."""
for entry in hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
# A more accurate way is to use the haversine formula, but for simplicity
# we use a simple distance check. The epsilon value is small anyway.
# This is mostly to capture cases where the user has slightly moved the location pin.
if (
abs(subentry.data[CONF_LATITUDE] - new_data[CONF_LATITUDE]) <= epsilon
and abs(subentry.data[CONF_LONGITUDE] - new_data[CONF_LONGITUDE])
<= epsilon
):
return True
return False
class GoogleWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Weather."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {
"api_key_url": "https://developers.google.com/maps/documentation/weather/get-api-key",
"restricting_api_keys_url": "https://developers.google.com/maps/api-security-best-practices#restricting-api-keys",
}
if user_input is not None:
api_key = user_input[CONF_API_KEY]
referrer = user_input.get(SECTION_API_KEY_OPTIONS, {}).get(CONF_REFERRER)
self._async_abort_entries_match({CONF_API_KEY: api_key})
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api = GoogleWeatherApi(
session=async_get_clientsession(self.hass),
api_key=api_key,
referrer=referrer,
language_code=self.hass.config.language,
)
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title="Google Weather",
data={
CONF_API_KEY: api_key,
CONF_REFERRER: referrer,
},
subentries=[
{
"subentry_type": "location",
"data": user_input[CONF_LOCATION],
"title": user_input[CONF_NAME],
"unique_id": None,
},
],
)
else:
user_input = {}
schema = STEP_USER_DATA_SCHEMA.schema.copy()
schema.update(_get_location_schema(self.hass).schema)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"location": LocationSubentryFlowHandler}
class LocationSubentryFlowHandler(ConfigSubentryFlow):
"""Handle a subentry flow for location."""
async def async_step_location(
self,
user_input: dict[str, Any] | None = None,
) -> SubentryFlowResult:
"""Handle the location step."""
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
if _is_location_already_configured(self.hass, user_input[CONF_LOCATION]):
return self.async_abort(reason="already_configured")
api: GoogleWeatherApi = self._get_entry().runtime_data.api
if await _validate_input(user_input, api, errors, description_placeholders):
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input[CONF_LOCATION],
)
else:
user_input = {}
return self.async_show_form(
step_id="location",
data_schema=self.add_suggested_values_to_schema(
_get_location_schema(self.hass), user_input
),
errors=errors,
description_placeholders=description_placeholders,
)
async_step_user = async_step_location

View File

@@ -0,0 +1,8 @@
"""Constants for the Google Weather integration."""
from typing import Final
DOMAIN = "google_weather"
SECTION_API_KEY_OPTIONS: Final = "api_key_options"
CONF_REFERRER: Final = "referrer"

View File

@@ -0,0 +1,169 @@
"""The Google Weather coordinator."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import TypeVar
from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
GoogleWeatherApi,
GoogleWeatherApiError,
HourlyForecastResponse,
)
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import (
TimestampDataUpdateCoordinator,
UpdateFailed,
)
_LOGGER = logging.getLogger(__name__)
T = TypeVar(
"T",
bound=(
CurrentConditionsResponse
| DailyForecastResponse
| HourlyForecastResponse
| None
),
)
@dataclass
class GoogleWeatherSubEntryRuntimeData:
"""Runtime data for a Google Weather sub-entry."""
coordinator_observation: GoogleWeatherCurrentConditionsCoordinator
coordinator_daily_forecast: GoogleWeatherDailyForecastCoordinator
coordinator_hourly_forecast: GoogleWeatherHourlyForecastCoordinator
@dataclass
class GoogleWeatherRuntimeData:
"""Runtime data for the Google Weather integration."""
api: GoogleWeatherApi
subentries_runtime_data: dict[str, GoogleWeatherSubEntryRuntimeData]
type GoogleWeatherConfigEntry = ConfigEntry[GoogleWeatherRuntimeData]
class GoogleWeatherBaseCoordinator(TimestampDataUpdateCoordinator[T]):
"""Base class for Google Weather coordinators."""
config_entry: GoogleWeatherConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
data_type_name: str,
update_interval: timedelta,
api_method: Callable[..., Awaitable[T]],
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=f"Google Weather {data_type_name} coordinator for {subentry.title}",
update_interval=update_interval,
)
self.subentry = subentry
self._data_type_name = data_type_name
self._api_method = api_method
async def _async_update_data(self) -> T:
"""Fetch data from API and handle errors."""
try:
return await self._api_method(
self.subentry.data[CONF_LATITUDE],
self.subentry.data[CONF_LONGITUDE],
)
except GoogleWeatherApiError as err:
_LOGGER.error(
"Error fetching %s for %s: %s",
self._data_type_name,
self.subentry.title,
err,
)
raise UpdateFailed(f"Error fetching {self._data_type_name}") from err
class GoogleWeatherCurrentConditionsCoordinator(
GoogleWeatherBaseCoordinator[CurrentConditionsResponse]
):
"""Handle fetching current weather conditions."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"current weather conditions",
timedelta(minutes=15),
api.async_get_current_conditions,
)
class GoogleWeatherDailyForecastCoordinator(
GoogleWeatherBaseCoordinator[DailyForecastResponse]
):
"""Handle fetching daily weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"daily weather forecast",
timedelta(hours=1),
api.async_get_daily_forecast,
)
class GoogleWeatherHourlyForecastCoordinator(
GoogleWeatherBaseCoordinator[HourlyForecastResponse]
):
"""Handle fetching hourly weather forecast."""
def __init__(
self,
hass: HomeAssistant,
config_entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
api: GoogleWeatherApi,
) -> None:
"""Initialize the data updater."""
super().__init__(
hass,
config_entry,
subentry,
"hourly weather forecast",
timedelta(hours=1),
api.async_get_hourly_forecast,
)

View File

@@ -0,0 +1,28 @@
"""Base entity for Google Weather."""
from __future__ import annotations
from homeassistant.config_entries import ConfigSubentry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
from .coordinator import GoogleWeatherConfigEntry
class GoogleWeatherBaseEntity(Entity):
"""Base entity for all Google Weather entities."""
_attr_has_entity_name = True
def __init__(
self, config_entry: GoogleWeatherConfigEntry, subentry: ConfigSubentry
) -> None:
"""Initialize base entity."""
self._attr_unique_id = subentry.subentry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="Google",
entry_type=DeviceEntryType.SERVICE,
)

View File

@@ -0,0 +1,12 @@
{
"domain": "google_weather",
"name": "Google Weather",
"codeowners": ["@tronikos"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_weather",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"requirements": ["python-google-weather-api==0.0.4"]
}

View File

@@ -0,0 +1,82 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No events subscribed.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No configuration options.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No discovery.
discovery:
status: exempt
comment: No discovery.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: No physical devices.
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: N/A
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repairs.
stale-devices:
status: exempt
comment: N/A
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,65 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "Unable to connect to the Google Weather API:\n\n{error_message}",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"api_key": "A unique alphanumeric string that associates your Google billing account with Google Weather API",
"location": "Location coordinates",
"name": "Location name"
},
"description": "Get your API key from [here]({api_key_url}).",
"sections": {
"api_key_options": {
"data": {
"referrer": "HTTP referrer"
},
"data_description": {
"referrer": "Specify this only if the API key has a [website application restriction]({restricting_api_keys_url})"
},
"name": "Optional API key options"
}
}
}
}
},
"config_subentries": {
"location": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
"entry_not_loaded": "Cannot add things while the configuration is disabled."
},
"entry_type": "Location",
"error": {
"cannot_connect": "[%key:component::google_weather::config::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"initiate_flow": {
"user": "Add location"
},
"step": {
"location": {
"data": {
"location": "[%key:common::config_flow::data::location%]",
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
"location": "[%key:component::google_weather::config::step::user::data_description::location%]",
"name": "[%key:component::google_weather::config::step::user::data_description::name%]"
}
}
}
}
}
}

View File

@@ -0,0 +1,366 @@
"""Weather entity."""
from __future__ import annotations
from google_weather_api import (
DailyForecastResponse,
HourlyForecastResponse,
WeatherCondition,
)
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_FORECAST_CLOUD_COVERAGE,
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_HUMIDITY,
ATTR_FORECAST_IS_DAYTIME,
ATTR_FORECAST_NATIVE_APPARENT_TEMP,
ATTR_FORECAST_NATIVE_DEW_POINT,
ATTR_FORECAST_NATIVE_PRECIPITATION,
ATTR_FORECAST_NATIVE_PRESSURE,
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED,
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_UV_INDEX,
ATTR_FORECAST_WIND_BEARING,
CoordinatorWeatherEntity,
Forecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import (
UnitOfLength,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
GoogleWeatherConfigEntry,
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
)
from .entity import GoogleWeatherBaseEntity
PARALLEL_UPDATES = 0
# Maps https://developers.google.com/maps/documentation/weather/weather-condition-icons
# to https://developers.home-assistant.io/docs/core/entity/weather/#recommended-values-for-state-and-condition
_CONDITION_MAP: dict[WeatherCondition.Type, str | None] = {
WeatherCondition.Type.TYPE_UNSPECIFIED: None,
WeatherCondition.Type.CLEAR: ATTR_CONDITION_SUNNY,
WeatherCondition.Type.MOSTLY_CLEAR: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.PARTLY_CLOUDY: ATTR_CONDITION_PARTLYCLOUDY,
WeatherCondition.Type.MOSTLY_CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.CLOUDY: ATTR_CONDITION_CLOUDY,
WeatherCondition.Type.WINDY: ATTR_CONDITION_WINDY,
WeatherCondition.Type.WIND_AND_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.CHANCE_OF_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.SCATTERED_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.RAIN_SHOWERS: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN_SHOWERS: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_TO_MODERATE_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.MODERATE_TO_HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.LIGHT_RAIN: ATTR_CONDITION_RAINY,
WeatherCondition.Type.HEAVY_RAIN: ATTR_CONDITION_POURING,
WeatherCondition.Type.RAIN_PERIODICALLY_HEAVY: ATTR_CONDITION_POURING,
WeatherCondition.Type.LIGHT_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.CHANCE_OF_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SCATTERED_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_SHOWERS: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_TO_MODERATE_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.MODERATE_TO_HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.LIGHT_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOWSTORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.SNOW_PERIODICALLY_HEAVY: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.HEAVY_SNOW_STORM: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.BLOWING_SNOW: ATTR_CONDITION_SNOWY,
WeatherCondition.Type.RAIN_AND_SNOW: ATTR_CONDITION_SNOWY_RAINY,
WeatherCondition.Type.HAIL: ATTR_CONDITION_HAIL,
WeatherCondition.Type.HAIL_SHOWERS: ATTR_CONDITION_HAIL,
WeatherCondition.Type.THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.THUNDERSHOWER: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.LIGHT_THUNDERSTORM_RAIN: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.SCATTERED_THUNDERSTORMS: ATTR_CONDITION_LIGHTNING_RAINY,
WeatherCondition.Type.HEAVY_THUNDERSTORM: ATTR_CONDITION_LIGHTNING_RAINY,
}
def _get_condition(
api_condition: WeatherCondition.Type, is_daytime: bool
) -> str | None:
"""Map Google Weather condition to Home Assistant condition."""
cond = _CONDITION_MAP[api_condition]
if cond == ATTR_CONDITION_SUNNY and not is_daytime:
return ATTR_CONDITION_CLEAR_NIGHT
return cond
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleWeatherConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
for subentry in entry.subentries.values():
async_add_entities(
[GoogleWeatherEntity(entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class GoogleWeatherEntity(
CoordinatorWeatherEntity[
GoogleWeatherCurrentConditionsCoordinator,
GoogleWeatherDailyForecastCoordinator,
GoogleWeatherHourlyForecastCoordinator,
GoogleWeatherDailyForecastCoordinator,
],
GoogleWeatherBaseEntity,
):
"""Representation of a Google Weather entity."""
_attr_attribution = "Data from Google Weather"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.MBAR
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
| WeatherEntityFeature.FORECAST_TWICE_DAILY
)
def __init__(
self,
entry: GoogleWeatherConfigEntry,
subentry: ConfigSubentry,
) -> None:
"""Initialize the weather entity."""
subentry_runtime_data = entry.runtime_data.subentries_runtime_data[
subentry.subentry_id
]
super().__init__(
observation_coordinator=subentry_runtime_data.coordinator_observation,
daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
hourly_coordinator=subentry_runtime_data.coordinator_hourly_forecast,
twice_daily_coordinator=subentry_runtime_data.coordinator_daily_forecast,
)
GoogleWeatherBaseEntity.__init__(self, entry, subentry)
@property
def condition(self) -> str | None:
"""Return the current condition."""
return _get_condition(
self.coordinator.data.weather_condition.type,
self.coordinator.data.is_daytime,
)
@property
def native_temperature(self) -> float:
"""Return the temperature."""
return self.coordinator.data.temperature.degrees
@property
def native_apparent_temperature(self) -> float:
"""Return the apparent temperature."""
return self.coordinator.data.feels_like_temperature.degrees
@property
def native_dew_point(self) -> float:
"""Return the dew point."""
return self.coordinator.data.dew_point.degrees
@property
def humidity(self) -> int:
"""Return the humidity."""
return self.coordinator.data.relative_humidity
@property
def uv_index(self) -> float:
"""Return the UV index."""
return float(self.coordinator.data.uv_index)
@property
def native_pressure(self) -> float:
"""Return the pressure."""
return self.coordinator.data.air_pressure.mean_sea_level_millibars
@property
def native_wind_gust_speed(self) -> float:
"""Return the wind gust speed."""
return self.coordinator.data.wind.gust.value
@property
def native_wind_speed(self) -> float:
"""Return the wind speed."""
return self.coordinator.data.wind.speed.value
@property
def wind_bearing(self) -> int:
"""Return the wind bearing."""
return self.coordinator.data.wind.direction.degrees
@property
def native_visibility(self) -> float:
"""Return the visibility."""
return self.coordinator.data.visibility.distance
@property
def cloud_coverage(self) -> float:
"""Return the Cloud coverage in %."""
return float(self.coordinator.data.cloud_cover)
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the daily forecast in native units."""
coordinator = self.forecast_coordinators["daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.daytime_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.daytime_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: max(
item.daytime_forecast.precipitation.probability.percent,
item.nighttime_forecast.precipitation.probability.percent,
),
ATTR_FORECAST_CLOUD_COVERAGE: item.daytime_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: (
item.daytime_forecast.precipitation.qpf.quantity
+ item.nighttime_forecast.precipitation.qpf.quantity
),
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_TEMP_LOW: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: (
item.feels_like_max_temperature.degrees
),
ATTR_FORECAST_WIND_BEARING: item.daytime_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: max(
item.daytime_forecast.wind.gust.value,
item.nighttime_forecast.wind.gust.value,
),
ATTR_FORECAST_NATIVE_WIND_SPEED: max(
item.daytime_forecast.wind.speed.value,
item.nighttime_forecast.wind.speed.value,
),
ATTR_FORECAST_UV_INDEX: item.daytime_forecast.uv_index,
}
for item in daily_data.forecast_days
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
coordinator = self.forecast_coordinators["hourly"]
assert coordinator
hourly_data = coordinator.data
assert isinstance(hourly_data, HourlyForecastResponse)
return [
{
ATTR_FORECAST_CONDITION: _get_condition(
item.weather_condition.type, item.is_daytime
),
ATTR_FORECAST_TIME: item.interval.start_time,
ATTR_FORECAST_HUMIDITY: item.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: item.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: item.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: item.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_PRESSURE: item.air_pressure.mean_sea_level_millibars,
ATTR_FORECAST_NATIVE_TEMP: item.temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: item.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: item.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: item.wind.speed.value,
ATTR_FORECAST_NATIVE_DEW_POINT: item.dew_point.degrees,
ATTR_FORECAST_UV_INDEX: item.uv_index,
ATTR_FORECAST_IS_DAYTIME: item.is_daytime,
}
for item in hourly_data.forecast_hours
]
@callback
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = self.forecast_coordinators["twice_daily"]
assert coordinator
daily_data = coordinator.data
assert isinstance(daily_data, DailyForecastResponse)
forecasts: list[Forecast] = []
for item in daily_data.forecast_days:
# Process daytime forecast
day_forecast = item.daytime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
day_forecast.weather_condition.type, is_daytime=True
),
ATTR_FORECAST_TIME: day_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: day_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: day_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: day_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: day_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.max_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_max_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: day_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: day_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: day_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: day_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: True,
}
)
# Process nighttime forecast
night_forecast = item.nighttime_forecast
forecasts.append(
{
ATTR_FORECAST_CONDITION: _get_condition(
night_forecast.weather_condition.type, is_daytime=False
),
ATTR_FORECAST_TIME: night_forecast.interval.start_time,
ATTR_FORECAST_HUMIDITY: night_forecast.relative_humidity,
ATTR_FORECAST_PRECIPITATION_PROBABILITY: night_forecast.precipitation.probability.percent,
ATTR_FORECAST_CLOUD_COVERAGE: night_forecast.cloud_cover,
ATTR_FORECAST_NATIVE_PRECIPITATION: night_forecast.precipitation.qpf.quantity,
ATTR_FORECAST_NATIVE_TEMP: item.min_temperature.degrees,
ATTR_FORECAST_NATIVE_APPARENT_TEMP: item.feels_like_min_temperature.degrees,
ATTR_FORECAST_WIND_BEARING: night_forecast.wind.direction.degrees,
ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: night_forecast.wind.gust.value,
ATTR_FORECAST_NATIVE_WIND_SPEED: night_forecast.wind.speed.value,
ATTR_FORECAST_UV_INDEX: night_forecast.uv_index,
ATTR_FORECAST_IS_DAYTIME: False,
}
)
return forecasts

View File

@@ -121,12 +121,15 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]):
"""Initialize AutomowerEntity."""
super().__init__(coordinator)
self.mower_id = mower_id
parts = self.mower_attributes.system.model.split(maxsplit=2)
model_witout_manufacturer = self.mower_attributes.system.model.removeprefix(
"Husqvarna "
).removeprefix("HUSQVARNA ")
parts = model_witout_manufacturer.split(maxsplit=1)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, mower_id)},
manufacturer=parts[0],
model=parts[1],
model_id=parts[2],
manufacturer="Husqvarna",
model=parts[0].capitalize().removesuffix("®"),
model_id=parts[1],
name=self.mower_attributes.system.name,
serial_number=self.mower_attributes.system.serial_number,
suggested_area="Garden",

View File

@@ -183,13 +183,6 @@ PUMP_CONTROL_MODE_MAP = {
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
}
SETPOINT_CHANGE_SOURCE_MAP = {
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kManual: "manual",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kSchedule: "schedule",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kExternal: "external",
clusters.Thermostat.Enums.SetpointChangeSourceEnum.kUnknownEnumValue: None,
}
HUMIDITY_SCALING_FACTOR = 100
TEMPERATURE_SCALING_FACTOR = 100
@@ -1495,16 +1488,4 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSensor,
required_attributes=(clusters.ServiceArea.Attributes.EstimatedEndTime,),
),
MatterDiscoverySchema(
platform=Platform.SENSOR,
entity_description=MatterSensorEntityDescription(
key="SetpointChangeSource",
translation_key="setpoint_change_source",
device_class=SensorDeviceClass.ENUM,
options=[v for v in SETPOINT_CHANGE_SOURCE_MAP.values() if v is not None],
device_to_ha=SETPOINT_CHANGE_SOURCE_MAP.get,
),
entity_class=MatterSensor,
required_attributes=(clusters.Thermostat.Attributes.SetpointChangeSource,),
),
]

View File

@@ -194,7 +194,7 @@
"name": "Altitude above sea level"
},
"auto_relock_timer": {
"name": "Autorelock time"
"name": "Auto-relock time"
},
"cook_time": {
"name": "Cooking time"
@@ -518,14 +518,6 @@
"rms_voltage": {
"name": "Effective voltage"
},
"setpoint_change_source": {
"name": "Setpoint change source",
"state": {
"external": "External",
"manual": "Manual",
"schedule": "Schedule"
}
},
"switch_current_position": {
"name": "Current switch position"
},

View File

@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["openai==2.2.0", "python-open-router==0.3.3"]
"requirements": ["openai==2.8.0", "python-open-router==0.3.3"]
}

View File

@@ -338,6 +338,13 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
options.pop(CONF_CODE_INTERPRETER)
if model.startswith(("o", "gpt-5")) and not model.startswith("gpt-5-pro"):
if model.startswith("gpt-5.1"):
reasoning_options = ["none", "low", "medium", "high"]
elif model.startswith("gpt-5"):
reasoning_options = ["minimal", "low", "medium", "high"]
else:
reasoning_options = ["low", "medium", "high"]
step_schema.update(
{
vol.Optional(
@@ -345,9 +352,7 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
default=RECOMMENDED_REASONING_EFFORT,
): SelectSelector(
SelectSelectorConfig(
options=["low", "medium", "high"]
if model.startswith("o")
else ["minimal", "low", "medium", "high"],
options=reasoning_options,
translation_key=CONF_REASONING_EFFORT,
mode=SelectSelectorMode.DROPDOWN,
)

View File

@@ -510,6 +510,9 @@ class OpenAIBaseLLMEntity(Entity):
"verbosity": options.get(CONF_VERBOSITY, RECOMMENDED_VERBOSITY)
}
if model_args["model"].startswith("gpt-5.1"):
model_args["prompt_cache_retention"] = "24h"
tools: list[ToolParam] = []
if chat_log.llm_api:
tools = [

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["openai==2.2.0"]
"requirements": ["openai==2.8.0"]
}

View File

@@ -146,7 +146,8 @@
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"minimal": "Minimal"
"minimal": "Minimal",
"none": "None"
}
},
"search_context_size": {

View File

@@ -40,7 +40,7 @@
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==3.1.0",
"async-upnp-client==0.45.0"
"async-upnp-client==0.46.0"
],
"ssdp": [
{

View File

@@ -0,0 +1,50 @@
"""The Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from pysaunum import SaunumClient, SaunumConnectionError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import PLATFORMS
from .coordinator import LeilSaunaCoordinator
_LOGGER = logging.getLogger(__name__)
type LeilSaunaConfigEntry = ConfigEntry[LeilSaunaCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Set up Saunum Leil Sauna from a config entry."""
host = entry.data[CONF_HOST]
client = SaunumClient(host=host)
# Test connection
try:
await client.connect()
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
coordinator = LeilSaunaCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
coordinator = entry.runtime_data
coordinator.client.close()
return unload_ok

View File

@@ -0,0 +1,107 @@
"""Climate platform for Saunum Leil Sauna Control Unit."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LeilSaunaConfigEntry
from .const import DELAYED_REFRESH_SECONDS
from .entity import LeilSaunaEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: LeilSaunaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Saunum Leil Sauna climate entity."""
coordinator = entry.runtime_data
async_add_entities([LeilSaunaClimate(coordinator)])
class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
"""Representation of a Saunum Leil Sauna climate entity."""
_attr_name = None
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = MIN_TEMPERATURE
_attr_max_temp = MAX_TEMPERATURE
@property
def current_temperature(self) -> float | None:
"""Return the current temperature in Celsius."""
return self.coordinator.data.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the target temperature in Celsius."""
return self.coordinator.data.target_temperature
@property
def hvac_mode(self) -> HVACMode:
"""Return current HVAC mode."""
session_active = self.coordinator.data.session_active
return HVACMode.HEAT if session_active else HVACMode.OFF
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
if not self.coordinator.data.session_active:
return HVACAction.OFF
heater_elements_active = self.coordinator.data.heater_elements_active
return (
HVACAction.HEATING
if heater_elements_active and heater_elements_active > 0
else HVACAction.IDLE
)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
try:
if hvac_mode == HVACMode.HEAT:
await self.coordinator.client.async_start_session()
else:
await self.coordinator.client.async_stop_session()
except SaunumException as err:
raise HomeAssistantError(f"Failed to set HVAC mode to {hvac_mode}") from err
# The device takes 1-2 seconds to turn heater elements on/off and
# update heater_elements_active. Wait and refresh again to ensure
# the HVAC action state reflects the actual heater status.
await asyncio.sleep(DELAYED_REFRESH_SECONDS.total_seconds())
await self.coordinator.async_request_refresh()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
try:
await self.coordinator.client.async_set_target_temperature(
int(kwargs[ATTR_TEMPERATURE])
)
except SaunumException as err:
raise HomeAssistantError(
f"Failed to set temperature to {kwargs[ATTR_TEMPERATURE]}"
) from err
await self.coordinator.async_request_refresh()

View File

@@ -0,0 +1,76 @@
"""Config flow for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import Any
from pysaunum import SaunumClient, SaunumException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
host = data[CONF_HOST]
client = SaunumClient(host=host)
try:
await client.connect()
# Try to read data to verify communication
await client.async_get_data()
finally:
client.close()
class LeilSaunaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Saunum Leil Sauna Control Unit."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
# Check for duplicate configuration
self._async_abort_entries_match(user_input)
try:
await validate_input(user_input)
except SaunumException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title="Saunum Leil Sauna",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)

View File

@@ -0,0 +1,16 @@
"""Constants for the Saunum Leil Sauna Control Unit integration."""
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "saunum"
# Platforms
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
]
DEFAULT_SCAN_INTERVAL: Final = timedelta(seconds=60)
DELAYED_REFRESH_SECONDS: Final = timedelta(seconds=3)

View File

@@ -0,0 +1,47 @@
"""Coordinator for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from pysaunum import SaunumClient, SaunumData, SaunumException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
if TYPE_CHECKING:
from . import LeilSaunaConfigEntry
_LOGGER = logging.getLogger(__name__)
class LeilSaunaCoordinator(DataUpdateCoordinator[SaunumData]):
"""Coordinator for fetching Saunum Leil Sauna data."""
config_entry: LeilSaunaConfigEntry
def __init__(
self,
hass: HomeAssistant,
client: SaunumClient,
config_entry: LeilSaunaConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
async def _async_update_data(self) -> SaunumData:
"""Fetch data from the sauna controller."""
try:
return await self.client.async_get_data()
except SaunumException as err:
raise UpdateFailed(f"Communication error: {err}") from err

View File

@@ -0,0 +1,27 @@
"""Base entity for Saunum Leil Sauna Control Unit integration."""
from __future__ import annotations
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LeilSaunaCoordinator
class LeilSaunaEntity(CoordinatorEntity[LeilSaunaCoordinator]):
"""Base entity for Saunum Leil Sauna."""
_attr_has_entity_name = True
def __init__(self, coordinator: LeilSaunaCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
entry_id = coordinator.config_entry.entry_id
self._attr_unique_id = entry_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry_id)},
name="Saunum Leil",
manufacturer="Saunum",
model="Leil Touch Panel",
)

View File

@@ -0,0 +1,12 @@
{
"domain": "saunum",
"name": "Saunum Leil",
"codeowners": ["@mettolen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/saunum",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pysaunum"],
"quality_scale": "silver",
"requirements": ["pysaunum==0.1.0"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver tier
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: Modbus TCP does not require authentication.
test-coverage: done
# Gold tier
devices: done
diagnostics: todo
discovery:
status: exempt
comment: Device uses generic Espressif hardware with no unique identifying information (MAC OUI or hostname) that would distinguish it from other Espressif-based devices on the network.
discovery-update-info: todo
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: Integration controls a single device; no dynamic device discovery needed.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::ip%]"
},
"data_description": {
"host": "IP address of your Saunum Leil sauna control unit"
},
"description": "To find the IP address, navigate to Settings → Modbus Settings on your Leil touch panel"
}
}
}
}

View File

@@ -30,7 +30,10 @@ from .entity import ShellyBlockEntity, get_entity_rpc_device_info
from .utils import (
async_remove_orphaned_entities,
async_remove_shelly_entity,
get_block_channel,
get_block_custom_name,
get_device_entry_gen,
get_rpc_component_name,
get_rpc_entity_name,
get_rpc_key_instances,
is_block_momentary_input,
@@ -74,7 +77,6 @@ RPC_EVENT: Final = ShellyRpcEventDescription(
SCRIPT_EVENT: Final = ShellyRpcEventDescription(
key="script",
translation_key="script",
device_class=None,
entity_registry_enabled_default=False,
)
@@ -195,6 +197,17 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity):
self._attr_event_types = list(BASIC_INPUTS_EVENTS_TYPES)
self.entity_description = description
if (
hasattr(self, "_attr_name")
and self._attr_name
and not get_block_custom_name(coordinator.device, block)
):
self._attr_translation_placeholders = {
"input_number": get_block_channel(block)
}
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
@@ -227,9 +240,20 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity):
self.event_id = int(key.split(":")[-1])
self._attr_device_info = get_entity_rpc_device_info(coordinator, key)
self._attr_unique_id = f"{coordinator.mac}-{key}"
self._attr_name = get_rpc_entity_name(coordinator.device, key)
self.entity_description = description
if description.key == "input":
component = key.split(":")[0]
component_id = key.split(":")[-1]
if not get_rpc_component_name(coordinator.device, key) and (
component.lower() == "input" and component_id.isnumeric()
):
self._attr_translation_placeholders = {"input_number": component_id}
else:
self._attr_name = get_rpc_entity_name(coordinator.device, key)
elif description.key == "script":
self._attr_name = get_rpc_entity_name(coordinator.device, key)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()

View File

@@ -168,6 +168,7 @@
},
"event": {
"input": {
"name": "Input {input_number}",
"state_attributes": {
"event_type": {
"state": {

View File

@@ -120,17 +120,35 @@ def get_number_of_channels(device: BlockDevice, block: Block) -> int:
def get_block_entity_name(
device: BlockDevice,
block: Block | None,
description: str | UndefinedType | None = None,
name: str | UndefinedType | None = None,
) -> str | None:
"""Naming for block based switch and sensors."""
channel_name = get_block_channel_name(device, block)
if description is not UNDEFINED and description:
return f"{channel_name} {description.lower()}" if channel_name else description
if name is not UNDEFINED and name:
return f"{channel_name} {name.lower()}" if channel_name else name
return channel_name
def get_block_custom_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get custom name from device settings."""
if block and (key := cast(str, block.type) + "s") and key in device.settings:
assert block.channel
if name := device.settings[key][int(block.channel)].get("name"):
return cast(str, name)
return None
def get_block_channel(block: Block | None, base: str = "1") -> str:
"""Get block channel."""
assert block and block.channel
return chr(int(block.channel) + ord(base))
def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | None:
"""Get name based on device and channel name."""
if (
@@ -140,19 +158,10 @@ def get_block_channel_name(device: BlockDevice, block: Block | None) -> str | No
):
return None
assert block.channel
if custom_name := get_block_custom_name(device, block):
return custom_name
channel_name: str | None = None
mode = cast(str, block.type) + "s"
if mode in device.settings:
channel_name = device.settings[mode][int(block.channel)].get("name")
if channel_name:
return channel_name
base = ord("1")
return f"Channel {chr(int(block.channel) + base)}"
return f"Channel {get_block_channel(block)}"
def get_block_sub_device_name(device: BlockDevice, block: Block) -> str:
@@ -160,18 +169,13 @@ def get_block_sub_device_name(device: BlockDevice, block: Block) -> str:
if TYPE_CHECKING:
assert block.channel
mode = cast(str, block.type) + "s"
if mode in device.settings:
if channel_name := device.settings[mode][int(block.channel)].get("name"):
return cast(str, channel_name)
if custom_name := get_block_custom_name(device, block):
return custom_name
if device.settings["device"]["type"] == MODEL_EM3:
base = ord("A")
return f"{device.name} Phase {chr(int(block.channel) + base)}"
return f"{device.name} Phase {get_block_channel(block, 'A')}"
base = ord("1")
return f"{device.name} Channel {chr(int(block.channel) + base)}"
return f"{device.name} Channel {get_block_channel(block)}"
def is_block_momentary_input(
@@ -387,6 +391,18 @@ def get_shelly_model_name(
return cast(str, MODEL_NAMES.get(model))
def get_rpc_component_name(device: RpcDevice, key: str) -> str | None:
"""Get component name from device config."""
if (
key in device.config
and key != "em:0" # workaround for Pro 3EM, we don't want to get name for em:0
and (name := device.config[key].get("name"))
):
return cast(str, name)
return None
def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
"""Get name based on device and channel name."""
if BLU_TRV_IDENTIFIER in key:
@@ -398,13 +414,11 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str | None:
component = key.split(":")[0]
component_id = key.split(":")[-1]
if key in device.config and key != "em:0":
# workaround for Pro 3EM, we don't want to get name for em:0
if component_name := device.config[key].get("name"):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return cast(str, component_name)
if component_name := get_rpc_component_name(device, key):
if component in (*VIRTUAL_COMPONENTS, "input", "presencezone", "script"):
return component_name
return cast(str, component_name) if instances == 1 else None
return component_name if instances == 1 else None
if component in (*VIRTUAL_COMPONENTS, "input"):
return f"{component.title()} {component_id}"

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
"requirements": ["async-upnp-client==0.45.0"]
"requirements": ["async-upnp-client==0.46.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["tplink-omada-client==1.4.4"]
"requirements": ["tplink-omada-client==1.5.3"]
}

View File

@@ -7,7 +7,12 @@ from dataclasses import dataclass
from functools import partial
from typing import Any, Generic, TypeVar
from tplink_omada_client import OmadaSiteClient, SwitchPortOverrides
from tplink_omada_client import (
GatewayPortSettings,
OmadaSiteClient,
PortProfileOverrides,
SwitchPortSettings,
)
from tplink_omada_client.definitions import GatewayPortMode, PoEMode, PortType
from tplink_omada_client.devices import (
OmadaDevice,
@@ -17,7 +22,6 @@ from tplink_omada_client.devices import (
OmadaSwitch,
OmadaSwitchPortDetails,
)
from tplink_omada_client.omadasiteclient import GatewayPortSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
@@ -184,7 +188,12 @@ SWITCH_PORT_DETAILS_SWITCHES: list[OmadaSwitchPortSwitchEntityDescription] = [
),
set_func=(
lambda client, device, port, enable: client.update_switch_port(
device, port, overrides=SwitchPortOverrides(enable_poe=enable)
device,
port,
settings=SwitchPortSettings(
profile_override_enabled=True,
profile_overrides=PortProfileOverrides(enable_poe=enable),
),
)
),
update_func=lambda p: p.poe_mode != PoEMode.DISABLED,

View File

@@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import DPCodeIntegerWrapper, IntegerTypeData, find_dpcode
from .util import get_dpcode
TUYA_HVAC_TO_HA = {
@@ -41,6 +41,16 @@ TUYA_HVAC_TO_HA = {
}
class _RoundedIntegerWrapper(DPCodeIntegerWrapper):
"""An integer that always rounds its value."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Read and round the device status."""
if (value := super().read_device_status(device)) is None:
return None
return round(value)
@dataclass(frozen=True, kw_only=True)
class TuyaClimateEntityDescription(ClimateEntityDescription):
"""Describe an Tuya climate entity."""
@@ -97,6 +107,12 @@ async def async_setup_entry(
manager,
CLIMATE_DESCRIPTIONS[device.category],
hass.config.units.temperature_unit,
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_CURRENT
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, DPCode.HUMIDITY_SET, prefer_function=True
),
)
)
async_add_entities(entities)
@@ -111,10 +127,8 @@ async def async_setup_entry(
class TuyaClimateEntity(TuyaEntity, ClimateEntity):
"""Tuya Climate Device."""
_current_humidity: IntegerTypeData | None = None
_current_temperature: IntegerTypeData | None = None
_hvac_to_tuya: dict[str, str]
_set_humidity: IntegerTypeData | None = None
_set_temperature: IntegerTypeData | None = None
entity_description: TuyaClimateEntityDescription
_attr_name = None
@@ -125,12 +139,17 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
device_manager: Manager,
description: TuyaClimateEntityDescription,
system_temperature_unit: UnitOfTemperature,
*,
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
) -> None:
"""Determine which values to use."""
self._attr_target_temperature_step = 1.0
self.entity_description = description
super().__init__(device, device_manager)
self._current_humidity_wrapper = current_humidity_wrapper
self._target_humidity_wrapper = target_humidity_wrapper
# If both temperature values for celsius and fahrenheit are present,
# use whatever the device is set to, with a fallback to celsius.
@@ -227,21 +246,14 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
]
# Determine dpcode to use for setting the humidity
if int_type := find_dpcode(
self.device,
DPCode.HUMIDITY_SET,
dptype=DPType.INTEGER,
prefer_function=True,
):
if target_humidity_wrapper:
self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY
self._set_humidity = int_type
self._attr_min_humidity = int(int_type.min_scaled)
self._attr_max_humidity = int(int_type.max_scaled)
# Determine dpcode to use for getting the current humidity
self._current_humidity = find_dpcode(
self.device, DPCode.HUMIDITY_CURRENT, dptype=DPType.INTEGER
)
self._attr_min_humidity = round(
target_humidity_wrapper.type_information.min_scaled
)
self._attr_max_humidity = round(
target_humidity_wrapper.type_information.max_scaled
)
# Determine fan modes
self._fan_mode_dp_code: str | None = None
@@ -303,20 +315,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._send_command([{"code": self._fan_mode_dp_code, "value": fan_mode}])
def set_humidity(self, humidity: int) -> None:
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if TYPE_CHECKING:
# guarded by ClimateEntityFeature.TARGET_HUMIDITY
assert self._set_humidity is not None
self._send_command(
[
{
"code": self._set_humidity.dpcode,
"value": self._set_humidity.scale_value_back(humidity),
}
]
)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
def set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
@@ -382,14 +383,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
if self._current_humidity is None:
return None
humidity = self.device.status.get(self._current_humidity.dpcode)
if humidity is None:
return None
return round(self._current_humidity.scale_value(humidity))
return self._read_wrapper(self._current_humidity_wrapper)
@property
def target_temperature(self) -> float | None:
@@ -406,14 +400,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
@property
def target_humidity(self) -> int | None:
"""Return the humidity currently set to be reached."""
if self._set_humidity is None:
return None
humidity = self.device.status.get(self._set_humidity.dpcode)
if humidity is None:
return None
return round(self._set_humidity.scale_value(humidity))
return self._read_wrapper(self._target_humidity_wrapper)
@property
def hvac_mode(self) -> HVACMode:

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any
from tuya_sharing import CustomerDevice, Manager
@@ -22,10 +22,57 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import EnumTypeData, IntegerTypeData, find_dpcode
from .models import DPCodeIntegerWrapper, find_dpcode
from .util import get_dpcode
class _DPCodePercentageMappingWrapper(DPCodeIntegerWrapper):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return False
def read_device_status(self, device: CustomerDevice) -> float | None:
if (value := self._read_device_status_raw(device)) is None:
return None
return round(
self.type_information.remap_value_to(
value,
0,
100,
self._position_reversed(device),
)
)
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
return round(
self.type_information.remap_value_from(
value,
0,
100,
self._position_reversed(device),
)
)
class _InvertedPercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values mapping to 0-100 range."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return True
class _ControlBackModePercentageMappingWrapper(_DPCodePercentageMappingWrapper):
"""Wrapper for DPCode position values with control_back_mode support."""
def _position_reversed(self, device: CustomerDevice) -> bool:
"""Check if the position and direction should be reversed."""
return device.status.get(DPCode.CONTROL_BACK_MODE) != "back"
@dataclass(frozen=True)
class TuyaCoverEntityDescription(CoverEntityDescription):
"""Describe an Tuya cover entity."""
@@ -33,11 +80,13 @@ class TuyaCoverEntityDescription(CoverEntityDescription):
current_state: DPCode | tuple[DPCode, ...] | None = None
current_state_inverse: bool = False
current_position: DPCode | tuple[DPCode, ...] | None = None
position_wrapper: type[_DPCodePercentageMappingWrapper] = (
_InvertedPercentageMappingWrapper
)
set_position: DPCode | None = None
open_instruction_value: str = "open"
close_instruction_value: str = "close"
stop_instruction_value: str = "stop"
motor_reverse_mode: DPCode | None = None
COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
@@ -117,8 +166,8 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
key=DPCode.CONTROL,
translation_key="curtain",
current_position=DPCode.PERCENT_CONTROL,
position_wrapper=_ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL,
motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
TuyaCoverEntityDescription(
@@ -126,8 +175,8 @@ COVERS: dict[DeviceCategory, tuple[TuyaCoverEntityDescription, ...]] = {
translation_key="indexed_curtain",
translation_placeholders={"index": "2"},
current_position=DPCode.PERCENT_CONTROL_2,
position_wrapper=_ControlBackModePercentageMappingWrapper,
set_position=DPCode.PERCENT_CONTROL_2,
motor_reverse_mode=DPCode.CONTROL_BACK_MODE,
device_class=CoverDeviceClass.CURTAIN,
),
),
@@ -159,7 +208,22 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := COVERS.get(device.category):
entities.extend(
TuyaCoverEntity(device, manager, description)
TuyaCoverEntity(
device,
manager,
description,
current_position=description.position_wrapper.find_dpcode(
device, description.current_position
),
set_position=description.position_wrapper.find_dpcode(
device, description.set_position, prefer_function=True
),
tilt_position=description.position_wrapper.find_dpcode(
device,
(DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
prefer_function=True,
),
)
for description in descriptions
if (
description.key in device.function
@@ -179,11 +243,7 @@ async def async_setup_entry(
class TuyaCoverEntity(TuyaEntity, CoverEntity):
"""Tuya Cover Device."""
_current_position: IntegerTypeData | None = None
_current_state: DPCode | None = None
_set_position: IntegerTypeData | None = None
_tilt: IntegerTypeData | None = None
_motor_reverse_mode_enum: EnumTypeData | None = None
entity_description: TuyaCoverEntityDescription
def __init__(
@@ -191,6 +251,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaCoverEntityDescription,
*,
current_position: _DPCodePercentageMappingWrapper | None = None,
set_position: _DPCodePercentageMappingWrapper | None = None,
tilt_position: _DPCodePercentageMappingWrapper | None = None,
) -> None:
"""Init Tuya Cover."""
super().__init__(device, device_manager)
@@ -198,6 +262,10 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_supported_features = CoverEntityFeature(0)
self._current_position = current_position or set_position
self._set_position = set_position
self._tilt_position = tilt_position
# Check if this cover is based on a switch or has controls
if get_dpcode(self.device, description.key):
if device.function[description.key].type == "Boolean":
@@ -216,72 +284,15 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
self._current_state = get_dpcode(self.device, description.current_state)
# Determine type to use for setting the position
if int_type := find_dpcode(
self.device,
description.set_position,
dptype=DPType.INTEGER,
prefer_function=True,
):
if set_position:
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
self._set_position = int_type
# Set as default, unless overwritten below
self._current_position = int_type
# Determine type for getting the position
if int_type := find_dpcode(
self.device,
description.current_position,
dptype=DPType.INTEGER,
prefer_function=True,
):
self._current_position = int_type
# Determine type to use for setting the tilt
if int_type := find_dpcode(
self.device,
(DPCode.ANGLE_HORIZONTAL, DPCode.ANGLE_VERTICAL),
dptype=DPType.INTEGER,
prefer_function=True,
):
if tilt_position:
self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION
self._tilt = int_type
# Determine type to use for checking motor reverse mode
if (motor_mode := description.motor_reverse_mode) and (
enum_type := find_dpcode(
self.device,
motor_mode,
dptype=DPType.ENUM,
prefer_function=True,
)
):
self._motor_reverse_mode_enum = enum_type
@property
def _is_position_reversed(self) -> bool:
"""Check if the cover position and direction should be reversed."""
# The default is True
# Having motor_reverse_mode == "back" cancels the inversion
return not (
self._motor_reverse_mode_enum
and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
)
@property
def current_cover_position(self) -> int | None:
"""Return cover current position."""
if self._current_position is None:
return None
if (position := self.device.status.get(self._current_position.dpcode)) is None:
return None
return round(
self._current_position.remap_value_to(
position, 0, 100, reverse=self._is_position_reversed
)
)
return self._read_wrapper(self._current_position)
@property
def current_cover_tilt_position(self) -> int | None:
@@ -289,13 +300,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
None is unknown, 0 is closed, 100 is fully open.
"""
if self._tilt is None:
return None
if (angle := self.device.status.get(self._tilt.dpcode)) is None:
return None
return round(self._tilt.remap_value_to(angle, 0, 100))
return self._read_wrapper(self._tilt_position)
@property
def is_closed(self) -> bool | None:
@@ -332,16 +337,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
if self._set_position is not None:
commands.append(
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
100, 0, 100, reverse=self._is_position_reversed
),
),
}
)
commands.append(self._set_position.get_update_command(self.device, 100))
self._send_command(commands)
@@ -361,40 +357,13 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
if self._set_position is not None:
commands.append(
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
0, 0, 100, reverse=self._is_position_reversed
),
),
}
)
commands.append(self._set_position.get_update_command(self.device, 0))
self._send_command(commands)
def set_cover_position(self, **kwargs: Any) -> None:
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
if TYPE_CHECKING:
# guarded by CoverEntityFeature.SET_POSITION
assert self._set_position is not None
self._send_command(
[
{
"code": self._set_position.dpcode,
"value": round(
self._set_position.remap_value_from(
kwargs[ATTR_POSITION],
0,
100,
reverse=self._is_position_reversed,
)
),
}
]
)
await self._async_send_dpcode_update(self._set_position, kwargs[ATTR_POSITION])
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
@@ -407,24 +376,8 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
]
)
def set_cover_tilt_position(self, **kwargs: Any) -> None:
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
if TYPE_CHECKING:
# guarded by CoverEntityFeature.SET_TILT_POSITION
assert self._tilt is not None
self._send_command(
[
{
"code": self._tilt.dpcode,
"value": round(
self._tilt.remap_value_from(
kwargs[ATTR_TILT_POSITION],
0,
100,
reverse=self._is_position_reversed,
)
),
}
]
await self._async_send_dpcode_update(
self._tilt_position, kwargs[ATTR_TILT_POSITION]
)

View File

@@ -18,12 +18,22 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import IntegerTypeData, find_dpcode
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
from .util import ActionDPCodeNotFoundError, get_dpcode
class _RoundedIntegerWrapper(DPCodeIntegerWrapper):
"""An integer that always rounds its value."""
def read_device_status(self, device: CustomerDevice) -> int | None:
"""Read and round the device status."""
if (value := super().read_device_status(device)) is None:
return None
return round(value)
@dataclass(frozen=True)
class TuyaHumidifierEntityDescription(HumidifierEntityDescription):
"""Describe an Tuya (de)humidifier entity."""
@@ -84,7 +94,27 @@ async def async_setup_entry(
if (
description := HUMIDIFIERS.get(device.category)
) and _has_a_valid_dpcode(device, description):
entities.append(TuyaHumidifierEntity(device, manager, description))
entities.append(
TuyaHumidifierEntity(
device,
manager,
description,
current_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, description.current_humidity
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device,
description.dpcode or DPCode(description.key),
prefer_function=True,
),
target_humidity_wrapper=_RoundedIntegerWrapper.find_dpcode(
device, description.humidity, prefer_function=True
),
)
)
async_add_entities(entities)
async_discover_device([*manager.device_map])
@@ -97,9 +127,6 @@ async def async_setup_entry(
class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
"""Tuya (de)humidifier Device."""
_current_humidity: IntegerTypeData | None = None
_set_humidity: IntegerTypeData | None = None
_switch_dpcode: DPCode | None = None
entity_description: TuyaHumidifierEntityDescription
_attr_name = None
@@ -108,115 +135,83 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaHumidifierEntityDescription,
*,
current_humidity_wrapper: _RoundedIntegerWrapper | None = None,
mode_wrapper: DPCodeEnumWrapper | None = None,
switch_wrapper: DPCodeBooleanWrapper | None = None,
target_humidity_wrapper: _RoundedIntegerWrapper | None = None,
) -> None:
"""Init Tuya (de)humidifier."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
# Determine main switch DPCode
self._switch_dpcode = get_dpcode(
self.device, description.dpcode or DPCode(description.key)
)
self._current_humidity_wrapper = current_humidity_wrapper
self._mode_wrapper = mode_wrapper
self._switch_wrapper = switch_wrapper
self._target_humidity_wrapper = target_humidity_wrapper
# Determine humidity parameters
if int_type := find_dpcode(
self.device,
description.humidity,
dptype=DPType.INTEGER,
prefer_function=True,
):
self._set_humidity = int_type
self._attr_min_humidity = int(int_type.min_scaled)
self._attr_max_humidity = int(int_type.max_scaled)
# Determine current humidity DPCode
if int_type := find_dpcode(
self.device,
description.current_humidity,
dptype=DPType.INTEGER,
):
self._current_humidity = int_type
if target_humidity_wrapper:
self._attr_min_humidity = round(
target_humidity_wrapper.type_information.min_scaled
)
self._attr_max_humidity = round(
target_humidity_wrapper.type_information.max_scaled
)
# Determine mode support and provided modes
if enum_type := find_dpcode(
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
):
if mode_wrapper:
self._attr_supported_features |= HumidifierEntityFeature.MODES
self._attr_available_modes = enum_type.range
self._attr_available_modes = mode_wrapper.type_information.range
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return the device is on or off."""
if self._switch_dpcode is None:
return False
return self.device.status.get(self._switch_dpcode, False)
return self._read_wrapper(self._switch_wrapper)
@property
def mode(self) -> str | None:
"""Return the current mode."""
return self.device.status.get(DPCode.MODE)
return self._read_wrapper(self._mode_wrapper)
@property
def target_humidity(self) -> int | None:
"""Return the humidity we try to reach."""
if self._set_humidity is None:
return None
humidity = self.device.status.get(self._set_humidity.dpcode)
if humidity is None:
return None
return round(self._set_humidity.scale_value(humidity))
return self._read_wrapper(self._target_humidity_wrapper)
@property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
if self._current_humidity is None:
return None
return self._read_wrapper(self._current_humidity_wrapper)
if (
current_humidity := self.device.status.get(self._current_humidity.dpcode)
) is None:
return None
return round(self._current_humidity.scale_value(current_humidity))
def turn_on(self, **kwargs: Any) -> None:
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
if self._switch_dpcode is None:
if self._switch_wrapper is None:
raise ActionDPCodeNotFoundError(
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
self._send_command([{"code": self._switch_dpcode, "value": True}])
await self._async_send_dpcode_update(self._switch_wrapper, True)
def turn_off(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
if self._switch_dpcode is None:
if self._switch_wrapper is None:
raise ActionDPCodeNotFoundError(
self.device,
self.entity_description.dpcode or self.entity_description.key,
)
self._send_command([{"code": self._switch_dpcode, "value": False}])
await self._async_send_dpcode_update(self._switch_wrapper, False)
def set_humidity(self, humidity: int) -> None:
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if self._set_humidity is None:
if self._target_humidity_wrapper is None:
raise ActionDPCodeNotFoundError(
self.device,
self.entity_description.humidity,
)
await self._async_send_dpcode_update(self._target_humidity_wrapper, humidity)
self._send_command(
[
{
"code": self._set_humidity.dpcode,
"value": self._set_humidity.scale_value_back(humidity),
}
]
)
def set_mode(self, mode: str) -> None:
async def async_set_mode(self, mode: str) -> None:
"""Set new target preset mode."""
self._send_command([{"code": DPCode.MODE, "value": mode}])
await self._async_send_dpcode_update(self._mode_wrapper, mode)

View File

@@ -29,7 +29,12 @@ from homeassistant.util.json import json_loads_object
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper, IntegerTypeData, find_dpcode
from .models import (
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
IntegerTypeData,
find_dpcode,
)
from .util import get_dpcode, get_dptype, remap_value
@@ -429,7 +434,13 @@ async def async_setup_entry(
if descriptions := LIGHTS.get(device.category):
entities.extend(
TuyaLightEntity(
device, manager, description, switch_wrapper=switch_wrapper
device,
manager,
description,
color_mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.color_mode, prefer_function=True
),
switch_wrapper=switch_wrapper,
)
for description in descriptions
if (
@@ -458,7 +469,6 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
_brightness: IntegerTypeData | None = None
_color_data_dpcode: DPCode | None = None
_color_data_type: ColorTypeData | None = None
_color_mode: DPCode | None = None
_color_temp: IntegerTypeData | None = None
_white_color_mode = ColorMode.COLOR_TEMP
_fixed_color_mode: ColorMode | None = None
@@ -471,19 +481,18 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
device_manager: Manager,
description: TuyaLightEntityDescription,
*,
color_mode_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper,
) -> None:
"""Init TuyaHaLight."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._color_mode_wrapper = color_mode_wrapper
self._switch_wrapper = switch_wrapper
color_modes: set[ColorMode] = {ColorMode.ONOFF}
# Determine DPCodes
self._color_mode_dpcode = get_dpcode(self.device, description.color_mode)
if int_type := find_dpcode(
self.device,
description.brightness,
@@ -537,15 +546,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
# work_mode "white"
elif (
color_supported(color_modes)
and (
color_mode_enum := find_dpcode(
self.device,
description.color_mode,
dptype=DPType.ENUM,
prefer_function=True,
)
)
and WorkMode.WHITE.value in color_mode_enum.range
and color_mode_wrapper is not None
and WorkMode.WHITE in color_mode_wrapper.type_information.range
):
color_modes.add(ColorMode.WHITE)
self._white_color_mode = ColorMode.WHITE
@@ -566,14 +568,13 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
self._switch_wrapper.get_update_command(self.device, True),
]
if self._color_mode_dpcode and (
if self._color_mode_wrapper and (
ATTR_WHITE in kwargs or ATTR_COLOR_TEMP_KELVIN in kwargs
):
commands += [
{
"code": self._color_mode_dpcode,
"value": WorkMode.WHITE,
},
self._color_mode_wrapper.get_update_command(
self.device, WorkMode.WHITE
),
]
if self._color_temp and ATTR_COLOR_TEMP_KELVIN in kwargs:
@@ -602,12 +603,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
and ATTR_COLOR_TEMP_KELVIN not in kwargs
)
):
if self._color_mode_dpcode:
if self._color_mode_wrapper:
commands += [
{
"code": self._color_mode_dpcode,
"value": WorkMode.COLOUR,
},
self._color_mode_wrapper.get_update_command(
self.device, WorkMode.COLOUR
),
]
if not (brightness := kwargs.get(ATTR_BRIGHTNESS)):
@@ -765,8 +765,8 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
# and HS, determine which mode the light is in. We consider it to be in HS color
# mode, when work mode is anything else than "white".
if (
self._color_mode_dpcode
and self.device.status.get(self._color_mode_dpcode) != WorkMode.WHITE
self._color_mode_wrapper
and self._read_wrapper(self._color_mode_wrapper) != WorkMode.WHITE
):
return ColorMode.HS
return self._white_color_mode

View File

@@ -36,8 +36,8 @@ class IntegerTypeData(TypeInformation):
min: int
max: int
scale: float
step: float
scale: int
step: int
unit: str | None = None
@property
@@ -55,7 +55,7 @@ class IntegerTypeData(TypeInformation):
"""Return the step scaled."""
return self.step / (10**self.scale)
def scale_value(self, value: float) -> float:
def scale_value(self, value: int) -> float:
"""Scale a value."""
return value / (10**self.scale)
@@ -93,8 +93,8 @@ class IntegerTypeData(TypeInformation):
dpcode,
min=int(parsed["min"]),
max=int(parsed["max"]),
scale=float(parsed["scale"]),
step=max(float(parsed["step"]), 1),
scale=int(parsed["scale"]),
step=int(parsed["step"]),
unit=parsed.get("unit"),
)

View File

@@ -16,9 +16,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import EnumTypeData, find_dpcode
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper
from .util import get_dpcode
TUYA_MODE_RETURN_HOME = "chargego"
@@ -64,7 +64,27 @@ async def async_setup_entry(
for device_id in device_ids:
device = manager.device_map[device_id]
if device.category == DeviceCategory.SD:
entities.append(TuyaVacuumEntity(device, manager))
entities.append(
TuyaVacuumEntity(
device,
manager,
charge_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_CHARGE, prefer_function=True
),
fan_speed_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.SUCTION, prefer_function=True
),
locate_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SEEK, prefer_function=True
),
mode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, DPCode.MODE, prefer_function=True
),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.POWER_GO, prefer_function=True
),
)
)
async_add_entities(entities)
async_discover_device([*manager.device_map])
@@ -77,51 +97,56 @@ async def async_setup_entry(
class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
"""Tuya Vacuum Device."""
_fan_speed: EnumTypeData | None = None
_attr_name = None
def __init__(self, device: CustomerDevice, device_manager: Manager) -> None:
def __init__(
self,
device: CustomerDevice,
device_manager: Manager,
*,
charge_wrapper: DPCodeBooleanWrapper | None,
fan_speed_wrapper: DPCodeEnumWrapper | None,
locate_wrapper: DPCodeBooleanWrapper | None,
mode_wrapper: DPCodeEnumWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
) -> None:
"""Init Tuya vacuum."""
super().__init__(device, device_manager)
self._charge_wrapper = charge_wrapper
self._fan_speed_wrapper = fan_speed_wrapper
self._locate_wrapper = locate_wrapper
self._mode_wrapper = mode_wrapper
self._switch_wrapper = switch_wrapper
self._attr_fan_speed_list = []
self._attr_supported_features = (
VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE
)
if get_dpcode(self.device, DPCode.PAUSE):
self._attr_supported_features |= VacuumEntityFeature.PAUSE
self._return_home_use_switch_charge = False
if get_dpcode(self.device, DPCode.SWITCH_CHARGE):
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
self._return_home_use_switch_charge = True
elif (
enum_type := find_dpcode(
self.device, DPCode.MODE, dptype=DPType.ENUM, prefer_function=True
)
) and TUYA_MODE_RETURN_HOME in enum_type.range:
if charge_wrapper or (
mode_wrapper
and TUYA_MODE_RETURN_HOME in mode_wrapper.type_information.range
):
self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
if get_dpcode(self.device, DPCode.SEEK):
if locate_wrapper:
self._attr_supported_features |= VacuumEntityFeature.LOCATE
if get_dpcode(self.device, DPCode.POWER_GO):
if switch_wrapper:
self._attr_supported_features |= (
VacuumEntityFeature.STOP | VacuumEntityFeature.START
)
if enum_type := find_dpcode(
self.device, DPCode.SUCTION, dptype=DPType.ENUM, prefer_function=True
):
self._fan_speed = enum_type
self._attr_fan_speed_list = enum_type.range
if fan_speed_wrapper:
self._attr_fan_speed_list = fan_speed_wrapper.type_information.range
self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
@property
def fan_speed(self) -> str | None:
"""Return the fan speed of the vacuum cleaner."""
return self.device.status.get(DPCode.SUCTION)
return self._read_wrapper(self._fan_speed_wrapper)
@property
def activity(self) -> VacuumActivity | None:
@@ -134,32 +159,34 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity):
return None
return TUYA_STATUS_TO_HA.get(status)
def start(self, **kwargs: Any) -> None:
async def async_start(self, **kwargs: Any) -> None:
"""Start the device."""
self._send_command([{"code": DPCode.POWER_GO, "value": True}])
await self._async_send_dpcode_update(self._switch_wrapper, True)
def stop(self, **kwargs: Any) -> None:
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the device."""
self._send_command([{"code": DPCode.POWER_GO, "value": False}])
await self._async_send_dpcode_update(self._switch_wrapper, False)
def pause(self, **kwargs: Any) -> None:
"""Pause the device."""
self._send_command([{"code": DPCode.POWER_GO, "value": False}])
def return_to_base(self, **kwargs: Any) -> None:
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Return device to dock."""
if self._return_home_use_switch_charge:
self._send_command([{"code": DPCode.SWITCH_CHARGE, "value": True}])
if self._charge_wrapper:
await self._async_send_dpcode_update(self._charge_wrapper, True)
else:
self._send_command([{"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}])
await self._async_send_dpcode_update(
self._mode_wrapper, TUYA_MODE_RETURN_HOME
)
def locate(self, **kwargs: Any) -> None:
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the device."""
self._send_command([{"code": DPCode.SEEK, "value": True}])
await self._async_send_dpcode_update(self._locate_wrapper, True)
def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
self._send_command([{"code": DPCode.SUCTION, "value": fan_speed}])
await self._async_send_dpcode_update(self._fan_speed_wrapper, fan_speed)
def send_command(
self,

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pythonkuma"],
"quality_scale": "platinum",
"requirements": ["pythonkuma==0.3.1"]
"requirements": ["pythonkuma==0.3.2"]
}

View File

@@ -12,4 +12,3 @@ SCORE_POINTS = "points"
UOM_BEATS_PER_MINUTE = "bpm"
UOM_BREATHS_PER_MINUTE = "br/min"
UOM_FREQUENCY = "times"
UOM_MMHG = "mmhg"

View File

@@ -30,6 +30,7 @@ from homeassistant.const import (
Platform,
UnitOfLength,
UnitOfMass,
UnitOfPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
@@ -48,7 +49,6 @@ from .const import (
UOM_BEATS_PER_MINUTE,
UOM_BREATHS_PER_MINUTE,
UOM_FREQUENCY,
UOM_MMHG,
)
from .coordinator import (
WithingsActivityDataUpdateCoordinator,
@@ -162,14 +162,16 @@ MEASUREMENT_SENSORS: dict[
key="diastolic_blood_pressure_mmhg",
measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE,
translation_key="diastolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
native_unit_of_measurement=UnitOfPressure.MMHG,
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
),
MeasurementType.SYSTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription(
key="systolic_blood_pressure_mmhg",
measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE,
translation_key="systolic_blood_pressure",
native_unit_of_measurement=UOM_MMHG,
native_unit_of_measurement=UnitOfPressure.MMHG,
device_class=SensorDeviceClass.PRESSURE,
state_class=SensorStateClass.MEASUREMENT,
),
MeasurementType.HEART_RATE: WithingsMeasurementSensorEntityDescription(

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import timedelta
from datetime import datetime, timedelta
from http import HTTPStatus
import logging
@@ -251,7 +251,7 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
translation_key="request_exception",
) from e
title_data[person.xuid] = title.titles[0]
person.last_seen_date_time_utc = self.last_seen_timestamp(person)
if (
self.current_friends - (new_friends := set(presence_data))
or not self.current_friends
@@ -261,6 +261,22 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
return XboxData(new_console_data, presence_data, title_data)
def last_seen_timestamp(self, person: Person) -> datetime | None:
"""Returns the most recent of two timestamps."""
# The Xbox API constantly fluctuates the "last seen" timestamp between two close values,
# causing unnecessary updates. We only accept the most recent one as valild to prevent this.
if not (prev_data := self.data.presence.get(person.xuid)):
return person.last_seen_date_time_utc
prev_dt = prev_data.last_seen_date_time_utc
cur_dt = person.last_seen_date_time_utc
if prev_dt and cur_dt:
return max(prev_dt, cur_dt)
return cur_dt
def remove_stale_devices(self, xuids: set[str]) -> None:
"""Remove stale devices from registry."""

View File

@@ -16,7 +16,7 @@
},
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
"requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"],
"requirements": ["yeelight==0.7.16", "async-upnp-client==0.46.0"],
"zeroconf": [
{
"name": "yeelink-*",

View File

@@ -255,6 +255,7 @@ FLOWS = {
"google_tasks",
"google_translate",
"google_travel_time",
"google_weather",
"govee_ble",
"govee_light_local",
"gpsd",
@@ -574,6 +575,7 @@ FLOWS = {
"samsungtv",
"sanix",
"satel_integra",
"saunum",
"schlage",
"scrape",
"screenlogic",

View File

@@ -2451,6 +2451,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"google_weather": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Google Weather"
},
"google_wifi": {
"integration_type": "hub",
"config_flow": false,
@@ -5751,6 +5757,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"saunum": {
"name": "Saunum Leil",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"schlage": {
"name": "Schlage",
"integration_type": "hub",

View File

@@ -59,7 +59,6 @@ from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
issue_registry as ir,
location as loc_helper,
)
@@ -79,7 +78,7 @@ from .context import (
template_context_manager,
template_cv,
)
from .helpers import raise_no_default
from .helpers import raise_no_default, resolve_area_id
from .render_info import RenderInfo, render_info_cv
if TYPE_CHECKING:
@@ -1167,13 +1166,6 @@ def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]:
return list(found.values())
def device_entities(hass: HomeAssistant, _device_id: str) -> Iterable[str]:
"""Get entity ids for entities tied to a device."""
entity_reg = er.async_get(hass)
entries = er.async_entries_for_device(entity_reg, _device_id)
return [entry.entity_id for entry in entries]
def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]:
"""Get entity ids for entities tied to an integration/domain.
@@ -1215,65 +1207,6 @@ def config_entry_id(hass: HomeAssistant, entity_id: str) -> str | None:
return None
def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None:
"""Get a device ID from an entity ID or device name."""
entity_reg = er.async_get(hass)
entity = entity_reg.async_get(entity_id_or_device_name)
if entity is not None:
return entity.device_id
dev_reg = dr.async_get(hass)
return next(
(
device_id
for device_id, device in dev_reg.devices.items()
if (name := device.name_by_user or device.name)
and (str(entity_id_or_device_name) == name)
),
None,
)
def device_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the device name from an device id, or entity id."""
device_reg = dr.async_get(hass)
if device := device_reg.async_get(lookup_value):
return device.name_by_user or device.name
ent_reg = er.async_get(hass)
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
if entity.device_id and (device := device_reg.async_get(entity.device_id)):
return device.name_by_user or device.name
return None
def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any:
"""Get the device specific attribute."""
device_reg = dr.async_get(hass)
if not isinstance(device_or_entity_id, str):
raise TemplateError("Must provide a device or entity ID")
device = None
if (
"." in device_or_entity_id
and (_device_id := device_id(hass, device_or_entity_id)) is not None
):
device = device_reg.async_get(_device_id)
elif "." not in device_or_entity_id:
device = device_reg.async_get(device_or_entity_id)
if device is None or not hasattr(device, attr_name):
return None
return getattr(device, attr_name)
def config_entry_attr(
hass: HomeAssistant, config_entry_id_: str, attr_name: str
) -> Any:
@@ -1292,13 +1225,6 @@ def config_entry_attr(
return getattr(config_entry, attr_name)
def is_device_attr(
hass: HomeAssistant, device_or_entity_id: str, attr_name: str, attr_value: Any
) -> bool:
"""Test if a device's attribute is a specific value."""
return bool(device_attr(hass, device_or_entity_id, attr_name) == attr_value)
def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
"""Return all open issues."""
current_issues = ir.async_get(hass).issues
@@ -1318,74 +1244,6 @@ def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | N
return None
def floors(hass: HomeAssistant) -> Iterable[str | None]:
"""Return all floors."""
floor_registry = fr.async_get(hass)
return [floor.floor_id for floor in floor_registry.async_list_floors()]
def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
"""Get the floor ID from a floor or area name, alias, device id, or entity id."""
floor_registry = fr.async_get(hass)
lookup_str = str(lookup_value)
if floor := floor_registry.async_get_floor_by_name(lookup_str):
return floor.floor_id
floors_list = floor_registry.async_get_floors_by_alias(lookup_str)
if floors_list:
return floors_list[0].floor_id
if aid := area_id(hass, lookup_value):
area_reg = ar.async_get(hass)
if area := area_reg.async_get_area(aid):
return area.floor_id
return None
def floor_name(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the floor name from a floor id."""
floor_registry = fr.async_get(hass)
if floor := floor_registry.async_get_floor(lookup_value):
return floor.name
if aid := area_id(hass, lookup_value):
area_reg = ar.async_get(hass)
if (
(area := area_reg.async_get_area(aid))
and area.floor_id
and (floor := floor_registry.async_get_floor(area.floor_id))
):
return floor.name
return None
def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
"""Return area IDs for a given floor ID or name."""
_floor_id: str | None
# If floor_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early
if floor_name(hass, floor_id_or_name) is not None:
_floor_id = floor_id_or_name
else:
_floor_id = floor_id(hass, floor_id_or_name)
if _floor_id is None:
return []
area_reg = ar.async_get(hass)
entries = ar.async_entries_for_floor(area_reg, _floor_id)
return [entry.id for entry in entries if entry.id]
def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
"""Return entity_ids for a given floor ID or name."""
return [
entity_id
for area_id in floor_areas(hass, floor_id_or_name)
for entity_id in area_entities(hass, area_id)
]
def areas(hass: HomeAssistant) -> Iterable[str | None]:
"""Return all areas."""
return list(ar.async_get(hass).areas)
@@ -1393,37 +1251,7 @@ def areas(hass: HomeAssistant) -> Iterable[str | None]:
def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
"""Get the area ID from an area name, alias, device id, or entity id."""
area_reg = ar.async_get(hass)
lookup_str = str(lookup_value)
if area := area_reg.async_get_area_by_name(lookup_str):
return area.id
areas_list = area_reg.async_get_areas_by_alias(lookup_str)
if areas_list:
return areas_list[0].id
ent_reg = er.async_get(hass)
dev_reg = dr.async_get(hass)
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
# If entity has an area ID, return that
if entity.area_id:
return entity.area_id
# If entity has a device ID, return the area ID for the device
if entity.device_id and (device := dev_reg.async_get(entity.device_id)):
return device.area_id
# Check if this could be a device ID
if device := dev_reg.async_get(lookup_value):
return device.area_id
return None
return resolve_area_id(hass, lookup_value)
def _get_area_name(area_reg: ar.AreaRegistry, valid_area_id: str) -> str:
@@ -2359,6 +2187,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"homeassistant.helpers.template.extensions.CollectionExtension"
)
self.add_extension("homeassistant.helpers.template.extensions.CryptoExtension")
self.add_extension("homeassistant.helpers.template.extensions.DeviceExtension")
self.add_extension("homeassistant.helpers.template.extensions.FloorExtension")
self.add_extension("homeassistant.helpers.template.extensions.LabelExtension")
self.add_extension("homeassistant.helpers.template.extensions.MathExtension")
self.add_extension("homeassistant.helpers.template.extensions.RegexExtension")
@@ -2462,23 +2292,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["area_devices"] = hassfunction(area_devices)
self.filters["area_devices"] = self.globals["area_devices"]
# Floor extensions
self.globals["floors"] = hassfunction(floors)
self.filters["floors"] = self.globals["floors"]
self.globals["floor_id"] = hassfunction(floor_id)
self.filters["floor_id"] = self.globals["floor_id"]
self.globals["floor_name"] = hassfunction(floor_name)
self.filters["floor_name"] = self.globals["floor_name"]
self.globals["floor_areas"] = hassfunction(floor_areas)
self.filters["floor_areas"] = self.globals["floor_areas"]
self.globals["floor_entities"] = hassfunction(floor_entities)
self.filters["floor_entities"] = self.globals["floor_entities"]
# Integration extensions
self.globals["integration_entities"] = hassfunction(integration_entities)
@@ -2492,23 +2305,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["config_entry_id"] = hassfunction(config_entry_id)
self.filters["config_entry_id"] = self.globals["config_entry_id"]
# Device extensions
self.globals["device_name"] = hassfunction(device_name)
self.filters["device_name"] = self.globals["device_name"]
self.globals["device_attr"] = hassfunction(device_attr)
self.filters["device_attr"] = self.globals["device_attr"]
self.globals["device_entities"] = hassfunction(device_entities)
self.filters["device_entities"] = self.globals["device_entities"]
self.globals["is_device_attr"] = hassfunction(is_device_attr)
self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context)
self.globals["device_id"] = hassfunction(device_id)
self.filters["device_id"] = self.globals["device_id"]
# Issue extensions
self.globals["issues"] = hassfunction(issues)
@@ -2530,14 +2326,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"area_id",
"area_name",
"closest",
"device_attr",
"device_id",
"distance",
"expand",
"floor_id",
"floor_name",
"has_value",
"is_device_attr",
"is_hidden_entity",
"is_state_attr",
"is_state",
@@ -2555,10 +2346,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
"area_id",
"area_name",
"closest",
"device_id",
"expand",
"floor_id",
"floor_name",
"has_value",
]
hass_tests = [

View File

@@ -3,6 +3,8 @@
from .base64 import Base64Extension
from .collection import CollectionExtension
from .crypto import CryptoExtension
from .devices import DeviceExtension
from .floors import FloorExtension
from .labels import LabelExtension
from .math import MathExtension
from .regex import RegexExtension
@@ -12,6 +14,8 @@ __all__ = [
"Base64Extension",
"CollectionExtension",
"CryptoExtension",
"DeviceExtension",
"FloorExtension",
"LabelExtension",
"MathExtension",
"RegexExtension",

View File

@@ -0,0 +1,139 @@
"""Device functions for Home Assistant templates."""
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class DeviceExtension(BaseTemplateExtension):
"""Extension for device-related template functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the device extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"device_entities",
self.device_entities,
as_global=True,
as_filter=True,
requires_hass=True,
),
TemplateFunction(
"device_id",
self.device_id,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"device_name",
self.device_name,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"device_attr",
self.device_attr,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"is_device_attr",
self.is_device_attr,
as_global=True,
as_test=True,
requires_hass=True,
limited_ok=False,
),
],
)
def device_entities(self, _device_id: str) -> Iterable[str]:
"""Get entity ids for entities tied to a device."""
entity_reg = er.async_get(self.hass)
entries = er.async_entries_for_device(entity_reg, _device_id)
return [entry.entity_id for entry in entries]
def device_id(self, entity_id_or_device_name: str) -> str | None:
"""Get a device ID from an entity ID or device name."""
entity_reg = er.async_get(self.hass)
entity = entity_reg.async_get(entity_id_or_device_name)
if entity is not None:
return entity.device_id
dev_reg = dr.async_get(self.hass)
return next(
(
device_id
for device_id, device in dev_reg.devices.items()
if (name := device.name_by_user or device.name)
and (str(entity_id_or_device_name) == name)
),
None,
)
def device_name(self, lookup_value: str) -> str | None:
"""Get the device name from an device id, or entity id."""
device_reg = dr.async_get(self.hass)
if device := device_reg.async_get(lookup_value):
return device.name_by_user or device.name
ent_reg = er.async_get(self.hass)
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
if entity.device_id and (
device := device_reg.async_get(entity.device_id)
):
return device.name_by_user or device.name
return None
def device_attr(self, device_or_entity_id: str, attr_name: str) -> Any:
"""Get the device specific attribute."""
device_reg = dr.async_get(self.hass)
if not isinstance(device_or_entity_id, str):
raise TemplateError("Must provide a device or entity ID")
device = None
if (
"." in device_or_entity_id
and (_device_id := self.device_id(device_or_entity_id)) is not None
):
device = device_reg.async_get(_device_id)
elif "." not in device_or_entity_id:
device = device_reg.async_get(device_or_entity_id)
if device is None or not hasattr(device, attr_name):
return None
return getattr(device, attr_name)
def is_device_attr(
self, device_or_entity_id: str, attr_name: str, attr_value: Any
) -> bool:
"""Test if a device's attribute is a specific value."""
return bool(self.device_attr(device_or_entity_id, attr_name) == attr_value)

View File

@@ -0,0 +1,157 @@
"""Floor functions for Home Assistant templates."""
from __future__ import annotations
from collections.abc import Iterable
from typing import TYPE_CHECKING, Any
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
)
from homeassistant.helpers.template.helpers import resolve_area_id
from .base import BaseTemplateExtension, TemplateFunction
if TYPE_CHECKING:
from homeassistant.helpers.template import TemplateEnvironment
class FloorExtension(BaseTemplateExtension):
"""Extension for floor-related template functions."""
def __init__(self, environment: TemplateEnvironment) -> None:
"""Initialize the floor extension."""
super().__init__(
environment,
functions=[
TemplateFunction(
"floors",
self.floors,
as_global=True,
requires_hass=True,
),
TemplateFunction(
"floor_id",
self.floor_id,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"floor_name",
self.floor_name,
as_global=True,
as_filter=True,
requires_hass=True,
limited_ok=False,
),
TemplateFunction(
"floor_areas",
self.floor_areas,
as_global=True,
as_filter=True,
requires_hass=True,
),
TemplateFunction(
"floor_entities",
self.floor_entities,
as_global=True,
as_filter=True,
requires_hass=True,
),
],
)
def floors(self) -> Iterable[str | None]:
"""Return all floors."""
floor_registry = fr.async_get(self.hass)
return [floor.floor_id for floor in floor_registry.async_list_floors()]
def floor_id(self, lookup_value: Any) -> str | None:
"""Get the floor ID from a floor or area name, alias, device id, or entity id."""
floor_registry = fr.async_get(self.hass)
lookup_str = str(lookup_value)
# Check if it's a floor name or alias
if floor := floor_registry.async_get_floor_by_name(lookup_str):
return floor.floor_id
floors_list = floor_registry.async_get_floors_by_alias(lookup_str)
if floors_list:
return floors_list[0].floor_id
# Resolve to area ID and get floor from area
if aid := resolve_area_id(self.hass, lookup_value):
area_reg = ar.async_get(self.hass)
if area := area_reg.async_get_area(aid):
return area.floor_id
return None
def floor_name(self, lookup_value: str) -> str | None:
"""Get the floor name from a floor id."""
floor_registry = fr.async_get(self.hass)
# Check if it's a floor ID
if floor := floor_registry.async_get_floor(lookup_value):
return floor.name
# Resolve to area ID and get floor name from area's floor
if aid := resolve_area_id(self.hass, lookup_value):
area_reg = ar.async_get(self.hass)
if (
(area := area_reg.async_get_area(aid))
and area.floor_id
and (floor := floor_registry.async_get_floor(area.floor_id))
):
return floor.name
return None
def _floor_id_or_name(self, floor_id_or_name: str) -> str | None:
"""Get the floor ID from a floor name or ID."""
# If floor_name returns a value, we know the input was an ID, otherwise we
# assume it's a name, and if it's neither, we return early.
if self.floor_name(floor_id_or_name) is not None:
return floor_id_or_name
return self.floor_id(floor_id_or_name)
def floor_areas(self, floor_id_or_name: str) -> Iterable[str]:
"""Return area IDs for a given floor ID or name."""
if (_floor_id := self._floor_id_or_name(floor_id_or_name)) is None:
return []
area_reg = ar.async_get(self.hass)
entries = ar.async_entries_for_floor(area_reg, _floor_id)
return [entry.id for entry in entries if entry.id]
def floor_entities(self, floor_id_or_name: str) -> Iterable[str]:
"""Return entity_ids for a given floor ID or name."""
ent_reg = er.async_get(self.hass)
dev_reg = dr.async_get(self.hass)
entity_ids = []
for area_id in self.floor_areas(floor_id_or_name):
# Get entities directly assigned to the area
entity_ids.extend(
[
entry.entity_id
for entry in er.async_entries_for_area(ent_reg, area_id)
]
)
# Also add entities tied to a device in the area that don't themselves
# have an area specified since they inherit the area from the device
entity_ids.extend(
[
entity.entity_id
for device in dr.async_entries_for_area(dev_reg, area_id)
for entity in er.async_entries_for_device(ent_reg, device.id)
if entity.area_id is None
]
)
return entity_ids

View File

@@ -2,10 +2,21 @@
from __future__ import annotations
from typing import Any, NoReturn
from typing import TYPE_CHECKING, Any, NoReturn
import voluptuous as vol
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
)
from .context import template_cv
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
def raise_no_default(function: str, value: Any) -> NoReturn:
"""Raise ValueError when no default is specified for template functions."""
@@ -14,3 +25,47 @@ def raise_no_default(function: str, value: Any) -> NoReturn:
f"Template error: {function} got invalid input '{value}' when {action} template"
f" '{template}' but no default was specified"
)
def resolve_area_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
"""Resolve lookup value to an area ID.
Accepts area name, area alias, device ID, or entity ID.
Returns the area ID or None if not found.
"""
area_reg = ar.async_get(hass)
dev_reg = dr.async_get(hass)
ent_reg = er.async_get(hass)
lookup_str = str(lookup_value)
# Check if it's an area name
if area := area_reg.async_get_area_by_name(lookup_str):
return area.id
# Check if it's an area alias
areas_list = area_reg.async_get_areas_by_alias(lookup_str)
if areas_list:
return areas_list[0].id
# Import here, not at top-level to avoid circular import
from homeassistant.helpers import config_validation as cv # noqa: PLC0415
# Check if it's an entity ID
try:
cv.entity_id(lookup_value)
except vol.Invalid:
pass
else:
if entity := ent_reg.async_get(lookup_value):
# If entity has an area ID, return that
if entity.area_id:
return entity.area_id
# If entity has a device ID, return the area ID for the device
if entity.device_id and (device := dev_reg.async_get(entity.device_id)):
return device.area_id
# Check if it's a device ID
if device := dev_reg.async_get(lookup_value):
return device.area_id
return None

View File

@@ -13,7 +13,7 @@ aiozoneinfo==0.2.3
annotatedyaml==1.0.2
astral==2.2
async-interrupt==1.2.2
async-upnp-client==0.45.0
async-upnp-client==0.46.0
atomicwrites-homeassistant==1.4.1
attrs==25.4.0
audioop-lts==0.2.1

10
mypy.ini generated
View File

@@ -2066,6 +2066,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.google_weather.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.govee_ble.*]
check_untyped_defs = true
disallow_incomplete_defs = true

16
requirements_all.txt generated
View File

@@ -145,7 +145,7 @@ adb-shell[async]==0.4.4
adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.7.0
adguardhome==0.8.0
# homeassistant.components.advantage_air
advantage-air==0.4.4
@@ -541,7 +541,7 @@ asusrouter==1.21.0
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.45.0
async-upnp-client==0.46.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -1625,7 +1625,7 @@ open-meteo==0.3.2
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
openai==2.2.0
openai==2.8.0
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -2339,6 +2339,9 @@ pysabnzbd==1.1.1
# homeassistant.components.saj
pysaj==0.0.16
# homeassistant.components.saunum
pysaunum==0.1.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2469,6 +2472,9 @@ python-gitlab==1.6.0
# homeassistant.components.google_drive
python-google-drive-api==0.1.0
# homeassistant.components.google_weather
python-google-weather-api==0.0.4
# homeassistant.components.analytics_insights
python-homeassistant-analytics==0.9.0
@@ -2564,7 +2570,7 @@ python-xbox==0.1.1
pythonegardia==1.0.52
# homeassistant.components.uptime_kuma
pythonkuma==0.3.1
pythonkuma==0.3.2
# homeassistant.components.tile
pytile==2024.12.0
@@ -2999,7 +3005,7 @@ total-connect-client==2025.5
tp-connected==0.0.4
# homeassistant.components.tplink_omada
tplink-omada-client==1.4.4
tplink-omada-client==1.5.3
# homeassistant.components.transmission
transmission-rpc==7.0.3

View File

@@ -133,7 +133,7 @@ adb-shell[async]==0.4.4
adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.7.0
adguardhome==0.8.0
# homeassistant.components.advantage_air
advantage-air==0.4.4
@@ -505,7 +505,7 @@ asusrouter==1.21.0
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
async-upnp-client==0.45.0
async-upnp-client==0.46.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -1396,7 +1396,7 @@ open-meteo==0.3.2
# homeassistant.components.open_router
# homeassistant.components.openai_conversation
openai==2.2.0
openai==2.8.0
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1950,6 +1950,9 @@ pyrympro==0.0.9
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
# homeassistant.components.saunum
pysaunum==0.1.0
# homeassistant.components.schlage
pyschlage==2025.9.0
@@ -2044,6 +2047,9 @@ python-fullykiosk==0.0.14
# homeassistant.components.google_drive
python-google-drive-api==0.1.0
# homeassistant.components.google_weather
python-google-weather-api==0.0.4
# homeassistant.components.analytics_insights
python-homeassistant-analytics==0.9.0
@@ -2124,7 +2130,7 @@ python-telegram-bot[socks]==22.1
python-xbox==0.1.1
# homeassistant.components.uptime_kuma
pythonkuma==0.3.1
pythonkuma==0.3.2
# homeassistant.components.tile
pytile==2024.12.0
@@ -2472,7 +2478,7 @@ toonapi==0.3.0
total-connect-client==2025.5
# homeassistant.components.tplink_omada
tplink-omada-client==1.4.4
tplink-omada-client==1.5.3
# homeassistant.components.transmission
transmission-rpc==7.0.3

View File

@@ -130,9 +130,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"pbr": {"setuptools"}
},
"delijn": {"pydelijn": {"async-timeout"}},
"devialet": {"async-upnp-client": {"async-timeout"}},
"dlna_dmr": {"async-upnp-client": {"async-timeout"}},
"dlna_dms": {"async-upnp-client": {"async-timeout"}},
"efergy": {
# https://github.com/tkdrob/pyefergy/issues/46
# pyefergy > codecov
@@ -195,7 +192,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"lifx": {"aiolifx": {"async-timeout"}},
"linkplay": {
"python-linkplay": {"async-timeout"},
"async-upnp-client": {"async-timeout"},
},
"loqed": {"loqedapi": {"async-timeout"}},
"matter": {"python-matter-server": {"async-timeout"}},
@@ -221,7 +217,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"nibe_heatpump": {"nibe": {"async-timeout"}},
"norway_air": {"pymetno": {"async-timeout"}},
"opengarage": {"open-garage": {"async-timeout"}},
"openhome": {"async-upnp-client": {"async-timeout"}},
"opensensemap": {"opensensemap-api": {"async-timeout"}},
"opnsense": {
# https://github.com/mtreinish/pyopnsense/issues/27
@@ -236,12 +231,9 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
},
"ring": {"ring-doorbell": {"async-timeout"}},
"rmvtransport": {"pyrmvtransport": {"async-timeout"}},
"samsungtv": {"async-upnp-client": {"async-timeout"}},
"screenlogic": {"screenlogicpy": {"async-timeout"}},
"sense": {"sense-energy": {"async-timeout"}},
"slimproto": {"aioslimproto": {"async-timeout"}},
"songpal": {"async-upnp-client": {"async-timeout"}},
"ssdp": {"async-upnp-client": {"async-timeout"}},
"surepetcare": {"surepy": {"async-timeout"}},
"travisci": {
# https://github.com/menegazzo/travispy seems to be unmaintained
@@ -252,10 +244,8 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
"travispy": {"pytest"},
},
"unifiprotect": {"uiprotect": {"async-timeout"}},
"upnp": {"async-upnp-client": {"async-timeout"}},
"volkszaehler": {"volkszaehler": {"async-timeout"}},
"whirlpool": {"whirlpool-sixth-sense": {"async-timeout"}},
"yeelight": {"async-upnp-client": {"async-timeout"}},
"zamg": {"zamg": {"async-timeout"}},
"zha": {
# https://github.com/waveform80/colorzero/issues/9

View File

@@ -1,11 +1,14 @@
"""Tests helpers."""
from collections.abc import AsyncGenerator, Generator, Iterable
import datetime
from unittest.mock import AsyncMock, patch
from anthropic.pagination import AsyncPage
from anthropic.types import (
Message,
MessageDeltaUsage,
ModelInfo,
RawContentBlockStartEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
@@ -123,7 +126,72 @@ async def mock_init_component(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> AsyncGenerator[None]:
"""Initialize integration."""
with patch("anthropic.resources.models.AsyncModels.retrieve"):
model_list = AsyncPage(
data=[
ModelInfo(
id="claude-haiku-4-5-20251001",
created_at=datetime.datetime(2025, 10, 15, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Haiku 4.5",
type="model",
),
ModelInfo(
id="claude-sonnet-4-5-20250929",
created_at=datetime.datetime(2025, 9, 29, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Sonnet 4.5",
type="model",
),
ModelInfo(
id="claude-opus-4-1-20250805",
created_at=datetime.datetime(2025, 8, 5, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Opus 4.1",
type="model",
),
ModelInfo(
id="claude-opus-4-20250514",
created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Opus 4",
type="model",
),
ModelInfo(
id="claude-sonnet-4-20250514",
created_at=datetime.datetime(2025, 5, 22, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Sonnet 4",
type="model",
),
ModelInfo(
id="claude-3-7-sonnet-20250219",
created_at=datetime.datetime(2025, 2, 24, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Sonnet 3.7",
type="model",
),
ModelInfo(
id="claude-3-5-haiku-20241022",
created_at=datetime.datetime(2024, 10, 22, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Haiku 3.5",
type="model",
),
ModelInfo(
id="claude-3-haiku-20240307",
created_at=datetime.datetime(2024, 3, 7, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Haiku 3",
type="model",
),
ModelInfo(
id="claude-3-opus-20240229",
created_at=datetime.datetime(2024, 2, 29, 0, 0, tzinfo=datetime.UTC),
display_name="Claude Opus 3",
type="model",
),
]
)
with (
patch("anthropic.resources.models.AsyncModels.retrieve"),
patch(
"anthropic.resources.models.AsyncModels.list",
new_callable=AsyncMock,
return_value=model_list,
),
):
assert await async_setup_component(hass, "anthropic", {})
await hass.async_block_till_done()
yield

View File

@@ -339,6 +339,99 @@ async def test_subentry_web_search_user_location(
}
async def test_model_list(
hass: HomeAssistant, mock_config_entry, mock_init_component
) -> None:
"""Test fetching and processing the list of models."""
subentry = next(iter(mock_config_entry.subentries.values()))
options_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
# Configure initial step
options = await hass.config_entries.subentries.async_configure(
options_flow["flow_id"],
{
"prompt": "You are a helpful assistant",
"recommended": False,
},
)
assert options["type"] == FlowResultType.FORM
assert options["step_id"] == "advanced"
assert options["data_schema"].schema["chat_model"].config["options"] == [
{
"label": "Claude Haiku 4.5",
"value": "claude-haiku-4-5",
},
{
"label": "Claude Sonnet 4.5",
"value": "claude-sonnet-4-5",
},
{
"label": "Claude Opus 4.1",
"value": "claude-opus-4-1",
},
{
"label": "Claude Opus 4",
"value": "claude-opus-4-0",
},
{
"label": "Claude Sonnet 4",
"value": "claude-sonnet-4-0",
},
{
"label": "Claude Sonnet 3.7",
"value": "claude-3-7-sonnet",
},
{
"label": "Claude Haiku 3.5",
"value": "claude-3-5-haiku",
},
{
"label": "Claude Haiku 3",
"value": "claude-3-haiku-20240307",
},
{
"label": "Claude Opus 3",
"value": "claude-3-opus-20240229",
},
]
async def test_model_list_error(
hass: HomeAssistant, mock_config_entry, mock_init_component
) -> None:
"""Test exception handling during fetching the list of models."""
subentry = next(iter(mock_config_entry.subentries.values()))
options_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
# Configure initial step
with patch(
"homeassistant.components.anthropic.config_flow.anthropic.resources.models.AsyncModels.list",
new_callable=AsyncMock,
side_effect=InternalServerError(
message=None,
response=Response(
status_code=500,
request=Request(method="POST", url=URL()),
),
body=None,
),
):
options = await hass.config_entries.subentries.async_configure(
options_flow["flow_id"],
{
"prompt": "You are a helpful assistant",
"recommended": False,
},
)
assert options["type"] == FlowResultType.FORM
assert options["step_id"] == "advanced"
assert options["data_schema"].schema["chat_model"].config["options"] == []
@pytest.mark.parametrize(
("current_options", "new_options", "expected_options"),
[

View File

@@ -1 +1,58 @@
"""Tests for the blueprint init."""
from pathlib import Path
from unittest.mock import patch
from homeassistant.components import automation, blueprint
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry
async def test_find_relevant_blueprints(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test finding relevant blueprints."""
config_entry = MockConfigEntry()
config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("test_domain", "test_device")},
name="Test Device",
)
entity_registry.async_get_or_create(
"person",
"test_domain",
"test_entity",
device_id=device.id,
original_name="Test Person",
)
with patch.object(
hass.config,
"path",
return_value=Path(automation.__file__).parent / "blueprints",
):
automation.async_get_blueprints(hass)
results = await blueprint.async_find_relevant_blueprints(hass, device.id)
for matches in results.values():
for match in matches:
match["blueprint"] = match["blueprint"].name
assert results == {
"automation": [
{
"blueprint": "Motion-activated Light",
"matched_input": {
"Person": [
"person.test_domain_test_entity",
]
},
}
]
}

View File

@@ -222,6 +222,7 @@ def setup_http_response(mock_http_response: Mock) -> None:
"status": "needsAction",
"position": "0000000000000001",
"due": "2023-11-18T00:00:00Z",
"updated": "2023-11-10T23:00:00.333Z",
},
{
"id": "task-2",
@@ -229,6 +230,8 @@ def setup_http_response(mock_http_response: Mock) -> None:
"status": "completed",
"position": "0000000000000002",
"notes": "long description",
"updated": "2023-11-12T12:31:04.132Z",
"completed": "2023-11-12T12:31:04.132Z",
},
],
},
@@ -262,6 +265,7 @@ async def test_get_items(
"uid": "task-2",
"summary": "Task 2",
"status": "completed",
"completed": "2023-11-12T12:31:04.132000+00:00",
"description": "long description",
},
]

View File

@@ -0,0 +1 @@
"""Tests for the Google Weather integration."""

View File

@@ -0,0 +1,83 @@
"""Common fixtures for the Google Weather tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from google_weather_api import (
CurrentConditionsResponse,
DailyForecastResponse,
HourlyForecastResponse,
)
import pytest
from homeassistant.components.google_weather.const import DOMAIN
from homeassistant.config_entries import ConfigSubentryDataWithId
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.google_weather.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return the default mocked config entry."""
config_entry = MockConfigEntry(
title="Google Weather",
domain=DOMAIN,
data={
CONF_API_KEY: "test-api-key",
},
subentries_data=[
ConfigSubentryDataWithId(
data={
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
subentry_type="location",
title="Home",
subentry_id="home-subentry-id",
unique_id=None,
)
],
)
config_entry.add_to_hass(hass)
return config_entry
@pytest.fixture
def mock_google_weather_api() -> Generator[AsyncMock]:
"""Mock Google Weather API."""
current_conditions = CurrentConditionsResponse.from_dict(
load_json_object_fixture("current_conditions.json", DOMAIN)
)
daily_forecast = DailyForecastResponse.from_dict(
load_json_object_fixture("daily_forecast.json", DOMAIN)
)
hourly_forecast = HourlyForecastResponse.from_dict(
load_json_object_fixture("hourly_forecast.json", DOMAIN)
)
with (
patch(
"homeassistant.components.google_weather.GoogleWeatherApi", autospec=True
) as mock_api,
patch(
"homeassistant.components.google_weather.config_flow.GoogleWeatherApi",
new=mock_api,
),
):
api = mock_api.return_value
api.async_get_current_conditions.return_value = current_conditions
api.async_get_daily_forecast.return_value = daily_forecast
api.async_get_hourly_forecast.return_value = hourly_forecast
yield api

View File

@@ -0,0 +1,88 @@
{
"currentTime": "2025-01-28T22:04:12.025273178Z",
"timeZone": {
"id": "America/Los_Angeles"
},
"isDaytime": true,
"weatherCondition": {
"iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny",
"description": {
"text": "Sunny",
"languageCode": "en"
},
"type": "CLEAR"
},
"temperature": {
"degrees": 13.7,
"unit": "CELSIUS"
},
"feelsLikeTemperature": {
"degrees": 13.1,
"unit": "CELSIUS"
},
"dewPoint": {
"degrees": 1.1,
"unit": "CELSIUS"
},
"heatIndex": {
"degrees": 13.7,
"unit": "CELSIUS"
},
"windChill": {
"degrees": 13.1,
"unit": "CELSIUS"
},
"relativeHumidity": 42,
"uvIndex": 1,
"precipitation": {
"probability": {
"percent": 0,
"type": "RAIN"
},
"qpf": {
"quantity": 0,
"unit": "MILLIMETERS"
}
},
"thunderstormProbability": 0,
"airPressure": {
"meanSeaLevelMillibars": 1019.16
},
"wind": {
"direction": {
"degrees": 335,
"cardinal": "NORTH_NORTHWEST"
},
"speed": {
"value": 8,
"unit": "KILOMETERS_PER_HOUR"
},
"gust": {
"value": 18,
"unit": "KILOMETERS_PER_HOUR"
}
},
"visibility": {
"distance": 16,
"unit": "KILOMETERS"
},
"cloudCover": 0,
"currentConditionsHistory": {
"temperatureChange": {
"degrees": -0.6,
"unit": "CELSIUS"
},
"maxTemperature": {
"degrees": 14.3,
"unit": "CELSIUS"
},
"minTemperature": {
"degrees": 3.7,
"unit": "CELSIUS"
},
"qpf": {
"quantity": 0,
"unit": "MILLIMETERS"
}
}
}

View File

@@ -0,0 +1,135 @@
{
"forecastDays": [
{
"interval": {
"startTime": "2025-02-10T15:00:00Z",
"endTime": "2025-02-11T15:00:00Z"
},
"displayDate": {
"year": 2025,
"month": 2,
"day": 10
},
"daytimeForecast": {
"interval": {
"startTime": "2025-02-10T15:00:00Z",
"endTime": "2025-02-11T03:00:00Z"
},
"weatherCondition": {
"iconBaseUri": "https://maps.gstatic.com/weather/v1/party_cloudy",
"description": {
"text": "Partly sunny",
"languageCode": "en"
},
"type": "PARTLY_CLOUDY"
},
"relativeHumidity": 54,
"uvIndex": 3,
"precipitation": {
"probability": {
"percent": 5,
"type": "RAIN"
},
"qpf": {
"quantity": 0,
"unit": "MILLIMETERS"
}
},
"thunderstormProbability": 0,
"wind": {
"direction": {
"degrees": 280,
"cardinal": "WEST"
},
"speed": {
"value": 6,
"unit": "KILOMETERS_PER_HOUR"
},
"gust": {
"value": 14,
"unit": "KILOMETERS_PER_HOUR"
}
},
"cloudCover": 53
},
"nighttimeForecast": {
"interval": {
"startTime": "2025-02-11T03:00:00Z",
"endTime": "2025-02-11T15:00:00Z"
},
"weatherCondition": {
"iconBaseUri": "https://maps.gstatic.com/weather/v1/partly_clear",
"description": {
"text": "Partly cloudy",
"languageCode": "en"
},
"type": "PARTLY_CLOUDY"
},
"relativeHumidity": 85,
"uvIndex": 0,
"precipitation": {
"probability": {
"percent": 10,
"type": "RAIN_AND_SNOW"
},
"qpf": {
"quantity": 0,
"unit": "MILLIMETERS"
}
},
"thunderstormProbability": 0,
"wind": {
"direction": {
"degrees": 201,
"cardinal": "SOUTH_SOUTHWEST"
},
"speed": {
"value": 6,
"unit": "KILOMETERS_PER_HOUR"
},
"gust": {
"value": 14,
"unit": "KILOMETERS_PER_HOUR"
}
},
"cloudCover": 70
},
"maxTemperature": {
"degrees": 13.3,
"unit": "CELSIUS"
},
"minTemperature": {
"degrees": 1.5,
"unit": "CELSIUS"
},
"feelsLikeMaxTemperature": {
"degrees": 13.3,
"unit": "CELSIUS"
},
"feelsLikeMinTemperature": {
"degrees": 1.5,
"unit": "CELSIUS"
},
"sunEvents": {
"sunriseTime": "2025-02-10T15:02:35.703929582Z",
"sunsetTime": "2025-02-11T01:43:00.762932858Z"
},
"moonEvents": {
"moonPhase": "WAXING_GIBBOUS",
"moonriseTimes": ["2025-02-10T23:54:17.713157984Z"],
"moonsetTimes": ["2025-02-10T14:13:58.625181191Z"]
},
"maxHeatIndex": {
"degrees": 13.3,
"unit": "CELSIUS"
},
"iceThickness": {
"thickness": 0,
"unit": "MILLIMETERS"
}
}
],
"timeZone": {
"id": "America/Los_Angeles"
}
}

View File

@@ -0,0 +1,92 @@
{
"forecastHours": [
{
"interval": {
"startTime": "2025-02-05T23:00:00Z",
"endTime": "2025-02-06T00:00:00Z"
},
"displayDateTime": {
"year": 2025,
"month": 2,
"day": 5,
"hours": 15,
"utcOffset": "-28800s"
},
"isDaytime": true,
"weatherCondition": {
"iconBaseUri": "https://maps.gstatic.com/weather/v1/sunny",
"description": {
"text": "Sunny",
"languageCode": "en"
},
"type": "CLEAR"
},
"temperature": {
"degrees": 12.7,
"unit": "CELSIUS"
},
"feelsLikeTemperature": {
"degrees": 12,
"unit": "CELSIUS"
},
"dewPoint": {
"degrees": 2.7,
"unit": "CELSIUS"
},
"heatIndex": {
"degrees": 12.7,
"unit": "CELSIUS"
},
"windChill": {
"degrees": 12,
"unit": "CELSIUS"
},
"wetBulbTemperature": {
"degrees": 7.7,
"unit": "CELSIUS"
},
"relativeHumidity": 51,
"uvIndex": 1,
"precipitation": {
"probability": {
"percent": 0,
"type": "RAIN"
},
"qpf": {
"quantity": 0,
"unit": "MILLIMETERS"
}
},
"thunderstormProbability": 0,
"airPressure": {
"meanSeaLevelMillibars": 1019.13
},
"wind": {
"direction": {
"degrees": 335,
"cardinal": "NORTH_NORTHWEST"
},
"speed": {
"value": 10,
"unit": "KILOMETERS_PER_HOUR"
},
"gust": {
"value": 19,
"unit": "KILOMETERS_PER_HOUR"
}
},
"visibility": {
"distance": 16,
"unit": "KILOMETERS"
},
"cloudCover": 0,
"iceThickness": {
"thickness": 0,
"unit": "MILLIMETERS"
}
}
],
"timeZone": {
"id": "America/Los_Angeles"
}
}

View File

@@ -0,0 +1,191 @@
# serializer version: 1
# name: test_forecast_service[daily]
dict({
'weather.home': dict({
'forecast': list([
dict({
'apparent_temperature': 13.3,
'cloud_coverage': 53,
'condition': 'partlycloudy',
'datetime': '2025-02-10T15:00:00Z',
'humidity': 54,
'precipitation': 0.0,
'precipitation_probability': 10,
'temperature': 13.3,
'templow': 1.5,
'uv_index': 3,
'wind_bearing': 280,
'wind_gust_speed': 14.0,
'wind_speed': 6.0,
}),
]),
}),
})
# ---
# name: test_forecast_service[hourly]
dict({
'weather.home': dict({
'forecast': list([
dict({
'apparent_temperature': 12.0,
'cloud_coverage': 0,
'condition': 'sunny',
'datetime': '2025-02-05T23:00:00Z',
'dew_point': 2.7,
'humidity': 51,
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 0,
'pressure': 1019.13,
'temperature': 12.7,
'uv_index': 1,
'wind_bearing': 335,
'wind_gust_speed': 19.0,
'wind_speed': 10.0,
}),
]),
}),
})
# ---
# name: test_forecast_service[twice_daily]
dict({
'weather.home': dict({
'forecast': list([
dict({
'apparent_temperature': 13.3,
'cloud_coverage': 53,
'condition': 'partlycloudy',
'datetime': '2025-02-10T15:00:00Z',
'humidity': 54,
'is_daytime': True,
'precipitation': 0.0,
'precipitation_probability': 5,
'temperature': 13.3,
'uv_index': 3,
'wind_bearing': 280,
'wind_gust_speed': 14.0,
'wind_speed': 6.0,
}),
dict({
'apparent_temperature': 1.5,
'cloud_coverage': 70,
'condition': 'partlycloudy',
'datetime': '2025-02-11T03:00:00Z',
'humidity': 85,
'is_daytime': False,
'precipitation': 0.0,
'precipitation_probability': 10,
'temperature': 1.5,
'uv_index': 0,
'wind_bearing': 201,
'wind_gust_speed': 14.0,
'wind_speed': 6.0,
}),
]),
}),
})
# ---
# name: test_forecast_subscription
list([
dict({
'apparent_temperature': 13.3,
'cloud_coverage': 53,
'condition': 'partlycloudy',
'datetime': '2025-02-10T15:00:00Z',
'humidity': 54,
'precipitation': 0.0,
'precipitation_probability': 10,
'temperature': 13.3,
'templow': 1.5,
'uv_index': 3,
'wind_bearing': 280,
'wind_gust_speed': 14.0,
'wind_speed': 6.0,
}),
])
# ---
# name: test_forecast_subscription.1
list([
dict({
'apparent_temperature': 13.3,
'cloud_coverage': 53,
'condition': 'partlycloudy',
'datetime': '2025-02-10T15:00:00Z',
'humidity': 54,
'precipitation': 0.0,
'precipitation_probability': 10,
'temperature': 13.3,
'templow': 1.5,
'uv_index': 3,
'wind_bearing': 280,
'wind_gust_speed': 14.0,
'wind_speed': 6.0,
}),
])
# ---
# name: test_weather[weather.home-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'weather',
'entity_category': None,
'entity_id': 'weather.home',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'google_weather',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <WeatherEntityFeature: 7>,
'translation_key': None,
'unique_id': 'home-subentry-id',
'unit_of_measurement': None,
})
# ---
# name: test_weather[weather.home-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'apparent_temperature': 13.1,
'attribution': 'Data from Google Weather',
'cloud_coverage': 0.0,
'dew_point': 1.1,
'friendly_name': 'Home',
'humidity': 42,
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure': 1019.16,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': <WeatherEntityFeature: 7>,
'temperature': 13.7,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'uv_index': 1.0,
'visibility': 16.0,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 335,
'wind_gust_speed': 18.0,
'wind_speed': 8.0,
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'weather.home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'sunny',
})
# ---

View File

@@ -0,0 +1,375 @@
"""Test the Google Weather config flow."""
from unittest.mock import AsyncMock
from google_weather_api import GoogleWeatherApiError
import pytest
from homeassistant import config_entries
from homeassistant.components.google_weather.const import (
CONF_REFERRER,
DOMAIN,
SECTION_API_KEY_OPTIONS,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry, get_schema_suggested_value
def _assert_create_entry_result(
result: dict, expected_referrer: str | None = None
) -> None:
"""Assert that the result is a create entry result."""
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Google Weather"
assert result["data"] == {
CONF_API_KEY: "test-api-key",
CONF_REFERRER: expected_referrer,
}
assert len(result["subentries"]) == 1
subentry = result["subentries"][0]
assert subentry["subentry_type"] == "location"
assert subentry["title"] == "test-name"
assert subentry["data"] == {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
}
async def test_create_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test creating a config entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "test-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
},
)
mock_google_weather_api.async_get_current_conditions.assert_called_once_with(
latitude=10.1, longitude=20.1
)
_assert_create_entry_result(result)
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_with_referrer(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test we get the form and optional referrer is specified."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "test-api-key",
SECTION_API_KEY_OPTIONS: {
CONF_REFERRER: "test-referrer",
},
CONF_LOCATION: {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
},
)
mock_google_weather_api.async_get_current_conditions.assert_called_once_with(
latitude=10.1, longitude=20.1
)
_assert_create_entry_result(result, expected_referrer="test-referrer")
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("api_exception", "expected_error"),
[
(GoogleWeatherApiError(), "cannot_connect"),
(ValueError(), "unknown"),
],
)
async def test_form_exceptions(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_google_weather_api: AsyncMock,
api_exception,
expected_error,
) -> None:
"""Test we handle exceptions."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
mock_google_weather_api.async_get_current_conditions.side_effect = api_exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "test-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": expected_error}
# On error, the form should have the previous user input
data_schema = result["data_schema"].schema
assert get_schema_suggested_value(data_schema, CONF_NAME) == "test-name"
assert get_schema_suggested_value(data_schema, CONF_API_KEY) == "test-api-key"
assert get_schema_suggested_value(data_schema, CONF_LOCATION) == {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
}
# Make sure the config flow tests finish with either an
# FlowResultType.CREATE_ENTRY or FlowResultType.ABORT so
# we can show the config flow is able to recover from an error.
mock_google_weather_api.async_get_current_conditions.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "test-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
},
)
_assert_create_entry_result(result)
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_api_key_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test user input for config_entry with API key that already exists."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "test-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.2,
CONF_LONGITUDE: 20.2,
},
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_google_weather_api.async_get_current_conditions.call_count == 0
async def test_form_location_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test user input for a location that already exists."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "test-name",
CONF_API_KEY: "another-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.1001,
CONF_LONGITUDE: 20.0999,
},
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_google_weather_api.async_get_current_conditions.call_count == 0
async def test_form_not_already_configured(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test user input for config_entry different than the existing one."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "new-test-name",
CONF_API_KEY: "new-test-api-key",
CONF_LOCATION: {
CONF_LATITUDE: 10.1002,
CONF_LONGITUDE: 20.0998,
},
},
)
mock_google_weather_api.async_get_current_conditions.assert_called_once_with(
latitude=10.1002, longitude=20.0998
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Google Weather"
assert result["data"] == {
CONF_API_KEY: "new-test-api-key",
CONF_REFERRER: None,
}
assert len(result["subentries"]) == 1
subentry = result["subentries"][0]
assert subentry["subentry_type"] == "location"
assert subentry["title"] == "new-test-name"
assert subentry["data"] == {
CONF_LATITUDE: 10.1002,
CONF_LONGITUDE: 20.0998,
}
assert len(mock_setup_entry.mock_calls) == 2
async def test_subentry_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test creating a location subentry."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
# After initial setup for 1 subentry, each API is called once
assert mock_google_weather_api.async_get_current_conditions.call_count == 1
assert mock_google_weather_api.async_get_daily_forecast.call_count == 1
assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "location"),
context={"source": "user"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "location"
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_NAME: "Work",
CONF_LOCATION: {
CONF_LATITUDE: 30.1,
CONF_LONGITUDE: 40.1,
},
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Work"
assert result2["data"] == {
CONF_LATITUDE: 30.1,
CONF_LONGITUDE: 40.1,
}
# Initial setup: 1 of each API call
# Subentry flow validation: 1 current conditions call
# Reload with 2 subentries: 2 of each API call
assert mock_google_weather_api.async_get_current_conditions.call_count == 1 + 1 + 2
assert mock_google_weather_api.async_get_daily_forecast.call_count == 1 + 2
assert mock_google_weather_api.async_get_hourly_forecast.call_count == 1 + 2
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert len(entry.subentries) == 2
async def test_subentry_flow_location_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test user input for a location that already exists."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "location"),
context={"source": "user"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "location"
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_NAME: "Work",
CONF_LOCATION: {
CONF_LATITUDE: 10.1,
CONF_LONGITUDE: 20.1,
},
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert len(entry.subentries) == 1
async def test_subentry_flow_entry_not_loaded(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test creating a location subentry when the parent entry is not loaded."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.config_entries.async_unload(mock_config_entry.entry_id)
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "location"),
context={"source": "user"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "entry_not_loaded"

View File

@@ -0,0 +1,69 @@
"""Test init of Google Weather integration."""
from unittest.mock import AsyncMock
from google_weather_api import GoogleWeatherApiError
import pytest
from homeassistant.components.google_weather.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_async_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test a successful setup entry."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
state = hass.states.get("weather.home")
assert state is not None
assert state.state != STATE_UNAVAILABLE
assert state.state == "sunny"
@pytest.mark.parametrize(
"failing_api_method",
[
"async_get_current_conditions",
"async_get_daily_forecast",
"async_get_hourly_forecast",
],
)
async def test_config_not_ready(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
failing_api_method: str,
) -> None:
"""Test for setup failure if an API call fails."""
getattr(
mock_google_weather_api, failing_api_method
).side_effect = GoogleWeatherApiError()
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test successful unload of entry."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED

View File

@@ -0,0 +1,214 @@
"""Test weather of Google Weather integration."""
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from google_weather_api import GoogleWeatherApiError, WeatherCondition
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.google_weather.weather import _CONDITION_MAP
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_SUNNY,
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
from tests.typing import WebSocketGenerator
async def test_weather(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test states of the weather."""
with patch(
"homeassistant.components.google_weather._PLATFORMS", [Platform.WEATHER]
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_availability(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Ensure that we mark the entities unavailable correctly when service is offline."""
entity_id = "weather.home"
await hass.config_entries.async_setup(mock_config_entry.entry_id)
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "sunny"
mock_google_weather_api.async_get_current_conditions.side_effect = (
GoogleWeatherApiError()
)
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
# Reset side effect, return a valid response again
mock_google_weather_api.async_get_current_conditions.side_effect = None
freezer.tick(timedelta(minutes=15))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
assert state.state == "sunny"
mock_google_weather_api.async_get_current_conditions.assert_called_with(
latitude=10.1, longitude=20.1
)
async def test_manual_update_entity(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test manual update entity via service homeassistant/update_entity."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await async_setup_component(hass, "homeassistant", {})
assert mock_google_weather_api.async_get_current_conditions.call_count == 1
mock_google_weather_api.async_get_current_conditions.assert_called_with(
latitude=10.1, longitude=20.1
)
await hass.services.async_call(
"homeassistant",
"update_entity",
{ATTR_ENTITY_ID: ["weather.home"]},
blocking=True,
)
assert mock_google_weather_api.async_get_current_conditions.call_count == 2
@pytest.mark.parametrize(
("api_condition", "is_daytime", "expected_ha_condition"),
[
(WeatherCondition.Type.CLEAR, True, ATTR_CONDITION_SUNNY),
(WeatherCondition.Type.CLEAR, False, ATTR_CONDITION_CLEAR_NIGHT),
(WeatherCondition.Type.PARTLY_CLOUDY, True, ATTR_CONDITION_PARTLYCLOUDY),
(WeatherCondition.Type.PARTLY_CLOUDY, False, ATTR_CONDITION_PARTLYCLOUDY),
(WeatherCondition.Type.TYPE_UNSPECIFIED, True, "unknown"),
],
)
async def test_condition(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_google_weather_api: AsyncMock,
api_condition: WeatherCondition.Type,
is_daytime: bool,
expected_ha_condition: str,
) -> None:
"""Test condition mapping."""
mock_google_weather_api.async_get_current_conditions.return_value.weather_condition.type = api_condition
mock_google_weather_api.async_get_current_conditions.return_value.is_daytime = (
is_daytime
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
state = hass.states.get("weather.home")
assert state.state == expected_ha_condition
def test_all_conditions_mapped() -> None:
"""Ensure all WeatherCondition.Type enum members are in the _CONDITION_MAP."""
for condition_type in WeatherCondition.Type:
assert condition_type in _CONDITION_MAP
@pytest.mark.parametrize(("forecast_type"), ["daily", "hourly", "twice_daily"])
async def test_forecast_service(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
mock_google_weather_api: AsyncMock,
forecast_type,
) -> None:
"""Test forecast service."""
await hass.config_entries.async_setup(mock_config_entry.entry_id)
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECASTS,
{
"entity_id": "weather.home",
"type": forecast_type,
},
blocking=True,
return_response=True,
)
assert response == snapshot
async def test_forecast_subscription(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
mock_google_weather_api: AsyncMock,
) -> None:
"""Test multiple forecast."""
client = await hass_ws_client(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await client.send_json_auto_id(
{
"type": "weather/subscribe_forecast",
"forecast_type": "daily",
"entity_id": "weather.home",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
subscription_id = msg["id"]
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast1 = msg["event"]["forecast"]
assert forecast1 != []
assert forecast1 == snapshot
freezer.tick(timedelta(hours=1) + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast2 = msg["event"]["forecast"]
assert forecast2 != []
assert forecast2 == snapshot

View File

@@ -19,8 +19,8 @@
}),
'labels': set({
}),
'manufacturer': 'HUSQVARNA',
'model': 'AUTOMOWER®',
'manufacturer': 'Husqvarna',
'model': 'Automower',
'model_id': '450XH',
'name': 'Test Mower 1',
'name_by_user': None,

View File

@@ -199,6 +199,39 @@ async def test_websocket_not_available(
assert mock.call_count == 1
@pytest.mark.parametrize(
("api_input", "model", "model_id"),
[
("HUSQVARNA AUTOMOWER® 450XH", "Automower", "450XH"),
("Automower 315X", "Automower", "315X"),
("Husqvarna Automower® 435 AWD", "Automower", "435 AWD"),
("Husqvarna CEORA® 544 EPOS", "Ceora", "544 EPOS"),
],
)
async def test_model_id_information(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_automower_client: AsyncMock,
device_registry: dr.DeviceRegistry,
values: dict[str, MowerAttributes],
api_input: str,
model: str,
model_id: str,
) -> None:
"""Test model and model_id parsing."""
values[TEST_MOWER_ID].system.model = api_input
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
reg_device = device_registry.async_get_device(
identifiers={(DOMAIN, TEST_MOWER_ID)},
)
assert reg_device is not None
assert reg_device.manufacturer == "Husqvarna"
assert reg_device.model == model
assert reg_device.model_id == model_id
async def test_device_info(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
@@ -206,7 +239,7 @@ async def test_device_info(
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test select platform."""
"""Test device info."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)

View File

@@ -334,7 +334,6 @@
"1/513/27": 4,
"1/513/28": 3,
"1/513/30": 0,
"1/513/48": 0,
"1/513/65532": 35,
"1/513/65533": 5,
"1/513/65528": [],

View File

@@ -633,7 +633,7 @@
'state': '1.0',
})
# ---
# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-entry]
# name: test_numbers[door_lock][number.mock_door_lock_auto_relock_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -651,7 +651,7 @@
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mock_door_lock_autorelock_time',
'entity_id': 'number.mock_door_lock_auto_relock_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -663,7 +663,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Autorelock time',
'original_name': 'Auto-relock time',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -673,10 +673,10 @@
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-state]
# name: test_numbers[door_lock][number.mock_door_lock_auto_relock_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Door Lock Autorelock time',
'friendly_name': 'Mock Door Lock Auto-relock time',
'max': 65534,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
@@ -684,7 +684,7 @@
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.mock_door_lock_autorelock_time',
'entity_id': 'number.mock_door_lock_auto_relock_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -806,7 +806,7 @@
'state': '3',
})
# ---
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_autorelock_time-entry]
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_auto_relock_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -824,7 +824,7 @@
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mock_door_lock_with_unbolt_autorelock_time',
'entity_id': 'number.mock_door_lock_with_unbolt_auto_relock_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -836,7 +836,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Autorelock time',
'original_name': 'Auto-relock time',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -846,10 +846,10 @@
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_autorelock_time-state]
# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_with_unbolt_auto_relock_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Door Lock with unbolt Autorelock time',
'friendly_name': 'Mock Door Lock with unbolt Auto-relock time',
'max': 65534,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
@@ -857,7 +857,7 @@
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.mock_door_lock_with_unbolt_autorelock_time',
'entity_id': 'number.mock_door_lock_with_unbolt_auto_relock_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -10507,66 +10507,6 @@
'state': '12.5',
})
# ---
# name: test_sensors[thermostat][sensor.longan_link_hvac_setpoint_change_source-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'manual',
'schedule',
'external',
]),
}),
'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.longan_link_hvac_setpoint_change_source',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Setpoint change source',
'platform': 'matter',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'setpoint_change_source',
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-SetpointChangeSource-513-48',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[thermostat][sensor.longan_link_hvac_setpoint_change_source-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Longan link HVAC Setpoint change source',
'options': list([
'manual',
'schedule',
'external',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.longan_link_hvac_setpoint_change_source',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'manual',
})
# ---
# name: test_sensors[thermostat][sensor.longan_link_hvac_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -2,9 +2,9 @@
from collections.abc import AsyncGenerator, Generator
import time
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from pymiele import MieleAction, MieleDevices
import pytest
from homeassistant.components.application_credentials import (
@@ -14,6 +14,7 @@ from homeassistant.components.application_credentials import (
from homeassistant.components.miele.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.json import JsonValueType
from . import get_actions_callback, get_data_callback
from .const import CLIENT_ID, CLIENT_SECRET
@@ -79,7 +80,7 @@ def load_device_file() -> str:
@pytest.fixture
async def device_fixture(hass: HomeAssistant, load_device_file: str) -> MieleDevices:
async def device_fixture(hass: HomeAssistant, load_device_file: str) -> dict[str, Any]:
"""Fixture for device."""
return await async_load_json_object_fixture(hass, load_device_file, DOMAIN)
@@ -91,7 +92,7 @@ def load_action_file() -> str:
@pytest.fixture
async def action_fixture(hass: HomeAssistant, load_action_file: str) -> MieleAction:
async def action_fixture(hass: HomeAssistant, load_action_file: str) -> dict[str, Any]:
"""Fixture for action."""
return await async_load_json_object_fixture(hass, load_action_file, DOMAIN)
@@ -103,7 +104,9 @@ def load_programs_file() -> str:
@pytest.fixture
async def programs_fixture(hass: HomeAssistant, load_programs_file: str) -> list[dict]:
async def programs_fixture(
hass: HomeAssistant, load_programs_file: str
) -> JsonValueType:
"""Fixture for available programs."""
return load_json_value_fixture(load_programs_file, DOMAIN)
@@ -141,7 +144,7 @@ async def setup_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
platforms,
) -> AsyncGenerator[None]:
) -> AsyncGenerator[MockConfigEntry]:
"""Set up one or all platforms."""
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
@@ -169,7 +172,7 @@ def mock_setup_entry() -> Generator[AsyncMock]:
async def push_data_and_actions(
hass: HomeAssistant,
mock_miele_client: MagicMock,
device_fixture: MieleDevices,
device_fixture: dict[str, Any],
) -> None:
"""Fixture to push data and actions through mock."""

View File

@@ -235,6 +235,56 @@ async def test_subentry_unsupported_model(
assert subentry_flow["errors"] == {"chat_model": "model_not_supported"}
@pytest.mark.parametrize(
("model", "reasoning_effort_options"),
[
("o4-mini", ["low", "medium", "high"]),
("gpt-5", ["minimal", "low", "medium", "high"]),
("gpt-5.1", ["none", "low", "medium", "high"]),
],
)
async def test_subentry_reasoning_effort_list(
hass: HomeAssistant,
mock_config_entry,
mock_init_component,
model,
reasoning_effort_options,
) -> None:
"""Test the list reasoning effort options."""
subentry = next(iter(mock_config_entry.subentries.values()))
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "init"
# Configure initial step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: ["assist"],
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "advanced"
# Configure advanced step
subentry_flow = await hass.config_entries.subentries.async_configure(
subentry_flow["flow_id"],
{
CONF_CHAT_MODEL: model,
},
)
assert subentry_flow["type"] is FlowResultType.FORM
assert subentry_flow["step_id"] == "model"
assert (
subentry_flow["data_schema"].schema[CONF_REASONING_EFFORT].config["options"]
== reasoning_effort_options
)
async def test_subentry_websearch_unsupported_reasoning_effort(
hass: HomeAssistant, mock_config_entry, mock_init_component
) -> None:

View File

@@ -0,0 +1 @@
"""Tests for the Saunum integration."""

View File

@@ -0,0 +1,77 @@
"""Configuration for Saunum Leil integration tests."""
from collections.abc import Generator
from unittest.mock import MagicMock, patch
from pysaunum import SaunumData
import pytest
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
entry_id="01K98T2T85R5GN0ZHYV25VFMMA",
title="Saunum Leil Sauna",
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
)
@pytest.fixture
def mock_saunum_client() -> Generator[MagicMock]:
"""Return a mocked Saunum client for config flow and integration tests."""
with (
patch(
"homeassistant.components.saunum.config_flow.SaunumClient", autospec=True
) as mock_client_class,
patch("homeassistant.components.saunum.SaunumClient", new=mock_client_class),
):
mock_client = mock_client_class.return_value
mock_client.is_connected = True
# Create mock data for async_get_data
mock_data = SaunumData(
session_active=False,
sauna_type=0,
sauna_duration=120,
fan_duration=10,
target_temperature=80,
fan_speed=2,
light_on=False,
current_temperature=75.0,
on_time=3600,
heater_elements_active=0,
door_open=False,
alarm_door_open=False,
alarm_door_sensor=False,
alarm_thermal_cutoff=False,
alarm_internal_temp=False,
alarm_temp_sensor_short=False,
alarm_temp_sensor_open=False,
)
mock_client.async_get_data.return_value = mock_data
yield mock_client
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client: MagicMock,
) -> MockConfigEntry:
"""Set up the integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,66 @@
# serializer version: 1
# name: test_entities[climate.saunum_leil-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 100,
'min_temp': 40,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.saunum_leil',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'saunum',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 1>,
'translation_key': None,
'unique_id': '01K98T2T85R5GN0ZHYV25VFMMA',
'unit_of_measurement': None,
})
# ---
# name: test_entities[climate.saunum_leil-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 75.0,
'friendly_name': 'Saunum Leil',
'hvac_action': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 100,
'min_temp': 40,
'supported_features': <ClimateEntityFeature: 1>,
'temperature': 80,
}),
'context': <ANY>,
'entity_id': 'climate.saunum_leil',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_entry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'saunum',
'01K98T2T85R5GN0ZHYV25VFMMA',
),
}),
'labels': set({
}),
'manufacturer': 'Saunum',
'model': 'Leil Touch Panel',
'model_id': None,
'name': 'Saunum Leil',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

View File

@@ -0,0 +1,232 @@
"""Test the Saunum climate platform."""
from __future__ import annotations
from dataclasses import replace
from freezegun.api import FrozenDateTimeFactory
from pysaunum import SaunumException
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
DOMAIN as CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "service_data", "client_method", "expected_args"),
[
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
"async_start_session",
(),
),
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.OFF},
"async_stop_session",
(),
),
(
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: 85},
"async_set_target_temperature",
(85,),
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_climate_service_calls(
hass: HomeAssistant,
mock_saunum_client,
service: str,
service_data: dict,
client_method: str,
expected_args: tuple,
) -> None:
"""Test climate service calls."""
entity_id = "climate.saunum_leil"
await hass.services.async_call(
CLIMATE_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)
getattr(mock_saunum_client, client_method).assert_called_once_with(*expected_args)
@pytest.mark.parametrize(
("heater_elements_active", "expected_hvac_action"),
[
(3, HVACAction.HEATING),
(0, HVACAction.IDLE),
],
)
async def test_climate_hvac_actions(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
heater_elements_active: int,
expected_hvac_action: HVACAction,
) -> None:
"""Test climate HVAC actions when session is active."""
# Get the existing mock data and modify only what we need
mock_saunum_client.async_get_data.return_value.session_active = True
mock_saunum_client.async_get_data.return_value.heater_elements_active = (
heater_elements_active
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.saunum_leil"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == HVACMode.HEAT
assert state.attributes.get(ATTR_HVAC_ACTION) == expected_hvac_action
@pytest.mark.parametrize(
(
"current_temperature",
"target_temperature",
"expected_current",
"expected_target",
),
[
(None, 80, None, 80),
(35.0, 30, 35, 30),
],
)
async def test_climate_temperature_edge_cases(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
current_temperature: float | None,
target_temperature: int,
expected_current: float | None,
expected_target: int,
) -> None:
"""Test climate with edge case temperature values."""
# Get the existing mock data and modify only what we need
base_data = mock_saunum_client.async_get_data.return_value
mock_saunum_client.async_get_data.return_value = replace(
base_data,
current_temperature=current_temperature,
target_temperature=target_temperature,
)
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
entity_id = "climate.saunum_leil"
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == expected_current
assert state.attributes.get(ATTR_TEMPERATURE) == expected_target
@pytest.mark.usefixtures("init_integration")
async def test_entity_unavailable_on_update_failure(
hass: HomeAssistant,
mock_saunum_client,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that entity becomes unavailable when coordinator update fails."""
entity_id = "climate.saunum_leil"
# Verify entity is initially available
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Make the next update fail
mock_saunum_client.async_get_data.side_effect = SaunumException("Read error")
# Move time forward to trigger a coordinator update (60 seconds)
freezer.tick(60)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Entity should now be unavailable
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize(
("service", "service_data", "client_method", "error_match"),
[
(
SERVICE_SET_HVAC_MODE,
{ATTR_HVAC_MODE: HVACMode.HEAT},
"async_start_session",
"Failed to set HVAC mode",
),
(
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: 85},
"async_set_target_temperature",
"Failed to set temperature",
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_action_error_handling(
hass: HomeAssistant,
mock_saunum_client,
service: str,
service_data: dict,
client_method: str,
error_match: str,
) -> None:
"""Test error handling when climate actions fail."""
entity_id = "climate.saunum_leil"
# Make the client method raise an exception
getattr(mock_saunum_client, client_method).side_effect = SaunumException(
"Communication error"
)
# Attempt to call service should raise HomeAssistantError
with pytest.raises(HomeAssistantError, match=error_match):
await hass.services.async_call(
CLIMATE_DOMAIN,
service,
{ATTR_ENTITY_ID: entity_id, **service_data},
blocking=True,
)

View File

@@ -0,0 +1,98 @@
"""Test the Saunum config flow."""
from __future__ import annotations
from pysaunum import SaunumConnectionError, SaunumException
import pytest
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
TEST_USER_INPUT = {CONF_HOST: "192.168.1.100"}
@pytest.mark.usefixtures("mock_saunum_client")
async def test_full_flow(hass: HomeAssistant) -> None:
"""Test full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert not result["errors"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Saunum Leil Sauna"
assert result["data"] == TEST_USER_INPUT
@pytest.mark.parametrize(
("side_effect", "error_base"),
[
(SaunumConnectionError("Connection failed"), "cannot_connect"),
(SaunumException("Read error"), "cannot_connect"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_form_errors(
hass: HomeAssistant,
mock_saunum_client,
side_effect: Exception,
error_base: str,
) -> None:
"""Test error handling and recovery."""
mock_saunum_client.connect.side_effect = side_effect
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_base}
# Test recovery - clear the error and try again
mock_saunum_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Saunum Leil Sauna"
assert result["data"] == TEST_USER_INPUT
@pytest.mark.usefixtures("mock_saunum_client")
async def test_form_duplicate(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test duplicate entry handling."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
TEST_USER_INPUT,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -0,0 +1,56 @@
"""Test Saunum Leil integration setup and teardown."""
from pysaunum import SaunumConnectionError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.saunum.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_setup_and_unload(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
) -> None:
"""Test integration setup and unload."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_async_setup_entry_connection_failed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_saunum_client,
) -> None:
"""Test integration setup fails when connection cannot be established."""
mock_config_entry.add_to_hass(hass)
mock_saunum_client.connect.side_effect = SaunumConnectionError("Connection failed")
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("init_integration")
async def test_device_entry(
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device registry entry."""
assert (
device_entry := device_registry.async_get_device(
identifiers={(DOMAIN, "01K98T2T85R5GN0ZHYV25VFMMA")}
)
)
assert device_entry == snapshot

View File

@@ -36,10 +36,28 @@ MOCK_SETTINGS = {
"mac": MOCK_MAC,
"hostname": "test-host",
"type": MODEL_25,
"num_inputs": 3,
"num_outputs": 2,
},
"coiot": {"update_period": 15},
"fw": "20201124-092159/v1.9.0@57ac4ad8",
"inputs": [
{
"name": "TV LEDs",
"btn_type": "momentary",
"btn_reverse": 0,
},
{
"name": "TV Spots",
"btn_type": "momentary",
"btn_reverse": 0,
},
{
"name": None,
"btn_type": "momentary",
"btn_reverse": 0,
},
],
"relays": [{"btn_type": "momentary"}, {"btn_type": "toggle"}],
"rollers": [{"positioning": True}],
"external_power": 0,
@@ -348,6 +366,7 @@ MOCK_SHELLY_COAP = {
"mac": MOCK_MAC,
"auth": False,
"fw": "20210715-092854/v1.11.0@57ac4ad8",
"num_inputs": 3,
"num_outputs": 2,
}

View File

@@ -1,5 +1,6 @@
"""Tests for Shelly button platform."""
import copy
from unittest.mock import Mock
from aioshelly.ble.const import BLE_SCRIPT_NAME
@@ -24,9 +25,14 @@ from . import (
patch_platforms,
register_entity,
)
from .conftest import MOCK_BLOCKS
DEVICE_BLOCK_ID = 4
UNORDERED_EVENT_TYPES = unordered(
["double", "long", "long_single", "single", "single_long", "triple"]
)
@pytest.fixture(autouse=True)
def fixture_platforms():
@@ -213,15 +219,57 @@ async def test_block_event(
assert state.attributes.get(ATTR_EVENT_TYPE) == "long"
async def test_block_event_single_output(
hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test block device event when num_outputs is 1."""
monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1)
await init_integration(hass, 1)
assert hass.states.get("event.test_name")
async def test_block_event_shix3_1(
hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Test block device event for SHIX3-1."""
monkeypatch.setitem(mock_block_device.shelly, "num_outputs", 1)
await init_integration(hass, 1, model=MODEL_I3)
entity_id = "event.test_name"
assert (state := hass.states.get(entity_id))
assert state.attributes.get(ATTR_EVENT_TYPES) == unordered(
["double", "long", "long_single", "single", "single_long", "triple"]
blocks = copy.deepcopy(MOCK_BLOCKS)
blocks[0] = Mock(
sensor_ids={
"inputEvent": "S",
"inputEventCnt": 2,
},
channel="0",
type="input",
description="input_0",
)
blocks[1] = Mock(
sensor_ids={
"inputEvent": "S",
"inputEventCnt": 2,
},
channel="1",
type="input",
description="input_1",
)
blocks[2] = Mock(
sensor_ids={
"inputEvent": "S",
"inputEventCnt": 2,
},
channel="2",
type="input",
description="input_2",
)
monkeypatch.setattr(mock_block_device, "blocks", blocks)
monkeypatch.delitem(mock_block_device.settings, "relays")
await init_integration(hass, 1, model=MODEL_I3)
assert (state := hass.states.get("event.test_name_tv_leds"))
assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES
assert (state := hass.states.get("event.test_name_tv_spots"))
assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES
assert (state := hass.states.get("event.test_name_input_3"))
assert state.attributes.get(ATTR_EVENT_TYPES) == UNORDERED_EVENT_TYPES

View File

@@ -5,7 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
from syrupy.assertion import SnapshotAssertion
from tplink_omada_client import SwitchPortOverrides
from tplink_omada_client import SwitchPortSettings
from tplink_omada_client.definitions import PoEMode
from tplink_omada_client.devices import (
OmadaGateway,
@@ -249,14 +249,16 @@ async def _test_poe_switch(
device: OmadaSwitch,
switch_port_details: OmadaSwitchPortDetails,
poe_enabled: bool,
overrides: SwitchPortOverrides = None,
settings: SwitchPortSettings,
) -> None:
assert device
assert device.mac == network_switch_mac
assert switch_port_details
assert switch_port_details.port == port_num
assert overrides
assert overrides.enable_poe == poe_enabled
assert settings
assert settings.profile_override_enabled
assert settings.profile_overrides
assert settings.profile_overrides.enable_poe == poe_enabled
entity = hass.states.get(entity_id)
assert entity == snapshot

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
@@ -46,13 +47,31 @@ async def test_platform_setup_and_discovery(
"mock_device_code",
["kt_5wnlzekkstwcdsvm"],
)
async def test_set_temperature(
@pytest.mark.parametrize(
("service", "service_data", "expected_command"),
[
(
SERVICE_SET_TEMPERATURE,
{ATTR_TEMPERATURE: 22.7},
{"code": "temp_set", "value": 23},
),
(
SERVICE_SET_FAN_MODE,
{ATTR_FAN_MODE: 2},
{"code": "windspeed", "value": "2"},
),
],
)
async def test_action(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
service: str,
service_data: dict[str, Any],
expected_command: dict[str, Any],
) -> None:
"""Test set temperature service."""
"""Test service action."""
entity_id = "climate.air_conditioner"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
@@ -60,15 +79,15 @@ async def test_set_temperature(
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
service,
{
ATTR_ENTITY_ID: entity_id,
ATTR_TEMPERATURE: 22.7,
**service_data,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "temp_set", "value": 23}]
mock_device.id, [expected_command]
)
@@ -76,44 +95,28 @@ async def test_set_temperature(
"mock_device_code",
["kt_5wnlzekkstwcdsvm"],
)
async def test_fan_mode_windspeed(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test fan mode with windspeed."""
entity_id = "climate.air_conditioner"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
assert state.attributes[ATTR_FAN_MODE] == 1
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_FAN_MODE: 2,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "windspeed", "value": "2"}]
)
@pytest.mark.parametrize(
"mock_device_code",
["kt_5wnlzekkstwcdsvm"],
("service", "service_data"),
[
(
SERVICE_SET_FAN_MODE,
{ATTR_FAN_MODE: 2},
),
(
SERVICE_SET_HUMIDITY,
{ATTR_HUMIDITY: 50},
),
],
)
async def test_fan_mode_no_valid_code(
async def test_action_not_supported(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
service: str,
service_data: dict[str, Any],
) -> None:
"""Test fan mode with no valid code."""
"""Test service action not supported."""
# Remove windspeed DPCode to simulate a device with no valid fan mode
mock_device.function.pop("windspeed", None)
mock_device.status_range.pop("windspeed", None)
@@ -128,38 +131,10 @@ async def test_fan_mode_no_valid_code(
with pytest.raises(ServiceNotSupported):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
service,
{
ATTR_ENTITY_ID: entity_id,
ATTR_FAN_MODE: 2,
},
blocking=True,
)
@pytest.mark.parametrize(
"mock_device_code",
["kt_5wnlzekkstwcdsvm"],
)
async def test_set_humidity_not_supported(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set humidity service (not available on this device)."""
entity_id = "climate.air_conditioner"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceNotSupported):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HUMIDITY,
{
ATTR_ENTITY_ID: entity_id,
ATTR_HUMIDITY: 50,
**service_data,
},
blocking=True,
)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
@@ -44,40 +45,28 @@ async def test_platform_setup_and_discovery(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
)
async def test_turn_on(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn on service."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "switch", "value": True}]
)
@pytest.mark.parametrize(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
("service", "service_data", "expected_command"),
[
(SERVICE_TURN_ON, {}, {"code": "switch", "value": True}),
(SERVICE_TURN_OFF, {}, {"code": "switch", "value": False}),
(
SERVICE_SET_HUMIDITY,
{ATTR_HUMIDITY: 50},
{"code": "dehumidify_set_value", "value": 50},
),
],
)
async def test_turn_off(
async def test_action(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
service: str,
service_data: dict[str, Any],
expected_command: dict[str, Any],
) -> None:
"""Test turn off service."""
"""Test service action."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
@@ -85,42 +74,15 @@ async def test_turn_off(
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "switch", "value": False}]
)
@pytest.mark.parametrize(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
)
async def test_set_humidity(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set humidity service."""
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_SET_HUMIDITY,
service,
{
ATTR_ENTITY_ID: entity_id,
ATTR_HUMIDITY: 50,
**service_data,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "dehumidify_set_value", "value": 50}]
mock_device.id, [expected_command]
)
@@ -128,84 +90,49 @@ async def test_set_humidity(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
)
async def test_turn_on_unsupported(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test turn on service (not supported by this device)."""
# Remove switch control - but keep other functionality
mock_device.status.pop("switch")
mock_device.function.pop("switch")
mock_device.status_range.pop("switch")
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
@pytest.mark.parametrize(
("service", "service_data", "translation_placeholders"),
[
(
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['switch', 'switch_spray']",
"available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"),
}
@pytest.mark.parametrize(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
{},
{
"expected": "['switch', 'switch_spray']",
"available": ("['child_lock', 'countdown_set']"),
},
),
(
SERVICE_TURN_OFF,
{},
{
"expected": "['switch', 'switch_spray']",
"available": ("['child_lock', 'countdown_set']"),
},
),
(
SERVICE_SET_HUMIDITY,
{ATTR_HUMIDITY: 50},
{
"expected": "['dehumidify_set_value']",
"available": ("['child_lock', 'countdown_set']"),
},
),
],
)
async def test_turn_off_unsupported(
async def test_action_unsupported(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
service: str,
service_data: dict[str, Any],
translation_placeholders: dict[str, Any],
) -> None:
"""Test turn off service (not supported by this device)."""
# Remove switch control - but keep other functionality
"""Test service actions when not supported by the device."""
# Remove switch control and dehumidify_set_value - but keep other functionality
mock_device.status.pop("switch")
mock_device.function.pop("switch")
mock_device.status_range.pop("switch")
entity_id = "humidifier.dehumidifier"
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['switch', 'switch_spray']",
"available": ("['child_lock', 'countdown_set', 'dehumidify_set_value']"),
}
@pytest.mark.parametrize(
"mock_device_code",
["cs_zibqa9dutqyaxym2"],
)
async def test_set_humidity_unsupported(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test set humidity service (not supported by this device)."""
# Remove set humidity control - but keep other functionality
mock_device.status.pop("dehumidify_set_value")
mock_device.function.pop("dehumidify_set_value")
mock_device.status_range.pop("dehumidify_set_value")
@@ -218,15 +145,12 @@ async def test_set_humidity_unsupported(
with pytest.raises(ServiceValidationError) as err:
await hass.services.async_call(
HUMIDIFIER_DOMAIN,
SERVICE_SET_HUMIDITY,
service,
{
ATTR_ENTITY_ID: entity_id,
ATTR_HUMIDITY: 50,
**service_data,
},
blocking=True,
)
assert err.value.translation_key == "action_dpcode_not_found"
assert err.value.translation_placeholders == {
"expected": "['dehumidify_set_value']",
"available": ("['child_lock', 'countdown_set', 'switch']"),
}
assert err.value.translation_placeholders == translation_placeholders

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from typing import Any
from unittest.mock import patch
import pytest
@@ -9,8 +10,13 @@ from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
DOMAIN as VACUUM_DOMAIN,
SERVICE_LOCATE,
SERVICE_RETURN_TO_BASE,
SERVICE_SET_FAN_SPEED,
SERVICE_START,
SERVICE_STOP,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
@@ -37,30 +43,77 @@ async def test_platform_setup_and_discovery(
@pytest.mark.parametrize(
"mock_device_code",
["sd_lr33znaodtyarrrz"],
("mock_device_code", "entity_id", "service", "service_data", "expected_command"),
[
(
"sd_i6hyjg3af7doaswm",
"vacuum.hoover",
SERVICE_RETURN_TO_BASE,
{},
{"code": "mode", "value": "chargego"},
),
(
# Based on #141278
"sd_lr33znaodtyarrrz",
"vacuum.v20",
SERVICE_RETURN_TO_BASE,
{},
{"code": "switch_charge", "value": True},
),
(
"sd_lr33znaodtyarrrz",
"vacuum.v20",
SERVICE_SET_FAN_SPEED,
{ATTR_FAN_SPEED: "gentle"},
{"code": "suction", "value": "gentle"},
),
(
"sd_i6hyjg3af7doaswm",
"vacuum.hoover",
SERVICE_LOCATE,
{},
{"code": "seek", "value": True},
),
(
"sd_i6hyjg3af7doaswm",
"vacuum.hoover",
SERVICE_START,
{},
{"code": "power_go", "value": True},
),
(
"sd_i6hyjg3af7doaswm",
"vacuum.hoover",
SERVICE_STOP,
{},
{"code": "power_go", "value": False},
),
],
)
async def test_return_home(
async def test_action(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
entity_id: str,
service: str,
service_data: dict[str, Any],
expected_command: dict[str, Any],
) -> None:
"""Test return home service."""
# Based on #141278
entity_id = "vacuum.v20"
"""Test service action."""
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
await hass.services.async_call(
VACUUM_DOMAIN,
SERVICE_RETURN_TO_BASE,
service,
{
ATTR_ENTITY_ID: entity_id,
**service_data,
},
blocking=True,
)
mock_manager.send_commands.assert_called_once_with(
mock_device.id, [{"code": "switch_charge", "value": True}]
mock_device.id, [expected_command]
)

Some files were not shown because too many files have changed in this diff Show More