Merge branch 'dev' into subscribe_config_flow_init_remove

This commit is contained in:
Erik Montnemery
2025-04-10 07:53:52 +02:00
committed by GitHub
164 changed files with 5287 additions and 1378 deletions

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

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

@@ -14,7 +14,7 @@ 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]

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

@@ -11,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,
@@ -108,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",
@@ -117,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:
@@ -154,10 +172,22 @@ 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",

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

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

@@ -43,7 +43,9 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"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": {
@@ -53,5 +55,13 @@
"authentication_failed": {
"message": "Incorrect credentials for panel."
}
},
"entity": {
"sensor": {
"faulting_points": {
"name": "Faulting points",
"unit_of_measurement": "points"
}
}
}
}

View File

@@ -13,7 +13,7 @@
},
"data_description": {
"email": "The email address associated with your Bring! account.",
"password": "The password to login to your Bring! account."
"password": "The password to log in to your Bring! account."
}
},
"reauth_confirm": {

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

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

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

@@ -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,9 +253,8 @@ class FibaroController:
device.ha_id = (
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
)
platform = None
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}"
@@ -393,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
@@ -403,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

@@ -4,12 +4,8 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: one coverage miss in line 110
config-flow:
status: todo
comment: data_description are missing
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done

View File

@@ -1,4 +1,11 @@
{
"common": {
"data_description_host": "The hostname or IP address of your FRITZ!Box router.",
"data_description_port": "Leave empty to use the default port.",
"data_description_username": "Username for the FRITZ!Box.",
"data_description_password": "Password for the FRITZ!Box.",
"data_description_ssl": "Use SSL to connect to the FRITZ!Box."
},
"config": {
"flow_title": "{name}",
"step": {
@@ -9,6 +16,11 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
"username": "[%key:component::fritz::common::data_description_username%]",
"password": "[%key:component::fritz::common::data_description_password%]",
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
},
"reauth_confirm": {
@@ -17,6 +29,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::fritz::common::data_description_username%]",
"password": "[%key:component::fritz::common::data_description_password%]"
}
},
"reconfigure": {
@@ -28,8 +44,9 @@
"ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router.",
"port": "Leave it empty to use the default port."
"host": "[%key:component::fritz::common::data_description_host%]",
"port": "[%key:component::fritz::common::data_description_port%]",
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
},
"user": {
@@ -43,8 +60,11 @@
"ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
"host": "The hostname or IP address of your FRITZ!Box router.",
"port": "Leave it empty to use the default port."
"host": "[%key:component::fritz::common::data_description_host%]",
"port": "[%key:component::fritz::common::data_description_port%]",
"username": "[%key:component::fritz::common::data_description_username%]",
"password": "[%key:component::fritz::common::data_description_password%]",
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
}
},
@@ -70,6 +90,10 @@
"data": {
"consider_home": "Seconds to consider a device at 'home'",
"old_discovery": "Enable old discovery method"
},
"data_description": {
"consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.",
"old_discovery": "Enable old discovery method. This is needed for some scenarios."
}
}
}
@@ -169,8 +193,12 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
"service_parameter_unknown": { "message": "Action or parameter unknown" },
"service_not_supported": { "message": "Action not supported" },
"service_parameter_unknown": {
"message": "Action or parameter unknown"
},
"service_not_supported": {
"message": "Action not supported"
},
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},

View File

@@ -9,8 +9,8 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "The email address to login to your FYTA account.",
"password": "The password to login to your FYTA account."
"username": "The email address to log in to your FYTA account.",
"password": "The password to log in to your FYTA account."
}
},
"reauth_confirm": {

View File

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

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

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

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

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

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

@@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import (
async_validate_trigger_config,
)
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
@@ -49,6 +50,7 @@ from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
@@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.async_ import create_eager_task
from . import ( # noqa: F401
type_air_purifiers,
type_cameras,
type_covers,
type_fans,
@@ -113,6 +116,8 @@ from .const import (
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONFIG_OPTIONS,
DEFAULT_EXCLUDE_ACCESSORY_MODE,
DEFAULT_HOMEKIT_MODE,
@@ -126,6 +131,7 @@ from .const import (
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
SIGNAL_RELOAD_ENTITIES,
TYPE_AIR_PURIFIER,
)
from .iidmanager import AccessoryIIDStorage
from .models import HomeKitConfigEntry, HomeKitEntryData
@@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION)
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)
TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE)
PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25)
def _has_all_unique_names_and_ports(
@@ -1136,6 +1144,21 @@ class HomeKit:
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
)
if domain == FAN_DOMAIN:
if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id
)
if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR):
config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER)
config[entity_id].setdefault(
CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id
)
if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR):
config[entity_id].setdefault(
CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id
)
if domain == HUMIDIFIER_DOMAIN and (
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
):

View File

