Merge branch 'dev' into websocket_api_wait_integration

This commit is contained in:
Erik Montnemery
2025-04-09 15:29:01 +02:00
committed by GitHub
416 changed files with 20297 additions and 4446 deletions

View File

@@ -653,7 +653,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Dependency review
uses: actions/dependency-review-action@v4.5.0
uses: actions/dependency-review-action@v4.6.0
with:
license-check: false # We use our own license audit checks

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.13
uses: github/codeql-action/init@v3.28.15
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.13
uses: github/codeql-action/analyze@v3.28.15
with:
category: "/language:python"

View File

@@ -859,8 +859,14 @@ async def _async_set_up_integrations(
integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config
)
all_domains = set(all_integrations)
domains = set(integrations)
# Detect all cycles
integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, all_integrations.values(), set(all_integrations)
)
)
all_domains = set(integrations_after_dependencies)
domains = set(integrations) & all_domains
_LOGGER.info(
"Domains to be set up: %s | %s",
@@ -868,6 +874,8 @@ async def _async_set_up_integrations(
all_domains - domains,
)
async_set_domains_to_be_loaded(hass, all_domains)
# Initialize recorder
if "recorder" in all_domains:
recorder.async_initialize_recorder(hass)
@@ -900,24 +908,12 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered = {
dep
for domain in stage_domains
for dep in all_integrations[domain].all_dependencies
for dep in integrations_after_dependencies[domain]
if dep not in stage_domains
}
stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
stage_all_domains = stage_domains | stage_dep_domains
stage_all_integrations = {
domain: all_integrations[domain] for domain in stage_all_domains
}
# Detect all cycles
stage_integrations_after_dependencies = (
await loader.resolve_integrations_after_dependencies(
hass, stage_all_integrations.values(), stage_all_domains
)
)
stage_all_domains = set(stage_integrations_after_dependencies)
stage_domains &= stage_all_domains
stage_dep_domains &= stage_all_domains
_LOGGER.info(
"Setting up stage %s: %s | %s\nDependencies: %s | %s",
@@ -928,8 +924,6 @@ async def _async_set_up_integrations(
stage_dep_domains_unfiltered - stage_dep_domains,
)
async_set_domains_to_be_loaded(hass, stage_all_domains)
if timeout is None:
await _async_setup_multi_components(hass, stage_all_domains, config)
continue

View File

@@ -0,0 +1,5 @@
{
"domain": "eve",
"name": "Eve",
"iot_standards": ["matter"]
}

View File

@@ -72,10 +72,10 @@
"level": {
"name": "Level",
"state": {
"high": "High",
"low": "Low",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "Moderate",
"very_high": "Very high"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -89,10 +89,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -123,10 +123,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -167,10 +167,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -181,10 +181,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -195,10 +195,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
"high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::high%]",
"low": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::low%]",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
"very_high": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::very_high%]"
"very_high": "[%key:common::state::very_high%]"
}
}
}

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.9"]
"requirements": ["aioairzone==1.0.0"]
}

View File

@@ -9,6 +9,8 @@ from aioairzone.const import (
AZD_HUMIDITY,
AZD_TEMP,
AZD_TEMP_UNIT,
AZD_THERMOSTAT_BATTERY,
AZD_THERMOSTAT_SIGNAL,
AZD_WEBSERVER,
AZD_WIFI_RSSI,
AZD_ZONES,
@@ -73,6 +75,20 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
device_class=SensorDeviceClass.BATTERY,
key=AZD_THERMOSTAT_BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
key=AZD_THERMOSTAT_SIGNAL,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
translation_key="thermostat_signal",
),
)

View File

@@ -76,6 +76,9 @@
"sensor": {
"rssi": {
"name": "RSSI"
},
"thermostat_signal": {
"name": "Signal strength"
}
}
}

View File

@@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
translation_domain=DOMAIN, translation_key="connection_closed"
) from exc
def _send_launch_app_command(self, app_link: str) -> None:
@@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
translation_domain=DOMAIN, translation_key="connection_closed"
) from exc

View File

@@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_ICON, CONF_APP_NAME
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
@@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
await asyncio.sleep(delay_secs)
except ConnectionClosed as exc:
raise HomeAssistantError(
"Connection to Android TV device is closed"
translation_domain=DOMAIN, translation_key="connection_closed"
) from exc

View File

@@ -54,5 +54,10 @@
}
}
}
},
"exceptions": {
"connection_closed": {
"message": "Connection to the Android TV device is closed"
}
}
}

View File

@@ -20,6 +20,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
SOURCE_REAUTH,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
@@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
if entry.source != SOURCE_IGNORE:
# Don't reload ignored entries or in the middle of reauth,
# e.g. if the user is entering a new PIN
if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist:
raise DeviceAlreadyConfigured

View File

@@ -36,9 +36,9 @@
"wi_fi_strength": {
"name": "Wi-Fi strength",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"high": "[%key:common::state::high%]"
}
}
}

View File

@@ -103,8 +103,8 @@
"temperature_range": {
"name": "Temperature range",
"state": {
"low": "Low",
"high": "High"
"low": "[%key:common::state::low%]",
"high": "[%key:common::state::high%]"
}
}
},

View File

@@ -124,8 +124,8 @@
"battery": {
"name": "Battery",
"state": {
"off": "Normal",
"on": "Low"
"off": "[%key:common::state::normal%]",
"on": "[%key:common::state::low%]"
}
},
"battery_charging": {
@@ -145,7 +145,7 @@
"cold": {
"name": "Cold",
"state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
"off": "[%key:common::state::normal%]",
"on": "Cold"
}
},
@@ -180,7 +180,7 @@
"heat": {
"name": "Heat",
"state": {
"off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
"off": "[%key:common::state::normal%]",
"on": "Hot"
}
},

View File

@@ -19,7 +19,7 @@
"bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.26.1",
"bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0",
"habluetooth==3.37.0"
]

View File

