Compare commits

..

2 Commits

Author SHA1 Message Date
abmantis
69a252092c Restore state in Energy cost sensors 2025-02-20 23:38:15 +00:00
abmantis
ec257a54f3 Simplify Energy cost sensor update method 2025-02-20 22:44:26 +00:00
173 changed files with 1333 additions and 6646 deletions

View File

@@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1
uses: sigstore/cosign-installer@v3.8.0
with:
cosign-release: "v2.2.3"

10
.vscode/launch.json vendored
View File

@@ -42,14 +42,6 @@
"--picked"
],
},
{
"name": "Home Assistant: Debug Current Test File",
"type": "debugpy",
"request": "launch",
"module": "pytest",
"console": "integratedTerminal",
"args": ["-vv", "${file}"]
},
{
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
@@ -85,4 +77,4 @@
]
}
]
}
}

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.24.0"
"habluetooth==3.22.1"
]
}

View File

@@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner):
"""Open connection to the router and get arp entries."""
try:
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8")
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
cisco_ssh.login(
self.host,
self.username,

View File

@@ -30,15 +30,10 @@ async def async_setup_entry(
async_add_entities(
[
DemoWaterHeater(
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco"
),
DemoWaterHeater(
"Demo Water Heater Celsius",
45,
UnitOfTemperature.CELSIUS,
True,
"eco",
1,
"Demo Water Heater Celsius", 45, UnitOfTemperature.CELSIUS, True, "eco"
),
]
)
@@ -57,7 +52,6 @@ class DemoWaterHeater(WaterHeaterEntity):
unit_of_measurement: str,
away: bool,
current_operation: str,
target_temperature_step: float,
) -> None:
"""Initialize the water_heater device."""
self._attr_name = name
@@ -80,7 +74,6 @@ class DemoWaterHeater(WaterHeaterEntity):
"gas",
"off",
]
self._attr_target_temperature_step = target_temperature_step
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""

View File

@@ -14,8 +14,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.1.1",
"aiodiscover==2.6.1",
"cached-ipaddress==0.8.1"
"aiodhcpwatcher==1.1.0",
"aiodiscover==2.6.0",
"cached-ipaddress==0.8.0"
]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==12.2.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.1.0"]
}

View File