@@ -85,6 +85,8 @@ from .const import (
SERV_ACCESSORY_INFO,
SERV_BATTERY_SERVICE,
SIGNAL_RELOAD_ENTITIES,
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@@ -112,6 +114,10 @@ SWITCH_TYPES = {
TYPE_SWITCH: "Switch",
TYPE_VALVE: "ValveSwitch",
}
FAN_TYPES = {
TYPE_AIR_PURIFIER: "AirPurifier",
TYPE_FAN: "Fan",
}
TYPES: Registry[str, type[HomeAccessory]] = Registry()
RELOAD_ON_CHANGE_ATTRS = (
@@ -178,7 +184,10 @@ def get_accessory( # noqa: C901
a_type = "WindowCovering"
elif state.domain == "fan":
a_type = "Fan"
if fan_type := config.get(CONF_TYPE):
a_type = FAN_TYPES[fan_type]
else:
a_type = "Fan"
elif state.domain == "humidifier":
a_type = "HumidifierDehumidifier"

View File

@@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode"
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor"
CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor"
CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor"
CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor"
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
@@ -120,12 +124,15 @@ TYPE_SHOWER = "shower"
TYPE_SPRINKLER = "sprinkler"
TYPE_SWITCH = "switch"
TYPE_VALVE = "valve"
TYPE_FAN = "fan"
TYPE_AIR_PURIFIER = "air_purifier"
# #### Categories ####
CATEGORY_RECEIVER = 34
# #### Services ####
SERV_ACCESSORY_INFO = "AccessoryInformation"
SERV_AIR_PURIFIER = "AirPurifier"
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
SERV_BATTERY_SERVICE = "BatteryService"
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
@@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor"
SERV_DOOR = "Door"
SERV_DOORBELL = "Doorbell"
SERV_FANV2 = "Fanv2"
SERV_FILTER_MAINTENANCE = "FilterMaintenance"
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
SERV_HUMIDITY_SENSOR = "HumiditySensor"
@@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName"
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
@@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication"
CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel"
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HARDWARE_REVISION = "HardwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected"
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
CHAR_STREAMING_STRATUS = "StreamingStatus"
CHAR_SWING_MODE = "SwingMode"
CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
@@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues"
# #### Thresholds ####
THRESHOLD_CO = 25
THRESHOLD_CO2 = 1000
THRESHOLD_FILTER_CHANGE_NEEDED = 10
# #### Default values ####
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C

View File

@@ -0,0 +1,469 @@
"""Class to hold all air purifier accessories."""
import logging
from typing import Any
from pyhap.characteristic import Characteristic
from pyhap.const import CATEGORY_AIR_PURIFIER
from pyhap.service import Service
from pyhap.util import callback as pyhap_callback
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import (
Event,
EventStateChangedData,
HassJobType,
State,
callback,
)
from homeassistant.helpers.event import async_track_state_change_event
from .accessories import TYPES
from .const import (
CHAR_ACTIVE,
CHAR_AIR_QUALITY,
CHAR_CURRENT_AIR_PURIFIER_STATE,
CHAR_CURRENT_HUMIDITY,
CHAR_CURRENT_TEMPERATURE,
CHAR_FILTER_CHANGE_INDICATION,
CHAR_FILTER_LIFE_LEVEL,
CHAR_NAME,
CHAR_PM25_DENSITY,
CHAR_TARGET_AIR_PURIFIER_STATE,
CONF_LINKED_FILTER_CHANGE_INDICATION,
CONF_LINKED_FILTER_LIFE_LEVEL,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
SERV_AIR_PURIFIER,
SERV_AIR_QUALITY_SENSOR,
SERV_FILTER_MAINTENANCE,
SERV_HUMIDITY_SENSOR,
SERV_TEMPERATURE_SENSOR,
THRESHOLD_FILTER_CHANGE_NEEDED,
)
from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan
from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality
_LOGGER = logging.getLogger(__name__)
CURRENT_STATE_INACTIVE = 0
CURRENT_STATE_IDLE = 1
CURRENT_STATE_PURIFYING_AIR = 2
TARGET_STATE_MANUAL = 0
TARGET_STATE_AUTO = 1
FILTER_CHANGE_FILTER = 1
FILTER_OK = 0
IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
@TYPES.register("AirPurifier")
class AirPurifier(Fan):
"""Generate an AirPurifier accessory for an air purifier entity.
Currently supports, in addition to Fan properties:
temperature; humidity; PM2.5; auto mode.
"""
def __init__(self, *args: Any) -> None:
"""Initialize a new AirPurifier accessory object."""
super().__init__(*args, category=CATEGORY_AIR_PURIFIER)
self.auto_preset: str | None = None
if self.preset_modes is not None:
for preset in self.preset_modes:
if str(preset).lower() == "auto":
self.auto_preset = preset
break
def create_services(self) -> Service:
"""Create and configure the primary service for this accessory."""
self.chars.append(CHAR_ACTIVE)
self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE)
self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE)
serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars)
self.set_primary_service(serv_air_purifier)
self.char_active: Characteristic = serv_air_purifier.configure_char(
CHAR_ACTIVE, value=0
)
self.preset_mode_chars: dict[str, Characteristic]
self.char_current_humidity: Characteristic | None = None
self.char_pm25_density: Characteristic | None = None
self.char_current_temperature: Characteristic | None = None
self.char_filter_change_indication: Characteristic | None = None
self.char_filter_life_level: Characteristic | None = None
self.char_target_air_purifier_state: Characteristic = (
serv_air_purifier.configure_char(
CHAR_TARGET_AIR_PURIFIER_STATE,
value=0,
)
)
self.char_current_air_purifier_state: Characteristic = (
serv_air_purifier.configure_char(
CHAR_CURRENT_AIR_PURIFIER_STATE,
value=0,
)
)
self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR)
if self.linked_humidity_sensor:
humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME)
serv_air_purifier.add_linked_service(humidity_serv)
self.char_current_humidity = humidity_serv.configure_char(
CHAR_CURRENT_HUMIDITY, value=0
)
humidity_state = self.hass.states.get(self.linked_humidity_sensor)
if humidity_state:
self._async_update_current_humidity(humidity_state)
self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR)
if self.linked_pm25_sensor:
pm25_serv = self.add_preload_service(
SERV_AIR_QUALITY_SENSOR,
[CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY],
)
serv_air_purifier.add_linked_service(pm25_serv)
self.char_pm25_density = pm25_serv.configure_char(
CHAR_PM25_DENSITY, value=0
)
self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY)
pm25_state = self.hass.states.get(self.linked_pm25_sensor)
if pm25_state:
self._async_update_current_pm25(pm25_state)
self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR)
if self.linked_temperature_sensor:
temperature_serv = self.add_preload_service(
SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE]
)
serv_air_purifier.add_linked_service(temperature_serv)
self.char_current_temperature = temperature_serv.configure_char(
CHAR_CURRENT_TEMPERATURE, value=0
)
temperature_state = self.hass.states.get(self.linked_temperature_sensor)
if temperature_state:
self._async_update_current_temperature(temperature_state)
self.linked_filter_change_indicator_binary_sensor = self.config.get(
CONF_LINKED_FILTER_CHANGE_INDICATION
)
self.linked_filter_life_level_sensor = self.config.get(
CONF_LINKED_FILTER_LIFE_LEVEL
)
if (
self.linked_filter_change_indicator_binary_sensor
or self.linked_filter_life_level_sensor
):
chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION]
if self.linked_filter_life_level_sensor:
chars.append(CHAR_FILTER_LIFE_LEVEL)
serv_filter_maintenance = self.add_preload_service(
SERV_FILTER_MAINTENANCE, chars
)
serv_air_purifier.add_linked_service(serv_filter_maintenance)
serv_filter_maintenance.configure_char(
CHAR_NAME,
value=cleanup_name_for_homekit(f"{self.display_name} Filter"),
)
self.char_filter_change_indication = serv_filter_maintenance.configure_char(
CHAR_FILTER_CHANGE_INDICATION,
value=0,
)
if self.linked_filter_change_indicator_binary_sensor:
filter_change_indicator_state = self.hass.states.get(
self.linked_filter_change_indicator_binary_sensor
)
if filter_change_indicator_state:
self._async_update_filter_change_indicator(
filter_change_indicator_state
)
if self.linked_filter_life_level_sensor:
self.char_filter_life_level = serv_filter_maintenance.configure_char(
CHAR_FILTER_LIFE_LEVEL,
value=0,
)
filter_life_level_state = self.hass.states.get(
self.linked_filter_life_level_sensor
)
if filter_life_level_state:
self._async_update_filter_life_level(filter_life_level_state)
return serv_air_purifier
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
"""Check if a preset mode switch should be added."""
return preset_mode.lower() != "auto"
@callback
@pyhap_callback # type: ignore[misc]
def run(self) -> None:
"""Handle accessory driver started event.
Run inside the Home Assistant event loop.
"""
if self.linked_humidity_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_humidity_sensor],
self._async_update_current_humidity_event,
job_type=HassJobType.Callback,
)
)
if self.linked_pm25_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_pm25_sensor],
self._async_update_current_pm25_event,
job_type=HassJobType.Callback,
)
)
if self.linked_temperature_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_temperature_sensor],
self._async_update_current_temperature_event,
job_type=HassJobType.Callback,
)
)
if self.linked_filter_change_indicator_binary_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_filter_change_indicator_binary_sensor],
self._async_update_filter_change_indicator_event,
job_type=HassJobType.Callback,
)
)
if self.linked_filter_life_level_sensor:
self._subscriptions.append(
async_track_state_change_event(
self.hass,
[self.linked_filter_life_level_sensor],
self._async_update_filter_life_level_event,
job_type=HassJobType.Callback,
)
)
super().run()
@callback
def _async_update_current_humidity_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_humidity(event.data["new_state"])
@callback
def _async_update_current_humidity(self, new_state: State | None) -> None:
"""Handle linked humidity sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_humidity := convert_to_float(new_state.state)) is None
or not self.char_current_humidity
or self.char_current_humidity.value == current_humidity
):
return
_LOGGER.debug(
"%s: Linked humidity sensor %s changed to %d",
self.entity_id,
self.linked_humidity_sensor,
current_humidity,
)
self.char_current_humidity.set_value(current_humidity)
@callback
def _async_update_current_pm25_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_pm25(event.data["new_state"])
@callback
def _async_update_current_pm25(self, new_state: State | None) -> None:
"""Handle linked pm25 sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_pm25 := convert_to_float(new_state.state)) is None
or not self.char_pm25_density
or self.char_pm25_density.value == current_pm25
):
return
_LOGGER.debug(
"%s: Linked pm25 sensor %s changed to %d",
self.entity_id,
self.linked_pm25_sensor,
current_pm25,
)
self.char_pm25_density.set_value(current_pm25)
air_quality = density_to_air_quality(current_pm25)
self.char_air_quality.set_value(air_quality)
_LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality)
@callback
def _async_update_current_temperature_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_current_temperature(event.data["new_state"])
@callback
def _async_update_current_temperature(self, new_state: State | None) -> None:
"""Handle linked temperature sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_temperature := convert_to_float(new_state.state)) is None
or not self.char_current_temperature
or self.char_current_temperature.value == current_temperature
):
return
_LOGGER.debug(
"%s: Linked temperature sensor %s changed to %d",
self.entity_id,
self.linked_temperature_sensor,
current_temperature,
)
self.char_current_temperature.set_value(current_temperature)
@callback
def _async_update_filter_change_indicator_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_filter_change_indicator(event.data.get("new_state"))
@callback
def _async_update_filter_change_indicator(self, new_state: State | None) -> None:
"""Handle linked filter change indicator binary sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
current_change_indicator = (
FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK
)
if (
not self.char_filter_change_indication
or self.char_filter_change_indication.value == current_change_indicator
):
return
_LOGGER.debug(
"%s: Linked filter change indicator binary sensor %s changed to %d",
self.entity_id,
self.linked_filter_change_indicator_binary_sensor,
current_change_indicator,
)
self.char_filter_change_indication.set_value(current_change_indicator)
@callback
def _async_update_filter_life_level_event(
self, event: Event[EventStateChangedData]
) -> None:
"""Handle state change event listener callback."""
self._async_update_filter_life_level(event.data.get("new_state"))
@callback
def _async_update_filter_life_level(self, new_state: State | None) -> None:
"""Handle linked filter life level sensor state change to update HomeKit value."""
if new_state is None or new_state.state in IGNORED_STATES:
return
if (
(current_life_level := convert_to_float(new_state.state)) is not None
and self.char_filter_life_level
and self.char_filter_life_level.value != current_life_level
):
_LOGGER.debug(
"%s: Linked filter life level sensor %s changed to %d",
self.entity_id,
self.linked_filter_life_level_sensor,
current_life_level,
)
self.char_filter_life_level.set_value(current_life_level)
if self.linked_filter_change_indicator_binary_sensor or not current_life_level:
# Handled by its own event listener
return
current_change_indicator = (
FILTER_CHANGE_FILTER
if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED)
else FILTER_OK
)
if (
not self.char_filter_change_indication
or self.char_filter_change_indication.value == current_change_indicator
):
return
_LOGGER.debug(
"%s: Linked filter life level sensor %s changed to %d",
self.entity_id,
self.linked_filter_life_level_sensor,
current_change_indicator,
)
self.char_filter_change_indication.set_value(current_change_indicator)
@callback
def async_update_state(self, new_state: State) -> None:
"""Update fan after state change."""
super().async_update_state(new_state)
# Handle State
state = new_state.state
if self.char_current_air_purifier_state is not None:
self.char_current_air_purifier_state.set_value(
CURRENT_STATE_PURIFYING_AIR
if state == STATE_ON
else CURRENT_STATE_INACTIVE
)
# Automatic mode is represented in HASS by a preset called Auto or auto
attributes = new_state.attributes
if ATTR_PRESET_MODE in attributes:
current_preset_mode = attributes.get(ATTR_PRESET_MODE)
self.char_target_air_purifier_state.set_value(
TARGET_STATE_AUTO
if current_preset_mode and current_preset_mode.lower() == "auto"
else TARGET_STATE_MANUAL
)
def set_chars(self, char_values: dict[str, Any]) -> None:
"""Handle automatic mode after state change."""
super().set_chars(char_values)
if (
CHAR_TARGET_AIR_PURIFIER_STATE in char_values
and self.auto_preset is not None
):
if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO:
super().set_preset_mode(True, self.auto_preset)
elif self.char_speed is not None:
super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()})