@@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_ACCESS_TOKEN] = token
try:
_, hub_name = await _validate_input(self.hass, self._discovered)
bond_id, hub_name = await _validate_input(self.hass, self._discovered)
except InputValidationError:
return
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered[CONF_NAME] = hub_name
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by dhcp discovery."""
host = discovery_info.ip
bond_id = discovery_info.hostname.partition("-")[2].upper()
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = discovery_info.host
bond_id = name.partition(".")[0]
await self.async_set_unique_id(bond_id)
return await self.async_step_any_discovery(bond_id, host)
async def async_step_any_discovery(
self, bond_id: str, host: str
) -> ConfigFlowResult:
"""Handle a flow initialized by discovery."""
for entry in self._async_current_entries():
if entry.unique_id != bond_id:
continue
updates = {CONF_HOST: host}
if entry.state == ConfigEntryState.SETUP_ERROR and (
if entry.state is ConfigEntryState.SETUP_ERROR and (
token := await async_get_token(self.hass, host)
):
updates[CONF_ACCESS_TOKEN] = token
@@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._discovered[CONF_HOST],
}
try:
_, hub_name = await _validate_input(self.hass, data)
bond_id, hub_name = await _validate_input(self.hass, data)
except InputValidationError as error:
errors["base"] = error.base
else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._discovered[CONF_HOST]}
)
return self.async_create_entry(
title=hub_name,
data=data,
@@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
except InputValidationError as error:
errors["base"] = error.base
else:
await self.async_set_unique_id(bond_id)
self._abort_if_unique_id_configured()
await self.async_set_unique_id(bond_id, raise_on_progress=False)
self._abort_if_unique_id_configured(
updates={CONF_HOST: user_input[CONF_HOST]}
)
return self.async_create_entry(title=hub_name, data=user_input)
return self.async_show_form(

View File

@@ -3,6 +3,16 @@
"name": "Bond",
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
"config_flow": true,
"dhcp": [
{
"hostname": "bond-*",
"macaddress": "3C6A2C1*"
},
{
"hostname": "bond-*",
"macaddress": "F44E38*"
}
],
"documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push",
"loggers": ["bond_async"],

View File

@@ -9,12 +9,12 @@ from bosch_alarm_mode2 import Panel
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL]
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
type BoschAlarmConfigEntry = ConfigEntry[Panel]
@@ -34,10 +34,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -
await panel.connect()
except (PermissionError, ValueError) as err:
await panel.disconnect()
raise ConfigEntryNotReady from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
await panel.disconnect()
raise ConfigEntryNotReady("Connection failed") from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
entry.runtime_data = panel

View File

@@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .const import DOMAIN
from .entity import BoschAlarmAreaEntity
async def async_setup_entry(
@@ -35,7 +34,7 @@ async def async_setup_entry(
)
class AreaAlarmControlPanel(AlarmControlPanelEntity):
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
"""An alarm control panel entity for a bosch alarm panel."""
_attr_has_entity_name = True
@@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
"""Initialise a Bosch Alarm control panel entity."""
self.panel = panel
self._area = panel.areas[area_id]
self._area_id = area_id
self._attr_unique_id = f"{unique_id}_area_{area_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(
DOMAIN,
unique_id,
),
)
super().__init__(panel, area_id, unique_id, False, False, True)
self._attr_unique_id = self._area_unique_id
@property
def alarm_state(self) -> AlarmControlPanelState | None:
@@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.panel.area_arm_all(self._area_id)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Run when entity attached to hass."""
await super().async_added_to_hass()
self._area.status_observer.attach(self.schedule_update_ha_state)
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Run when entity removed from hass."""
await super().async_will_remove_from_hass()
self._area.status_observer.detach(self.schedule_update_ha_state)
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
import ssl
from typing import Any
@@ -10,7 +11,12 @@ from typing import Any
from bosch_alarm_mode2 import Panel
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import (
CONF_CODE,
CONF_HOST,
@@ -107,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else:
self._data = user_input
self._data[CONF_MODEL] = model
if self.source == SOURCE_RECONFIGURE:
if (
self._get_reconfigure_entry().data[CONF_MODEL]
!= self._data[CONF_MODEL]
):
return self.async_abort(reason="device_mismatch")
return await self.async_step_auth()
return self.async_show_form(
step_id="user",
@@ -116,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reconfigure step."""
return await self.async_step_user()
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -153,13 +172,77 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
else:
if serial_number:
await self.async_set_unique_id(str(serial_number))
self._abort_if_unique_id_configured()
else:
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
if self.source == SOURCE_USER:
if serial_number:
self._abort_if_unique_id_configured()
else:
self._async_abort_entries_match(
{CONF_HOST: self._data[CONF_HOST]}
)
return self.async_create_entry(
title=f"Bosch {model}", data=self._data
)
if serial_number:
self._abort_if_unique_id_mismatch(reason="device_mismatch")
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data=self._data,
)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an authentication error."""
self._data = dict(entry_data)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the reauth step."""
errors: dict[str, str] = {}
# Each model variant requires a different authentication flow
if "Solution" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
elif "AMAX" in self._data[CONF_MODEL]:
schema = STEP_AUTH_DATA_SCHEMA_AMAX
else:
schema = STEP_AUTH_DATA_SCHEMA_BG
if user_input is not None:
reauth_entry = self._get_reauth_entry()
self._data.update(user_input)
try:
(_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
except (PermissionError, ValueError) as e:
errors["base"] = "invalid_auth"
_LOGGER.error("Authentication Error: %s", e)
except (
OSError,
ConnectionRefusedError,
ssl.SSLError,
TimeoutError,
) as e:
_LOGGER.error("Connection Error: %s", e)
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(schema, user_input),
errors=errors,
)

View File