@@ -12,8 +12,8 @@ from typing import Any, Final, Literal, cast
from homeassistant.components.sensor import (
ATTR_LAST_RESET,
ATTR_STATE_CLASS,
RestoreSensor,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
)
from homeassistant.components.sensor.recorder import reset_detected
@@ -222,7 +222,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None:
future.set_result(None)
class EnergyCostSensor(SensorEntity):
class EnergyCostSensor(RestoreSensor):
"""Calculate costs incurred by consuming energy.
This is intended as a fallback for when no specific cost sensor is available for the
@@ -312,40 +312,22 @@ class EnergyCostSensor(SensorEntity):
return
# Determine energy price
if self._config["entity_energy_price"] is not None:
energy_price_state = self.hass.states.get(
self._config["entity_energy_price"]
)
if energy_price_state is None:
return
try:
energy_price = float(energy_price_state.state)
except ValueError:
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities except
# price are in place. This means that the cost will update the first
# time the energy is updated after the price entity is in place.
self._reset(energy_state)
return
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_price_unit
else:
energy_price = cast(float, self._config["number_energy_price"])
energy_price_unit = default_price_unit
energy_price_tuple = self._get_energy_price(valid_units, default_price_unit)
if energy_price_tuple is None:
return
if self._last_energy_sensor_state is None:
# Initialize as it's the first time all required entities are in place.
self._reset(energy_state)
# Initialize as it's the first time all required entities are in place or
# only the price is missing. In the later case, cost will update the first
# time the energy is updated after the price entity is in place.
if self._attr_native_value is None:
self._reset(energy_state)
else:
self._last_energy_sensor_state = energy_state
return
energy_price, energy_price_unit = energy_price_tuple
if energy_price is None:
return
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
@@ -383,20 +365,9 @@ class EnergyCostSensor(SensorEntity):
old_energy_value = float(self._last_energy_sensor_state.state)
cur_value = cast(float, self._attr_native_value)
if energy_price_unit is None:
converted_energy_price = energy_price
else:
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
converted_energy_price = converter(
energy_price,
energy_unit,
energy_price_unit,
)
converted_energy_price = self._convert_energy_price(
energy_price, energy_price_unit, energy_unit
)
self._attr_native_value = (
cur_value + (energy - old_energy_value) * converted_energy_price
@@ -404,8 +375,53 @@ class EnergyCostSensor(SensorEntity):
self._last_energy_sensor_state = energy_state
def _get_energy_price(
self,
valid_units: set[str],
default_unit: str | None,
) -> tuple[float | None, str | None] | None:
if self._config["entity_energy_price"] is None:
return cast(float, self._config["number_energy_price"]), default_unit
energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
if energy_price_state is None:
return None
try:
energy_price = float(energy_price_state.state)
except ValueError:
return (None, None)
energy_price_unit: str | None = energy_price_state.attributes.get(
ATTR_UNIT_OF_MEASUREMENT, ""
).partition("/")[2]
# For backwards compatibility we don't validate the unit of the price
# If it is not valid, we assume it's our default price unit.
if energy_price_unit not in valid_units:
energy_price_unit = default_unit
return energy_price, energy_price_unit
def _convert_energy_price(
self, energy_price: float, energy_price_unit: str | None, energy_unit: str
) -> float:
if energy_price_unit is None:
return energy_price
converter: Callable[[float, str, str], float]
if energy_unit in VALID_ENERGY_UNITS:
converter = unit_conversion.EnergyConverter.convert
else:
converter = unit_conversion.VolumeConverter.convert
return converter(energy_price, energy_unit, energy_price_unit)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
if (sensor_data := await self.async_get_last_sensor_data()) is not None:
self._attr_native_value = sensor_data.native_value
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
if energy_state:
name = energy_state.name

View File

@@ -25,7 +25,6 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_MODE,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
@@ -41,10 +40,11 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_DURATION,
ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL,
ATTR_PERIOD,
ATTR_SETPOINT,
ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP,
CONF_LOCATION_IDX,
DOMAIN,
SCAN_INTERVAL_DEFAULT,
@@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_SETPOINT): vol.All(
vol.Required(ATTR_ZONE_TEMP): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
@@ -222,7 +222,7 @@ def setup_service_functions(
# Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)})
schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)})
system_mode_schemas.append(schema)
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
@@ -232,8 +232,8 @@ def setup_service_functions(
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema(
{
vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION): vol.All(
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_HOURS): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
@@ -246,8 +246,8 @@ def setup_service_functions(
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema(
{
vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_PERIOD): vol.All(
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_DAYS): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),

View File

@@ -29,7 +29,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -38,10 +38,11 @@ from homeassistant.util import dt as dt_util
from . import EVOHOME_KEY
from .const import (
ATTR_DURATION,
ATTR_DURATION_DAYS,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL,
ATTR_PERIOD,
ATTR_SETPOINT,
ATTR_SYSTEM_MODE,
ATTR_ZONE_TEMP,
EvoService,
)
from .coordinator import EvoDataUpdateCoordinator
@@ -179,7 +180,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
return
# otherwise it is EvoService.SET_ZONE_OVERRIDE
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp)
if ATTR_DURATION_UNTIL in data:
duration: timedelta = data[ATTR_DURATION_UNTIL]
@@ -348,16 +349,16 @@ class EvoController(EvoClimateEntity):
Data validation is not required, it will have been done upstream.
"""
if service == EvoService.SET_SYSTEM_MODE:
mode = data[ATTR_MODE]
mode = data[ATTR_SYSTEM_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM
mode = EvoSystemMode.AUTO_WITH_RESET
if ATTR_PERIOD in data:
if ATTR_DURATION_DAYS in data:
until = dt_util.start_of_local_day()
until += data[ATTR_PERIOD]
until += data[ATTR_DURATION_DAYS]
elif ATTR_DURATION in data:
until = dt_util.now() + data[ATTR_DURATION]
elif ATTR_DURATION_HOURS in data:
until = dt_util.now() + data[ATTR_DURATION_HOURS]
else:
until = None

View File

@@ -18,10 +18,11 @@ USER_DATA: Final = "user_data"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
ATTR_PERIOD: Final = "period" # number of days
ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_SYSTEM_MODE: Final = "mode"
ATTR_DURATION_DAYS: Final = "period"
ATTR_DURATION_HOURS: Final = "duration"
ATTR_SETPOINT: Final = "setpoint"
ATTR_ZONE_TEMP: Final = "setpoint"
ATTR_DURATION_UNTIL: Final = "duration"

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "bronze",
"requirements": ["flexit_bacnet==2.2.3"]
}

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.17"],
"requirements": ["pyfritzhome==0.6.15"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250221.0"]
"requirements": ["home-assistant-frontend==20250214.0"]
}

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import mimetypes
from pathlib import Path
from google import genai # type: ignore[attr-defined]
from google.genai.errors import APIError, ClientError
from PIL import Image
from requests.exceptions import Timeout
from google.ai import generativelanguage_v1beta
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPIError
import google.generativeai as genai
import google.generativeai.types as genai_types
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -28,13 +29,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
TIMEOUT_MILLIS,
)
from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL
SERVICE_GENERATE_CONTENT = "generate_content"
CONF_IMAGE_FILENAME = "image_filename"
@@ -42,8 +37,6 @@ CONF_IMAGE_FILENAME = "image_filename"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (Platform.CONVERSATION,)
type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Google Generative AI Conversation."""
@@ -51,47 +44,42 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def generate_content(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
prompt_parts = [call.data[CONF_PROMPT]]
image_filenames = call.data[CONF_IMAGE_FILENAME]
for image_filename in image_filenames:
if not hass.config.is_allowed_path(image_filename):
raise HomeAssistantError(
f"Cannot read `{image_filename}`, no access to path; "
"`allowlist_external_dirs` may need to be adjusted in "
"`configuration.yaml`"
)
if not Path(image_filename).exists():
raise HomeAssistantError(f"`{image_filename}` does not exist")
mime_type, _ = mimetypes.guess_type(image_filename)
if mime_type is None or not mime_type.startswith("image"):
raise HomeAssistantError(f"`{image_filename}` is not an image")
prompt_parts.append(
{
"mime_type": mime_type,
"data": await hass.async_add_executor_job(
Path(image_filename).read_bytes
),
}
)
def append_images_to_prompt():
image_filenames = call.data[CONF_IMAGE_FILENAME]
for image_filename in image_filenames:
if not hass.config.is_allowed_path(image_filename):
raise HomeAssistantError(
f"Cannot read `{image_filename}`, no access to path; "
"`allowlist_external_dirs` may need to be adjusted in "
"`configuration.yaml`"
)
if not Path(image_filename).exists():
raise HomeAssistantError(f"`{image_filename}` does not exist")
mime_type, _ = mimetypes.guess_type(image_filename)
if mime_type is None or not mime_type.startswith("image"):
raise HomeAssistantError(f"`{image_filename}` is not an image")
prompt_parts.append(Image.open(image_filename))
await hass.async_add_executor_job(append_images_to_prompt)
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
DOMAIN
)[0]
client = config_entry.runtime_data
model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL)
try:
response = await client.aio.models.generate_content(
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
)
response = await model.generate_content_async(prompt_parts)
except (
APIError,
GoogleAPIError,
ValueError,
genai_types.BlockedPromptException,
genai_types.StopCandidateException,
) as err:
raise HomeAssistantError(f"Error generating content: {err}") from err
if response.prompt_feedback:
raise HomeAssistantError(
f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}"
)
if not response.candidates[0].content.parts:
raise HomeAssistantError("Unknown error generating content")
if not response.parts:
raise HomeAssistantError("Error generating content")
return {"text": response.text}
@@ -112,34 +100,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Google Generative AI Conversation from a config entry."""
genai.configure(api_key=entry.data[CONF_API_KEY])
try:
client = genai.Client(api_key=entry.data[CONF_API_KEY])
await client.aio.models.get(
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
client = generativelanguage_v1beta.ModelServiceAsyncClient(
client_options=ClientOptions(api_key=entry.data[CONF_API_KEY])
)
except (APIError, Timeout) as err:
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
raise ConfigEntryAuthFailed(err.message) from err
if isinstance(err, Timeout):
await client.get_model(
name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0
)
except (GoogleAPIError, ValueError) as err:
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
raise ConfigEntryAuthFailed(err) from err
if isinstance(err, DeadlineExceeded):
raise ConfigEntryNotReady(err) from err
raise ConfigEntryError(err) from err
else:
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload GoogleGenerativeAI."""
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False

View File

@@ -3,13 +3,15 @@
from __future__ import annotations
from collections.abc import Mapping
from functools import partial
import logging
from types import MappingProxyType
from typing import Any
from google import genai # type: ignore[attr-defined]
from google.genai.errors import APIError, ClientError
from requests.exceptions import Timeout
from google.ai import generativelanguage_v1beta
from google.api_core.client_options import ClientOptions
from google.api_core.exceptions import ClientError, GoogleAPIError
import google.generativeai as genai
import voluptuous as vol
from homeassistant.config_entries import (
@@ -51,7 +53,6 @@ from .const import (
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
TIMEOUT_MILLIS,
)
_LOGGER = logging.getLogger(__name__)
@@ -69,20 +70,15 @@ RECOMMENDED_OPTIONS = {
}
async def validate_input(data: dict[str, Any]) -> None:
async def validate_input(hass: HomeAssistant, 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.
"""
client = genai.Client(api_key=data[CONF_API_KEY])
await client.aio.models.list(
config={
"http_options": {
"timeout": TIMEOUT_MILLIS,
},
"query_base": True,
}
client = generativelanguage_v1beta.ModelServiceAsyncClient(
client_options=ClientOptions(api_key=data[CONF_API_KEY])
)
await client.list_models(timeout=5.0)
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -97,9 +93,9 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
try:
await validate_input(user_input)
except (APIError, Timeout) as err:
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
await validate_input(self.hass, user_input)
except GoogleAPIError as err:
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
@@ -170,7 +166,6 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False
)
self._genai_client = config_entry.runtime_data
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -193,9 +188,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
schema = await google_generative_ai_config_option_schema(
self.hass, options, self._genai_client
)
schema = await google_generative_ai_config_option_schema(self.hass, options)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
@@ -205,7 +198,6 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any],
genai_client: genai.Client,
) -> dict:
"""Return a schema for Google Generative AI completion options."""
hass_apis: list[SelectOptionDict] = [
@@ -244,21 +236,18 @@ async def google_generative_ai_config_option_schema(
if options.get(CONF_RECOMMENDED):
return schema
api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
api_models = [api_model async for api_model in api_models_pager]
api_models = await hass.async_add_executor_job(partial(genai.list_models))
models = [
SelectOptionDict(
label=api_model.display_name,
value=api_model.name,
)
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
for api_model in sorted(api_models, key=lambda x: x.display_name)
if (
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro
and api_model.display_name
and api_model.name
and api_model.supported_actions
and "vision" not in api_model.name
and "generateContent" in api_model.supported_actions
and "generateContent" in api_model.supported_generation_methods
)
]

View File

@@ -22,5 +22,3 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE"
TIMEOUT_MILLIS = 10000

View File

@@ -6,18 +6,11 @@ import codecs
from collections.abc import Callable
from typing import Any, Literal, cast
from google.genai.errors import APIError
from google.genai.types import (
AutomaticFunctionCallingConfig,
Content,
FunctionDeclaration,
GenerateContentConfig,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from google.api_core.exceptions import GoogleAPIError
import google.generativeai as genai
from google.generativeai import protos
import google.generativeai.types as genai_types
from google.protobuf.json_format import MessageToDict
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation
@@ -64,40 +57,21 @@ async def async_setup_entry(
SUPPORTED_SCHEMA_KEYS = {
"min_items",
"example",
"property_ordering",
"pattern",
"minimum",
"default",
"any_of",
"max_length",
"title",
"min_properties",
"min_length",
"max_items",
"maximum",
"nullable",
"max_properties",
"type",
"description",
"enum",
"format",
"description",
"nullable",
"enum",
"items",
"properties",
"required",
}
def _camel_to_snake(name: str) -> str:
"""Convert camel case to snake case."""
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
def _format_schema(schema: dict[str, Any]) -> Schema:
"""Format the schema to be compatible with Gemini API."""
if subschemas := schema.get("allOf"):
for subschema in subschemas: # Gemini API does not support allOf keys
def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
"""Format the schema to protobuf."""
if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")):
for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
@@ -106,38 +80,42 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
result = {}
for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "any_of":
val = [_format_schema(subschema) for subschema in val]
if key == "type":
key = "type_"
val = val.upper()
if key == "items":
elif key == "format":
if schema.get("type") == "string" and val != "enum":
continue
if schema.get("type") not in ("number", "integer", "string"):
continue
key = "format_"
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
if result.get("enum") and result.get("type") != "STRING":
if result.get("enum") and result.get("type_") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
result["type"] = "STRING"
result["type_"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
if result.get("type") == "OBJECT" and not result.get("properties"):
if result.get("type_") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type": "STRING"}}
result["properties"] = {"json": {"type_": "STRING"}}
result["required"] = []
return cast(Schema, result)
return result
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> Tool:
) -> dict[str, Any]:
"""Format tool specification."""
if tool.parameters.schema:
@@ -147,14 +125,16 @@ def _format_tool(
else:
parameters = None
return Tool(
function_declarations=[
FunctionDeclaration(
name=tool.name,
description=tool.description,
parameters=parameters,
)
]
return protos.Tool(
{
"function_declarations": [
{
"name": tool.name,
"description": tool.description,
"parameters": parameters,
}
]
}
)
@@ -171,12 +151,14 @@ def _escape_decode(value: Any) -> Any:
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> Content:
) -> protos.Content:
"""Create a Google tool response content."""
return Content(
return protos.Content(
parts=[
Part.from_function_response(
name=tool_result.tool_name, response=tool_result.tool_result
protos.Part(
function_response=protos.FunctionResponse(
name=tool_result.tool_name, response=tool_result.tool_result
)
)
for tool_result in content
]
@@ -187,36 +169,33 @@ def _convert_content(
content: conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent,
) -> Content:
) -> genai_types.ContentDict:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
role = "model" if content.role == "assistant" else content.role
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
)
return {"role": role, "parts": content.content}
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
parts = []
if content.content:
parts.append(Part.from_text(text=content.content))
parts.append(protos.Part(text=content.content))
if content.tool_calls:
parts.extend(
[
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
protos.Part(
function_call=protos.FunctionCall(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
)
for tool_call in content.tool_calls
]
)
return Content(role="model", parts=parts)
return protos.Content({"role": "model", "parts": parts})
class GoogleGenerativeAIConversationEntity(
@@ -230,7 +209,6 @@ class GoogleGenerativeAIConversationEntity(
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -295,7 +273,7 @@ class GoogleGenerativeAIConversationEntity(
except conversation.ConverseError as err:
return err.as_conversation_result()
tools: list[Tool | Callable[..., Any]] | None = None
tools: list[dict[str, Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
@@ -310,22 +288,13 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
prompt_content = cast(
conversation.SystemContent,
chat_log.content[0],
)
if prompt_content.content:
prompt = prompt_content.content
else:
raise HomeAssistantError("Invalid prompt content")
messages: list[Content] = []
prompt = chat_log.content[0].content # type: ignore[union-attr]
messages: list[genai_types.ContentDict] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:-1]:
for chat_content in chat_log.content[1:]:
if chat_content.role == "tool_result":
# mypy doesn't like picking a type based on checking shared property 'role'
tool_results.append(cast(conversation.ToolResultContent, chat_content))
@@ -348,93 +317,85 @@ class GoogleGenerativeAIConversationEntity(
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
temperature=self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
safety_settings=[
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
threshold=self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
model = genai.GenerativeModel(
model_name=model_name,
generation_config={
"temperature": self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
threshold=self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
"top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
"max_output_tokens": self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
threshold=self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
},
safety_settings={
"HARASSMENT": self.entry.options.get(
CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
SafetySetting(
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
threshold=self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
"HATE": self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
],
"SEXUAL": self.entry.options.get(
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
"DANGEROUS": self.entry.options.get(
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
),
},
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
automatic_function_calling=AutomaticFunctionCallingConfig(
disable=True, maximum_remote_calls=None
),
)
if not supports_system_instruction:
messages = [
Content(role="user", parts=[Part.from_text(text=prompt)]),
Content(role="model", parts=[Part.from_text(text="Ok")]),
{"role": "user", "parts": prompt},
{"role": "model", "parts": "Ok"},
*messages,
]
chat = self._genai_client.aio.chats.create(
model=model_name, history=messages, config=generateContentConfig
)
chat_request: str | Content = user_input.text
chat = model.start_chat(history=messages)
chat_request = user_input.text
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
chat_response = await chat.send_message(message=chat_request)
if chat_response.prompt_feedback:
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
)
chat_response = await chat.send_message_async(chat_request)
except (
APIError,
GoogleAPIError,
ValueError,
genai_types.BlockedPromptException,
genai_types.StopCandidateException,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
error = f"Sorry, I had a problem talking to Google Generative AI: {err}"
if isinstance(
err, genai_types.StopCandidateException
) and "finish_reason: SAFETY\n" in str(err):
error = "The message got blocked by your safety settings"
else:
error = (
f"Sorry, I had a problem talking to Google Generative AI: {err}"
)
raise HomeAssistantError(error) from err
response_parts = chat_response.candidates[0].content.parts
if not response_parts:
LOGGER.debug("Response: %s", chat_response.parts)
if not chat_response.parts:
raise HomeAssistantError(
"Sorry, I had a problem getting a response from Google Generative AI."
)
content = " ".join(
[part.text.strip() for part in response_parts if part.text]
[part.text.strip() for part in chat_response.parts if part.text]
)
tool_calls = []
for part in response_parts:
for part in chat_response.parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name
tool_args = _escape_decode(tool_call.args)
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"])
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
@@ -457,7 +418,7 @@ class GoogleGenerativeAIConversationEntity(
response = intent.IntentResponse(language=user_input.language)
response.async_set_speech(
" ".join([part.text.strip() for part in response_parts if part.text])
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
return conversation.ConversationResult(
response=response, conversation_id=chat_log.conversation_id

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["google-genai==1.1.0"]
"requirements": ["google-generativeai==0.8.2"]
}

View File

@@ -119,13 +119,12 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
assert self.todo_items
if previous_uid:
pos = self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
pos = (
self.todo_items.index(
next(item for item in self.todo_items if item.uid == previous_uid)
)
+ 1
)
if pos < self.todo_items.index(
next(item for item in self.todo_items if item.uid == uid)
):
pos += 1
else:
pos = 0

View File

@@ -102,18 +102,6 @@ async def _validate_auth(
return True
def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
"""Get a set of current hosts from the entry."""
hosts = set(entry.data[CONF_HOST])
if hasattr(entry, "runtime_data"):
hosts.update(
player.ip_address
for player in entry.runtime_data.heos.players.values()
if player.ip_address is not None
)
return hosts
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
@@ -137,15 +125,10 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING:
assert discovery_info.ssdp_location
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
await self.async_set_unique_id(DOMAIN)
# Connect to discovered host and get system information
hostname = urlparse(discovery_info.ssdp_location).hostname
assert hostname is not None
# Abort early when discovered host is part of the current system
if entry and hostname in _get_current_hosts(entry):
return self.async_abort(reason="single_instance_allowed")
# Connect to discovered host and get system information
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
try:
await heos.connect()
@@ -163,23 +146,8 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
# Select the preferred host, if available
if system_info.preferred_hosts:
hostname = system_info.preferred_hosts[0].ip_address
# Move to confirmation when not configured
if entry is None:
self._discovered_host = hostname
return await self.async_step_confirm_discovery()
# Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
_LOGGER.debug(
"Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
)
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_HOST: hostname},
reason="reconfigure_successful",
)
return self.async_abort(reason="single_instance_allowed")
self._discovered_host = hostname
return await self.async_step_confirm_discovery()
async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None
@@ -199,7 +167,6 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
await self.async_set_unique_id(DOMAIN)
self._abort_if_unique_id_configured(error="single_instance_allowed")
# Try connecting to host if provided
errors: dict[str, str] = {}
host = None

View File

@@ -7,8 +7,9 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyheos"],
"quality_scale": "platinum",
"quality_scale": "silver",
"requirements": ["pyheos==1.0.2"],
"single_config_entry": true,
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"

View File

@@ -38,7 +38,9 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery-update-info:
status: todo
comment: Explore if this is possible.
discovery: done
docs-data-update: done
docs-examples: done

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.67", "babel==2.15.0"]
"requirements": ["holidays==0.66", "babel==2.15.0"]
}

View File

@@ -187,7 +187,6 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,

View File

@@ -1,160 +0,0 @@
"""Provides button entities for Home Connect."""
from aiohomeconnect.model import CommandKey, EventKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription):
"""Describes Home Connect button entity."""
key: CommandKey
COMMAND_BUTTONS = (
HomeConnectCommandButtonEntityDescription(
key=CommandKey.BSH_COMMON_OPEN_DOOR,
translation_key="open_door",
),
HomeConnectCommandButtonEntityDescription(
key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR,
translation_key="partly_open_door",
),
HomeConnectCommandButtonEntityDescription(
key=CommandKey.BSH_COMMON_PAUSE_PROGRAM,
translation_key="pause_program",
),
HomeConnectCommandButtonEntityDescription(
key=CommandKey.BSH_COMMON_RESUME_PROGRAM,
translation_key="resume_program",
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
entities.extend(
HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
for description in COMMAND_BUTTONS
if description.key in appliance.commands
)
if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
entities.append(
HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect button entities."""
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
"""Describes Home Connect button entity."""
entity_description: ButtonEntityDescription
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: ButtonEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
# The entity is subscribed to the appliance connected event,
# but it will receive also the disconnected event
ButtonEntityDescription(
key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
),
)
self.entity_description = desc
self.appliance = appliance
self.unique_id = f"{appliance.info.ha_id}-{desc.key}"
def update_native_value(self) -> None:
"""Set the value of the entity."""
class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
"""Button entity for Home Connect commands."""
entity_description: HomeConnectCommandButtonEntityDescription
async def async_press(self) -> None:
"""Press the button."""
try:
await self.coordinator.client.put_command(
self.appliance.info.ha_id,
command_key=self.entity_description.key,
value=True,
)
except HomeConnectError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="execute_command",
translation_placeholders={
**get_dict_from_home_connect_error(error),
"command": self.entity_description.key,
},
) from error
class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
"""Button entity for stopping a program."""
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
ButtonEntityDescription(
key="StopProgram",
translation_key="stop_program",
),
)
async def async_press(self) -> None:
"""Press the button."""
try:
await self.coordinator.client.stop_program(self.appliance.info.ha_id)
except HomeConnectError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="stop_program",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error

View File

@@ -1,6 +1,5 @@
"""Common callbacks for all Home Connect platforms."""
from collections import defaultdict
from collections.abc import Callable
from functools import partial
from typing import cast
@@ -10,32 +9,7 @@ from aiohomeconnect.model import EventKey
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
def _create_option_entities(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData],
list[HomeConnectOptionEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the required option entities for the appliances."""
option_entities_to_add = [
entity
for entity in get_option_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
]
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in option_entities_to_add
}
)
async_add_entities(option_entities_to_add)
from .entity import HomeConnectEntity
def _handle_paired_or_connected_appliance(
@@ -44,12 +18,6 @@ def _handle_paired_or_connected_appliance(
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData],
list[HomeConnectOptionEntity],
]
| None,
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Handle a new paired appliance or an appliance that has been connected.
@@ -66,28 +34,6 @@ def _handle_paired_or_connected_appliance(
for entity in get_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
]
if get_option_entities_for_appliance:
entities_to_add.extend(
entity
for entity in get_option_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
)
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
@@ -101,17 +47,11 @@ def _handle_paired_or_connected_appliance(
def _handle_depaired_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
known_entity_unique_ids.pop(entity_unique_id, None)
if appliance_id in changed_options_listener_remove_callbacks:
for listener in changed_options_listener_remove_callbacks.pop(
appliance_id
):
listener()
def setup_home_connect_entry(
@@ -120,44 +60,13 @@ def setup_home_connect_entry(
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData],
list[HomeConnectOptionEntity],
]
| None = None,
) -> None:
"""Set up the callbacks for paired and depaired appliances."""
known_entity_unique_ids: dict[str, str] = {}
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = (
defaultdict(list)
)
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = get_entities_for_appliance(entry, appliance)
if get_option_entities_for_appliance:
entities_to_add.extend(get_option_entities_for_appliance(entry, appliance))
for event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
changed_options_listener_remove_callback = (
entry.runtime_data.async_add_listener(
partial(
_create_option_entities,
entry,
appliance,
known_entity_unique_ids,
get_option_entities_for_appliance,
async_add_entities,
),
(appliance.info.ha_id, event_key),
)
)
entry.async_on_unload(changed_options_listener_remove_callback)
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
changed_options_listener_remove_callback
)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
@@ -174,8 +83,6 @@ def setup_home_connect_entry(
entry,
known_entity_unique_ids,
get_entities_for_appliance,
get_option_entities_for_appliance,
changed_options_listener_remove_callbacks,
async_add_entities,
),
(
@@ -186,12 +93,7 @@ def setup_home_connect_entry(
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
partial(
_handle_depaired_appliance,
entry,
known_entity_unique_ids,
changed_options_listener_remove_callbacks,
),
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
)
)

View File

@@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
AVAILABLE_MAPS_ENUM = {
REFERENCE_MAP_ID_OPTIONS = {
bsh_key_to_translation_key(option): option
for option in (
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
@@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = {
for option_key, options in (
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
AVAILABLE_MAPS_ENUM,
REFERENCE_MAP_ID_OPTIONS,
),
(
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,

View File

@@ -7,19 +7,16 @@ from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
CommandKey,
Event,
EventKey,
EventMessage,
EventType,
GetSetting,
HomeAppliance,
OptionKey,
ProgramKey,
SettingKey,
Status,
StatusKey,
@@ -31,7 +28,7 @@ from aiohomeconnect.model.error import (
HomeConnectRequestError,
UnauthorizedError,
)
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from aiohomeconnect.model.program import EnumerateProgram
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
@@ -54,21 +51,16 @@ EVENT_STREAM_RECONNECT_DELAY = 30
class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data."""
commands: set[CommandKey]
events: dict[EventKey, Event]
info: HomeAppliance
options: dict[OptionKey, ProgramDefinitionOption]
programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status]
def update(self, other: HomeConnectApplianceData) -> None:
"""Update data with data from other instance."""
self.commands.update(other.commands)
self.events.update(other.events)
self.info.connected = other.info.connected
self.options.clear()
self.options.update(other.options)
self.programs.clear()
self.programs.extend(other.programs)
self.settings.update(other.settings)
@@ -180,9 +172,8 @@ class HomeConnectCoordinator(
settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
event_key = event.key
if event_key in SettingKey:
setting_key = SettingKey(event_key)
if event.key in SettingKey:
setting_key = SettingKey(event.key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
@@ -192,16 +183,7 @@ class HomeConnectCoordinator(
value=event.value,
)
else:
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(cast(str, event.value)),
)
events[event_key] = event
events[event.key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
@@ -356,7 +338,6 @@ class HomeConnectCoordinator(
programs = []
events = {}
options = {}
if appliance.type in APPLIANCES_WITH_PROGRAMS:
try:
all_programs = await self.client.get_all_programs(appliance.ha_id)
@@ -370,17 +351,15 @@ class HomeConnectCoordinator(
)
else:
programs.extend(all_programs.programs)
current_program_key = None
program_options = None
for program, event_key in (
(
all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
(
all_programs.active,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
(
all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
):
if program and program.key:
events[event_key] = Event(
@@ -391,41 +370,10 @@ class HomeConnectCoordinator(
"",
program.key,
)
current_program_key = program.key
program_options = program.options
if current_program_key:
options = await self.get_options_definitions(
appliance.ha_id, current_program_key
)
for option in program_options or []:
option_event_key = EventKey(option.key)
events[option_event_key] = Event(
option_event_key,
option.key,
0,
"",
"",
option.value,
option.name,
display_value=option.display_value,
unit=option.unit,
)
try:
commands = {
command.key
for command in (
await self.client.get_available_commands(appliance.ha_id)
).commands
}
except HomeConnectError:
commands = set()
appliance_data = HomeConnectApplianceData(
commands=commands,
events=events,
info=appliance,
options=options,
programs=programs,
settings=settings,
status=status,
@@ -435,48 +383,3 @@ class HomeConnectCoordinator(
appliance_data = appliance_data_to_update
return appliance_data
async def get_options_definitions(
self, ha_id: str, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
return {
option.key: option
for option in (
await self.client.get_available_program(ha_id, program_key=program_key)
).options
or []
}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
) -> None:
"""Update options for appliance."""
options = self.data[ha_id].options
events = self.data[ha_id].events
options_to_notify = options.copy()
options.clear()
if program_key is not ProgramKey.UNKNOWN:
options.update(await self.get_options_definitions(ha_id, program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None
if option_value is not None:
option_event_key = EventKey(option.key)
events[option_event_key] = Event(
option_event_key,
option.key.value,
0,
"",
"",
option_value,
option.name,
unit=option.unit,
)
options_to_notify.update(options)
for option_key in options_to_notify:
for listener in self.context_listeners.get(
(ha_id, EventKey(option_key)),
[],
):
listener()

View File

@@ -1,22 +1,17 @@
"""Home Connect entity base class."""
from abc import abstractmethod
import contextlib
import logging
from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from aiohomeconnect.model import EventKey
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -65,59 +60,3 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
return (
self.appliance.info.connected and self._attr_available and super().available
)
class HomeConnectOptionEntity(HomeConnectEntity):
"""Class for entities that represents program options."""
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.bsh_key in self.appliance.options
@property
def option_value(self) -> str | int | float | bool | None:
"""Return the state of the entity."""
if event := self.appliance.events.get(EventKey(self.bsh_key)):
return event.value
return None
async def async_set_option(self, value: str | float | bool) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the active program, new state: %s",
self.entity_id,
self.state,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the selected program, new state: %s",
self.entity_id,
self.state,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def bsh_key(self) -> OptionKey:
"""Return the BSH key."""
return cast(OptionKey, self.entity_description.key)

View File

@@ -208,39 +208,6 @@
},
"door-assistant_freezer": {
"default": "mdi:door"
},
"silence_on_demand": {
"default": "mdi:volume-mute",
"state": {
"on": "mdi:volume-mute",
"off": "mdi:volume-high"
}
},
"half_load": {
"default": "mdi:fraction-one-half"
},
"hygiene_plus": {
"default": "mdi:silverware-clean"
},
"eco_dry": {
"default": "mdi:sprout"
},
"fast_pre_heat": {
"default": "mdi:fire"
},
"i_dos_1_active": {
"default": "mdi:numeric-1-circle"
},
"i_dos_2_active": {
"default": "mdi:numeric-2-circle"
}
},
"time": {
"start_in_relative": {
"default": "mdi:progress-clock"
},
"finish_in_relative": {
"default": "mdi:progress-clock"
}
}
}

View File

@@ -3,7 +3,7 @@
import logging
from typing import cast
from aiohomeconnect.model import GetSetting, OptionKey, SettingKey
from aiohomeconnect.model import GetSetting, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.number import (
@@ -11,7 +11,6 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -25,17 +24,11 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
NUMBERS = (
NumberEntityDescription(
@@ -83,11 +76,6 @@ NUMBERS = (
device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_temperature",
),
NumberEntityDescription(
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT,
translation_key="color_temperature_percent",
native_unit_of_measurement="%",
),
NumberEntityDescription(
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
device_class=NumberDeviceClass.VOLUME,
@@ -100,32 +88,6 @@ NUMBERS = (
),
)
NUMBER_OPTIONS = (
NumberEntityDescription(
key=OptionKey.BSH_COMMON_DURATION,
translation_key="duration",
),
NumberEntityDescription(
key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
translation_key="finish_in_relative",
),
NumberEntityDescription(
key=OptionKey.BSH_COMMON_START_IN_RELATIVE,
translation_key="start_in_relative",
),
NumberEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY,
translation_key="fill_quantity",
device_class=NumberDeviceClass.VOLUME,
native_step=1,
),
NumberEntityDescription(
key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE,
translation_key="setpoint_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
@@ -139,18 +101,6 @@ def _get_entities_for_appliance(
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
for description in NUMBER_OPTIONS
if description.key in appliance.options
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
@@ -161,7 +111,6 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -235,44 +184,3 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
or not hasattr(self, "_attr_native_step")
):
await self.async_fetch_constraints()
class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
"""Number option class for Home Connect."""
async def async_set_native_value(self, value: float) -> None:
"""Set the native value of the entity."""
await self.async_set_option(value)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_native_value = cast(float | None, self.option_value)
option_definition = self.appliance.options.get(self.bsh_key)
if option_definition:
if option_definition.unit:
candidate_unit = UNIT_MAP.get(
option_definition.unit, option_definition.unit
)
if (
not hasattr(self, "_attr_native_unit_of_measurement")
or candidate_unit != self._attr_native_unit_of_measurement
):
self._attr_native_unit_of_measurement = candidate_unit
self.__dict__.pop("unit_of_measurement", None)
option_constraints = option_definition.constraints
if option_constraints:
if (
not hasattr(self, "_attr_native_min_value")
or self._attr_native_min_value != option_constraints.min
) and option_constraints.min:
self._attr_native_min_value = option_constraints.min
if (
not hasattr(self, "_attr_native_max_value")
or self._attr_native_max_value != option_constraints.max
) and option_constraints.max:
self._attr_native_max_value = option_constraints.max
if (
not hasattr(self, "_attr_native_step")
or self._attr_native_step != option_constraints.step_size
) and option_constraints.step_size:
self._attr_native_step = option_constraints.step_size

View File

@@ -1,12 +1,11 @@
"""Provides a select platform for Home Connect."""
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model import EventKey, ProgramKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
@@ -18,60 +17,18 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
AVAILABLE_MAPS_ENUM,
BEAN_AMOUNT_OPTIONS,
BEAN_CONTAINER_OPTIONS,
CLEANING_MODE_OPTIONS,
COFFEE_MILK_RATIO_OPTIONS,
COFFEE_TEMPERATURE_OPTIONS,
DOMAIN,
DRYING_TARGET_OPTIONS,
FLOW_RATE_OPTIONS,
HOT_WATER_TEMPERATURE_OPTIONS,
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP,
SPIN_SPEED_OPTIONS,
SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP,
VARIO_PERFECT_OPTIONS,
VENTING_LEVEL_OPTIONS,
WARMING_LEVEL_OPTIONS,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
bsh_key_to_translation_key(option): option
for option in (
"Cooking.Hood.EnumType.ColorTemperature.custom",
"Cooking.Hood.EnumType.ColorTemperature.warm",
"Cooking.Hood.EnumType.ColorTemperature.warmToNeutral",
"Cooking.Hood.EnumType.ColorTemperature.neutral",
"Cooking.Hood.EnumType.ColorTemperature.neutralToCold",
"Cooking.Hood.EnumType.ColorTemperature.cold",
)
}
AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = {
**{
bsh_key_to_translation_key(option): option
for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",)
},
**{
str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}"
for option in range(1, 100)
},
}
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@dataclass(frozen=True, kw_only=True)
@@ -87,14 +44,6 @@ class HomeConnectProgramSelectEntityDescription(
error_translation_key: str
@dataclass(frozen=True, kw_only=True)
class HomeConnectSelectEntityDescription(SelectEntityDescription):
"""Entity Description class for settings and options that have enumeration values."""
translation_key_values: dict[str, str]
values_translation_key: dict[str, str]
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
@@ -116,225 +65,20 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
),
)
SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectSelectEntityDescription(
key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP,
translation_key="current_map",
options=list(AVAILABLE_MAPS_ENUM),
translation_key_values=AVAILABLE_MAPS_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE,
translation_key="functional_light_color_temperature",
options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM),
translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
translation_key="ambient_light_color",
options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM),
translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items()
},
),
)
PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
translation_key="reference_map_id",
options=list(AVAILABLE_MAPS_ENUM),
translation_key_values=AVAILABLE_MAPS_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
translation_key="cleaning_mode",
options=list(CLEANING_MODE_OPTIONS),
translation_key_values=CLEANING_MODE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in CLEANING_MODE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
translation_key="bean_amount",
options=list(BEAN_AMOUNT_OPTIONS),
translation_key_values=BEAN_AMOUNT_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in BEAN_AMOUNT_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
translation_key="coffee_temperature",
options=list(COFFEE_TEMPERATURE_OPTIONS),
translation_key_values=COFFEE_TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
translation_key="bean_container",
options=list(BEAN_CONTAINER_OPTIONS),
translation_key_values=BEAN_CONTAINER_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in BEAN_CONTAINER_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE,
translation_key="flow_rate",
options=list(FLOW_RATE_OPTIONS),
translation_key_values=FLOW_RATE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in FLOW_RATE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
translation_key="coffee_milk_ratio",
options=list(COFFEE_MILK_RATIO_OPTIONS),
translation_key_values=COFFEE_MILK_RATIO_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in FLOW_RATE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
translation_key="hot_water_temperature",
options=list(HOT_WATER_TEMPERATURE_OPTIONS),
translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET,
translation_key="drying_target",
options=list(DRYING_TARGET_OPTIONS),
translation_key_values=DRYING_TARGET_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in DRYING_TARGET_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL,
translation_key="venting_level",
options=list(VENTING_LEVEL_OPTIONS),
translation_key_values=VENTING_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in VENTING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL,
translation_key="intensive_level",
options=list(INTENSIVE_LEVEL_OPTIONS),
translation_key_values=INTENSIVE_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_OVEN_WARMING_LEVEL,
translation_key="warming_level",
options=list(WARMING_LEVEL_OPTIONS),
translation_key_values=WARMING_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
translation_key="washer_temperature",
options=list(TEMPERATURE_OPTIONS),
translation_key_values=TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
translation_key="spin_speed",
options=list(SPIN_SPEED_OPTIONS),
translation_key_values=SPIN_SPEED_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in SPIN_SPEED_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
translation_key="vario_perfect",
options=list(VARIO_PERFECT_OPTIONS),
translation_key_values=VARIO_PERFECT_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in VARIO_PERFECT_OPTIONS.items()
},
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*(
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else []
),
*[
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
for desc in SELECT_ENTITY_DESCRIPTIONS
if desc.key in appliance.settings
],
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
if desc.key in appliance.options
]
return (
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else []
)
async def async_setup_entry(
@@ -347,7 +91,6 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -405,122 +148,3 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
},
) from err
class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
"""Select setting class for Home Connect."""
entity_description: HomeConnectSelectEntityDescription
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
desc,
)
async def async_select_option(self, option: str) -> None:
"""Select new option."""
value = self.entity_description.translation_key_values[option]
try:
await self.coordinator.client.set_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
value=value,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: value,
},
) from err
def update_native_value(self) -> None:
"""Set the value of the entity."""
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
self._attr_current_option = self.entity_description.values_translation_key.get(
data.value
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
if (
not setting
or not setting.constraints
or not setting.constraints.allowed_values
):
with contextlib.suppress(HomeConnectError):
setting = await self.coordinator.client.get_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
)
if setting and setting.constraints and setting.constraints.allowed_values:
self._attr_options = [
self.entity_description.values_translation_key[option]
for option in setting.constraints.allowed_values
if option in self.entity_description.values_translation_key
]
class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
"""Select option class for Home Connect."""
entity_description: HomeConnectSelectEntityDescription
_original_option_keys: set[str | None]
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key.keys())
super().__init__(
coordinator,
appliance,
desc,
)
async def async_select_option(self, option: str) -> None:
"""Select new option."""
await self.async_set_option(
self.entity_description.translation_key_values[option]
)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_current_option = (
self.entity_description.values_translation_key.get(
cast(str, self.option_value), None
)
if self.option_value is not None
else None
)
if (
(option_definition := self.appliance.options.get(self.bsh_key))
and (option_constraints := option_definition.constraints)
and option_constraints.allowed_values
and self._original_option_keys != set(option_constraints.allowed_values)
):
self._original_option_keys = set(option_constraints.allowed_values)
self._attr_options = [
self.entity_description.values_translation_key[option]
for option in self._original_option_keys
if option is not None
]
self.__dict__.pop("options", None)

View File

@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
@@ -56,6 +56,12 @@ BSH_PROGRAM_SENSORS = (
"WasherDryer",
),
),
HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_DURATION,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
appliance_types=("Oven",),
),
HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
native_unit_of_measurement=PERCENTAGE,

View File

@@ -98,9 +98,6 @@
},
"required_program_or_one_option_at_least": {
"message": "A program or at least one of the possible options for a program should be specified"
},
"set_option": {
"message": "Error setting the option for the program: {error}"
}
},
"issues": {
@@ -815,23 +812,6 @@
"name": "Wine compartment door"
}
},
"button": {
"open_door": {
"name": "Open door"
},
"partly_open_door": {
"name": "Partly open door"
},
"pause_program": {
"name": "Pause program"
},
"resume_program": {
"name": "Resume program"
},
"stop_program": {
"name": "Stop program"
}
},
"light": {
"cooking_lighting": {
"name": "Functional light"
@@ -874,29 +854,11 @@
"wine_compartment_3_setpoint_temperature": {
"name": "Wine compartment 3 temperature"
},
"color_temperature_percent": {
"name": "Functional light color temperature percent"
},
"washer_i_dos_1_base_level": {
"name": "i-Dos 1 base level"
},
"washer_i_dos_2_base_level": {
"name": "i-Dos 2 base level"
},
"duration": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]"
},
"start_in_relative": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
},
"finish_in_relative": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
},
"fill_quantity": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]"
},
"setpoint_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]"
}
},
"select": {
@@ -1217,226 +1179,6 @@
"laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
"laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
}
},
"current_map": {
"name": "Current map",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
}
},
"functional_light_color_temperature": {
"name": "Functional light color temperature",
"state": {
"cooking_hood_enum_type_color_temperature_custom": "Custom",
"cooking_hood_enum_type_color_temperature_warm": "Warm",
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
"cooking_hood_enum_type_color_temperature_cold": "Cold"
}
},
"ambient_light_color": {
"name": "Ambient light color",
"state": {
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
}
},
"reference_map_id": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
}
},
"cleaning_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
}
},
"bean_amount": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]",
"consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]",
"consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]",
"consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]",
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]"
}
},
"coffee_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]"
}
},
"bean_container": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]",
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]"
}
},
"flow_rate": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]",
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]",
"consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]"
}
},
"coffee_milk_ratio": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]"
}
},
"hot_water_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]"
}
},
"drying_target": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]",
"state": {
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]",
"laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]",
"laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]",
"laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]",
"laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]"
}
},
"venting_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
"state": {
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
"cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]",
"cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]",
"cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]",
"cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]",
"cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]"
}
},
"intensive_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
"state": {
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]"
}
},
"warming_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]",
"state": {
"cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]",
"cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]",
"cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]"
}
},
"washer_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
"state": {
"laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
"laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]",
"laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]",
"laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]",
"laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]",
"laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]",
"laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]",
"laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]",
"laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]",
"laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
"laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
"laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
"laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]"
}
},
"spin_speed": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
"state": {
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]"
}
},
"vario_perfect": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
"state": {
"laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]",
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
}
}
},
"sensor": {
@@ -1623,45 +1365,6 @@
},
"door_assistant_freezer": {
"name": "Freezer door assistant"
},
"multiple_beverages": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
},
"intensiv_zone": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
},
"brilliance_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]"
},
"vario_speed_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
},
"silence_on_demand": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
},
"half_load": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]"
},
"extra_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]"
},
"hygiene_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
},
"eco_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]"
},
"zeolite_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
},
"fast_pre_heat": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]"
},
"i_dos1_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
},
"i_dos2_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
}
},
"time": {

View File

@@ -3,7 +3,7 @@
import logging
from typing import Any, cast
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import EnumerateProgram
@@ -37,7 +37,7 @@ from .coordinator import (
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -100,61 +100,6 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
translation_key="power",
)
SWITCH_OPTIONS = (
SwitchEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES,
translation_key="multiple_beverages",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE,
translation_key="intensiv_zone",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY,
translation_key="brilliance_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS,
translation_key="vario_speed_plus",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND,
translation_key="silence_on_demand",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
translation_key="half_load",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY,
translation_key="extra_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS,
translation_key="hygiene_plus",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY,
translation_key="eco_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY,
translation_key="zeolite_dry",
),
SwitchEntityDescription(
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
translation_key="fast_pre_heat",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
translation_key="i_dos1_active",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
translation_key="i_dos2_active",
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
@@ -178,21 +123,10 @@ def _get_entities_for_appliance(
for description in SWITCHES
if description.key in appliance.settings
)
return entities
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
for description in SWITCH_OPTIONS
if description.key in appliance.options
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
@@ -203,7 +137,6 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -470,19 +403,3 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
self.power_off_state = BSH_POWER_STANDBY
else:
self.power_off_state = None
class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity):
"""Switch option class for Home Connect."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the option."""
await self.async_set_option(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the option."""
await self.async_set_option(False)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_is_on = cast(bool | None, self.option_value)

View File

@@ -28,13 +28,12 @@ from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
FirmwareInfo,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
guess_hardware_owners,
probe_silabs_firmware_info,
probe_silabs_firmware_type,
)
_LOGGER = logging.getLogger(__name__)
@@ -53,7 +52,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Instantiate base flow."""
super().__init__(*args, **kwargs)
self._probed_firmware_info: FirmwareInfo | None = None
self._probed_firmware_type: ApplicationType | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
@@ -65,8 +64,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Shared translation placeholders."""
placeholders = {
"firmware_type": (
self._probed_firmware_info.firmware_type.value
if self._probed_firmware_info is not None
self._probed_firmware_type.value
if self._probed_firmware_type is not None
else "unknown"
),
"model": self._hardware_name,
@@ -121,49 +120,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
async def _probe_firmware_info(
self,
probe_methods: tuple[ApplicationType, ...] = (
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
),
) -> bool:
async def _probe_firmware_type(self) -> bool:
"""Probe the firmware currently on the device."""
assert self._device is not None
self._probed_firmware_info = await probe_silabs_firmware_info(
self._probed_firmware_type = await probe_silabs_firmware_type(
self._device,
probe_methods=probe_methods,
)
return (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type
in (
probe_methods=(
# We probe in order of frequency: Zigbee, Thread, then multi-PAN
ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
),
)
return self._probed_firmware_type in (
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
if not await self._probe_firmware_info():
if not await self._probe_firmware_type():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# Allow the stick to be used with ZHA without flashing
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
):
if self._probed_firmware_type == ApplicationType.EZSP:
return await self.async_step_confirm_zigbee()
if not is_hassio(self.hass):
@@ -349,12 +338,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
self._probed_firmware_type = ApplicationType.EZSP
if user_input is not None:
await self.hass.config_entries.flow.async_init(
@@ -382,7 +366,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
if not await self._probe_firmware_info():
if not await self._probe_firmware_type():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
@@ -474,11 +458,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
self._probed_firmware_type = ApplicationType.SPINEL
if user_input is not None:
# OTBR discovery is done automatically via hassio
@@ -517,14 +497,14 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
_probed_firmware_info: FirmwareInfo
def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
super().__init__(*args, **kwargs)
self._config_entry = config_entry
self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
# Make `context` a regular dictionary
self.context = {}

View File

@@ -5,5 +5,5 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": ["universal-silabs-flasher==0.0.29"]
"requirements": ["universal-silabs-flasher==0.0.25"]
}

View File

@@ -42,7 +42,6 @@ class ApplicationType(StrEnum):
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
ROUTER = "router"
@classmethod
def from_flasher_application_type(
@@ -249,10 +248,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
return guesses[-1][0]
async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
) -> ApplicationType | None:
"""Probe the running firmware on a Silabs device."""
flasher = Flasher(
device=device,
**(
@@ -270,26 +269,4 @@ async def probe_silabs_firmware_info(
if flasher.app_type is None:
return None
return FirmwareInfo(
device=device,
firmware_type=ApplicationType.from_flasher_application_type(flasher.app_type),
firmware_version=(
flasher.app_version.orig_version
if flasher.app_version is not None
else None
),
source="probe",
owners=[],
)
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
if fw_info is None:
return None
return fw_info.firmware_type
return ApplicationType.from_flasher_application_type(flasher.app_type)

View File

@@ -10,10 +10,7 @@ from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -121,7 +118,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Create the config entry."""
assert self._usb_info is not None
assert self._hw_variant is not None
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
return self.async_create_entry(
title=self._hw_variant.full_name,
@@ -133,7 +130,7 @@ class HomeAssistantSkyConnectConfigFlow(
"description": self._usb_info.description, # For backwards compatibility
"product": self._usb_info.description,
"device": self._usb_info.device,
"firmware": self._probed_firmware_info.firmware_type.value,
"firmware": self._probed_firmware_type.value,
},
)
@@ -206,26 +203,18 @@ class HomeAssistantSkyConnectOptionsFlowHandler(
self._hardware_name = self._hw_variant.full_name
self._device = self._usb_info.device
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
"firmware": self._probed_firmware_info.firmware_type.value,
"firmware": self._probed_firmware_type.value,
},
options=self.config_entry.options,
)

View File

@@ -24,10 +24,7 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
)
from homeassistant.components.homeassistant_hardware.util import ApplicationType
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
@@ -82,13 +79,10 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
await self._probe_firmware_info()
await self._probe_firmware_type()
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (
self._probed_firmware_info is not None
and self._probed_firmware_info.firmware_type is ApplicationType.EZSP
):
if self._probed_firmware_type is ApplicationType.EZSP:
discovery_flow.async_create_flow(
self.hass,
ZHA_DOMAIN,
@@ -104,11 +98,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
title=BOARD_NAME,
data={
# Assume the firmware type is EZSP if we cannot probe it
FIRMWARE: (
self._probed_firmware_info.firmware_type
if self._probed_firmware_info is not None
else ApplicationType.EZSP
).value,
FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value,
},
)
@@ -274,14 +264,6 @@ class HomeAssistantYellowOptionsFlowHandler(
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
self._probed_firmware_info = FirmwareInfo(
device=self._device,
firmware_type=ApplicationType(self.config_entry.data["firmware"]),
firmware_version=None,
source="guess",
owners=[],
)
# Regenerate the translation placeholders
self._get_translation_placeholders()
@@ -303,13 +285,13 @@ class HomeAssistantYellowOptionsFlowHandler(
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._probed_firmware_info is not None
assert self._probed_firmware_type is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
FIRMWARE: self._probed_firmware_info.firmware_type.value,
FIRMWARE: self._probed_firmware_type.value,
},
)

View File

@@ -157,7 +157,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
key="rainfall_day",
device_class=SensorDeviceClass.PRECIPITATION,
state_class=SensorStateClass.TOTAL_INCREASING,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
key="humidity",

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==4.9.2",
"fnv-hash-fast==1.2.3",
"fnv-hash-fast==1.2.2",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -10,6 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
"requirements": ["aiohue==4.7.4"],
"requirements": ["aiohue==4.7.3"],
"zeroconf": ["_hue._tcp.local."]
}

View File

@@ -107,9 +107,7 @@ class HueLight(HueBaseEntity, LightEntity):
self._attr_effect_list = []
if effects := resource.effects:
self._attr_effect_list = [
x.value
for x in effects.status_values
if x not in (EffectStatus.NO_EFFECT, EffectStatus.UNKNOWN)
x.value for x in effects.status_values if x != EffectStatus.NO_EFFECT
]
if timed_effects := resource.timed_effects:
self._attr_effect_list += [

View File

@@ -4,20 +4,17 @@ from __future__ import annotations
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from inkbird_ble import INKBIRDBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
)
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_TYPE, DOMAIN
from .const import DOMAIN
PLATFORMS: list[Platform] = [Platform.SENSOR]
@@ -28,33 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
address = entry.unique_id
assert address is not None
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type)
@callback
def _async_on_update(service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
nonlocal device_type
update = data.update(service_info)
if device_type is None and data.device_type is not None:
device_type_str = str(data.device_type)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_DEVICE_TYPE: device_type_str}
)
device_type = device_type_str
return update
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=_async_on_update,
data = INKBIRDBluetoothDeviceData()
coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
)
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
return True

View File

@@ -1,5 +1,3 @@
"""Constants for the INKBIRD Bluetooth integration."""
DOMAIN = "inkbird"
CONF_DEVICE_TYPE = "device_type"

View File

@@ -28,5 +28,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
"requirements": ["inkbird-ble==0.7.0"]
"requirements": ["inkbird-ble==0.5.8"]
}

View File

@@ -17,7 +17,7 @@ from homeassistant.util.dt import parse_datetime
from .browse_media import build_item_response, build_root_response
from .client_wrapper import get_artwork_url
from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
from .const import CONTENT_TYPE_MAP, LOGGER
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .entity import JellyfinClientEntity
@@ -169,9 +169,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
if self.now_playing is None:
return None
return get_artwork_url(
self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH
)
return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
@property
def supported_features(self) -> MediaPlayerEntityFeature:

View File

@@ -49,7 +49,6 @@ from .helpers import (
InputType,
async_update_config_entry,
generate_unique_id,
purge_device_registry,
register_lcn_address_devices,
register_lcn_host_device,
)
@@ -121,9 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
register_lcn_host_device(hass, config_entry)
register_lcn_address_devices(hass, config_entry)
# clean up orphaned devices
purge_device_registry(hass, config_entry.entry_id, {**config_entry.data})
# forward config_entry to components
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@@ -3,18 +3,19 @@
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import CONF_DOMAIN_DATA, DOMAIN
from .helpers import (
AddressType,
DeviceConnectionType,
InputType,
generate_unique_id,
get_device_connection,
get_device_model,
)
@@ -35,14 +36,6 @@ class LcnEntity(Entity):
self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None
self._name: str = config[CONF_NAME]
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
generate_unique_id(self.config_entry.entry_id, self.address),
)
},
)
@property
def unique_id(self) -> str:
@@ -51,6 +44,28 @@ class LcnEntity(Entity):
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
)
@property
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}"
model = (
"LCN resource"
f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})"
)
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"{address}.{self.config[CONF_RESOURCE]}",
model=model,
manufacturer="Issendorff",
via_device=(
DOMAIN,
generate_unique_id(
self.config_entry.entry_id, self.config[CONF_ADDRESS]
),
),
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.device_connection = get_device_connection(

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from copy import deepcopy
from itertools import chain
import re
from typing import cast
@@ -21,6 +22,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_RESOURCE,
CONF_SENSORS,
CONF_SOURCE,
CONF_SWITCHES,
)
from homeassistant.core import HomeAssistant
@@ -28,14 +30,23 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import (
BINSENSOR_PORTS,
CONF_CLIMATES,
CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE,
CONF_OUTPUT,
CONF_SCENES,
CONF_SOFTWARE_SERIAL,
CONNECTION,
DEVICE_CONNECTIONS,
DOMAIN,
LED_PORTS,
LOGICOP_PORTS,
OUTPUT_PORTS,
S0_INPUTS,
SETPOINTS,
THRESHOLDS,
VARIABLES,
)
# typing
@@ -85,6 +96,31 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
raise ValueError("Unknown domain")
def get_device_model(domain_name: str, domain_data: ConfigType) -> str:
"""Return the model for the specified domain_data."""
if domain_name in ("switch", "light"):
return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay"
if domain_name in ("binary_sensor", "sensor"):
if domain_data[CONF_SOURCE] in BINSENSOR_PORTS:
return "Binary Sensor"
if domain_data[CONF_SOURCE] in chain(
VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS
):
return "Variable"
if domain_data[CONF_SOURCE] in LED_PORTS:
return "Led"
if domain_data[CONF_SOURCE] in LOGICOP_PORTS:
return "Logical Operation"
return "Key"
if domain_name == "cover":
return "Motor"
if domain_name == "climate":
return "Regulator"
if domain_name == "scene":
return "Scene"
raise ValueError("Unknown domain")
def generate_unique_id(
entry_id: str,
address: AddressType,
@@ -133,6 +169,13 @@ def purge_device_registry(
) -> None:
"""Remove orphans from device registry which are not in entry data."""
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
# Find all devices that are referenced in the entity registry.
references_entities = {
entry.device_id
for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id)
}
# Find device that references the host.
references_host = set()
@@ -155,6 +198,7 @@ def purge_device_registry(
entry.id
for entry in dr.async_entries_for_config_entry(device_registry, entry_id)
}
- references_entities
- references_host
- references_entry_data
)

View File

@@ -581,44 +581,36 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
self._device_state = (
self.coordinator.data[self._device_state_id].value
if self._device_state_id in self.coordinator.data
else None
)
if value in [0, None, time.min] or (
self._device_state == "power_off"
and self.entity_description.key
in [TimerProperty.REMAIN, TimerProperty.TOTAL]
):
# Reset to None when power_off
if value in [0, None, time.min]:
# Reset to None
value = None
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
if self.entity_description.key in TIME_SENSOR_DESC:
# Set timestamp for absolute time
# Set timestamp for time
value = local_now.replace(hour=value.hour, minute=value.minute)
else:
# Set timestamp for delta
event_data = timedelta(
new_state = (
self.coordinator.data[self._device_state_id].value
if self._device_state_id in self.coordinator.data
else None
)
if (
self.native_value is not None
and self._device_state == new_state
):
# Skip update when same state
return
self._device_state = new_state
time_delta = timedelta(
hours=value.hour, minutes=value.minute, seconds=value.second
)
new_time = (
(local_now - event_data)
value = (
(local_now - time_delta)
if self.entity_description.key == TimerProperty.RUNNING
else (local_now + event_data)
else (local_now + time_delta)
)
# The remain_time may change during the wash/dry operation depending on various reasons.
# If there is a diff of more than 60sec, the new timestamp is used
if (
parse_native_value := dt_util.parse_datetime(
str(self.native_value)
)
) is None or abs(new_time - parse_native_value) > timedelta(
seconds=60
):
value = new_time
else:
value = self.native_value
elif self.entity_description.device_class == SensorDeviceClass.DURATION:
# Set duration
value = self._get_duration(

View File

@@ -9,13 +9,15 @@ import dns.rdata
import dns.rdataclass
import dns.rdatatype
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .coordinator import MinecraftServerCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -29,18 +31,32 @@ def load_dnspython_rdata_classes() -> None:
dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call]
async def async_setup_entry(
hass: HomeAssistant, entry: MinecraftServerConfigEntry
) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Minecraft Server from a config entry."""
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
await hass.async_add_executor_job(load_dnspython_rdata_classes)
# Create coordinator instance and store it.
coordinator = MinecraftServerCoordinator(hass, entry)
# Create API instance.
api = MinecraftServer(
hass,
entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
entry.data[CONF_ADDRESS],
)
# Initialize API instance.
try:
await api.async_initialize()
except MinecraftServerAddressError as error:
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
# Create coordinator instance.
coordinator = MinecraftServerCoordinator(hass, entry, api)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Store coordinator instance.
domain_data = hass.data.setdefault(DOMAIN, {})
domain_data[entry.entry_id] = coordinator
# Set up platforms.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -48,16 +64,21 @@ async def async_setup_entry(
return True
async def async_unload_entry(
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload Minecraft Server config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
# Unload platforms.
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
# Clean up.
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
async def async_migrate_entry(
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> bool:
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old config entry to a new format."""
# 1 --> 2: Use config entry ID as base for unique IDs.
@@ -131,9 +152,7 @@ async def async_migrate_entry(
async def _async_migrate_device_identifiers(
hass: HomeAssistant,
config_entry: MinecraftServerConfigEntry,
old_unique_id: str | None,
hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None
) -> None:
"""Migrate the device identifiers to the new format."""
device_registry = dr.async_get(hass)

View File

@@ -5,10 +5,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .const import DOMAIN
from .coordinator import MinecraftServerCoordinator
from .entity import MinecraftServerEntity
KEY_STATUS = "status"
@@ -25,11 +27,11 @@ BINARY_SENSOR_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MinecraftServerConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Minecraft Server binary sensor platform."""
coordinator = config_entry.runtime_data
coordinator = hass.data[DOMAIN][config_entry.entry_id]
# Add binary sensor entities.
async_add_entities(
@@ -47,7 +49,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit
self,
coordinator: MinecraftServerCoordinator,
description: BinarySensorEntityDescription,
config_entry: MinecraftServerConfigEntry,
config_entry: ConfigEntry,
) -> None:
"""Initialize binary sensor base entity."""
super().__init__(coordinator, config_entry)

View File

@@ -6,22 +6,17 @@ from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
MinecraftServer,
MinecraftServerAddressError,
MinecraftServerConnectionError,
MinecraftServerData,
MinecraftServerNotInitializedError,
MinecraftServerType,
)
type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator]
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
@@ -30,15 +25,16 @@ _LOGGER = logging.getLogger(__name__)
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
"""Minecraft Server data update coordinator."""
config_entry: MinecraftServerConfigEntry
_api: MinecraftServer
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: MinecraftServerConfigEntry,
config_entry: ConfigEntry,
api: MinecraftServer,
) -> None:
"""Initialize coordinator instance."""
self._api = api
super().__init__(
hass=hass,
@@ -48,22 +44,6 @@ class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
update_interval=SCAN_INTERVAL,
)
async def _async_setup(self) -> None:
"""Set up the Minecraft Server data coordinator."""
# Create API instance.
self._api = MinecraftServer(
self.hass,
self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
self.config_entry.data[CONF_ADDRESS],
)
# Initialize API instance.
try:
await self._api.async_initialize()
except MinecraftServerAddressError as error:
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
async def _async_update_data(self) -> MinecraftServerData:
"""Get updated data from the server."""
try:

View File

@@ -5,19 +5,20 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
from .coordinator import MinecraftServerConfigEntry
from .const import DOMAIN
TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = config_entry.runtime_data
coordinator = hass.data[DOMAIN][config_entry.entry_id]
return {
"config_entry": {

View File

@@ -29,7 +29,7 @@ rules:
status: done
comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
has-entity-name: done
runtime-data: done
runtime-data: todo
test-before-configure: done
test-before-setup:
status: done

View File

@@ -7,14 +7,15 @@ from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .api import MinecraftServerData, MinecraftServerType
from .const import KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerCoordinator
from .entity import MinecraftServerEntity
ATTR_PLAYERS_LIST = "players_list"
@@ -157,11 +158,11 @@ SENSOR_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MinecraftServerConfigEntry,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Minecraft Server sensor platform."""
coordinator = config_entry.runtime_data
coordinator = hass.data[DOMAIN][config_entry.entry_id]
# Add sensor entities.
async_add_entities(
@@ -183,7 +184,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity):
self,
coordinator: MinecraftServerCoordinator,
description: MinecraftServerSensorEntityDescription,
config_entry: MinecraftServerConfigEntry,
config_entry: ConfigEntry,
) -> None:
"""Initialize sensor base entity."""
super().__init__(coordinator, config_entry)

View File

@@ -384,11 +384,6 @@ class ModbusHub:
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
)
entry = self._pb_request[use_call]
if use_call in {"write_registers", "write_coils"}:
if not isinstance(value, list):
value = [value]
kwargs[entry.value_attr_name] = value
try:
result: ModbusPDU = await entry.func(address, **kwargs)

View File

@@ -58,12 +58,12 @@
},
"services": {
"lock_n_go": {
"name": "Lock 'n' Go",
"description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.",
"name": "Lock 'n' go",
"description": "Nuki Lock 'n' Go.",
"fields": {
"unlatch": {
"name": "Unlatch",
"description": "Whether to also unlatch the door when unlocking it."
"description": "Whether to unlatch the lock."
}
}
},

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from html import unescape
from json import dumps, loads
import logging
@@ -11,10 +10,10 @@ from typing import cast
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import (
AuthenticationError,
NotFoundError,
HttpRequestException,
OneDriveException,
)
from onedrive_personal_sdk.models.items import Item, ItemUpdate
from onedrive_personal_sdk.models.items import ItemUpdate
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
@@ -26,7 +25,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import (
OneDriveConfigEntry,
OneDriveRuntimeData,
@@ -51,38 +50,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
# get approot, will be created automatically if it does not exist
approot = await _handle_item_operation(client.get_approot, "approot")
folder_name = entry.data[CONF_FOLDER_NAME]
try:
backup_folder = await _handle_item_operation(
lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]),
folder_name,
)
except NotFoundError:
_LOGGER.debug("Creating backup folder %s", folder_name)
backup_folder = await _handle_item_operation(
lambda: client.create_folder(parent_id=approot.id, name=folder_name),
folder_name,
)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
)
approot = await client.get_approot()
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "approot"},
) from err
# write instance id to description
if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
await _handle_item_operation(
lambda: client.update_drive_item(
backup_folder.id, ItemUpdate(description=instance_id)
),
folder_name,
)
# update in case folder was renamed manually inside OneDrive
if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name}
instance_id = await async_get_instance_id(hass)
backup_folder_name = f"backups_{instance_id[:8]}"
try:
backup_folder = await client.create_folder(
parent_id=approot.id, name=backup_folder_name
)
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to create backup folder", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": backup_folder_name},
) from err
coordinator = OneDriveUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
@@ -158,47 +152,3 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
data=ItemUpdate(description=""),
)
_LOGGER.debug("Migrated backup file %s", file.name)
async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1:
_LOGGER.debug(
"Migrating OneDrive config entry from version %s.%s", version, minor_version
)
instance_id = await async_get_instance_id(hass)
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_FOLDER_ID: "id", # will be updated during setup_entry
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
},
)
_LOGGER.debug("Migration to version 1.2 successful")
return True
async def _handle_item_operation(
func: Callable[[], Awaitable[Item]], folder: str
) -> Item:
try:
return await func()
except NotFoundError:
raise
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": folder},
) from err

