mirror of
https://github.com/home-assistant/core.git
synced 2025-11-18 07:20:13 +00:00
Compare commits
29 Commits
setpoint_c
...
blueprint-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84e4a0f22e | ||
|
|
bdca592219 | ||
|
|
5c0c7b9ec3 | ||
|
|
9717599fb9 | ||
|
|
4d7de2f814 | ||
|
|
779590ce1c | ||
|
|
f3a185ff9c | ||
|
|
5a5a106984 | ||
|
|
796b421d99 | ||
|
|
0c03e8dbe9 | ||
|
|
47cf4e3ffe | ||
|
|
0ea0fc151d | ||
|
|
b7e5afec9f | ||
|
|
7a2bb67e82 | ||
|
|
e0612bec07 | ||
|
|
a06f4b6776 | ||
|
|
275670a526 | ||
|
|
d0d62526dd | ||
|
|
aefdf412b0 | ||
|
|
56ab6b2512 | ||
|
|
d1dea85cf5 | ||
|
|
84b0d39763 | ||
|
|
3aff225bc3 | ||
|
|
04458e01be | ||
|
|
ae51cfb8c0 | ||
|
|
c116a9c037 | ||
|
|
fb58758684 | ||
|
|
25fbcbc68c | ||
|
|
a670286b45 |
@@ -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
4
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"google_tasks",
|
||||
"google_translate",
|
||||
"google_travel_time",
|
||||
"google_weather",
|
||||
"google_wifi",
|
||||
"google",
|
||||
"nest",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["adguardhome"],
|
||||
"requirements": ["adguardhome==0.7.0"]
|
||||
"requirements": ["adguardhome==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
84
homeassistant/components/google_weather/__init__.py
Normal file
84
homeassistant/components/google_weather/__init__.py
Normal 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)
|
||||
198
homeassistant/components/google_weather/config_flow.py
Normal file
198
homeassistant/components/google_weather/config_flow.py
Normal 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
|
||||
8
homeassistant/components/google_weather/const.py
Normal file
8
homeassistant/components/google_weather/const.py
Normal 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"
|
||||
169
homeassistant/components/google_weather/coordinator.py
Normal file
169
homeassistant/components/google_weather/coordinator.py
Normal 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,
|
||||
)
|
||||
28
homeassistant/components/google_weather/entity.py
Normal file
28
homeassistant/components/google_weather/entity.py
Normal 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,
|
||||
)
|
||||
12
homeassistant/components/google_weather/manifest.json
Normal file
12
homeassistant/components/google_weather/manifest.json
Normal 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"]
|
||||
}
|
||||
82
homeassistant/components/google_weather/quality_scale.yaml
Normal file
82
homeassistant/components/google_weather/quality_scale.yaml
Normal 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
|
||||
65
homeassistant/components/google_weather/strings.json
Normal file
65
homeassistant/components/google_weather/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
366
homeassistant/components/google_weather/weather.py
Normal file
366
homeassistant/components/google_weather/weather.py
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
50
homeassistant/components/saunum/__init__.py
Normal file
50
homeassistant/components/saunum/__init__.py
Normal 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
|
||||
107
homeassistant/components/saunum/climate.py
Normal file
107
homeassistant/components/saunum/climate.py
Normal 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()
|
||||
76
homeassistant/components/saunum/config_flow.py
Normal file
76
homeassistant/components/saunum/config_flow.py
Normal 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,
|
||||
)
|
||||
16
homeassistant/components/saunum/const.py
Normal file
16
homeassistant/components/saunum/const.py
Normal 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)
|
||||
47
homeassistant/components/saunum/coordinator.py
Normal file
47
homeassistant/components/saunum/coordinator.py
Normal 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
|
||||
27
homeassistant/components/saunum/entity.py
Normal file
27
homeassistant/components/saunum/entity.py
Normal 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",
|
||||
)
|
||||
12
homeassistant/components/saunum/manifest.json
Normal file
12
homeassistant/components/saunum/manifest.json
Normal 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"]
|
||||
}
|
||||
74
homeassistant/components/saunum/quality_scale.yaml
Normal file
74
homeassistant/components/saunum/quality_scale.yaml
Normal 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
|
||||
22
homeassistant/components/saunum/strings.json
Normal file
22
homeassistant/components/saunum/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
},
|
||||
"event": {
|
||||
"input": {
|
||||
"name": "Input {input_number}",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pythonkuma"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pythonkuma==0.3.1"]
|
||||
"requirements": ["pythonkuma==0.3.2"]
|
||||
}
|
||||
|
||||
@@ -12,4 +12,3 @@ SCORE_POINTS = "points"
|
||||
UOM_BEATS_PER_MINUTE = "bpm"
|
||||
UOM_BREATHS_PER_MINUTE = "br/min"
|
||||
UOM_FREQUENCY = "times"
|
||||
UOM_MMHG = "mmhg"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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-*",
|
||||
|
||||
2
homeassistant/generated/config_flows.py
generated
2
homeassistant/generated/config_flows.py
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
139
homeassistant/helpers/template/extensions/devices.py
Normal file
139
homeassistant/helpers/template/extensions/devices.py
Normal 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)
|
||||
157
homeassistant/helpers/template/extensions/floors.py
Normal file
157
homeassistant/helpers/template/extensions/floors.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
10
mypy.ini
generated
@@ -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
16
requirements_all.txt
generated
@@ -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
|
||||
|
||||
16
requirements_test_all.txt
generated
16
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
1
tests/components/google_weather/__init__.py
Normal file
1
tests/components/google_weather/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Google Weather integration."""
|
||||
83
tests/components/google_weather/conftest.py
Normal file
83
tests/components/google_weather/conftest.py
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
135
tests/components/google_weather/fixtures/daily_forecast.json
Normal file
135
tests/components/google_weather/fixtures/daily_forecast.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
191
tests/components/google_weather/snapshots/test_weather.ambr
Normal file
191
tests/components/google_weather/snapshots/test_weather.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
375
tests/components/google_weather/test_config_flow.py
Normal file
375
tests/components/google_weather/test_config_flow.py
Normal 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"
|
||||
69
tests/components/google_weather/test_init.py
Normal file
69
tests/components/google_weather/test_init.py
Normal 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
|
||||
214
tests/components/google_weather/test_weather.py
Normal file
214
tests/components/google_weather/test_weather.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
1
tests/components/saunum/__init__.py
Normal file
1
tests/components/saunum/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Saunum integration."""
|
||||
77
tests/components/saunum/conftest.py
Normal file
77
tests/components/saunum/conftest.py
Normal 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
|
||||
66
tests/components/saunum/snapshots/test_climate.ambr
Normal file
66
tests/components/saunum/snapshots/test_climate.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
32
tests/components/saunum/snapshots/test_init.ambr
Normal file
32
tests/components/saunum/snapshots/test_init.ambr
Normal 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,
|
||||
})
|
||||
# ---
|
||||
232
tests/components/saunum/test_climate.py
Normal file
232
tests/components/saunum/test_climate.py
Normal 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,
|
||||
)
|
||||
98
tests/components/saunum/test_config_flow.py
Normal file
98
tests/components/saunum/test_config_flow.py
Normal 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"
|
||||
56
tests/components/saunum/test_init.py
Normal file
56
tests/components/saunum/test_init.py
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user