View File

@@ -4,6 +4,7 @@ import logging
from typing import Any
from pyhap.const import CATEGORY_FAN
from pyhap.service import Service
from homeassistant.components.fan import (
ATTR_DIRECTION,
@@ -56,9 +57,9 @@ class Fan(HomeAccessory):
Currently supports: state, speed, oscillate, direction.
"""
def __init__(self, *args: Any) -> None:
def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None:
"""Initialize a new Fan accessory object."""
super().__init__(*args, category=CATEGORY_FAN)
super().__init__(*args, category=category)
self.chars: list[str] = []
state = self.hass.states.get(self.entity_id)
assert state
@@ -79,12 +80,8 @@ class Fan(HomeAccessory):
self.chars.append(CHAR_SWING_MODE)
if features & FanEntityFeature.SET_SPEED:
self.chars.append(CHAR_ROTATION_SPEED)
if self.preset_modes and len(self.preset_modes) == 1:
self.chars.append(CHAR_TARGET_FAN_STATE)
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
self.set_primary_service(serv_fan)
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
serv_fan = self.create_services()
self.char_direction = None
self.char_speed = None
@@ -107,13 +104,21 @@ class Fan(HomeAccessory):
properties={PROP_MIN_STEP: percentage_step},
)
if self.preset_modes and len(self.preset_modes) == 1:
if (
self.preset_modes
and len(self.preset_modes) == 1
# NOTE: This would be missing for air purifiers
and CHAR_TARGET_FAN_STATE in self.chars
):
self.char_target_fan_state = serv_fan.configure_char(
CHAR_TARGET_FAN_STATE,
value=0,
)
elif self.preset_modes:
for preset_mode in self.preset_modes:
if not self.should_add_preset_mode_switch(preset_mode):
continue
preset_serv = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
)
@@ -126,7 +131,7 @@ class Fan(HomeAccessory):
)
def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
return self.set_preset_mode(value, preset_mode)
self.set_preset_mode(value, preset_mode)
self.preset_mode_chars[preset_mode] = preset_serv.configure_char(
CHAR_ON,
@@ -137,10 +142,27 @@ class Fan(HomeAccessory):
if CHAR_SWING_MODE in self.chars:
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
self.async_update_state(state)
serv_fan.setter_callback = self._set_chars
serv_fan.setter_callback = self.set_chars
def _set_chars(self, char_values: dict[str, Any]) -> None:
_LOGGER.debug("Fan _set_chars: %s", char_values)
def create_services(self) -> Service:
"""Create and configure the primary service for this accessory."""
if self.preset_modes and len(self.preset_modes) == 1:
self.chars.append(CHAR_TARGET_FAN_STATE)
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
self.set_primary_service(serv_fan)
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
return serv_fan
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
"""Check if a preset mode switch should be added.
Always true for fans, but can be overridden by subclasses.
"""
return True
def set_chars(self, char_values: dict[str, Any]) -> None:
"""Set characteristic values."""
_LOGGER.debug("Fan set_chars: %s", char_values)
if CHAR_ACTIVE in char_values:
if char_values[CHAR_ACTIVE]:
# If the device supports set speed we

View File

@@ -62,9 +62,13 @@ from .const import (
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_FILTER_CHANGE_INDICATION,
CONF_LINKED_FILTER_LIFE_LEVEL,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_OBSTRUCTION_SENSOR,
CONF_LINKED_PM25_SENSOR,
CONF_LINKED_TEMPERATURE_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -98,6 +102,8 @@ from .const import (
FEATURE_PLAY_STOP,
FEATURE_TOGGLE_MUTE,
MAX_NAME_LENGTH,
TYPE_AIR_PURIFIER,
TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
)
FAN_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All(
cv.string,
vol.In(
(
TYPE_FAN,
TYPE_AIR_PURIFIER,
)
),
),
vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN),
vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain(
binary_sensor.DOMAIN
),
vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN),
}
)
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
@@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "cover":
config = COVER_SCHEMA(config)
elif domain == "fan":
config = FAN_SCHEMA(config)
elif domain == "sensor":
config = SENSOR_SCHEMA(config)

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

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

View File

@@ -136,6 +136,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
# Process new device
new_devices = current_devices - self._devices_last_update
if new_devices:
self.data = data
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
self._add_new_devices(new_devices)

View File

@@ -2,106 +2,17 @@
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_ble_device_from_address,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_TYPE, DOMAIN
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
"""Coordinator for INKBIRD Bluetooth devices."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
data: INKBIRDBluetoothDeviceData,
) -> None:
"""Initialize the INKBIRD Bluetooth processor coordinator."""
self._data = data
self._entry = entry
address = entry.unique_id
assert address is not None
entry.async_on_unload(
async_track_time_interval(
hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
)
)
super().__init__(
hass=hass,
logger=_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=self._async_on_update,
needs_poll_method=self._async_needs_poll,
poll_method=self._async_poll_data,
)
async def _async_poll_data(
self, last_service_info: BluetoothServiceInfoBleak
) -> SensorUpdate:
"""Poll the device."""
return await self._data.async_poll(last_service_info.device)
@callback
def _async_needs_poll(
self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
) -> bool:
return (
not self.hass.is_stopping
and self._data.poll_needed(service_info, last_poll)
and bool(
async_ble_device_from_address(
self.hass, service_info.device.address, connectable=True
)
)
)
@callback
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
update = self._data.update(service_info)
if (
self._entry.data.get(CONF_DEVICE_TYPE) is None
and self._data.device_type is not None
):
device_type_str = str(self._data.device_type)
self.hass.config_entries.async_update_entry(
self._entry,
data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
)
return update
@callback
def _async_schedule_poll(self, _: datetime) -> None:
"""Schedule a poll of the device."""
if self._last_service_info and self._async_needs_poll(
self._last_service_info, self._last_poll
):
self._debounced_poll.async_schedule_call()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""