View File

@@ -74,7 +74,7 @@ def async_register_backup_agents_listener(
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]:
"""Handle backup errors."""
"""Handle backup errors with a specific translation key."""
@wraps(func)
async def wrapper(

View File

@@ -8,47 +8,22 @@ from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import (
CONF_DELETE_PERMANENTLY,
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
OAUTH_SCOPES,
)
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
from .coordinator import OneDriveConfigEntry
FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle OneDrive OAuth2 authentication."""
DOMAIN = DOMAIN
MINOR_VERSION = 2
client: OneDriveClient
approot: AppRoot
def __init__(self) -> None:
"""Initialize the OneDrive config flow."""
super().__init__()
self.step_data: dict[str, Any] = {}
@property
def logger(self) -> logging.Logger:
@@ -60,15 +35,6 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH_SCOPES)}
@property
def apps_folder(self) -> str:
"""Return the name of the Apps folder (translated)."""
return (
path.split("/")[-1]
if (path := self.approot.parent_reference.path)
else "Apps"
)
async def async_oauth_create_entry(
self,
data: dict[str, Any],
@@ -78,12 +44,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def get_access_token() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
self.client = OneDriveClient(
graph_client = OneDriveClient(
get_access_token, async_get_clientsession(self.hass)
)
try:
self.approot = await self.client.get_approot()
approot = await graph_client.get_approot()
except OneDriveException:
self.logger.exception("Failed to connect to OneDrive")
return self.async_abort(reason="connection_error")
@@ -91,118 +57,26 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self.logger.exception("Unknown error")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(self.approot.parent_reference.drive_id)
if self.source != SOURCE_USER:
self._abort_if_unique_id_mismatch(
reason="wrong_drive",
)
await self.async_set_unique_id(approot.parent_reference.drive_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_drive",
)
return self.async_update_reload_and_abort(
entry=reauth_entry,
data=data,
)
if self.source != SOURCE_RECONFIGURE:
self._abort_if_unique_id_configured()
self._abort_if_unique_id_configured()
self.step_data = data
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_reconfigure_folder()
return await self.async_step_folder_name()
async def async_step_folder_name(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to ask for the folder name."""
errors: dict[str, str] = {}
instance_id = await async_get_instance_id(self.hass)
if user_input is not None:
try:
folder = await self.client.create_folder(
self.approot.id, user_input[CONF_FOLDER_NAME]
)
except OneDriveException:
self.logger.debug("Failed to create folder", exc_info=True)
errors["base"] = "folder_creation_error"
else:
if folder.description and folder.description != instance_id:
errors[CONF_FOLDER_NAME] = "folder_already_in_use"
if not errors:
title = (
f"{self.approot.created_by.user.display_name}'s OneDrive"
if self.approot.created_by.user
and self.approot.created_by.user.display_name
else "OneDrive"
)
return self.async_create_entry(
title=title,
data={
**self.step_data,
CONF_FOLDER_ID: folder.id,
CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME],
},
)
default_folder_name = (
f"backups_{instance_id[:8]}"
if user_input is None
else user_input[CONF_FOLDER_NAME]
)
return self.async_show_form(
step_id="folder_name",
data_schema=self.add_suggested_values_to_schema(
FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name}
),
description_placeholders={
"apps_folder": self.apps_folder,
"approot": self.approot.name,
},
errors=errors,
)
async def async_step_reconfigure_folder(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the folder name."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
if (
new_folder_name := user_input[CONF_FOLDER_NAME]
) != reconfigure_entry.data[CONF_FOLDER_NAME]:
try:
await self.client.update_drive_item(
reconfigure_entry.data[CONF_FOLDER_ID],
ItemUpdate(name=new_folder_name),
)
except OneDriveException:
self.logger.debug("Failed to update folder", exc_info=True)
errors["base"] = "folder_rename_error"
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name},
)
return self.async_show_form(
step_id="reconfigure_folder",
data_schema=self.add_suggested_values_to_schema(
FOLDER_NAME_SCHEMA,
{CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]},
),
description_placeholders={
"apps_folder": self.apps_folder,
"approot": self.approot.name,
},
errors=errors,
title = (
f"{approot.created_by.user.display_name}'s OneDrive"
if approot.created_by.user and approot.created_by.user.display_name
else "OneDrive"
)
return self.async_create_entry(title=title, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -218,12 +92,6 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the entry."""
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(

View File

@@ -6,8 +6,6 @@ from typing import Final
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive"
CONF_FOLDER_NAME: Final = "folder_name"
CONF_FOLDER_ID: Final = "folder_id"
CONF_DELETE_PERMANENTLY: Final = "delete_permanently"

View File

@@ -73,7 +73,10 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow:
status: exempt
comment: |
Nothing to reconfigure.
repair-issues: done
stale-devices:
status: exempt

View File

@@ -7,26 +7,6 @@
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The OneDrive integration needs to re-authenticate your account"
},
"folder_name": {
"title": "Pick a folder name",
"description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`",
"data": {
"folder_name": "Folder name"
},
"data_description": {
"folder_name": "Name of the folder"
}
},
"reconfigure_folder": {
"title": "Change the folder name",
"description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.",
"data": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]"
},
"data_description": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]"
}
}
},
"abort": {
@@ -43,16 +23,10 @@
"connection_error": "Failed to connect to OneDrive.",
"wrong_drive": "New account does not contain previously configured OneDrive.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"error": {
"folder_rename_error": "Failed to rename folder",
"folder_creation_error": "Failed to create folder",
"folder_already_in_use": "Folder already used for backups from another Home Assistant instance"
}
},
"options": {

View File

@@ -287,9 +287,6 @@ class OpenAIConversationEntity(
try:
result = await client.chat.completions.create(**model_args)
except openai.RateLimitError as err:
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/prosegur",
"iot_class": "cloud_polling",
"loggers": ["pyprosegur"],
"requirements": ["pyprosegur==0.0.14"]
"requirements": ["pyprosegur==0.0.9"]
}

View File

@@ -59,11 +59,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
async def _async_update_data(self) -> PyLoadData:
"""Fetch data from API endpoint."""
try:
if not self.version:
self.version = await self.pyload.version()
return PyLoadData(
**await self.pyload.get_status(),
free_space=await self.pyload.free_space(),
)
except InvalidAuth:
except InvalidAuth as e:
try:
await self.pyload.login()
except InvalidAuth as exc:
@@ -72,10 +75,10 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from exc
_LOGGER.debug(
"Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
)
return self.data
raise UpdateFailed(
"Unable to retrieve data due to cookie expiration"
) from e
except CannotConnect as e:
raise UpdateFailed(
"Unable to connect and retrieve data from pyLoad API"
@@ -88,7 +91,6 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
try:
await self.pyload.login()
self.version = await self.pyload.version()
except CannotConnect as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,

View File

@@ -13,5 +13,5 @@
"cloudapp/QBUSMQTTGW/+/state"
],
"quality_scale": "bronze",
"requirements": ["qbusmqttapi==1.3.0"]
"requirements": ["qbusmqttapi==1.2.4"]
}

View File

@@ -50,11 +50,6 @@ STATES_META_SCHEMA_VERSION = 38
LAST_REPORTED_SCHEMA_VERSION = 43
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
# https://github.com/home-assistant/core/pull/120779
# fixed the foreign keys in the states table but it did
# not bump the schema version which means only databases
# created with schema 44 and later do not need the rebuild.
INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics"
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"

View File

@@ -8,7 +8,7 @@
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.38",
"fnv-hash-fast==1.2.3",
"fnv-hash-fast==1.2.2",
"psutil-home-assistant==0.0.1"
]
}

View File

@@ -52,7 +52,6 @@ from .auto_repairs.statistics.schema import (
from .const import (
CONTEXT_ID_AS_BINARY_SCHEMA_VERSION,
EVENT_TYPE_IDS_SCHEMA_VERSION,
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION,
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION,
STATES_META_SCHEMA_VERSION,
SupportedDialect,
@@ -2491,10 +2490,9 @@ class BaseMigration(ABC):
if self.initial_schema_version > self.max_initial_schema_version:
_LOGGER.debug(
"Data migration '%s' not needed, database created with version %s "
"after migrator was added in version %s",
"after migrator was added",
self.migration_id,
self.initial_schema_version,
self.max_initial_schema_version,
)
return False
if self.start_schema_version < self.required_schema_version:
@@ -2870,14 +2868,7 @@ class EventIDPostMigration(BaseRunTimeMigration):
"""Migration to remove old event_id index from states."""
migration_id = "event_id_post_migration"
# Note we don't subtract 1 from the max_initial_schema_version
# in this case because we need to run this migration on databases
# version >= 43 because the schema was not bumped when the table
# rebuild was added in
# https://github.com/home-assistant/core/pull/120779
# which means its only safe to assume version 44 and later
# do not need the table rebuild
max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION
max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1
task = MigrationTask
migration_version = 2

View File

@@ -60,21 +60,20 @@ class RememberTheMilkEntity(Entity):
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
if rtm_id is None:
if hass_id is None or rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
)
_LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
if hass_id is not None:
self._rtm_config.set_rtm_id(
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
)
self._rtm_config.set_rtm_id(
self._name,
hass_id,
result.list.id,
result.list.taskseries.id,
result.list.taskseries.task.id,
)
else:
self._rtm_api.rtm.tasks.setName(
name=task_name,

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.12.0"]
"requirements": ["reolink-aio==0.11.10"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
"requirements": ["pyrisco==0.6.7"]
"requirements": ["pyrisco==0.6.5"]
}

View File

@@ -42,12 +42,6 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]):
try:
meters = await self.rympro.last_read()
for meter_id, meter in meters.items():
meter["monthly_consumption"] = await self.rympro.monthly_consumption(
meter_id
)
meter["daily_consumption"] = await self.rympro.daily_consumption(
meter_id
)
meter["consumption_forecast"] = await self.rympro.consumption_forecast(
meter_id
)

View File

@@ -36,20 +36,6 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = (
suggested_display_precision=3,
value_key="read",
),
RymProSensorEntityDescription(
key="monthly_consumption",
translation_key="monthly_consumption",
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_key="monthly_consumption",
),
RymProSensorEntityDescription(
key="daily_consumption",
translation_key="daily_consumption",
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=3,
value_key="daily_consumption",
),
RymProSensorEntityDescription(
key="monthly_forecast",
translation_key="monthly_forecast",

View File

@@ -23,12 +23,6 @@
"total_consumption": {
"name": "Total consumption"
},
"monthly_consumption": {
"name": "Monthly consumption"
},
"daily_consumption": {
"name": "Daily consumption"
},
"monthly_forecast": {
"name": "Monthly forecast"
}

View File

@@ -68,11 +68,11 @@ from .utils import (
async_create_issue_unsupported_firmware,
get_block_device_sleep_period,
get_device_entry_gen,
get_device_info_model,
get_host,
get_http_port,
get_rpc_device_wakeup_period,
get_rpc_ws_url,
get_shelly_model_name,
update_device_fw_info,
)
@@ -165,7 +165,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
connections={(CONNECTION_NETWORK_MAC, self.mac)},
identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly",
model=get_shelly_model_name(self.model, self.sleep_period, self.device),
model=get_device_info_model(self.device),
model_id=self.model,
sw_version=self.sw_version,
hw_version=f"gen{get_device_entry_gen(self.config_entry)}",

View File

@@ -315,25 +315,12 @@ def get_model_name(info: dict[str, Any]) -> str:
return cast(str, MODEL_NAMES.get(info["type"], info["type"]))
def get_shelly_model_name(
model: str,
sleep_period: int,
device: BlockDevice | RpcDevice,
) -> str | None:
"""Get Shelly model name.
def get_device_info_model(device: BlockDevice | RpcDevice) -> str | None:
"""Return the device model for deviceinfo."""
if isinstance(device, RpcDevice) and (model := device.xmod_info.get("n")):
return cast(str, model)
Assume that XMOD devices are not sleepy devices.
"""
if (
sleep_period == 0
and isinstance(device, RpcDevice)
and (model_name := device.xmod_info.get("n"))
):
# Use the model name from XMOD data
return cast(str, model_name)
# Use the model name from aioshelly
return cast(str, MODEL_NAMES.get(model))
return cast(str, MODEL_NAMES.get(device.model))
def get_rpc_channel_name(device: RpcDevice, key: str) -> str:

View File

@@ -1,36 +0,0 @@
"""Support for the Swedish weather institute weather base entities."""
from __future__ import annotations
import aiohttp
from pysmhi import SMHIPointForecast
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class SmhiWeatherBaseEntity(Entity):
"""Representation of a base weather entity."""
_attr_attribution = "Swedish weather institute (SMHI)"
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
latitude: str,
longitude: str,
session: aiohttp.ClientSession,
) -> None:
"""Initialize the SMHI base weather entity."""
self._attr_unique_id = f"{latitude}, {longitude}"
self._smhi_api = SMHIPointForecast(longitude, latitude, session=session)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, f"{latitude}, {longitude}")},
manufacturer="SMHI",
model="v2",
configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html",
)

View File

@@ -9,7 +9,7 @@ import logging
from typing import Any, Final
import aiohttp
from pysmhi import SMHIForecast, SmhiForecastException
from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
@@ -55,12 +55,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client, sun
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.util import Throttle
from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT
from .entity import SmhiWeatherBaseEntity
from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT
_LOGGER = logging.getLogger(__name__)
@@ -114,14 +114,18 @@ async def async_setup_entry(
async_add_entities([entity], True)
class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity):
class SmhiWeather(WeatherEntity):
"""Representation of a weather entity."""
_attr_attribution = "Swedish weather institute (SMHI)"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_has_entity_name = True
_attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
@@ -133,10 +137,18 @@ class SmhiWeather(SmhiWeatherBaseEntity, WeatherEntity):
session: aiohttp.ClientSession,
) -> None:
"""Initialize the SMHI weather entity."""
super().__init__(latitude, longitude, session)
self._attr_unique_id = f"{latitude}, {longitude}"
self._forecast_daily: list[SMHIForecast] | None = None
self._forecast_hourly: list[SMHIForecast] | None = None
self._fail_count = 0
self._smhi_api = SMHIPointForecast(longitude, latitude, session=session)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, f"{latitude}, {longitude}")},
manufacturer="SMHI",
model="v2",
configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html",
)
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:

View File

@@ -9,6 +9,7 @@ from stookwijzer import Stookwijzer
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
@@ -43,6 +44,7 @@ async def async_migrate_entry(
if entry.version == 1:
latitude, longitude = await Stookwijzer.async_transform_coordinates(
async_get_clientsession(hass),
entry.data[CONF_LOCATION][CONF_LATITUDE],
entry.data[CONF_LOCATION][CONF_LONGITUDE],
)

View File

@@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector
from .const import DOMAIN
@@ -26,6 +27,7 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
latitude, longitude = await Stookwijzer.async_transform_coordinates(
async_get_clientsession(self.hass),
user_input[CONF_LOCATION][CONF_LATITUDE],
user_input[CONF_LOCATION][CONF_LONGITUDE],
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["stookwijzer==1.5.7"]
"requirements": ["stookwijzer==1.5.4"]
}

View File

@@ -7,7 +7,6 @@ from collections.abc import Callable
from contextlib import suppress
import logging
from awesomeversion import AwesomeVersion
from synology_dsm import SynologyDSM
from synology_dsm.api.core.security import SynoCoreSecurity
from synology_dsm.api.core.system import SynoCoreSystem
@@ -136,9 +135,6 @@ class SynoApi:
)
await self.async_login()
self.information = self.dsm.information
await self.information.update()
# check if surveillance station is used
self._with_surveillance_station = bool(
self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY)
@@ -169,10 +165,7 @@ class SynoApi:
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
# check if file station is used and permitted
self._with_file_station = bool(
self.information.awesome_version >= AwesomeVersion("6.0")
and self.dsm.apis.get(SynoFileStation.LIST_API_KEY)
)
self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY))
if self._with_file_station:
shares: list | None = None
with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
@@ -324,6 +317,7 @@ class SynoApi:
async def _fetch_device_configuration(self) -> None:
"""Fetch initial device config."""
self.information = self.dsm.information
self.network = self.dsm.network
await self.network.update()

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
"requirements": ["py-synologydsm-api==2.7.0"],
"requirements": ["py-synologydsm-api==2.6.3"],
"ssdp": [
{
"manufacturer": "Synology",

View File

@@ -72,7 +72,7 @@ class ThermoBeaconConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -2,47 +2,25 @@
from __future__ import annotations
from functools import partial
import logging
from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData
from thermopro_ble import ThermoProBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SIGNAL_DATA_UPDATED
from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
def process_service_info(
hass: HomeAssistant,
entry: ConfigEntry,
data: ThermoProBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
update = data.update(service_info)
async_dispatcher_send(
hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update
)
return update
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up ThermoPro BLE device from a config entry."""
address = entry.unique_id
@@ -54,12 +32,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=partial(process_service_info, hass, entry, data),
update_method=data.update,
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
return True

View File

@@ -1,157 +0,0 @@
"""Thermopro button platform."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData, ThermoProDevice
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_track_unavailable,
)
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import now
from .const import DOMAIN, SIGNAL_AVAILABILITY_UPDATED, SIGNAL_DATA_UPDATED
PARALLEL_UPDATES = 1 # one connection at a time
@dataclass(kw_only=True, frozen=True)
class ThermoProButtonEntityDescription(ButtonEntityDescription):
"""Describe a ThermoPro button entity."""
press_action_fn: Callable[[HomeAssistant, str], Coroutine[None, Any, Any]]
async def _async_set_datetime(hass: HomeAssistant, address: str) -> None:
"""Set Date&Time for a given device."""
ble_device = async_ble_device_from_address(hass, address, connectable=True)
assert ble_device is not None
await ThermoProDevice(ble_device).set_datetime(now(), am_pm=False)
BUTTON_ENTITIES: tuple[ThermoProButtonEntityDescription, ...] = (
ThermoProButtonEntityDescription(
key="datetime",
translation_key="set_datetime",
icon="mdi:calendar-clock",
entity_category=EntityCategory.CONFIG,
press_action_fn=_async_set_datetime,
),
)
MODELS_THAT_SUPPORT_BUTTONS = {"TP358", "TP393"}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the thermopro button platform."""
address = entry.unique_id
assert address is not None
availability_signal = f"{SIGNAL_AVAILABILITY_UPDATED}_{entry.entry_id}"
entity_added = False
@callback
def _async_on_data_updated(
data: ThermoProBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
update: SensorUpdate,
) -> None:
nonlocal entity_added
sensor_device_info = update.devices[data.primary_device_id]
if sensor_device_info.model not in MODELS_THAT_SUPPORT_BUTTONS:
return
if not entity_added:
name = sensor_device_info.name
assert name is not None
entity_added = True
async_add_entities(
ThermoProButtonEntity(
description=description,
data=data,
availability_signal=availability_signal,
address=address,
)
for description in BUTTON_ENTITIES
)
if service_info.connectable:
async_dispatcher_send(hass, availability_signal, True)
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", _async_on_data_updated
)
)
class ThermoProButtonEntity(ButtonEntity):
"""Representation of a ThermoPro button entity."""
_attr_has_entity_name = True
entity_description: ThermoProButtonEntityDescription
def __init__(
self,
description: ThermoProButtonEntityDescription,
data: ThermoProBluetoothDeviceData,
availability_signal: str,
address: str,
) -> None:
"""Initialize the thermopro button entity."""
self.entity_description = description
self._address = address
self._availability_signal = availability_signal
self._attr_unique_id = f"{address}-{description.key}"
self._attr_device_info = dr.DeviceInfo(
name=data.get_device_name(),
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
)
async def async_added_to_hass(self) -> None:
"""Connect availability dispatcher."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
self._availability_signal,
self._async_on_availability_changed,
)
)
self.async_on_remove(
async_track_unavailable(
self.hass, self._async_on_unavailable, self._address, connectable=True
)
)
@callback
def _async_on_unavailable(self, _: BluetoothServiceInfoBleak) -> None:
self._async_on_availability_changed(False)
@callback
def _async_on_availability_changed(self, available: bool) -> None:
self._attr_available = available
self.async_write_ha_state()
async def async_press(self) -> None:
"""Execute the press action for the entity."""
await self.entity_description.press_action_fn(self.hass, self._address)

View File

@@ -1,6 +1,3 @@
"""Constants for the ThermoPro Bluetooth integration."""
DOMAIN = "thermopro"
SIGNAL_DATA_UPDATED = f"{DOMAIN}_service_info_updated"
SIGNAL_AVAILABILITY_UPDATED = f"{DOMAIN}_availability_updated"

View File

@@ -9,6 +9,7 @@ from thermopro_ble import (
Units,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
@@ -22,7 +23,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -110,7 +110,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ThermoPro BLE sensors."""