@@ -0,0 +1,73 @@
"""Diagnostics for bosch alarm."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from . import BoschAlarmConfigEntry
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: BoschAlarmConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"model": entry.runtime_data.model,
"serial_number": entry.runtime_data.serial_number,
"protocol_version": entry.runtime_data.protocol_version,
"firmware_version": entry.runtime_data.firmware_version,
"areas": [
{
"id": area_id,
"name": area.name,
"all_ready": area.all_ready,
"part_ready": area.part_ready,
"faults": area.faults,
"alarms": area.alarms,
"disarmed": area.is_disarmed(),
"arming": area.is_arming(),
"pending": area.is_pending(),
"part_armed": area.is_part_armed(),
"all_armed": area.is_all_armed(),
"armed": area.is_armed(),
"triggered": area.is_triggered(),
}
for area_id, area in entry.runtime_data.areas.items()
],
"points": [
{
"id": point_id,
"name": point.name,
"open": point.is_open(),
"normal": point.is_normal(),
}
for point_id, point in entry.runtime_data.points.items()
],
"doors": [
{
"id": door_id,
"name": door.name,
"open": door.is_open(),
"locked": door.is_locked(),
}
for door_id, door in entry.runtime_data.doors.items()
],
"outputs": [
{
"id": output_id,
"name": output.name,
"active": output.is_active(),
}
for output_id, output in entry.runtime_data.outputs.items()
],
"history_events": entry.runtime_data.events,
},
}

View File

@@ -0,0 +1,88 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from bosch_alarm_mode2 import Panel
from homeassistant.components.sensor import Entity
from homeassistant.helpers.device_registry import DeviceInfo
from .const import DOMAIN
PARALLEL_UPDATES = 0
class BoschAlarmEntity(Entity):
"""A base entity for a bosch alarm panel."""
_attr_has_entity_name = True
def __init__(self, panel: Panel, unique_id: str) -> None:
"""Set up a entity for a bosch alarm panel."""
self.panel = panel
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=f"Bosch {panel.model}",
manufacturer="Bosch Security Systems",
)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self.panel.connection_status()
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
class BoschAlarmAreaEntity(BoschAlarmEntity):
"""A base entity for area related entities within a bosch alarm panel."""
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
observe_alarms: bool,
observe_ready: bool,
observe_status: bool,
) -> None:
"""Set up a area related entity for a bosch alarm panel."""
super().__init__(panel, unique_id)
self._area_id = area_id
self._area_unique_id = f"{unique_id}_area_{area_id}"
self._observe_alarms = observe_alarms
self._observe_ready = observe_ready
self._observe_status = observe_status
self._area = panel.areas[area_id]
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._area_unique_id)},
name=self._area.name,
manufacturer="Bosch Security Systems",
via_device=(DOMAIN, unique_id),
)
async def async_added_to_hass(self) -> None:
"""Observe state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.attach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.attach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.attach(self.schedule_update_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Stop observing state changes."""
await super().async_added_to_hass()
if self._observe_alarms:
self._area.alarm_observer.detach(self.schedule_update_ha_state)
if self._observe_ready:
self._area.ready_observer.detach(self.schedule_update_ha_state)
if self._observe_status:
self._area.status_observer.detach(self.schedule_update_ha_state)

View File

@@ -0,0 +1,9 @@
{
"entity": {
"sensor": {
"faulting_points": {
"default": "mdi:alert-circle-outline"
}
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["bosch-alarm-mode2==0.4.3"]
"requirements": ["bosch-alarm-mode2==0.4.6"]
}

View File

@@ -40,7 +40,7 @@ rules:
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
@@ -62,9 +62,9 @@ rules:
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt

View File

@@ -0,0 +1,86 @@
"""Support for Bosch Alarm Panel History as a sensor."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from bosch_alarm_mode2 import Panel
from bosch_alarm_mode2.panel import Area
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschAlarmConfigEntry
from .entity import BoschAlarmAreaEntity
@dataclass(kw_only=True, frozen=True)
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
"""Describes Bosch Alarm sensor entity."""
value_fn: Callable[[Area], int]
observe_alarms: bool = False
observe_ready: bool = False
observe_status: bool = False
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
BoschAlarmSensorEntityDescription(
key="faulting_points",
translation_key="faulting_points",
value_fn=lambda area: area.faults,
observe_ready=True,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschAlarmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up bosch alarm sensors."""
panel = config_entry.runtime_data
unique_id = config_entry.unique_id or config_entry.entry_id
async_add_entities(
BoschAreaSensor(panel, area_id, unique_id, template)
for area_id in panel.areas
for template in SENSOR_TYPES
)
PARALLEL_UPDATES = 0
class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
"""An area sensor entity for a bosch alarm panel."""
entity_description: BoschAlarmSensorEntityDescription
def __init__(
self,
panel: Panel,
area_id: int,
unique_id: str,
entity_description: BoschAlarmSensorEntityDescription,
) -> None:
"""Set up an area sensor entity for a bosch alarm panel."""
super().__init__(
panel,
area_id,
unique_id,
entity_description.observe_alarms,
entity_description.observe_ready,
entity_description.observe_status,
)
self.entity_description = entity_description
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
@property
def native_value(self) -> int:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._area)

View File

@@ -22,6 +22,18 @@
"installer_code": "The installer code from your panel",
"user_code": "The user code from your panel"
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
},
"data_description": {
"password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
"installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
"user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
}
}
},
"error": {
@@ -30,7 +42,26 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"device_mismatch": "Please ensure you reconfigure against the same device."
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to panel."
},
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
},
"entity": {
"sensor": {
"faulting_points": {
"name": "Faulting points",
"unit_of_measurement": "points"
}
}
}
}

View File

@@ -74,7 +74,7 @@
},
"get_events": {
"name": "Get events",
"description": "Get events on a calendar within a time range.",
"description": "Retrieves events on a calendar within a time range.",
"fields": {
"start_date_time": {
"name": "Start time",

View File

@@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
flow_id=flow_id, user_input=tokens
)
self.hass.async_create_task(await_tokens())
# It's a background task because it should be cancelled on shutdown and there's nothing else
# we can do in such case. There's also no need to wait for this during setup.
self.hass.async_create_background_task(
await_tokens(), name="Awaiting OAuth tokens"
)
return authorize_url

View File

@@ -162,7 +162,7 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
if self.mode == HumidifierComelitMode.OFF:
if not self._attr_is_on:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="humidity_while_off",
@@ -190,9 +190,13 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
await self.coordinator.api.set_humidity_status(
self._device.index, self._set_command
)
self._attr_is_on = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.OFF
)
self._attr_is_on = False
self.async_write_ha_state()

View File

@@ -52,7 +52,9 @@
"rest": "Rest",
"sabotated": "Sabotated"
}
},
}
},
"humidifier": {
"humidifier": {
"name": "Humidifier"
},

View File

@@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from homeassistant.util.ssl import client_context_no_verify
from .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator
@@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
key=entry.data.get(CONF_API_KEY),
uuid=entry.data.get(CONF_UUID),
password=entry.data.get(CONF_PASSWORD),
ssl_context=client_context_no_verify(),
)
_LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err:

View File

@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.ssl import client_context_no_verify
from .const import DOMAIN, KEY_MAC, TIMEOUT
@@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
key=key,
uuid=uuid,
password=password,
ssl_context=client_context_no_verify(),
)
except (TimeoutError, ClientError):
self.host = None

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
"requirements": ["pydaikin==2.14.1"],
"requirements": ["pydaikin==2.15.0"],
"zeroconf": ["_dkapi._tcp.local."]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
"requirements": ["dsmr-parser==1.4.2"]
"requirements": ["dsmr-parser==1.4.3"]
}

View File

@@ -51,8 +51,8 @@
"electricity_active_tariff": {
"name": "Active tariff",
"state": {
"low": "Low",
"normal": "Normal"
"low": "[%key:common::state::low%]",
"normal": "[%key:common::state::normal%]"
}
},
"electricity_delivered_tariff_1": {

View File

@@ -140,8 +140,8 @@
"electricity_tariff": {
"name": "Electricity tariff",
"state": {
"low": "Low",
"high": "High"
"low": "[%key:common::state::low%]",
"high": "[%key:common::state::high%]"
}
},
"power_failure_count": {

View File

@@ -55,7 +55,7 @@
"fields": {
"entity_id": {
"name": "Entity",
"description": "Ecobee thermostat on which to create the vacation."
"description": "ecobee thermostat on which to create the vacation."
},
"vacation_name": {
"name": "Vacation name",
@@ -101,7 +101,7 @@
"fields": {
"entity_id": {
"name": "Entity",
"description": "Ecobee thermostat on which to delete the vacation."
"description": "ecobee thermostat on which to delete the vacation."
},
"vacation_name": {
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
@@ -149,7 +149,7 @@
},
"set_mic_mode": {
"name": "Set mic mode",
"description": "Enables/disables Alexa microphone (only for Ecobee 4).",
"description": "Enables/disables Alexa microphone (only for ecobee 4).",
"fields": {
"mic_enabled": {
"name": "Mic enabled",
@@ -177,7 +177,7 @@
"fields": {
"entity_id": {
"name": "Entity",
"description": "Ecobee thermostat on which to set active sensors."
"description": "ecobee thermostat on which to set active sensors."
},
"preset_mode": {
"name": "Climate Name",
@@ -203,12 +203,12 @@
},
"issues": {
"migrate_aux_heat": {
"title": "Migration of Ecobee set_aux_heat action",
"title": "Migration of ecobee set_aux_heat action",
"fix_flow": {
"step": {
"confirm": {
"description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Disable legacy Ecobee set_aux_heat action"
"description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
"title": "Disable legacy ecobee set_aux_heat action"
}
}
}

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.4.0"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
}

View File

@@ -176,9 +176,9 @@
"water_amount": {
"name": "Water flow level",
"state": {
"high": "High",
"low": "Low",
"medium": "Medium",
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"ultrahigh": "Ultrahigh"
}
},

View File

@@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
async def async_setup_entry(

View File

@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"current_speed": {
"default": "mdi:pump"
},
"service_hours": {
"default": "mdi:wrench-clock"
},
"error_code": {
"default": "mdi:alert-octagon",
"state": {
"no_error": "mdi:check-circle"
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
"""EHEIM Digital sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
"""Class describing EHEIM Digital sensor entities."""
value_fn: Callable[[_DeviceT_co], float | str | None]
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
native_unit_of_measurement=PERCENTAGE,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](
coordinator, device, description
)
for description in CLASSICVARIO_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSensor(
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
):
"""Represent a EHEIM Digital sensor entity."""
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalSensorDescription[_DeviceT_co],
) -> None:
"""Initialize an EHEIM Digital number entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
def _async_update_attrs(self) -> None:
self._attr_native_value = self.entity_description.value_fn(self._device)

View File

@@ -46,6 +46,22 @@
}
}
}
},
"sensor": {
"current_speed": {
"name": "Current speed"
},
"service_hours": {
"name": "Remaining hours until service"
},
"error_code": {
"name": "Error code",
"state": {
"no_error": "No error",
"rotor_stuck": "Rotor stuck",
"air_in_filter": "Air in filter"
}
}
}
}
}

View File

@@ -16,7 +16,13 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
INSTALLER_AUTH_USERNAME = "installer"
AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]:
"""Return a dictionary without AVOID_REFLECT_KEYS."""
return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS}
async def validate_input(
hass: HomeAssistant,
@@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders["serial"] = serial
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self._async_generate_schema(),
data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(),
without_avoid_reflect_keys(user_input or reauth_entry.data),
),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SERIAL: self.unique_id,
CONF_HOST: host,
}
return self.async_show_form(
step_id="user",
data_schema=self._async_generate_schema(),
data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(),
without_avoid_reflect_keys(user_input or {}),
),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
}
description_placeholders["serial"] = serial
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
self._async_generate_schema(), suggested_values
self._async_generate_schema(),
without_avoid_reflect_keys(user_input or reconfigure_entry.data),
),
description_placeholders=description_placeholders,
errors=errors,

View File

@@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
]
for end_point in end_points:
response = await envoy.request(end_point)
fixture_data[end_point] = response.text.replace("\n", "").replace(
serial, CLEAN_TEXT
)
fixture_data[f"{end_point}_log"] = json_dumps(
{
"headers": dict(response.headers.items()),
"code": response.status_code,
}
)
try:
response = await envoy.request(end_point)
fixture_data[end_point] = response.text.replace("\n", "").replace(
serial, CLEAN_TEXT
)
fixture_data[f"{end_point}_log"] = json_dumps(
{
"headers": dict(response.headers.items()),
"code": response.status_code,
}
)
except EnvoyError as err:
fixture_data[f"{end_point}_log"] = {"Error": repr(err)}
return fixture_data
@@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics(
fixture_data: dict[str, Any] = {}
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
try:
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
except EnvoyError as err:
fixture_data["Error"] = repr(err)
fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
diagnostic_data: dict[str, Any] = {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.25.1"],
"requirements": ["pyenphase==1.25.5"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.12.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"]
}

View File

@@ -13,7 +13,7 @@ from aioesphomeapi import (
APIConnectionError,
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
EncryptionHelloAPIError,
EncryptionPlaintextAPIError,
EntityInfo,
HomeassistantServiceCall,
InvalidAuthAPIError,
@@ -571,7 +571,7 @@ class ESPHomeManager:
if isinstance(
err,
(
EncryptionHelloAPIError,
EncryptionPlaintextAPIError,
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidAuthAPIError,

View File

@@ -16,9 +16,9 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.8.0",
"aioesphomeapi==29.9.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.12.0"
"bleak-esphome==2.13.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -152,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
if evo_device.model.startswith("VisionProWifi"):
if evo_device.id == evo_device.tcs.id:
# this system does not have a distinct ID for the zone
self._attr_unique_id = f"{evo_device.id}z"
else:

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.0.4"]
"requirements": ["evohome-async==1.0.5"]
}

View File

@@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import (
)
from pyfibaro.fibaro_data_helper import read_rooms
from pyfibaro.fibaro_device import DeviceModel
from pyfibaro.fibaro_device_manager import FibaroDeviceManager
from pyfibaro.fibaro_info import InfoModel
from pyfibaro.fibaro_scene import SceneModel
from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver
from pyfibaro.fibaro_state_resolver import FibaroEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
@@ -81,8 +82,8 @@ class FibaroController:
self._client = fibaro_client
self._fibaro_info = info
# Whether to import devices from plugins
self._import_plugins = import_plugins
# The fibaro device manager exposes higher level API to access fibaro devices
self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins)
# Mapping roomId to room object
self._room_map = read_rooms(fibaro_client)
self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object
@@ -91,79 +92,30 @@ class FibaroController:
) # List of devices by entity platform
# All scenes
self._scenes = self._client.read_scenes()
self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId
# Event callbacks by device id
self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {}
# Unique serial number of the hub
self.hub_serial = info.serial_number
# Device infos by fibaro device id
self._device_infos: dict[int, DeviceInfo] = {}
self._read_devices()
def enable_state_handler(self) -> None:
"""Start StateHandler thread for monitoring updates."""
self._client.register_update_handler(self._on_state_change)
def disconnect(self) -> None:
"""Close push channel."""
self._fibaro_device_manager.close()
def disable_state_handler(self) -> None:
"""Stop StateHandler thread used for monitoring updates."""
self._client.unregister_update_handler()
def _on_state_change(self, state: Any) -> None:
"""Handle change report received from the HomeCenter."""
callback_set = set()
for change in state.get("changes", []):
try:
dev_id = change.pop("id")
if dev_id not in self._device_map:
continue
device = self._device_map[dev_id]
for property_name, value in change.items():
if property_name == "log":
if value and value != "transfer OK":
_LOGGER.debug("LOG %s: %s", device.friendly_name, value)
continue
if property_name == "logTemp":
continue
if property_name in device.properties:
device.properties[property_name] = value
_LOGGER.debug(
"<- %s.%s = %s", device.ha_id, property_name, str(value)
)
else:
_LOGGER.warning("%s.%s not found", device.ha_id, property_name)
if dev_id in self._callbacks:
callback_set.add(dev_id)
except (ValueError, KeyError):
pass
for item in callback_set:
for callback in self._callbacks[item]:
callback()
resolver = FibaroStateResolver(state)
for event in resolver.get_events():
# event does not always have a fibaro id, therefore it is
# essential that we first check for relevant event type
if (
event.event_type.lower() == "centralsceneevent"
and event.fibaro_id in self._event_callbacks
):
for callback in self._event_callbacks[event.fibaro_id]:
callback(event)
def register(self, device_id: int, callback: Any) -> None:
def register(
self, device_id: int, callback: Callable[[DeviceModel], None]
) -> Callable[[], None]:
"""Register device with a callback for updates."""
device_callbacks = self._callbacks.setdefault(device_id, [])
device_callbacks.append(callback)
return self._fibaro_device_manager.add_change_listener(device_id, callback)
def register_event(
self, device_id: int, callback: Callable[[FibaroEvent], None]
) -> None:
) -> Callable[[], None]:
"""Register device with a callback for central scene events.
The callback receives one parameter with the event.
"""
device_callbacks = self._event_callbacks.setdefault(device_id, [])
device_callbacks.append(callback)
return self._fibaro_device_manager.add_event_listener(device_id, callback)
def get_children(self, device_id: int) -> list[DeviceModel]:
"""Get a list of child devices."""
@@ -286,7 +238,7 @@ class FibaroController:
def _read_devices(self) -> None:
"""Read and process the device list."""
devices = self._client.read_devices()
devices = self._fibaro_device_manager.get_devices()
self._device_map = {}
last_climate_parent = None
last_endpoint = None
@@ -301,8 +253,8 @@ class FibaroController:
device.ha_id = (
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
)
if device.enabled and (not device.is_plugin or self._import_plugins):
platform = self._map_device_to_platform(device)
platform = self._map_device_to_platform(device)
if platform is None:
continue
device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}"
@@ -392,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
controller.enable_state_handler()
return True
@@ -402,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b
_LOGGER.debug("Shutting down Fibaro connection")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
entry.runtime_data.disable_state_handler()
entry.runtime_data.disconnect()
return unload_ok

View File

@@ -36,9 +36,13 @@ class FibaroEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
self.controller.register(self.fibaro_device.fibaro_id, self._update_callback)
self.async_on_remove(
self.controller.register(
self.fibaro_device.fibaro_id, self._update_callback
)
)
def _update_callback(self) -> None:
def _update_callback(self, fibaro_device: DeviceModel) -> None:
"""Update the state."""
self.schedule_update_ha_state(True)

View File

@@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity):
await super().async_added_to_hass()
# Register event callback
self.controller.register_event(
self.fibaro_device.fibaro_id, self._event_callback
self.async_on_remove(
self.controller.register_event(
self.fibaro_device.fibaro_id, self._event_callback
)
)
def _event_callback(self, event: FibaroEvent) -> None:
if event.key_id == self._button:
if (
event.event_type.lower() == "centralsceneevent"
and event.key_id == self._button
):
self._trigger_event(event.key_event_type)
self.schedule_update_ha_state()

View File

@@ -53,5 +53,5 @@
"documentation": "https://www.home-assistant.io/integrations/flux_led",
"iot_class": "local_push",
"loggers": ["flux_led"],
"requirements": ["flux-led==1.1.3"]
"requirements": ["flux-led==1.2.0"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["forecast-solar==4.0.0"]
"requirements": ["forecast-solar==4.1.0"]
}

View File

@@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FritzBinarySensorEntityDescription(

View File

@@ -31,6 +31,9 @@ from .entity import FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
# Set a sane value to avoid too many updates
PARALLEL_UPDATES = 5
@dataclass(frozen=True, kw_only=True)
class FritzButtonDescription(ButtonEntityDescription):

View File

@@ -22,6 +22,9 @@ from .entity import FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -18,6 +18,9 @@ from .entity import FritzBoxBaseEntity
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@@ -14,9 +14,7 @@ rules:
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions:
status: todo
comment: include the proper docs snippet
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
@@ -31,15 +29,11 @@ rules:
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters:
status: todo
comment: add the proper configuration_basic block
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates:
status: todo
comment: not set at the moment, we use a coordinator
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
@@ -50,7 +44,7 @@ rules:
diagnostics: done
discovery-update-info: todo
discovery: done
docs-data-update: todo
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: exempt

View File

@@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime:
"""Calculate uptime with deviation."""

View File

@@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
# Set a sane value to avoid too many updates
PARALLEL_UPDATES = 5
async def _async_deflection_entities_list(
avm_wrapper: AvmWrapper, device_friendly_name: str

View File

@@ -20,6 +20,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
# Set a sane value to avoid too many updates
PARALLEL_UPDATES = 5
@dataclass(frozen=True, kw_only=True)
class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription):

View File

@@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suitable=lambda device: device.battery_level is not None,
native_value=lambda device: device.battery_level,

View File

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

View File

@@ -79,9 +79,9 @@
"state": {
"no_data": "No data",
"too_low": "Too low",
"low": "Low",
"low": "[%key:common::state::low%]",
"perfect": "Perfect",
"high": "High",
"high": "[%key:common::state::high%]",
"too_high": "Too high"
}
},
@@ -90,9 +90,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
"low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
"low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
"high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
"high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -101,9 +101,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
"low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
"low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
"high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
"high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -112,9 +112,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
"low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
"low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
"high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
"high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -123,9 +123,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
"low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
"low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
"high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
"high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.0.3"]
"requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"]
}

View File

@@ -179,28 +179,30 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(title="", data=user_input)
if not (
user_input.get(CONF_LLM_HASS_API, "none") != "none"
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
):
# Don't allow to save options that enable the Google Seearch tool with an Assist API
return self.async_create_entry(title="", data=user_input)
errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
}
options = user_input
schema = await google_generative_ai_config_option_schema(
self.hass, options, self._genai_client
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(schema),
step_id="init", data_schema=vol.Schema(schema), errors=errors
)
@@ -301,7 +303,7 @@ async def google_generative_ai_config_option_schema(
CONF_TEMPERATURE,
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},

View File

@@ -55,6 +55,10 @@ from .const import (
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
ERROR_GETTING_RESPONSE = (
"Sorry, I had a problem getting a response from Google Generative AI."
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -429,6 +433,12 @@ class GoogleGenerativeAIConversationEntity(
raise HomeAssistantError(
f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
)
if not chat_response.candidates:
LOGGER.error(
"No candidates found in the response: %s",
chat_response,
)
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
except (
APIError,
@@ -452,9 +462,7 @@ class GoogleGenerativeAIConversationEntity(
response_parts = chat_response.candidates[0].content.parts
if not response_parts:
raise HomeAssistantError(
"Sorry, I had a problem getting a response from Google Generative AI."
)
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
content = " ".join(
[part.text.strip() for part in response_parts if part.text]
)

View File

@@ -40,9 +40,13 @@
"enable_google_search_tool": "Enable Google Search tool"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template."
"prompt": "Instruct how the LLM should respond. This can be a template.",
"enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
}
}
},
"error": {
"invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"."
}
},
"services": {

View File

@@ -265,6 +265,11 @@
"version_latest": {
"name": "Newest version"
}
},
"update": {
"update": {
"name": "[%key:component::update::title%]"
}
}
},
"services": {

View File

@@ -39,7 +39,7 @@ from .entity import (
from .update_helper import update_addon, update_core
ENTITY_DESCRIPTION = UpdateEntityDescription(
name="Update",
translation_key="update",
key=ATTR_VERSION_LATEST,
)

View File

@@ -182,6 +182,6 @@ async def websocket_update_addon(
async def websocket_update_core(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Websocket handler to update an addon."""
"""Websocket handler to update Home Assistant Core."""
await update_core(hass, None, msg["backup"])
connection.send_result(msg[WS_ID])