View File

@@ -0,0 +1,100 @@
"""The INKBIRD Bluetooth integration."""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_ble_device_from_address,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DEVICE_TYPE
_LOGGER = logging.getLogger(__name__)
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
"""Coordinator for INKBIRD Bluetooth devices."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
data: INKBIRDBluetoothDeviceData,
) -> None:
"""Initialize the INKBIRD Bluetooth processor coordinator."""
self._data = data
self._entry = entry
address = entry.unique_id
assert address is not None
entry.async_on_unload(
async_track_time_interval(
hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
)
)
super().__init__(
hass=hass,
logger=_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=self._async_on_update,
needs_poll_method=self._async_needs_poll,
poll_method=self._async_poll_data,
)
async def _async_poll_data(
self, last_service_info: BluetoothServiceInfoBleak
) -> SensorUpdate:
"""Poll the device."""
return await self._data.async_poll(last_service_info.device)
@callback
def _async_needs_poll(
self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
) -> bool:
return (
not self.hass.is_stopping
and self._data.poll_needed(service_info, last_poll)
and bool(
async_ble_device_from_address(
self.hass, service_info.device.address, connectable=True
)
)
)
@callback
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""
update = self._data.update(service_info)
if (
self._entry.data.get(CONF_DEVICE_TYPE) is None
and self._data.device_type is not None
):
device_type_str = str(self._data.device_type)
self.hass.config_entries.async_update_entry(
self._entry,
data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
)
return update
@callback
def _async_schedule_poll(self, _: datetime) -> None:
"""Schedule a poll of the device."""
if self._last_service_info and self._async_needs_poll(
self._last_service_info, self._last_poll
):
self._debounced_poll.async_schedule_call()

View File

@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"]
"requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.7"]
}

View File

@@ -199,7 +199,7 @@ turn_on:
example: "[255, 100, 100]"
selector:
color_rgb:
kelvin: &kelvin
color_temp_kelvin: &color_temp_kelvin
filter: *color_temp_support
selector:
color_temp:
@@ -316,7 +316,7 @@ toggle:
fields:
transition: *transition
rgb_color: *rgb_color
kelvin: *kelvin
color_temp_kelvin: *color_temp_kelvin
brightness_pct: *brightness_pct
effect: *effect
advanced_fields:

View File

@@ -19,8 +19,8 @@
"field_flash_name": "Flash",
"field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.",
"field_hs_color_name": "Hue/Sat color",
"field_kelvin_description": "Color temperature in Kelvin.",
"field_kelvin_name": "Color temperature",
"field_color_temp_kelvin_description": "Color temperature in Kelvin.",
"field_color_temp_kelvin_name": "Color temperature",
"field_profile_description": "Name of a light profile to use.",
"field_profile_name": "Profile",
"field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.",
@@ -328,9 +328,9 @@
"name": "[%key:component::light::common::field_color_temp_name%]",
"description": "[%key:component::light::common::field_color_temp_description%]"
},
"kelvin": {
"name": "[%key:component::light::common::field_kelvin_name%]",
"description": "[%key:component::light::common::field_kelvin_description%]"
"color_temp_kelvin": {
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
},
"brightness": {
"name": "[%key:component::light::common::field_brightness_name%]",
@@ -426,9 +426,9 @@
"name": "[%key:component::light::common::field_color_temp_name%]",
"description": "[%key:component::light::common::field_color_temp_description%]"
},
"kelvin": {
"name": "[%key:component::light::common::field_kelvin_name%]",
"description": "[%key:component::light::common::field_kelvin_description%]"
"color_temp_kelvin": {
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
},
"brightness": {
"name": "[%key:component::light::common::field_brightness_name%]",

View File

@@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-preset"
self._presets: list[motionmount.Preset] = []
self._attr_current_option = None
def _update_options(self, presets: list[motionmount.Preset]) -> None:
"""Convert presets to select options."""

View File

@@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
def validate_config(config: ConfigType) -> ConfigType:
"""Validate that the configuration is valid, throws if it isn't."""
if config[CONF_MIN] >= config[CONF_MAX]:
raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'")
if config[CONF_MIN] > config[CONF_MAX]:
raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}")
return config

View File

@@ -43,8 +43,8 @@
"data_description": {
"broker": "The hostname or IP address of your MQTT broker.",
"port": "The port your MQTT broker listens to. For example 1883.",
"username": "The username to login to your MQTT broker.",
"password": "The password to login to your MQTT broker.",
"username": "The username to log in to your MQTT broker.",
"password": "The password to log in to your MQTT broker.",
"advanced_options": "Enable and select **Next** to set advanced options.",
"certificate": "The custom CA certificate file to validate your MQTT brokers certificate.",
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",

View File

@@ -151,6 +151,9 @@ async def async_setup_entry(
assert event.object_id is not None
if event.object_id in added_ids:
return
player = mass.players.get(event.object_id)
if TYPE_CHECKING:
assert player is not None
if not player.expose_to_ha:
return
added_ids.add(event.object_id)

View File

@@ -241,10 +241,10 @@
"name": "Reachability"
},
"rf_strength": {
"name": "Radio"
"name": "RF strength"
},
"wifi_strength": {
"name": "Wi-Fi"
"name": "Wi-Fi strength"
},
"health_idx": {
"name": "Health index",

View File

@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling",
"loggers": ["nexia"],
"requirements": ["nexia==2.4.0"]
"requirements": ["nexia==2.7.0"]
}

View File

@@ -0,0 +1,84 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not require polling.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: todo
comment: |
Be more specific in the config flow with catching exceptions.
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: todo
docs-installation-instructions: done
docs-removal-instructions: todo
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters:
status: exempt
comment: No options to configure
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: done
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: |
This integration does not require a websession.
strict-typing: todo

View File

@@ -323,7 +323,7 @@ class NumberDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
Unit of measurement: `var`
Unit of measurement: `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"
@@ -497,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
NumberDeviceClass.PRESSURE: set(UnitOfPressure),
NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
NumberDeviceClass.SIGNAL_STRENGTH: {
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,

View File

@@ -140,7 +140,7 @@
},
"exceptions": {
"auth_failed": {
"message": "Unable to login to Ohme"
"message": "Unable to log in to Ohme"
},
"device_info_failed": {
"message": "Unable to get Ohme device information"

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"requirements": ["opower==0.10.0"]
"requirements": ["opower==0.11.1"]
}

View File

@@ -63,15 +63,24 @@ class PterodactylAPI:
self.pterodactyl = None
self.identifiers = []
def get_game_servers(self) -> list[str]:
"""Get all game servers."""
paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr]
return paginated_response.collect()
async def async_init(self):
"""Initialize the Pterodactyl API."""
self.pterodactyl = PterodactylClient(self.host, self.api_key)
try:
paginated_response = await self.hass.async_add_executor_job(
self.pterodactyl.client.servers.list_servers
)
except (BadRequestError, PterodactylApiError, ConnectionError) as error:
game_servers = await self.hass.async_add_executor_job(self.get_game_servers)
except (
BadRequestError,
PterodactylApiError,
ConnectionError,
StopIteration,
) as error:
raise PterodactylConnectionError(error) from error
except HTTPError as error:
if error.response.status_code == 401:
@@ -79,7 +88,6 @@ class PterodactylAPI:
raise PterodactylConnectionError(error) from error
else:
game_servers = paginated_response.collect()
for game_server in game_servers:
self.identifiers.append(game_server["attributes"]["identifier"])

View File

@@ -17,8 +17,8 @@
"port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.",
"use_https": "Use an HTTPS (SSL) connection to the Reolink device.",
"baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.",
"username": "Username to login to the Reolink device itself. Not the Reolink cloud account.",
"password": "Password to login to the Reolink device itself. Not the Reolink cloud account."
"username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.",
"password": "Password to log in to the Reolink device itself. Not the Reolink cloud account."
}
},
"privacy": {
@@ -33,7 +33,7 @@
"not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"",
"password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}",
"unknown": "[%key:common::config_flow::error::unknown%]",
"update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
"update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
"webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}"
},
"abort": {

View File

@@ -36,11 +36,11 @@
"fan_speed": {
"state": {
"default": "Default",
"normal": "Normal",
"silent": "Silent",
"normal": "[%key:common::state::normal%]",
"high": "[%key:common::state::high%]",
"intensive": "Intensive",
"silent": "Silent",
"super_silent": "Super silent",
"high": "High",
"auto": "Auto"
}
}

View File

@@ -352,7 +352,7 @@ class SensorDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
Unit of measurement: `var`
Unit of measurement: `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"
@@ -596,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
SensorDeviceClass.PRESSURE: set(UnitOfPressure),
SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
SensorDeviceClass.SIGNAL_STRENGTH: {
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,

View File

@@ -12,6 +12,7 @@ from aioshelly.exceptions import (
CustomPortNotSupported,
DeviceConnectionError,
InvalidAuthError,
InvalidHostError,
MacAddressMismatchError,
)
from aioshelly.rpc_device import RpcDevice
@@ -157,6 +158,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
self.info = await self._async_get_info(host, port)
except DeviceConnectionError:
errors["base"] = "cannot_connect"
except InvalidHostError:
errors["base"] = "invalid_host"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

View File

@@ -277,3 +277,7 @@ ROLE_TO_DEVICE_CLASS_MAP = {
"current_humidity": SensorDeviceClass.HUMIDITY,
"current_temperature": SensorDeviceClass.TEMPERATURE,
}
# We want to check only the first 5 KB of the script if it contains emitEvent()
# so that the integration startup remains fast.
MAX_SCRIPT_SIZE = 5120

View File

@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
"requirements": ["aioshelly==13.4.0"],
"requirements": ["aioshelly==13.4.1"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@@ -51,6 +51,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
"custom_port_not_supported": "Gen1 device does not support custom port.",

View File

@@ -58,6 +58,7 @@ from .const import (
GEN2_BETA_RELEASE_URL,
GEN2_RELEASE_URL,
LOGGER,
MAX_SCRIPT_SIZE,
RPC_INPUTS_EVENTS_TYPES,
SHAIR_MAX_WORK_HOURS,
SHBTN_INPUTS_EVENTS_TYPES,
@@ -642,7 +643,7 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None:
async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]:
"""Return a list of event types for a specific script."""
code_response = await device.script_getcode(id)
code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE)
matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"])
return sorted([*{str(event_type.group(1)) for event_type in matches}])

View File

@@ -10,7 +10,9 @@ import pysma
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CONNECTIONS,
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_SSL,
@@ -19,6 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
serial_number=sma_device_info["serial"],
)
# Add the MAC address to connections, if it comes via DHCP
if CONF_MAC in entry.data:
device_info[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])
}
# Define the coordinator
async def async_update_data():
"""Update the used SMA sensors."""