View File

@@ -17,12 +17,5 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"button": {
"set_datetime": {
"name": "Set Date&Time"
}
}
}
}

View File

@@ -101,9 +101,6 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
_from_stations: list[StationInfoModel]
_to_stations: list[StationInfoModel]
_time: str | None
_days: list
_product: str | None
_data: dict[str, Any]
@staticmethod
@@ -246,10 +243,8 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the select station step."""
if user_input is not None:
api_key: str = self._data[CONF_API_KEY]
train_from: str = (
user_input.get(CONF_FROM) or self._from_stations[0].signature
)
train_to: str = user_input.get(CONF_TO) or self._to_stations[0].signature
train_from: str = user_input[CONF_FROM]
train_to: str = user_input[CONF_TO]
train_time: str | None = self._data.get(CONF_TIME)
train_days: list = self._data[CONF_WEEKDAY]
filter_product: str | None = self._data[CONF_FILTER_PRODUCT]

View File

@@ -46,7 +46,6 @@ class UnifiEntityLoader:
hub.api.port_forwarding.update,
hub.api.sites.update,
hub.api.system_information.update,
hub.api.firewall_policies.update,
hub.api.traffic_rules.update,
hub.api.traffic_routes.update,
hub.api.wlans.update,

View File

@@ -55,9 +55,6 @@
"off": "mdi:network-off"
}
},
"firewall_policy_control": {
"default": "mdi:security-network"
},
"port_forward_control": {
"default": "mdi:upload-network"
},

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
"requirements": ["aiounifi==82"],
"requirements": ["aiounifi==81"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -6,6 +6,7 @@ from typing import Any
from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
@@ -66,9 +67,9 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) -
if mac == "":
return
for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
if (
(not (hub := config_entry.runtime_data).available)
for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
if config_entry.state is not ConfigEntryState.LOADED or (
((hub := config_entry.runtime_data) and not hub.available)
or (client := hub.api.clients.get(mac)) is None
or client.is_wired
):
@@ -84,8 +85,10 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) ->
- Total time between first seen and last seen is less than 15 minutes.
- Neither IP, hostname nor name is configured.
"""
for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
if not (hub := config_entry.runtime_data).available:
for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
if config_entry.state is not ConfigEntryState.LOADED or (
(hub := config_entry.runtime_data) and not hub.available
):
continue
clients_to_remove = []

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