View File

@@ -2,6 +2,7 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
ATTR_DESTINATION_POSITION = "destination_position"
ATTR_QUEUE_IDS = "queue_ids"
DOMAIN = "heos"
ENTRY_TITLE = "HEOS System"
@@ -9,6 +10,7 @@ SERVICE_GET_QUEUE = "get_queue"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
SERVICE_GROUP_VOLUME_UP = "group_volume_up"
SERVICE_MOVE_QUEUE_ITEM = "move_queue_item"
SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"

View File

@@ -6,6 +6,9 @@
"remove_from_queue": {
"service": "mdi:playlist-remove"
},
"move_queue_item": {
"service": "mdi:playlist-edit"
},
"group_volume_set": {
"service": "mdi:volume-medium"
},

View File

@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["pyheos"],
"quality_scale": "platinum",
"requirements": ["pyheos==1.0.4"],
"requirements": ["pyheos==1.0.5"],
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"

View File

@@ -71,6 +71,7 @@ BASE_SUPPORTED_FEATURES = (
PLAY_STATE_TO_STATE = {
None: MediaPlayerState.IDLE,
PlayState.UNKNOWN: MediaPlayerState.IDLE,
PlayState.PLAY: MediaPlayerState.PLAYING,
PlayState.STOP: MediaPlayerState.IDLE,
PlayState.PAUSE: MediaPlayerState.PAUSED,
@@ -479,6 +480,13 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""Remove items from the queue."""
await self._player.remove_from_queue(queue_ids)
@catch_action_error("move queue item")
async def async_move_queue_item(
self, queue_ids: list[int], destination_position: int
) -> None:
"""Move items in the queue."""
await self._player.move_queue_item(queue_ids, destination_position)
@property
def available(self) -> bool:
"""Return True if the device is available."""

View File

@@ -19,6 +19,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.typing import VolDictType, VolSchemaType
from .const import (
ATTR_DESTINATION_POSITION,
ATTR_PASSWORD,
ATTR_QUEUE_IDS,
ATTR_USERNAME,
@@ -27,6 +28,7 @@ from .const import (
SERVICE_GROUP_VOLUME_DOWN,
SERVICE_GROUP_VOLUME_SET,
SERVICE_GROUP_VOLUME_UP,
SERVICE_MOVE_QUEUE_ITEM,
SERVICE_REMOVE_FROM_QUEUE,
SERVICE_SIGN_IN,
SERVICE_SIGN_OUT,
@@ -87,6 +89,16 @@ REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = {
GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = {
vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float
}
MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = {
vol.Required(ATTR_QUEUE_IDS): vol.All(
cv.ensure_list,
[vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))],
vol.Unique(),
),
vol.Required(ATTR_DESTINATION_POSITION): vol.All(
vol.Coerce(int), vol.Range(min=1, max=1000)
),
}
MEDIA_PLAYER_ENTITY_SERVICES: Final = (
# Player queue services
@@ -96,6 +108,9 @@ MEDIA_PLAYER_ENTITY_SERVICES: Final = (
EntityServiceDescription(
SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA
),
EntityServiceDescription(
SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA
),
# Group volume services
EntityServiceDescription(
SERVICE_GROUP_VOLUME_SET,

View File

@@ -17,6 +17,26 @@ remove_from_queue:
multiple: true
type: number
move_queue_item:
target:
entity:
integration: heos
domain: media_player
fields:
queue_ids:
required: true
selector:
text:
multiple: true
type: number
destination_position:
required: true
selector:
number:
min: 1
max: 1000
step: 1
group_volume_set:
target:
entity:

View File

@@ -100,6 +100,20 @@
}
}
},
"move_queue_item": {
"name": "Move queue item",
"description": "Move one or more items within the play queue.",
"fields": {
"queue_ids": {
"name": "Queue IDs",
"description": "The IDs (indexes) of the items in the queue to move."
},
"destination_position": {
"name": "Destination position",
"description": "The position index in the queue to move the items to."
}
}
},
"group_volume_down": {
"name": "Turn down group volume",
"description": "Turns down the group volume."

View File

@@ -34,9 +34,9 @@
}
},
"error": {
"invalid_username": "Failed to sign into Hive. Your email address is not recognised.",
"invalid_password": "Failed to sign into Hive. Incorrect password, please try again.",
"invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.",
"invalid_username": "Failed to sign in to Hive. Your email address is not recognised.",
"invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.",
"invalid_code": "Failed to sign in to Hive. Your two-factor authentication code was incorrect.",
"no_internet_available": "An Internet connection is required to connect to Hive.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},

View File

@@ -5,37 +5,18 @@ from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from .common import setup_home_connect_entry
from .const import (
BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED,
BSH_DOOR_STATE_OPEN,
DOMAIN,
REFRIGERATION_STATUS_DOOR_CLOSED,
REFRIGERATION_STATUS_DOOR_OPEN,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
PARALLEL_UPDATES = 0
@@ -173,8 +154,6 @@ def _get_entities_for_appliance(
for description in BINARY_SENSORS
if description.key in appliance.status
)
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
return entities
@@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity)
def available(self) -> bool:
"""Return the availability."""
return self.coordinator.last_update_success
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
"""Binary sensor for Home Connect Generic Door."""
_attr_has_entity_name = False
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
HomeConnectBinarySensorEntityDescription(
key=StatusKey.BSH_COMMON_DOOR_STATE,
device_class=BinarySensorDeviceClass.DOOR,
boolean_map={
BSH_DOOR_STATE_CLOSED: False,
BSH_DOOR_STATE_LOCKED: False,
BSH_DOOR_STATE_OPEN: True,
},
entity_registry_enabled_default=False,
),
)
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
self._attr_name = f"{appliance.info.name} Door"
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
automations = automations_with_entity(self.hass, self.entity_id)
scripts = scripts_with_entity(self.hass, self.entity_id)
items = automations + scripts
if not items:
return
entity_reg: er.EntityRegistry = er.async_get(self.hass)
entity_automations = [
automation_entity
for automation_id in automations
if (automation_entity := entity_reg.async_get(automation_id))
]
entity_scripts = [
script_entity
for script_id in scripts
if (script_entity := entity_reg.async_get(script_id))
]
items_list = [
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
for item in entity_automations
] + [
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
for item in entity_scripts
]
async_create_issue(
self.hass,
DOMAIN,
f"deprecated_binary_common_door_sensor_{self.entity_id}",
breaks_in_ha_version="2025.5.0",
is_fixable=True,
is_persistent=True,
severity=IssueSeverity.WARNING,
translation_key="deprecated_binary_common_door_sensor",
translation_placeholders={
"entity": self.entity_id,
"items": "\n".join(items_list),
},
)
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
await super().async_will_remove_from_hass()
async_delete_issue(
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
)

View File

@@ -74,6 +74,19 @@ class HomeConnectApplianceData:
self.settings.update(other.settings)
self.status.update(other.status)
@classmethod
def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData:
"""Return empty data."""
return cls(
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
@@ -362,15 +375,7 @@ class HomeConnectCoordinator(
model=appliance.vib,
)
if appliance.ha_id not in self.data:
self.data[appliance.ha_id] = HomeConnectApplianceData(
commands=set(),
events={},
info=appliance,
options={},
programs=[],
settings={},
status={},
)
self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
else:
self.data[appliance.ha_id].info.connected = appliance.connected
old_appliances.remove(appliance.ha_id)
@@ -406,6 +411,15 @@ class HomeConnectCoordinator(
name=appliance.name,
model=appliance.vib,
)
if not appliance.connected:
_LOGGER.debug(
"Appliance %s is not connected, skipping data fetch",
appliance.ha_id,
)
if appliance_data_to_update:
appliance_data_to_update.info.connected = False
return appliance_data_to_update
return HomeConnectApplianceData.empty(appliance)
try:
settings = {
setting.key: setting

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.16.3"],
"requirements": ["aiohomeconnect==0.17.0"],
"single_config_entry": true
}

View File

@@ -132,17 +132,6 @@
}
}
},
"deprecated_binary_common_door_sensor": {
"title": "Deprecated binary door sensor detected in some automations or scripts",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]",
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
}
}
}
},
"deprecated_command_actions": {
"title": "The command related actions are deprecated in favor of the new buttons",
"fix_flow": {
@@ -487,9 +476,9 @@
},
"warming_level": {
"options": {
"cooking_oven_enum_type_warming_level_low": "Low",
"cooking_oven_enum_type_warming_level_medium": "Medium",
"cooking_oven_enum_type_warming_level_high": "High"
"cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]",
"cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]",
"cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]"
}
},
"washer_temperature": {
@@ -522,9 +511,9 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
"laundry_care_washer_enum_type_spin_speed_ul_low": "Low",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "Medium",
"laundry_care_washer_enum_type_spin_speed_ul_high": "High"
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]",
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]"
}
},
"vario_perfect": {
@@ -1468,9 +1457,9 @@
"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%]"
"cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]",
"cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]",
"cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]"
}
},
"washer_temperature": {
@@ -1505,9 +1494,9 @@
"laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::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%]"
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]",
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]"
}
},
"vario_perfect": {

View File

@@ -71,7 +71,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Postpone loading the config entry if the device is missing
device_path = entry.data[DEVICE]
if not await hass.async_add_executor_job(os.path.exists, device_path):
raise ConfigEntryNotReady
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_disconnected",
)
await hass.config_entries.async_forward_entry_setups(entry, ["update"])

View File

@@ -5,17 +5,21 @@ from __future__ import annotations
from homeassistant.components.hardware.models import HardwareInfo, USBInfo
from homeassistant.core import HomeAssistant, callback
from .config_flow import HomeAssistantSkyConnectConfigFlow
from .const import DOMAIN
from .util import get_hardware_variant
DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/"
EXPECTED_ENTRY_VERSION = (
HomeAssistantSkyConnectConfigFlow.VERSION,
HomeAssistantSkyConnectConfigFlow.MINOR_VERSION,
)
@callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info."""
entries = hass.config_entries.async_entries(DOMAIN)
return [
HardwareInfo(
board=None,
@@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
url=DOCUMENTATION_URL,
)
for entry in entries
# Ignore unmigrated config entries in the hardware page
if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION
]

View File

@@ -195,5 +195,10 @@
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
},
"exceptions": {
"device_disconnected": {
"message": "The device is not plugged in"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"config": {
"flow_title": "Homee {name} ({host})",
"flow_title": "homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
@@ -18,9 +18,9 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"host": "The IP address of your Homee.",
"username": "The username for your Homee.",
"password": "The password for your Homee."
"host": "The IP address of your homee.",
"username": "The username for your homee.",
"password": "The password for your homee."
}
}
}
@@ -45,7 +45,7 @@
"load_alarm": {
"name": "Load",
"state": {
"off": "Normal",
"off": "[%key:common::state::normal%]",
"on": "Overload"
}
},
@@ -352,7 +352,7 @@
},
"exceptions": {
"connection_closed": {
"message": "Could not connect to Homee while setting attribute."
"message": "Could not connect to homee while setting attribute."
}
}
}