View File

@@ -7,26 +7,43 @@ from typing import Any
import pysma
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_NAME,
CONF_PASSWORD,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_GROUP, DOMAIN, GROUPS
_LOGGER = logging.getLogger(__name__)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
async def validate_input(
hass: HomeAssistant,
user_input: dict[str, Any],
data: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL])
session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL])
protocol = "https" if data[CONF_SSL] else "http"
url = f"{protocol}://{data[CONF_HOST]}"
protocol = "https" if user_input[CONF_SSL] else "http"
host = data[CONF_HOST] if data is not None else user_input[CONF_HOST]
url = URL.build(scheme=protocol, host=host)
sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP])
sma = pysma.SMA(
session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP]
)
# new_session raises SmaAuthenticationException on failure
await sma.new_session()
@@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_GROUP: GROUPS[0],
CONF_PASSWORD: vol.UNDEFINED,
}
self._discovery_data: dict[str, Any] = {}
async def _handle_user_input(
self, user_input: dict[str, Any], discovery: bool = False
) -> tuple[dict[str, str], dict[str, str]]:
"""Handle the user input."""
errors: dict[str, str] = {}
device_info: dict[str, str] = {}
if not discovery:
self._data[CONF_HOST] = user_input[CONF_HOST]
self._data[CONF_SSL] = user_input[CONF_SSL]
self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
self._data[CONF_GROUP] = user_input[CONF_GROUP]
self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
try:
device_info = await validate_input(
self.hass, user_input=user_input, data=self._data
)
except pysma.exceptions.SmaConnectionException:
errors["base"] = "cannot_connect"
except pysma.exceptions.SmaAuthenticationException:
errors["base"] = "invalid_auth"
except pysma.exceptions.SmaReadException:
errors["base"] = "cannot_retrieve_device_info"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors, device_info
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First step in config flow."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
self._data[CONF_HOST] = user_input[CONF_HOST]
self._data[CONF_SSL] = user_input[CONF_SSL]
self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
self._data[CONF_GROUP] = user_input[CONF_GROUP]
self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
try:
device_info = await validate_input(self.hass, user_input)
except pysma.exceptions.SmaConnectionException:
errors["base"] = "cannot_connect"
except pysma.exceptions.SmaAuthenticationException:
errors["base"] = "invalid_auth"
except pysma.exceptions.SmaReadException:
errors["base"] = "cannot_retrieve_device_info"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
errors, device_info = await self._handle_user_input(user_input=user_input)
if not errors:
await self.async_set_unique_id(str(device_info["serial"]))
await self.async_set_unique_id(
str(device_info["serial"]), raise_on_progress=False
)
self._abort_if_unique_id_configured(updates=self._data)
return self.async_create_entry(
title=self._data[CONF_HOST], data=self._data
)
@@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
self._discovery_data[CONF_HOST] = discovery_info.ip
self._discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress)
self._discovery_data[CONF_NAME] = discovery_info.hostname
self._data[CONF_HOST] = discovery_info.ip
self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC])
await self.async_set_unique_id(discovery_info.hostname.replace("SMA", ""))
self._abort_if_unique_id_configured()
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
errors: dict[str, str] = {}
if user_input is not None:
errors, device_info = await self._handle_user_input(
user_input=user_input, discovery=True
)
if not errors:
return self.async_create_entry(
title=self._data[CONF_HOST], data=self._data
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean,
vol.Optional(
CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL]
): cv.boolean,
vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In(
GROUPS
),
vol.Required(CONF_PASSWORD): cv.string,
}
),
errors=errors,
)

View File

@@ -3,6 +3,13 @@
"name": "SMA Solar",
"codeowners": ["@kellerza", "@rklomp", "@erwindouna"],
"config_flow": true,
"dhcp": [
{
"hostname": "sma*",
"macaddress": "0015BB*"
},
{ "registered_devices": true }
],
"documentation": "https://www.home-assistant.io/integrations/sma",
"iot_class": "local_polling",
"loggers": ["pysma"],

View File

@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Login",
"description": "Enter your SmartTub email address and password to login",
"description": "Enter your SmartTub email address and password to log in",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"

View File

@@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
raise ConfigEntryAuthFailed from err
except SmlightConnectionError as err:
raise UpdateFailed(err) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect_device",
translation_placeholders={"error": str(err)},
) from err
@abstractmethod
async def _internal_update_data(self) -> _DataT:

View File

@@ -11,6 +11,7 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["pysmlight==0.2.4"],
"zeroconf": [
{

View File

@@ -0,0 +1,85 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: done
comment: |
Entities subscribe to SSE events from pysmlight library.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: done
comment: Handled implicitly within coordinator
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not provide actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not provide an option flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: Handled by coordinator
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Device type integration.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: done
stale-devices:
status: exempt
comment: |
Device type integration.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -15,6 +15,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "Username for the device's web login.",
"password": "Password for the device's web login."
}
},
"reauth_confirm": {
@@ -23,6 +27,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::smlight::config::step::auth::data_description::username%]",
"password": "[%key:component::smlight::config::step::auth::data_description::password%]"
}
},
"confirm_discovery": {
@@ -137,6 +145,14 @@
}
}
},
"exceptions": {
"firmware_update_failed": {
"message": "Firmware update failed for {device_name}."
},
"cannot_connect_device": {
"message": "An error occurred while connecting to the SMLIGHT device: {error}."
}
},
"issues": {
"unsupported_firmware": {
"title": "SLZB core firmware update required",

View File

@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .const import DOMAIN, LOGGER
from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData
from .entity import SmEntity
@@ -210,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity):
def _update_failed(self, event: MessageEvent) -> None:
self._update_done()
self.coordinator.in_progress = False
raise HomeAssistantError(f"Update failed for {self.name}")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_failed",
translation_placeholders={
"device_name": str(self.name),
},
)
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any

View File

@@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = {
SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH],
SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.REMOTE.value: [Platform.SENSOR],
SupportedModels.ROLLER_SHADE.value: [
Platform.COVER,
Platform.BINARY_SENSOR,
Platform.SENSOR,
],
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -80,6 +86,7 @@ CLASS_BY_DEVICE = {
SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt,
SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
}

View File

@@ -35,6 +35,8 @@ class SupportedModels(StrEnum):
RELAY_SWITCH_1 = "relay_switch_1"
LEAK = "leak"
REMOTE = "remote"
ROLLER_SHADE = "roller_shade"
HUBMINI_MATTER = "hubmini_matter"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.HUB2: SupportedModels.HUB2,
SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM,
SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1,
SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
SwitchbotModel.LEAK: SupportedModels.LEAK,
SwitchbotModel.REMOTE: SupportedModels.REMOTE,
SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
}
SUPPORTED_MODEL_TYPES = (

View File

@@ -37,6 +37,8 @@ async def async_setup_entry(
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt):
async_add_entities([SwitchBotBlindTiltEntity(coordinator)])
elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade):
async_add_entities([SwitchBotRollerShadeEntity(coordinator)])
else:
async_add_entities([SwitchBotCurtainEntity(coordinator)])
@@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
self.async_write_ha_state()
class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
"""Representation of a Switchbot."""
_device: switchbot.SwitchbotRollerShade
_attr_device_class = CoverDeviceClass.SHADE
_attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.STOP
| CoverEntityFeature.SET_POSITION
)
_attr_translation_key = "cover"
_attr_name = None
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the switchbot."""
super().__init__(coordinator)
self._attr_is_closed = None
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes:
return
self._attr_current_cover_position = last_state.attributes.get(
ATTR_CURRENT_POSITION
)
self._last_run_success = last_state.attributes.get("last_run_success")
if self._attr_current_cover_position is not None:
self._attr_is_closed = self._attr_current_cover_position <= 20
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the roller shade."""
_LOGGER.debug("Switchbot to open roller shade %s", self._address)
self._last_run_success = bool(await self._device.open())
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the roller shade."""
_LOGGER.debug("Switchbot to close roller shade %s", self._address)
self._last_run_success = bool(await self._device.close())
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the moving of roller shade."""
_LOGGER.debug("Switchbot to stop roller shade %s", self._address)
self._last_run_success = bool(await self._device.stop())
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position = kwargs.get(ATTR_POSITION)
_LOGGER.debug("Switchbot to move at %d %s", position, self._address)
self._last_run_success = bool(await self._device.set_position(position))
self._attr_is_opening = self._device.is_opening()
self._attr_is_closing = self._device.is_closing()
self.async_write_ha_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_is_closing = self._device.is_closing()
self._attr_is_opening = self._device.is_opening()
self._attr_current_cover_position = self.parsed_data["position"]
self._attr_is_closed = self.parsed_data["position"] <= 20
self.async_write_ha_state()

View File