View File

@@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity):
# e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit.
# Can be 0 - 2 (Off, Heat, Cool)
# If the HVAC is switched off, it must be idle
# This works around a bug in some devices (like Eve radiator valves) that
# return they are heating when they are not.
target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET)
if target == HeatingCoolingTargetValues.OFF:
return HVACAction.IDLE
value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT)
current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value)
@@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity):
):
return HVACAction.FAN
# If the HVAC is switched off, it must be idle
# This works around a bug in some devices (like Eve radiator valves) that
# return they are heating when they are not.
if target == HeatingCoolingTargetValues.OFF:
return HVACAction.IDLE
return current_hass_value
@property

View File

@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "Please enter the credentials used to log into mytotalconnectcomfort.com.",
"description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"

View File

@@ -3,25 +3,34 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Final
from aiohttp import hdrs
from aiohttp.web import Application, Request, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPException
from multidict import CIMultiDict, istr
from homeassistant.core import callback
REFERRER_POLICY: Final[istr] = istr("Referrer-Policy")
X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options")
X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options")
@callback
def setup_headers(app: Application, use_x_frame_options: bool) -> None:
"""Create headers middleware for the app."""
added_headers = {
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"Server": "", # Empty server header, to prevent aiohttp of setting one.
}
added_headers = CIMultiDict(
{
REFERRER_POLICY: "no-referrer",
X_CONTENT_TYPE_OPTIONS: "nosniff",
hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one.
}
)
if use_x_frame_options:
added_headers["X-Frame-Options"] = "SAMEORIGIN"
added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN"
@middleware
async def headers_middleware(

View File

@@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
SCAN_INTERVAL = timedelta(seconds=30)
NOTIFY_SCHEMA = vol.Any(
None,

View File

@@ -9,7 +9,7 @@
"requirements": [
"huawei-lte-api==1.10.0",
"stringcase==1.2.0",
"url-normalize==1.4.3"
"url-normalize==2.2.0"
],
"ssdp": [
{

View File

@@ -197,5 +197,11 @@
}
}
}
},
"issues": {
"deprecated_effect_none": {
"title": "Light turned on with deprecated effect",
"description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect."
}
}
}