@@ -141,7 +141,7 @@
"state_attributes": {
"preset_mode": {
"state": {
"off": "Normal",
"off": "[%key:common::state::normal%]",
"keep": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"

View File

@@ -221,7 +221,7 @@
"state_attributes": {
"preset_mode": {
"state": {
"off": "Normal",
"off": "[%key:common::state::normal%]",
"keep": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"

View File

@@ -48,7 +48,7 @@
"state_attributes": {
"preset_mode": {
"state": {
"off": "Normal",
"off": "[%key:common::state::normal%]",
"on": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"

View File

@@ -12,7 +12,7 @@
},
"data_description": {
"host": "The hostname or IP address of your Traccar Server",
"username": "The username (email) you use to login to your Traccar Server"
"username": "The username (email) you use to log in to your Traccar Server"
}
}
},

View File

@@ -321,9 +321,9 @@
"vacuum_cistern": {
"name": "Water tank adjustment",
"state": {
"low": "Low",
"low": "[%key:common::state::low%]",
"middle": "Middle",
"high": "High",
"high": "[%key:common::state::high%]",
"closed": "[%key:common::state::closed%]"
}
},

View File

@@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity):
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
_attr_translation_key = "ventilation"
_attributes: dict[str, Any] = {}
def __init__(
self,
@@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity):
self._attr_supported_features |= FanEntityFeature.SET_SPEED
# evaluate quickmodes
quickmodes: list[str] = (
self._attributes["vicare_quickmodes"] = quickmodes = list[str](
device.getVentilationQuickmodes()
if is_supported(
"getVentilationQuickmodes",
@@ -196,26 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
if (
self._attr_supported_features & FanEntityFeature.TURN_OFF
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
):
if VentilationQuickmode.STANDBY in self._attributes[
"vicare_quickmodes"
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
return False
return self.percentage is not None and self.percentage > 0
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
@property
def icon(self) -> str | None:
"""Return the icon to use in the frontend."""
if (
self._attr_supported_features & FanEntityFeature.TURN_OFF
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
):
if VentilationQuickmode.STANDBY in self._attributes[
"vicare_quickmodes"
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
return "mdi:fan-off"
if hasattr(self, "_attr_preset_mode"):
if self._attr_preset_mode == VentilationMode.VENTILATION:
@@ -242,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity):
"""Set the speed of the fan, as a percentage."""
if self._attr_preset_mode != str(VentilationMode.PERMANENT):
self.set_preset_mode(VentilationMode.PERMANENT)
elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
elif VentilationQuickmode.STANDBY in self._attributes[
"vicare_quickmodes"
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
@@ -254,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity):
target_mode = VentilationMode.to_vicare_mode(preset_mode)
_LOGGER.debug("changing ventilation mode to %s", target_mode)
self._api.activateVentilationMode(target_mode)
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Show Device Attributes."""
return self._attributes

View File

@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
"requirements": ["weheat==2025.2.26"]
"requirements": ["weheat==2025.3.7"]
}

View File

@@ -2,13 +2,11 @@
from __future__ import annotations
import logging
from typing import Any
from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode
from homeassistant.components.climate import (
ENTITY_ID_FORMAT,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
@@ -22,15 +20,10 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WhirlpoolConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
from .entity import WhirlpoolEntity
AIRCON_MODE_MAP = {
AirconMode.Cool: HVACMode.COOL,
@@ -71,14 +64,13 @@ async def async_setup_entry(
"""Set up entry."""
appliances_manager = config_entry.runtime_data
aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons]
async_add_entities(aircons, True)
async_add_entities(aircons)
class AirConEntity(ClimateEntity):
class AirConEntity(WhirlpoolEntity, ClimateEntity):
"""Representation of an air conditioner."""
_attr_fan_modes = SUPPORTED_FAN_MODES
_attr_has_entity_name = True
_attr_name = None
_attr_hvac_modes = SUPPORTED_HVAC_MODES
_attr_max_temp = SUPPORTED_MAX_TEMP
@@ -97,29 +89,8 @@ class AirConEntity(ClimateEntity):
def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None:
"""Initialize the entity."""
super().__init__(aircon)
self._aircon = aircon
self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass)
self._attr_unique_id = aircon.said
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, aircon.said)},
name=aircon.name if aircon.name is not None else aircon.said,
manufacturer="Whirlpool",
model="Sixth Sense",
)
async def async_added_to_hass(self) -> None:
"""Register updates callback."""
self._aircon.register_attr_callback(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Unregister updates callback."""
self._aircon.unregister_attr_callback(self.async_write_ha_state)
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._aircon.get_online()
@property
def current_temperature(self) -> float:

View File

@@ -19,8 +19,9 @@ class WhirlpoolEntity(Entity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.said)},
name=appliance.name.capitalize(),
name=appliance.name.capitalize() if appliance.name else appliance.said,
manufacturer="Whirlpool",
model_id=appliance.appliance_info.model_number,
)
self._attr_unique_id = f"{appliance.said}{unique_id_suffix}"

View File

@@ -26,6 +26,7 @@ from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
@@ -79,10 +80,19 @@ def add_province_and_language_to_schema(
}
if provinces := all_countries.get(country):
if _country.subdivisions_aliases and (
subdiv_aliases := _country.get_subdivision_aliases()
):
province_options: list[Any] = [
SelectOptionDict(value=k, label=", ".join(v))
for k, v in subdiv_aliases.items()
]
else:
province_options = provinces
province_schema = {
vol.Optional(CONF_PROVINCE): SelectSelector(
SelectSelectorConfig(
options=provinces,
options=province_options,
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_PROVINCE,
)

View File

@@ -61,8 +61,8 @@
"power_failure_alarm": {
"name": "Power failure alarm",
"state": {
"normal": "Normal",
"alert": "Alert",
"normal": "[%key:common::state::normal%]",
"off": "[%key:common::state::off%]"
}
},

View File

@@ -514,6 +514,7 @@ class ZHAGatewayProxy(EventBase):
self._log_queue_handler.listener = logging.handlers.QueueListener(
log_simple_queue, log_relay_handler
)
self._log_queue_handler_count: int = 0
self._unsubs: list[Callable[[], None]] = []
self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol))
@@ -747,7 +748,10 @@ class ZHAGatewayProxy(EventBase):
if filterer:
self._log_queue_handler.addFilter(filterer)
if self._log_queue_handler.listener:
# Only start a new log queue handler if the old one is no longer running
self._log_queue_handler_count += 1
if self._log_queue_handler.listener and self._log_queue_handler_count == 1:
self._log_queue_handler.listener.start()
for logger_name in DEBUG_RELAY_LOGGERS:
@@ -763,7 +767,10 @@ class ZHAGatewayProxy(EventBase):
for logger_name in DEBUG_RELAY_LOGGERS:
logging.getLogger(logger_name).removeHandler(self._log_queue_handler)
if self._log_queue_handler.listener:
# Only stop the log queue handler if nothing else is using it
self._log_queue_handler_count -= 1
if self._log_queue_handler.listener and self._log_queue_handler_count == 0:
self._log_queue_handler.listener.stop()
if filterer:

View File

@@ -420,17 +420,20 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
dev_path = discovery_info.device
self.usb_path = dev_path
self._title = usb.human_readable_device_name(
dev_path,
serial_number,
manufacturer,
description,
vid,
pid,
)
self.context["title_placeholders"] = {
CONF_NAME: self._title.split(" - ")[0].strip()
}
if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2":
title = "Home Assistant Connect ZWA-2"
else:
human_name = usb.human_readable_device_name(
dev_path,
serial_number,
manufacturer,
description,
vid,
pid,
)
title = human_name.split(" - ")[0].strip()
self.context["title_placeholders"] = {CONF_NAME: title}
self._title = title
return await self.async_step_usb_confirm()
async def async_step_usb_confirm(

View File

@@ -21,6 +21,13 @@
"pid": "8A2A",
"description": "*z-wave*",
"known_devices": ["Nortek HUSBZB-1"]
},
{
"vid": "303A",
"pid": "4001",
"description": "*nabu casa zwa-2*",
"manufacturer": "nabu casa",
"known_devices": ["Nabu Casa Connect ZWA-2"]
}
],
"zeroconf": ["_zwave-js-server._tcp.local."]

View File

@@ -72,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
from .loader import async_suggest_report_issue
from .setup import (
DATA_SETUP_DONE,
SetupPhases,
async_pause_setup,
async_process_deps_reqs,
async_setup_component,
async_start_setup,
async_wait_component,
)
from .util import ulid as ulid_util
from .util.async_ import create_eager_task
@@ -1511,6 +1511,22 @@ class ConfigEntriesFlowManager(
future.set_result(None)
self._discovery_event_debouncer.async_shutdown()
@callback
def async_flow_removed(
self,
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
) -> None:
"""Handle a removed config flow."""
flow = cast(ConfigFlow, flow)
# Clean up issue if this is a reauth flow
if flow.context["source"] == SOURCE_REAUTH:
if (entry_id := flow.context.get("entry_id")) is not None and (
entry := self.config_entries.async_get_entry(entry_id)
) is not None:
issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
async def async_finish_flow(
self,
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
@@ -1523,20 +1539,6 @@ class ConfigEntriesFlowManager(
"""
flow = cast(ConfigFlow, flow)
# Mark the step as done.
# We do this to avoid a circular dependency where async_finish_flow sets up a
# new entry, which needs the integration to be set up, which is waiting for
# init to be done.
self._set_pending_import_done(flow)
# Clean up issue if this is a reauth flow
if flow.context["source"] == SOURCE_REAUTH:
if (entry_id := flow.context.get("entry_id")) is not None and (
entry := self.config_entries.async_get_entry(entry_id)
) is not None:
issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
# If there's a config entry with a matching unique ID,
# update the discovery key.
@@ -1575,6 +1577,12 @@ class ConfigEntriesFlowManager(
)
return result
# Mark the step as done.
# We do this to avoid a circular dependency where async_finish_flow sets up a
# new entry, which needs the integration to be set up, which is waiting for
# init to be done.
self._set_pending_import_done(flow)
# Avoid adding a config entry for a integration
# that only supports a single config entry, but already has an entry
if (
@@ -2732,11 +2740,7 @@ class ConfigEntries:
Config entries which are created after Home Assistant is started can't be waited
for, the function will just return if the config entry is loaded or not.
"""
setup_done = self.hass.data.get(DATA_SETUP_DONE, {})
if setup_future := setup_done.get(entry.domain):
await setup_future
# The component was not loaded.
if entry.domain not in self.hass.config.components:
if not await async_wait_component(self.hass, entry.domain):
return False
return entry.state is ConfigEntryState.LOADED

View File

@@ -603,6 +603,7 @@ class UnitOfReactivePower(StrEnum):
"""Reactive power units."""
VOLT_AMPERE_REACTIVE = "var"
KILO_VOLT_AMPERE_REACTIVE = "kvar"
_DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum(

View File

@@ -207,6 +207,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
Handler key is the domain of the component that we want to set up.
"""
@callback
def async_flow_removed(
self,
flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT],
) -> None:
"""Handle a removed data entry flow."""
@abc.abstractmethod
async def async_finish_flow(
self,
@@ -457,6 +464,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
"""Remove a flow from in progress."""
if (flow := self._progress.pop(flow_id, None)) is None:
raise UnknownFlow
self.async_flow_removed(flow)
self._async_remove_flow_from_index(flow)
flow.async_cancel_progress_task()
try:
@@ -485,6 +493,10 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
description_placeholders=err.description_placeholders,
)
if flow.flow_id not in self._progress:
# The flow was removed during the step
raise UnknownFlow
# Setup the flow handler's preview if needed
if result.get("preview") is not None:
await self._async_setup_preview(flow)

View File

@@ -613,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "sleepiq",
"macaddress": "64DBA0*",
},
{
"domain": "sma",
"hostname": "sma*",
"macaddress": "0015BB*",
},
{
"domain": "sma",
"registered_devices": True,
},
{
"domain": "smartthings",
"hostname": "st*",

View File

@@ -148,4 +148,11 @@ USB = [
"pid": "8A2A",
"vid": "10C4",
},
{
"description": "*nabu casa zwa-2*",
"domain": "zwave_js",
"manufacturer": "nabu casa",
"pid": "4001",
"vid": "303A",
},
]

View File

@@ -130,7 +130,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.11.2
pydantic==2.11.3
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -212,3 +212,8 @@ async-timeout==4.0.3
# https://github.com/home-assistant/core/issues/122508
# https://github.com/home-assistant/core/issues/118004
aiofiles>=24.1.0
# multidict < 6.4.0 has memory leaks
# https://github.com/aio-libs/multidict/issues/1134
# https://github.com/aio-libs/multidict/issues/1131
multidict>=6.4.2

View File

@@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__)
ATTR_COMPONENT: Final = "component"
# DATA_SETUP is a dict, indicating domains which are currently
# _DATA_SETUP is a dict, indicating domains which are currently
# being setup or which failed to setup:
# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain
# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain
# being setup and the Task is the `_async_setup_component` helper.
# - Tasks are removed from DATA_SETUP if setup was successful, that is,
# - Tasks are removed from _DATA_SETUP if setup was successful, that is,
# the task returned True.
DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
# DATA_SETUP_DONE is a dict, indicating components which will be setup:
# - Events are added to DATA_SETUP_DONE during bootstrap by
# _DATA_SETUP_DONE is a dict, indicating components which will be setup:
# - Events are added to _DATA_SETUP_DONE during bootstrap by
# async_set_domains_to_be_loaded, the key is the domain which will be loaded.
# - Events are set and removed from DATA_SETUP_DONE when async_setup_component
# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component
# is finished, regardless of if the setup was successful or not.
DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
# DATA_SETUP_STARTED is a dict, indicating when an attempt
# _DATA_SETUP_STARTED is a dict, indicating when an attempt
# to setup a component started.
DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
"setup_started"
)
# DATA_SETUP_TIME is a defaultdict, indicating how time was spent
# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent
# setting up a component.
DATA_SETUP_TIME: HassKey[
_DATA_SETUP_TIME: HassKey[
defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]
] = HassKey("setup_time")
DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
"bootstrap_persistent_errors"
)
@@ -104,8 +104,8 @@ def async_notify_setup_error(
# pylint: disable-next=import-outside-toplevel
from .components import persistent_notification
if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None:
errors = hass.data[DATA_PERSISTENT_ERRORS] = {}
if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None:
errors = hass.data[_DATA_PERSISTENT_ERRORS] = {}
errors[component] = errors.get(component) or display_link
@@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str])
- Properly handle after_dependencies.
- Keep track of domains which will load but have not yet finished loading
"""
setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
setup_futures = hass.data.setdefault(DATA_SETUP, {})
setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components
if overlap := old_domains & domains:
_LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap)
@@ -158,8 +158,8 @@ async def async_setup_component(
if domain in hass.config.components:
return True
setup_futures = hass.data.setdefault(DATA_SETUP, {})
setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
if existing_setup_future := setup_futures.get(domain):
return await existing_setup_future
@@ -200,35 +200,40 @@ async def _async_process_dependencies(
Returns a list of dependencies which failed to set up.
"""
setup_futures = hass.data.setdefault(DATA_SETUP, {})
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
dependencies_tasks = {
dep: setup_futures.get(dep)
or create_eager_task(
async_setup_component(hass, dep, config),
name=f"setup {dep} as dependency of {integration.domain}",
loop=hass.loop,
)
for dep in integration.dependencies
if dep not in hass.config.components
}
dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
to_be_loaded = hass.data.get(DATA_SETUP_DONE, {})
for dep in integration.dependencies:
fut = setup_futures.get(dep)
if fut is None:
if dep in hass.config.components:
continue
fut = create_eager_task(
async_setup_component(hass, dep, config),
name=f"setup {dep} as dependency of {integration.domain}",
loop=hass.loop,
)
dependencies_tasks[dep] = fut
to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {})
# We don't want to just wait for the futures from `to_be_loaded` here.
# We want to ensure that our after_dependencies are always actually
# scheduled to be set up, as if for whatever reason they had not been,
# we would deadlock waiting for them here.
for dep in integration.after_dependencies:
if (
dep not in dependencies_tasks
and dep in to_be_loaded
and dep not in hass.config.components
):
dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task(
if dep not in to_be_loaded or dep in dependencies_tasks:
continue
fut = setup_futures.get(dep)
if fut is None:
if dep in hass.config.components:
continue
fut = create_eager_task(
async_setup_component(hass, dep, config),
name=f"setup {dep} as after dependency of {integration.domain}",
loop=hass.loop,
)
dependencies_tasks[dep] = fut
if not dependencies_tasks:
return []
@@ -478,7 +483,7 @@ async def _async_setup_component(
)
# Cleanup
hass.data[DATA_SETUP].pop(domain, None)
hass.data[_DATA_SETUP].pop(domain, None)
hass.bus.async_fire_internal(
EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain)
@@ -568,8 +573,8 @@ async def async_process_deps_reqs(
Module is a Python module of either a component or platform.
"""
if (processed := hass.data.get(DATA_DEPS_REQS)) is None:
processed = hass.data[DATA_DEPS_REQS] = set()
if (processed := hass.data.get(_DATA_DEPS_REQS)) is None:
processed = hass.data[_DATA_DEPS_REQS] = set()
elif integration.domain in processed:
return
@@ -684,7 +689,7 @@ class SetupPhases(StrEnum):
"""Wait time for the packages to import."""
@singleton.singleton(DATA_SETUP_STARTED)
@singleton.singleton(_DATA_SETUP_STARTED)
def _setup_started(
hass: core.HomeAssistant,
) -> dict[tuple[str, str | None], float]:
@@ -727,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator
)
@singleton.singleton(DATA_SETUP_TIME)
@singleton.singleton(_DATA_SETUP_TIME)
def _setup_times(
hass: core.HomeAssistant,
) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]:
@@ -827,3 +832,11 @@ def async_get_domain_setup_times(
) -> Mapping[str | None, dict[SetupPhases, float]]:
"""Return timing data for each integration."""
return _setup_times(hass).get(domain, {})
async def async_wait_component(hass: HomeAssistant, domain: str) -> bool:
"""Wait until a component is set up if pending, then return if it is set up."""
setup_done = hass.data.get(_DATA_SETUP_DONE, {})
if setup_future := setup_done.get(domain):
await setup_future
return domain in hass.config.components

14
requirements_all.txt generated
View File

@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.4.0
aioshelly==13.4.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -645,7 +645,7 @@ bluetooth-data-tools==1.27.0
bond-async==0.2.1
# homeassistant.components.bosch_alarm
bosch-alarm-mode2==0.4.3
bosch-alarm-mode2==0.4.6
# homeassistant.components.bosch_shc
boschshcpy==0.2.91
@@ -1314,7 +1314,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
led-ble==1.1.6
led-ble==1.1.7
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1489,7 +1489,7 @@ nettigo-air-monitor==4.1.0
neurio==0.3.1
# homeassistant.components.nexia
nexia==2.4.0
nexia==2.7.0
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.10.0
opower==0.11.1
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -2005,7 +2005,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
pyheos==1.0.4
pyheos==1.0.5
# homeassistant.components.hive
pyhive-integration==1.0.2
@@ -3064,7 +3064,7 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2025.2.26
weheat==2025.3.7
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.20.0

View File

@@ -14,7 +14,7 @@ license-expression==30.4.1
mock-open==1.4.0
mypy-dev==1.16.0a7
pre-commit==4.0.0
pydantic==2.11.2
pydantic==2.11.3
pylint==3.3.6
pylint-per-file-ignores==1.4.0
pipdeptree==2.25.1

View File

@@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.4.0
aioshelly==13.4.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -570,7 +570,7 @@ bluetooth-data-tools==1.27.0
bond-async==0.2.1
# homeassistant.components.bosch_alarm
bosch-alarm-mode2==0.4.3
bosch-alarm-mode2==0.4.6
# homeassistant.components.bosch_shc
boschshcpy==0.2.91
@@ -1114,7 +1114,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
led-ble==1.1.6
led-ble==1.1.7
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1253,7 +1253,7 @@ netmap==0.7.0.2
nettigo-air-monitor==4.1.0
# homeassistant.components.nexia
nexia==2.4.0
nexia==2.7.0
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1344,7 +1344,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.10.0
opower==0.11.1
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1635,7 +1635,7 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
pyheos==1.0.4
pyheos==1.0.5
# homeassistant.components.hive
pyhive-integration==1.0.2
@@ -2472,7 +2472,7 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
weheat==2025.2.26
weheat==2025.3.7
# homeassistant.components.whirlpool
whirlpool-sixth-sense==0.20.0

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