View File

@@ -29,6 +29,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import color as color_util
from ..bridge import HueBridge
@@ -44,6 +45,9 @@ FALLBACK_MIN_KELVIN = 6500
FALLBACK_MAX_KELVIN = 2000
FALLBACK_KELVIN = 5800 # halfway
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
DEPRECATED_EFFECT_NONE = "None"
async def async_setup_entry(
hass: HomeAssistant,
@@ -233,6 +237,23 @@ class HueLight(HueBaseEntity, LightEntity):
self._color_temp_active = color_temp is not None
flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT)
if effect_str == DEPRECATED_EFFECT_NONE:
# deprecated effect "None" is now "off"
effect_str = EFFECT_OFF
async_create_issue(
self.hass,
DOMAIN,
"deprecated_effect_none",
breaks_in_ha_version="2025.10.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_effect_none",
)
self.logger.warning(
"Detected deprecated effect 'None' in %s, use 'off' instead. "
"This will stop working in HA 2025.10",
self.entity_id,
)
if effect_str == EFFECT_OFF:
# ignore effect if set to "off" and we have no effect active
# the special effect "off" is only used to stop an active effect

View File

@@ -62,7 +62,7 @@
"mode": {
"name": "Mode",
"state": {
"normal": "Normal",
"normal": "[%key:common::state::normal%]",
"home": "[%key:common::state::home%]",
"away": "[%key:common::state::not_home%]",
"auto": "Auto",

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