Merge branch 'dev' into whirlpool_bronze

This commit is contained in:
Abílio Costa 2025-04-14 20:10:01 +01:00 committed by GitHub
commit 5567ffcb64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
332 changed files with 10603 additions and 2907 deletions

View File

@ -291,6 +291,7 @@ homeassistant.components.kaleidescape.*
homeassistant.components.knocki.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
homeassistant.components.kulersky.*
homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*

2
CODEOWNERS generated
View File

@ -937,6 +937,8 @@ build.json @home-assistant/supervisor
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87
/tests/components/mikrotik/ @engrbm87
/homeassistant/components/mill/ @danielhiversen

View File

@ -53,6 +53,7 @@ from .components import (
logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401
onboarding as onboarding_pre_import, # noqa: F401
person as person_pre_import, # noqa: F401
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
repairs as repairs_pre_import, # noqa: F401
search as search_pre_import, # noqa: F401

View File

@ -34,7 +34,7 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"auto": "Auto"
"auto": "[%key:common::state::auto%]"
}
},
"modes": {

View File

@ -266,7 +266,7 @@ async def _transform_stream(
raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use":
tool_block = cast(ToolUseBlockParam, current_block)
tool_args = json.loads(current_tool_args)
tool_args = json.loads(current_tool_args) if current_tool_args else {}
tool_block["input"] = tool_args
yield {
"tool_calls": [

View File

@ -0,0 +1,136 @@
"""Backup onboarding views."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from functools import wraps
from http import HTTPStatus
from typing import TYPE_CHECKING, Any, Concatenate
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
import voluptuous as vol
from homeassistant.components.http import KEY_HASS
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.onboarding import (
BaseOnboardingView,
NoAuthBaseOnboardingView,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
if TYPE_CHECKING:
from homeassistant.components.onboarding import OnboardingStoreData
async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
"""Set up the backup views."""
hass.http.register_view(BackupInfoView(data))
hass.http.register_view(RestoreBackupView(data))
hass.http.register_view(UploadBackupView(data))
def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
func: Callable[
Concatenate[_ViewT, BackupManager, web.Request, _P],
Coroutine[Any, Any, web.Response],
],
) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
"""Home Assistant API decorator to check onboarding and inject manager."""
@wraps(func)
async def with_backup(
self: _ViewT,
request: web.Request,
*args: _P.args,
**kwargs: _P.kwargs,
) -> web.Response:
"""Check admin and call function."""
if self._data["done"]:
raise HTTPUnauthorized
manager = await async_get_backup_manager(request.app[KEY_HASS])
return await func(self, manager, request, *args, **kwargs)
return with_backup
class BackupInfoView(NoAuthBaseOnboardingView):
"""Get backup info view."""
url = "/api/onboarding/backup/info"
name = "api:onboarding:backup:info"
@with_backup_manager
async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Return backup info."""
backups, _ = await manager.async_get_backups()
return self.json(
{
"backups": list(backups.values()),
"state": manager.state,
"last_action_event": manager.last_action_event,
}
)
class RestoreBackupView(NoAuthBaseOnboardingView):
"""Restore backup view."""
url = "/api/onboarding/backup/restore"
name = "api:onboarding:backup:restore"
@RequestDataValidator(
vol.Schema(
{
vol.Required("backup_id"): str,
vol.Required("agent_id"): str,
vol.Optional("password"): str,
vol.Optional("restore_addons"): [str],
vol.Optional("restore_database", default=True): bool,
vol.Optional("restore_folders"): [vol.Coerce(Folder)],
}
)
)
@with_backup_manager
async def post(
self, manager: BackupManager, request: web.Request, data: dict[str, Any]
) -> web.Response:
"""Restore a backup."""
try:
await manager.async_restore_backup(
data["backup_id"],
agent_id=data["agent_id"],
password=data.get("password"),
restore_addons=data.get("restore_addons"),
restore_database=data["restore_database"],
restore_folders=data.get("restore_folders"),
restore_homeassistant=True,
)
except IncorrectPasswordError:
return self.json(
{"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
)
except HomeAssistantError as err:
return self.json(
{"code": "restore_failed", "message": str(err)},
status_code=HTTPStatus.BAD_REQUEST,
)
return web.Response(status=HTTPStatus.OK)
class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView):
"""Upload backup view."""
url = "/api/onboarding/backup/upload"
name = "api:onboarding:backup:upload"
@with_backup_manager
async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
"""Upload a backup file."""
return await self._post(request)

View File

@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.5",
"bluetooth-data-tools==1.27.0",
"dbus-fast==2.43.0",
"habluetooth==3.37.0"
"habluetooth==3.38.1"
]
}

View File

@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
self.logger.exception("Unexpected error updating %s data", self.name)
return
self._process_update(update, was_available)
@callback
def async_set_updated_data(self, update: _DataT) -> None:
"""Manually update the processor with new data.
If the data comes in via a different method, like a
notification, this method can be used to update the
processor with the new data.
This is useful for devices that retrieve
some of their data via notifications.
"""
was_available = self._available
self._available = True
self._process_update(update, was_available)
def _process_update(
self, update: _DataT, was_available: bool | None = None
) -> None:
"""Process the update from the bluetooth device."""
if not self.last_update_success:
self.last_update_success = True
self.logger.info("Coordinator %s recovered", self.name)

View File

@ -51,9 +51,9 @@
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"auto": "Auto",
"low": "Low",
"medium": "Medium",
"high": "High",
"low": "[%key:common::state::low%]",
"medium": "[%key:common::state::medium%]",
"high": "[%key:common::state::high%]",
"top": "Top",
"middle": "Middle",
"focus": "Focus",

View File

@ -83,7 +83,6 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
config_entry_entry_id: str,
) -> None:
"""Initialize the alarm panel."""
self._api = coordinator.api
self._area_index = area.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
@ -137,30 +136,38 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
if code != str(self._api.device_pin):
if code != str(self.coordinator.api.device_pin):
return
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE])
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[DISABLE]
)
await self._async_update_state(
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY])
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[AWAY]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME])
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[HOME]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
)
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT])
await self.coordinator.api.set_zone_status(
self._area.index, ALARM_ACTIONS[NIGHT]
)
await self._async_update_state(
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
)

View File

@ -50,7 +50,6 @@ class ComelitVedoBinarySensorEntity(
config_entry_entry_id: str,
) -> None:
"""Init sensor entity."""
self._api = coordinator.api
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id

View File

@ -19,10 +19,10 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -89,7 +89,7 @@ async def async_setup_entry(
)
class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity):
class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
"""Climate device."""
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
@ -102,7 +102,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
def __init__(
@ -112,13 +111,7 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
config_entry_entry_id: str,
) -> None:
"""Init light entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
super().__init__(coordinator, device, config_entry_entry_id)
self._update_attributes()
def _update_attributes(self) -> None:

View File

@ -11,9 +11,9 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -34,13 +34,10 @@ async def async_setup_entry(
)
class ComelitCoverEntity(
CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity
):
class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
"""Cover device."""
_attr_device_class = CoverDeviceClass.SHUTTER
_attr_has_entity_name = True
_attr_name = None
def __init__(
@ -50,13 +47,7 @@ class ComelitCoverEntity(
config_entry_entry_id: str,
) -> None:
"""Init cover entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
super().__init__(coordinator, device, config_entry_entry_id)
# Device doesn't provide a status so we assume UNKNOWN at first startup
self._last_action: int | None = None
self._last_state: str | None = None
@ -101,7 +92,7 @@ class ComelitCoverEntity(
async def _cover_set_state(self, action: int, state: int) -> None:
"""Set desired cover state."""
self._last_state = self.state
await self._api.set_device_status(COVER, self._device.index, action)
await self.coordinator.api.set_device_status(COVER, self._device.index, action)
self.coordinator.data[COVER][self._device.index].status = state
self.async_write_ha_state()

View File

@ -0,0 +1,29 @@
"""Base entity for Comelit."""
from __future__ import annotations
from aiocomelit import ComelitSerialBridgeObject
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitSerialBridge
class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]):
"""Comelit Bridge base entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: ComelitSerialBridge,
device: ComelitSerialBridgeObject,
config_entry_entry_id: str,
) -> None:
"""Init cover entity."""
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)

View File

@ -19,10 +19,10 @@ from homeassistant.components.humidifier import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -92,14 +92,13 @@ async def async_setup_entry(
async_add_entities(entities)
class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity):
class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
"""Humidifier device."""
_attr_supported_features = HumidifierEntityFeature.MODES
_attr_available_modes = [MODE_NORMAL, MODE_AUTO]
_attr_min_humidity = 10
_attr_max_humidity = 90
_attr_has_entity_name = True
def __init__(
self,
@ -112,13 +111,8 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
device_class: HumidifierDeviceClass,
) -> None:
"""Init light entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
super().__init__(coordinator, device, config_entry_entry_id)
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}"
self._attr_device_info = coordinator.platform_device_info(device, device_class)
self._attr_device_class = device_class
self._attr_translation_key = device_class.value
self._active_mode = active_mode

View File

@ -4,15 +4,14 @@ from __future__ import annotations
from typing import Any, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -33,29 +32,13 @@ async def async_setup_entry(
)
class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
"""Light device."""
_attr_color_mode = ColorMode.ONOFF
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.ONOFF}
def __init__(
self,
coordinator: ComelitSerialBridge,
device: ComelitSerialBridgeObject,
config_entry_entry_id: str,
) -> None:
"""Init light entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
async def _light_set_state(self, state: int) -> None:
"""Set desired light state."""
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)

View File

@ -7,5 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "bronze",
"requirements": ["aiocomelit==0.11.3"]
}

View File

@ -0,0 +1,92 @@
rules:
# Bronze
action-setup:
status: exempt
comment: no actions
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: no actions
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: no events
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: todo
comment: wrap api calls in try block
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: no configuration parameters
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info:
status: exempt
comment: device not discoverable
discovery:
status: exempt
comment: device not discoverable
docs-data-update: done
docs-examples: done
docs-known-limitations:
status: exempt
comment: no known limitations, yet
docs-supported-devices:
status: todo
comment: review and complete missing ones
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: missing implementation
entity-category:
status: todo
comment: PR in progress
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations:
status: todo
comment: PR in progress
icon-translations: done
reconfiguration-flow:
status: todo
comment: PR in progress
repair-issues:
status: exempt
comment: no known use cases for repair issues or flows, yet
stale-devices:
status: todo
comment: missing implementation
# Platinum
async-dependency: done
inject-websession:
status: todo
comment: implement aiohttp_client.async_create_clientsession
strict-typing: done

View File

@ -19,6 +19,7 @@ from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -95,10 +96,9 @@ async def async_setup_vedo_entry(
async_add_entities(entities)
class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity):
class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
"""Sensor device."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
@ -109,13 +109,7 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
super().__init__(coordinator, device, config_entry_entry_id)
self.entity_description = description
@ -144,7 +138,6 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
self._api = coordinator.api
self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id

View File

@ -10,9 +10,9 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
from .entity import ComelitBridgeBaseEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@ -39,10 +39,9 @@ async def async_setup_entry(
async_add_entities(entities)
class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device."""
_attr_has_entity_name = True
_attr_name = None
def __init__(
@ -52,13 +51,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
config_entry_entry_id: str,
) -> None:
"""Init switch entity."""
self._api = coordinator.api
self._device = device
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
super().__init__(coordinator, device, config_entry_entry_id)
self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}"
self._attr_device_info = coordinator.platform_device_info(device, device.type)
if device.type == OTHER:
self._attr_device_class = SwitchDeviceClass.OUTLET

View File

@ -387,7 +387,7 @@ class ChatLog:
self,
conversing_domain: str,
user_input: ConversationInput,
user_llm_hass_api: str | None = None,
user_llm_hass_api: str | list[str] | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""

View File

@ -45,6 +45,17 @@
}
}
},
"light": {
"bed_light": {
"state_attributes": {
"effect": {
"state": {
"rainbow": "mdi:looks"
}
}
}
}
},
"number": {
"volume": {
"default": "mdi:volume-high"

View File

@ -15,6 +15,7 @@ from homeassistant.components.light import (
ATTR_WHITE,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
EFFECT_OFF,
ColorMode,
LightEntity,
LightEntityFeature,
@ -28,7 +29,7 @@ from . import DOMAIN
LIGHT_COLORS = [(56, 86), (345, 75)]
LIGHT_EFFECT_LIST = ["rainbow", "none"]
LIGHT_EFFECT_LIST = ["rainbow", EFFECT_OFF]
LIGHT_TEMPS = [4166, 2631]
@ -48,6 +49,7 @@ async def async_setup_entry(
available=True,
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
translation_key="bed_light",
device_name="Bed Light",
state=False,
unique_id="light_1",
@ -119,8 +121,10 @@ class DemoLight(LightEntity):
rgbw_color: tuple[int, int, int, int] | None = None,
rgbww_color: tuple[int, int, int, int, int] | None = None,
supported_color_modes: set[ColorMode] | None = None,
translation_key: str | None = None,
) -> None:
"""Initialize the light."""
self._attr_translation_key = translation_key
self._available = True
self._brightness = brightness
self._ct = ct or random.choice(LIGHT_TEMPS)

View File

@ -28,10 +28,10 @@
"state_attributes": {
"fan_mode": {
"state": {
"auto_high": "Auto High",
"auto_low": "Auto Low",
"on_high": "On High",
"on_low": "On Low"
"auto_high": "Auto high",
"auto_low": "Auto low",
"on_high": "On high",
"on_low": "On low"
}
},
"swing_mode": {
@ -39,14 +39,14 @@
"1": "1",
"2": "2",
"3": "3",
"auto": "Auto",
"auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]"
}
},
"swing_horizontal_mode": {
"state": {
"rangefull": "Full range",
"auto": "Auto",
"auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]"
}
}
@ -58,7 +58,7 @@
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]",
"auto": "[%key:common::state::auto%]",
"sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"smart": "Smart",
"on": "[%key:common::state::on%]"
@ -78,12 +78,23 @@
}
}
},
"light": {
"bed_light": {
"state_attributes": {
"effect": {
"state": {
"rainbow": "Rainbow"
}
}
}
}
},
"select": {
"speed": {
"state": {
"light_speed": "Light Speed",
"ludicrous_speed": "Ludicrous Speed",
"ridiculous_speed": "Ridiculous Speed"
"light_speed": "Light speed",
"ludicrous_speed": "Ludicrous speed",
"ridiculous_speed": "Ridiculous speed"
}
}
},
@ -102,7 +113,7 @@
"model_s": {
"state_attributes": {
"cleaned_area": {
"name": "Cleaned Area"
"name": "Cleaned area"
}
}
}

View File

@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
"requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
}

View File

@ -179,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
one = timedelta(days=1)
if start_time is None:
# Max 3 years of data
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
if agreement_date is None:
start = dt_util.now(tz) - timedelta(days=3 * 365)
else:
start = max(
agreement_date.replace(tzinfo=tz),
dt_util.now(tz) - timedelta(days=3 * 365),
)
start = dt_util.now(tz) - timedelta(days=3 * 365)
else:
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
if agreement_date is not None:
start = max(agreement_date.replace(tzinfo=tz), start)
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
_LOGGER.debug("Data lookup range: %s - %s", start, end)
start_step = end - lookback
start_step = max(end - lookback, start)
end_step = end
usage: dict[datetime, dict[str, float | int]] = {}
while True:

View File

@ -17,7 +17,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_MESSAGE,
@ -27,7 +26,6 @@ from .const import (
FEED_ID,
FEED_NAME,
FEED_TAG,
LOGGER,
)
@ -153,24 +151,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult:
"""Import config from yaml."""
url = import_info[CONF_URL]
api_key = import_info[CONF_API_KEY]
include_only_feeds = None
if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None:
include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID]))
config = {
CONF_API_KEY: api_key,
CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
CONF_URL: url,
}
LOGGER.debug(config)
result = await self.async_step_user(config)
if errors := result.get("errors"):
return self.async_abort(reason=errors["base"])
return result
class EmoncmsOptionsFlow(OptionsFlow):
"""Emoncms Options flow handler."""

View File

@ -4,24 +4,16 @@ from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
CONF_API_KEY,
CONF_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
CONF_VALUE_TEMPLATE,
PERCENTAGE,
UnitOfApparentPower,
UnitOfElectricCurrent,
@ -36,22 +28,15 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import template
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .config_flow import sensor_name
from .const import (
CONF_EXCLUDE_FEEDID,
CONF_ONLY_INCLUDE_FEEDID,
DOMAIN,
FEED_ID,
FEED_NAME,
FEED_TAG,
@ -205,88 +190,7 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr"
ATTR_SIZE = "Size"
ATTR_TAG = "Tag"
ATTR_USERID = "UserId"
CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2
DEFAULT_UNIT = UnitOfPower.WATT
ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none"
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_URL): cv.string,
vol.Required(CONF_ID): cv.positive_int,
vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All(
cv.ensure_list, [cv.positive_int]
),
vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All(
cv.ensure_list, [cv.positive_int]
),
vol.Optional(CONF_SENSOR_NAMES): vol.All(
{cv.positive_int: vol.All(cv.string, vol.Length(min=1))}
),
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Import config from yaml."""
if CONF_VALUE_TEMPLATE in config:
async_create_issue(
hass,
DOMAIN,
f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.ERROR,
translation_key=f"remove_{CONF_VALUE_TEMPLATE}",
translation_placeholders={
"domain": DOMAIN,
"parameter": CONF_VALUE_TEMPLATE,
},
)
return
if CONF_ONLY_INCLUDE_FEEDID not in config:
async_create_issue(
hass,
DOMAIN,
f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}",
translation_placeholders={
"domain": DOMAIN,
},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if (
result.get("type") == FlowResultType.CREATE_ENTRY
or result.get("reason") == "already_configured"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
is_fixable=False,
issue_domain=DOMAIN,
breaks_in_ha_version="2025.3.0",
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "emoncms",
},
)
async def async_setup_entry(

View File

@ -35,7 +35,7 @@ async def validate_input(data):
lon = weather_data.lon
return {
CONF_TITLE: weather_data.metadata.get("location"),
CONF_TITLE: weather_data.metadata.location,
CONF_STATION: weather_data.station_id,
CONF_LATITUDE: lat,
CONF_LONGITUDE: lon,

View File

@ -7,7 +7,7 @@ from datetime import timedelta
import logging
import xml.etree.ElementTree as ET
from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc
from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@ -65,6 +65,6 @@ class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]):
"""Fetch data from EC."""
try:
await self.ec_data.update()
except (ET.ParseError, ec_exc.UnknownStationId) as ex:
except (ET.ParseError, ECWeatherUpdateFailed, ec_exc.UnknownStationId) as ex:
raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex
return self.ec_data

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
"requirements": ["env-canada==0.8.0"]
"requirements": ["env-canada==0.10.1"]
}

View File

@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = (
key="timestamp",
translation_key="timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda data: data.metadata.get("timestamp"),
value_fn=lambda data: data.metadata.timestamp,
),
ECSensorEntityDescription(
key="uv_index",
@ -289,7 +289,7 @@ class ECBaseSensorEntity[DataT: ECDataType](
super().__init__(coordinator)
self.entity_description = description
self._ec_data = coordinator.ec_data
self._attr_attribution = self._ec_data.metadata["attribution"]
self._attr_attribution = self._ec_data.metadata.attribution
self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}"
self._attr_device_info = coordinator.device_info
@ -313,8 +313,8 @@ class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]):
"""Initialize the sensor."""
super().__init__(coordinator, description)
self._attr_extra_state_attributes = {
ATTR_LOCATION: self._ec_data.metadata.get("location"),
ATTR_STATION: self._ec_data.metadata.get("station"),
ATTR_LOCATION: self._ec_data.metadata.location,
ATTR_STATION: self._ec_data.metadata.station,
}
@ -329,8 +329,8 @@ class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]):
return None
extra_state_attrs = {
ATTR_LOCATION: self._ec_data.metadata.get("location"),
ATTR_STATION: self._ec_data.metadata.get("station"),
ATTR_LOCATION: self._ec_data.metadata.location,
ATTR_STATION: self._ec_data.metadata.station,
}
for index, alert in enumerate(value, start=1):
extra_state_attrs[f"alert_{index}"] = alert.get("title")

View File

@ -115,7 +115,7 @@ class ECWeatherEntity(
"""Initialize Environment Canada weather."""
super().__init__(coordinator)
self.ec_data = coordinator.ec_data
self._attr_attribution = self.ec_data.metadata["attribution"]
self._attr_attribution = self.ec_data.metadata.attribution
self._attr_translation_key = "forecast"
self._attr_unique_id = _calculate_unique_id(
coordinator.config_entry.unique_id, False

View File

@ -14,6 +14,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
@ -23,7 +24,7 @@ from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView
from .manager import ESPHomeManager, cleanup_instance
from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -89,4 +90,7 @@ async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) ->
"""Remove an esphome config entry."""
if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS):
async_remove_scanner(hass, bluetooth_mac_address.upper())
async_delete_issue(
hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
)
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()

View File

@ -50,7 +50,6 @@ from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboar
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
ESPHOME_URL = "https://esphome.io/"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
@ -74,6 +73,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._device_info: DeviceInfo | None = None
# The ESPHome name as per its config
self._device_name: str | None = None
self._device_mac: str | None = None
async def _async_step_user_base(
self, user_input: dict[str, Any] | None = None, error: str | None = None
@ -95,7 +95,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema(fields),
errors=errors,
description_placeholders={"esphome_url": ESPHOME_URL},
)
async def async_step_user(
@ -265,12 +264,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if already configured
await self.async_set_unique_id(mac_address)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._host, CONF_PORT: self._port}
await self._async_validate_mac_abort_configured(
mac_address, self._host, self._port
)
return await self.async_step_discovery_confirm()
async def _async_validate_mac_abort_configured(
self, formatted_mac: str, host: str, port: int | None
) -> None:
"""Validate if the MAC address is already configured."""
if not (
entry := self.hass.config_entries.async_entry_for_domain_unique_id(
self.handler, formatted_mac
)
):
return
configured_port: int | None = entry.data.get(CONF_PORT)
configured_psk: str | None = entry.data.get(CONF_NOISE_PSK)
await self._fetch_device_info(host, port or configured_port, configured_psk)
updates: dict[str, Any] = {}
if self._device_mac == formatted_mac:
updates[CONF_HOST] = host
if port is not None:
updates[CONF_PORT] = port
self._abort_if_unique_id_configured(updates=updates)
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult:
@ -314,8 +333,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
mac_address = format_mac(discovery_info.macaddress)
await self.async_set_unique_id(format_mac(mac_address))
await self._async_validate_mac_abort_configured(
mac_address, discovery_info.ip, None
)
# This should never happen since we only listen to DHCP requests
# for configured devices.
return self.async_abort(reason="already_configured")
@ -398,17 +420,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def fetch_device_info(self) -> str | None:
async def _fetch_device_info(
self, host: str, port: int | None, noise_psk: str | None
) -> str | None:
"""Fetch device info from API and return any errors."""
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
assert self._host is not None
assert self._port is not None
cli = APIClient(
self._host,
self._port,
host,
port or 6053,
"",
zeroconf_instance=zeroconf_instance,
noise_psk=self._noise_psk,
noise_psk=noise_psk,
)
try:
@ -419,6 +441,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
except InvalidEncryptionKeyAPIError as ex:
if ex.received_name:
self._device_name = ex.received_name
if ex.received_mac:
self._device_mac = format_mac(ex.received_mac)
self._name = ex.received_name
return ERROR_INVALID_ENCRYPTION_KEY
except ResolveAPIError:
@ -427,9 +451,20 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error"
finally:
await cli.disconnect(force=True)
self._name = self._device_info.friendly_name or self._device_info.name
self._device_name = self._device_info.name
self._device_mac = format_mac(self._device_info.mac_address)
return None
async def fetch_device_info(self) -> str | None:
"""Fetch device info from API and return any errors."""
assert self._host is not None
assert self._port is not None
if error := await self._fetch_device_info(
self._host, self._port, self._noise_psk
):
return error
assert self._device_info is not None
mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source != SOURCE_REAUTH:

View File

@ -10,6 +10,7 @@ from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
@ -60,11 +61,26 @@ class ESPHomeDashboardManager:
async def async_setup(self) -> None:
"""Restore the dashboard from storage."""
self._data = await self._store.async_load()
if (data := self._data) and (info := data.get("info")):
await self.async_set_dashboard_info(
info["addon_slug"], info["host"], info["port"]
if not (data := self._data) or not (info := data.get("info")):
return
if is_hassio(self._hass):
from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
get_addons_info,
)
if (addons := get_addons_info(self._hass)) is not None and info[
"addon_slug"
] not in addons:
# The addon is not installed anymore, but it make come back
# so we don't want to remove the dashboard, but for now
# we don't want to use it.
_LOGGER.debug("Addon %s is no longer installed", info["addon_slug"])
return
await self.async_set_dashboard_info(
info["addon_slug"], info["host"], info["port"]
)
@callback
def async_get(self) -> ESPHomeDashboardCoordinator | None:
"""Get the current dashboard."""

View File

@ -48,6 +48,7 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
template,
)
from homeassistant.helpers.device_registry import format_mac
@ -80,6 +81,8 @@ from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
if TYPE_CHECKING:
from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
SubscribeLogsResponse,
@ -418,7 +421,7 @@ class ESPHomeManager:
assert reconnect_logic is not None, "Reconnect logic must be set"
hass = self.hass
cli = self.cli
stored_device_name = entry.data.get(CONF_DEVICE_NAME)
stored_device_name: str | None = entry.data.get(CONF_DEVICE_NAME)
unique_id_is_mac_address = unique_id and ":" in unique_id
if entry.options.get(CONF_SUBSCRIBE_LOGS):
self._async_subscribe_logs(self._async_get_equivalent_log_level())
@ -448,12 +451,36 @@ class ESPHomeManager:
if not mac_address_matches and not unique_id_is_mac_address:
hass.config_entries.async_update_entry(entry, unique_id=device_mac)
issue = DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
if not mac_address_matches and unique_id_is_mac_address:
# If the unique id is a mac address
# and does not match we have the wrong device and we need
# to abort the connection. This can happen if the DHCP
# server changes the IP address of the device and we end up
# connecting to the wrong device.
if stored_device_name == device_info.name:
# If the device name matches it might be a device replacement
# or they made a mistake and flashed the same firmware on
# multiple devices. In this case we start a repair flow
# to ask them if its a mistake, or if they want to migrate
# the config entry to the replacement hardware.
shared_data = {
"name": device_info.name,
"mac": format_mac(device_mac),
"stored_mac": format_mac(unique_id),
"model": device_info.model,
"ip": self.host,
}
async_create_issue(
hass,
DOMAIN,
issue,
is_fixable=True,
severity=IssueSeverity.ERROR,
translation_key="device_conflict",
translation_placeholders=shared_data,
data={**shared_data, "entry_id": entry.entry_id},
)
_LOGGER.error(
"Unexpected device found at %s; "
"expected `%s` with mac address `%s`, "
@ -475,6 +502,7 @@ class ESPHomeManager:
# flow.
return
async_delete_issue(hass, DOMAIN, issue)
# Make sure we have the correct device name stored
# so we can map the device to ESPHome Dashboard config
# If we got here, we know the mac address matches or we
@ -568,7 +596,7 @@ class ESPHomeManager:
async def on_connect_error(self, err: Exception) -> None:
"""Start reauth flow if appropriate connect error type."""
if isinstance(
if not isinstance(
err,
(
EncryptionPlaintextAPIError,
@ -577,7 +605,36 @@ class ESPHomeManager:
InvalidAuthAPIError,
),
):
self.entry.async_start_reauth(self.hass)
return
if isinstance(err, InvalidEncryptionKeyAPIError):
if (
(received_name := err.received_name)
and (received_mac := err.received_mac)
and (unique_id := self.entry.unique_id)
and ":" in unique_id
):
formatted_received_mac = format_mac(received_mac)
formatted_expected_mac = format_mac(unique_id)
if formatted_received_mac != formatted_expected_mac:
_LOGGER.error(
"Unexpected device found at %s; "
"expected `%s` with mac address `%s`, "
"found `%s` with mac address `%s`",
self.host,
self.entry.data.get(CONF_DEVICE_NAME),
formatted_expected_mac,
received_name,
formatted_received_mac,
)
# If the device comes back online, discovery
# will update the config entry with the new IP address
# and reload which will try again to connect to the device.
# In the mean time we stop the reconnect logic
# so we don't keep trying to connect to the wrong device.
if self.reconnect_logic:
await self.reconnect_logic.stop()
return
self.entry.async_start_reauth(self.hass)
@callback
def _async_handle_logging_changed(self, _event: Event) -> None:
@ -873,3 +930,40 @@ async def cleanup_instance(
await data.async_cleanup()
await data.client.disconnect()
return data
async def async_replace_device(
hass: HomeAssistant,
entry_id: str,
old_mac: str, # will be lower case (format_mac)
new_mac: str, # will be lower case (format_mac)
) -> None:
"""Migrate an ESPHome entry to replace an existing device."""
entry = hass.config_entries.async_get_entry(entry_id)
assert entry is not None
hass.config_entries.async_update_entry(entry, unique_id=new_mac)
dev_reg = dr.async_get(hass)
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
dev_reg.async_update_device(
device.id,
new_connections={(dr.CONNECTION_NETWORK_MAC, new_mac)},
)
ent_reg = er.async_get(hass)
upper_mac = new_mac.upper()
old_upper_mac = old_mac.upper()
for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
# <upper_mac>-<entity type>-<object_id>
old_unique_id = entity.unique_id.split("-")
new_unique_id = "-".join([upper_mac, *old_unique_id[1:]])
if entity.unique_id != new_unique_id and entity.unique_id.startswith(
old_upper_mac
):
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
domain_data = DomainData.get(hass)
store = domain_data.get_or_create_store(hass, entry)
if data := await store.async_load():
data["device_info"]["mac_address"] = upper_mac
await store.async_save(data)

View File

@ -1,7 +1,7 @@
{
"domain": "esphome",
"name": "ESPHome",
"after_dependencies": ["zeroconf", "tag"],
"after_dependencies": ["hassio", "zeroconf", "tag"],
"codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
"aioesphomeapi==29.9.0",
"aioesphomeapi==29.10.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.13.1"
],

View File

@ -2,11 +2,95 @@
from __future__ import annotations
from typing import cast
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.assist_pipeline.repair_flows import (
AssistInProgressDeprecatedRepairFlow,
)
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from .manager import async_replace_device
class ESPHomeRepair(RepairsFlow):
"""Handler for an issue fixing flow."""
def __init__(self, data: dict[str, str | int | float | None] | None) -> None:
"""Initialize."""
self._data = data
super().__init__()
@callback
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
assert issue is not None
return issue.translation_placeholders or {}
class DeviceConflictRepair(ESPHomeRepair):
"""Handler for an issue fixing device conflict."""
@property
def entry_id(self) -> str:
"""Return the config entry id."""
assert isinstance(self._data, dict)
return cast(str, self._data["entry_id"])
@property
def mac(self) -> str:
"""Return the MAC address of the new device."""
assert isinstance(self._data, dict)
return cast(str, self._data["mac"])
@property
def stored_mac(self) -> str:
"""Return the MAC address of the stored device."""
assert isinstance(self._data, dict)
return cast(str, self._data["stored_mac"])
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return self.async_show_menu(
step_id="init",
menu_options=["migrate", "manual"],
description_placeholders=self._async_get_placeholders(),
)
async def async_step_migrate(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the migrate step of a fix flow."""
if user_input is None:
return self.async_show_form(
step_id="migrate",
data_schema=vol.Schema({}),
description_placeholders=self._async_get_placeholders(),
)
entry_id = self.entry_id
await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac)
self.hass.config_entries.async_schedule_reload(entry_id)
return self.async_create_entry(data={})
async def async_step_manual(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the manual step of a fix flow."""
if user_input is None:
return self.async_show_form(
step_id="manual",
data_schema=vol.Schema({}),
description_placeholders=self._async_get_placeholders(),
)
self.hass.config_entries.async_schedule_reload(self.entry_id)
return self.async_create_entry(data={})
async def async_create_fix_flow(
@ -17,6 +101,8 @@ async def async_create_fix_flow(
"""Create flow."""
if issue_id.startswith("assist_in_progress_deprecated"):
return AssistInProgressDeprecatedRepairFlow(data)
if issue_id.startswith("device_conflict"):
return DeviceConflictRepair(data)
# If ESPHome adds confirm-only repairs in the future, this should be changed
# to return a ConfirmRepairFlow instead of raising a ValueError
raise ValueError(f"unknown repair {issue_id}")

View File

@ -23,7 +23,7 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"description": "Please enter connection settings of your [ESPHome]({esphome_url}) node."
"description": "Please enter connection settings of your ESPHome device."
},
"authenticate": {
"data": {
@ -47,8 +47,8 @@
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
},
"discovery_confirm": {
"description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
"title": "Discovered ESPHome node"
"description": "Do you want to add the device `{name}` to Home Assistant?",
"title": "Discovered ESPHome device"
}
},
"flow_title": "{name}"
@ -130,6 +130,29 @@
"service_calls_not_allowed": {
"title": "{name} is not permitted to perform Home Assistant actions",
"description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow."
},
"device_conflict": {
"title": "Device conflict for {name}",
"fix_flow": {
"step": {
"init": {
"title": "Device conflict for {name}",
"description": "**The device `{name}` (`{model}`) at `{ip}` has reported a MAC address change from `{stored_mac}` to `{mac}`.**\n\nIf you have multiple devices with the same name, please rename or remove the one with MAC address `{mac}` to avoid conflicts.\n\nIf this is a hardware replacement, please confirm that you would like to migrate the Home Assistant configuration to the new device with MAC address `{mac}`.",
"menu_options": {
"migrate": "Migrate configuration to new device",
"manual": "Remove or rename device"
}
},
"migrate": {
"title": "Confirm device replacement for {name}",
"description": "Are you sure you want to migrate the Home Assistant configuration for `{name}` (`{model}`) at `{ip}` from `{stored_mac}` to `{mac}`?"
},
"manual": {
"title": "Remove or rename device {name}",
"description": "To resolve the conflict, either remove the device with MAC address `{mac}` from the network and restart the one with MAC address `{stored_mac}`, or re-flash the device with MAC address `{mac}` using a different name than `{name}`. Submit again once done."
}
}
}
}
}
}

View File

@ -18,7 +18,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
@ -178,16 +178,6 @@ class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity):
self._name = f"{self.hostname} Wake on LAN"
self._attr_unique_id = f"{self._mac}_wake_on_lan"
self._is_available = True
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self._mac)},
default_manufacturer="AVM",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
via_device=(
DOMAIN,
avm_wrapper.unique_id,
),
)
async def async_press(self) -> None:
"""Press the button."""

View File

@ -526,7 +526,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
def manage_device_info(
self, dev_info: Device, dev_mac: str, consider_home: bool
) -> bool:
"""Update device lists."""
"""Update device lists and return if device is new."""
_LOGGER.debug("Client dev_info: %s", dev_info)
if dev_mac in self._devices:
@ -536,6 +536,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device = FritzDevice(dev_mac, dev_info.name)
device.update(dev_info, consider_home)
self._devices[dev_mac] = device
# manually register device entry for new connected device
dr.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
connections={(CONNECTION_NETWORK_MAC, dev_mac)},
default_manufacturer="AVM",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
via_device=(DOMAIN, self.unique_id),
)
return True
async def async_send_signal_device_update(self, new_device: bool) -> None:

View File

@ -26,6 +26,9 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
self._avm_wrapper = avm_wrapper
self._mac: str = device.mac_address
self._name: str = device.hostname or DEFAULT_DEVICE_NAME
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}
)
@property
def name(self) -> str:

View File

@ -514,16 +514,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
self._name = f"{device.hostname} Internet Access"
self._attr_unique_id = f"{self._mac}_internet_access"
self._attr_entity_category = EntityCategory.CONFIG
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, self._mac)},
default_manufacturer="AVM",
default_model="FRITZ!Box Tracked device",
default_name=device.hostname,
via_device=(
DOMAIN,
avm_wrapper.unique_id,
),
)
@property
def is_on(self) -> bool | None:

View File

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

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.1.0"]
"requirements": ["av==13.1.0", "Pillow==11.2.1"]
}

View File

@ -74,7 +74,7 @@ def build_rrule(task: TaskData) -> rrule:
bysetpos = None
if rrule_frequency == MONTHLY and task.weeksOfMonth:
bysetpos = task.weeksOfMonth
bysetpos = [i + 1 for i in task.weeksOfMonth]
weekdays = weekdays if weekdays else [MO]
return rrule(

View File

@ -11,9 +11,12 @@ import aiohttp
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
issue_registry as ir,
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
@ -86,8 +89,18 @@ async def async_unload_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
"""Unload a config entry."""
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
async_delete_issue(hass, DOMAIN, "deprecated_command_actions")
issue_registry = ir.async_get(hass)
issues_to_delete = [
"deprecated_set_program_and_option_actions",
"deprecated_command_actions",
] + [
issue_id
for (issue_domain, issue_id) in issue_registry.issues
if issue_domain == DOMAIN
and issue_id.startswith("home_connect_too_many_connected_paired_events")
]
for issue_id in issues_to_delete:
issue_registry.async_delete(DOMAIN, issue_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -39,7 +39,7 @@ from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
@ -47,6 +47,9 @@ from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes
MAX_EXECUTIONS = 5
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
@ -114,6 +117,7 @@ class HomeConnectCoordinator(
] = {}
self.device_registry = dr.async_get(self.hass)
self.data = {}
self._execution_tracker: dict[str, list[float]] = defaultdict(list)
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@ -172,7 +176,7 @@ class HomeConnectCoordinator(
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
)
async def _event_listener(self) -> None:
async def _event_listener(self) -> None: # noqa: C901
"""Match event with listener for event type."""
retry_time = 10
while True:
@ -238,6 +242,9 @@ class HomeConnectCoordinator(
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
if self.refreshed_too_often_recently(event_message_ha_id):
continue
appliance_info = await self.client.get_specific_appliance(
event_message_ha_id
)
@ -592,3 +599,60 @@ class HomeConnectCoordinator(
[],
):
listener()
def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool:
"""Check if the appliance data hasn't been refreshed too often recently."""
now = self.hass.loop.time()
if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS:
return True
execution_tracker = self._execution_tracker[appliance_ha_id] = [
timestamp
for timestamp in self._execution_tracker[appliance_ha_id]
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
]
execution_tracker.append(now)
if len(execution_tracker) >= MAX_EXECUTIONS:
ir.async_create_issue(
self.hass,
DOMAIN,
f"home_connect_too_many_connected_paired_events_{appliance_ha_id}",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="home_connect_too_many_connected_paired_events",
data={
"entry_id": self.config_entry.entry_id,
"appliance_ha_id": appliance_ha_id,
},
translation_placeholders={
"appliance_name": self.data[appliance_ha_id].info.name,
"times": str(MAX_EXECUTIONS),
"time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60),
"home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/",
"home_assistant_core_new_issue_url": (
"https://github.com/home-assistant/core/issues/new?template=bug_report.yml"
f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/"
),
},
)
return True
return False
async def reset_execution_tracker(self, appliance_ha_id: str) -> None:
"""Reset the execution tracker for a specific appliance."""
self._execution_tracker.pop(appliance_ha_id, None)
appliance_info = await self.client.get_specific_appliance(appliance_ha_id)
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
self.data[appliance_ha_id].update(appliance_data)
for listener, context in self._special_listeners.values():
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
listener()
self._call_all_event_listeners_for_appliance(appliance_ha_id)

View File

@ -0,0 +1,60 @@
"""Repairs flows for Home Connect."""
from typing import cast
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from .coordinator import HomeConnectConfigEntry
class EnableApplianceUpdatesFlow(RepairsFlow):
"""Handler for enabling appliance's updates after being refreshed too many times."""
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
assert self.data
entry = self.hass.config_entries.async_get_entry(
cast(str, self.data["entry_id"])
)
assert entry
entry = cast(HomeConnectConfigEntry, entry)
await entry.runtime_data.reset_execution_tracker(
cast(str, self.data["appliance_ha_id"])
)
return self.async_create_entry(data={})
issue_registry = ir.async_get(self.hass)
description_placeholders = None
if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
description_placeholders = issue.translation_placeholders
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders=description_placeholders,
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id.startswith("home_connect_too_many_connected_paired_events"):
return EnableApplianceUpdatesFlow()
return ConfirmRepairFlow()

View File

@ -110,6 +110,17 @@
}
},
"issues": {
"home_connect_too_many_connected_paired_events": {
"title": "{appliance_name} sent too many connected or paired events",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]",
"description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})."
}
}
}
},
"deprecated_time_alarm_clock_in_automations_scripts": {
"title": "Deprecated alarm clock entity detected in some automations or scripts",
"fix_flow": {

View File

@ -245,6 +245,13 @@ def get_accessory( # noqa: C901
a_type = "CarbonDioxideSensor"
elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX:
a_type = "LightSensor"
else:
_LOGGER.debug(
"%s: Unsupported sensor type (device_class=%s) (unit=%s)",
state.entity_id,
device_class,
unit,
)
elif state.domain == "switch":
if switch_type := config.get(CONF_TYPE):

View File

@ -35,6 +35,7 @@ from homeassistant.core import State, callback
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_ACTIVE,
CHAR_CONFIGURED_NAME,
CHAR_NAME,
CHAR_ON,
CHAR_ROTATION_DIRECTION,
@ -120,7 +121,9 @@ class Fan(HomeAccessory):
continue
preset_serv = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
SERV_SWITCH,
[CHAR_NAME, CHAR_CONFIGURED_NAME],
unique_id=preset_mode,
)
serv_fan.add_linked_service(preset_serv)
preset_serv.configure_char(
@ -129,6 +132,9 @@ class Fan(HomeAccessory):
f"{self.display_name} {preset_mode}"
),
)
preset_serv.configure_char(
CHAR_CONFIGURED_NAME, value=cleanup_name_for_homekit(preset_mode)
)
def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
self.set_preset_mode(value, preset_mode)

View File

@ -41,6 +41,7 @@ from .const import (
ATTR_KEY_NAME,
CATEGORY_RECEIVER,
CHAR_ACTIVE,
CHAR_CONFIGURED_NAME,
CHAR_MUTE,
CHAR_NAME,
CHAR_ON,
@ -100,41 +101,67 @@ class MediaPlayer(HomeAccessory):
)
if FEATURE_ON_OFF in feature_list:
name = self.generate_service_name(FEATURE_ON_OFF)
serv_on_off = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF
SERV_SWITCH, [CHAR_CONFIGURED_NAME, CHAR_NAME], unique_id=FEATURE_ON_OFF
)
serv_on_off.configure_char(
CHAR_NAME, value=self.generate_service_name(FEATURE_ON_OFF)
)
serv_on_off.configure_char(
CHAR_CONFIGURED_NAME,
value=self.generated_configured_name(FEATURE_ON_OFF),
)
serv_on_off.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char(
CHAR_ON, value=False, setter_callback=self.set_on_off
)
if FEATURE_PLAY_PAUSE in feature_list:
name = self.generate_service_name(FEATURE_PLAY_PAUSE)
serv_play_pause = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE
SERV_SWITCH,
[CHAR_CONFIGURED_NAME, CHAR_NAME],
unique_id=FEATURE_PLAY_PAUSE,
)
serv_play_pause.configure_char(
CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_PAUSE)
)
serv_play_pause.configure_char(
CHAR_CONFIGURED_NAME,
value=self.generated_configured_name(FEATURE_PLAY_PAUSE),
)
serv_play_pause.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_pause
)
if FEATURE_PLAY_STOP in feature_list:
name = self.generate_service_name(FEATURE_PLAY_STOP)
serv_play_stop = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP
SERV_SWITCH,
[CHAR_CONFIGURED_NAME, CHAR_NAME],
unique_id=FEATURE_PLAY_STOP,
)
serv_play_stop.configure_char(
CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_STOP)
)
serv_play_stop.configure_char(
CHAR_CONFIGURED_NAME,
value=self.generated_configured_name(FEATURE_PLAY_STOP),
)
serv_play_stop.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_stop
)
if FEATURE_TOGGLE_MUTE in feature_list:
name = self.generate_service_name(FEATURE_TOGGLE_MUTE)
serv_toggle_mute = self.add_preload_service(
SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE
SERV_SWITCH,
[CHAR_CONFIGURED_NAME, CHAR_NAME],
unique_id=FEATURE_TOGGLE_MUTE,
)
serv_toggle_mute.configure_char(
CHAR_NAME, value=self.generate_service_name(FEATURE_TOGGLE_MUTE)
)
serv_toggle_mute.configure_char(
CHAR_CONFIGURED_NAME,
value=self.generated_configured_name(FEATURE_TOGGLE_MUTE),
)
serv_toggle_mute.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char(
CHAR_ON, value=False, setter_callback=self.set_toggle_mute
)
@ -146,6 +173,10 @@ class MediaPlayer(HomeAccessory):
f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"
)
def generated_configured_name(self, mode: str) -> str:
"""Generate name for individual service."""
return cleanup_name_for_homekit(MODE_FRIENDLY_NAME[mode])
def set_on_off(self, value: bool) -> None:
"""Move switch state to value if call came from HomeKit."""
_LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value)

View File

@ -49,6 +49,7 @@ from homeassistant.helpers.event import async_call_later
from .accessories import TYPES, HomeAccessory, HomeDriver
from .const import (
CHAR_ACTIVE,
CHAR_CONFIGURED_NAME,
CHAR_IN_USE,
CHAR_NAME,
CHAR_ON,
@ -360,11 +361,13 @@ class SelectSwitch(HomeAccessory):
options = state.attributes[ATTR_OPTIONS]
for option in options:
serv_option = self.add_preload_service(
SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option
)
serv_option.configure_char(
CHAR_NAME, value=cleanup_name_for_homekit(option)
SERV_OUTLET,
[CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_IN_USE],
unique_id=option,
)
name = cleanup_name_for_homekit(option)
serv_option.configure_char(CHAR_NAME, value=name)
serv_option.configure_char(CHAR_CONFIGURED_NAME, value=name)
serv_option.configure_char(CHAR_IN_USE, value=False)
self.select_chars[option] = serv_option.configure_char(
CHAR_ON,

View File

@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import async_initialize_triggers
from .accessories import TYPES, HomeAccessory
from .aidmanager import get_system_unique_id
from .const import (
CHAR_CONFIGURED_NAME,
CHAR_NAME,
CHAR_PROGRAMMABLE_SWITCH_EVENT,
CHAR_SERVICE_LABEL_INDEX,
@ -66,7 +67,7 @@ class DeviceTriggerAccessory(HomeAccessory):
trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts))
serv_stateless_switch = self.add_preload_service(
SERV_STATELESS_PROGRAMMABLE_SWITCH,
[CHAR_NAME, CHAR_SERVICE_LABEL_INDEX],
[CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_SERVICE_LABEL_INDEX],
unique_id=unique_id,
)
self.triggers.append(
@ -77,6 +78,9 @@ class DeviceTriggerAccessory(HomeAccessory):
)
)
serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name)
serv_stateless_switch.configure_char(
CHAR_CONFIGURED_NAME, value=trigger_name
)
serv_stateless_switch.configure_char(
CHAR_SERVICE_LABEL_INDEX, value=idx + 1
)

View File

@ -9,10 +9,11 @@ from functools import partial
import logging
from operator import attrgetter
from types import MappingProxyType
from typing import Any
from typing import Any, cast
from aiohomekit import Controller
from aiohomekit.controller import TransportType
from aiohomekit.controller.ble.discovery import BleDiscovery
from aiohomekit.exceptions import (
AccessoryDisconnectedError,
AccessoryNotFoundError,
@ -372,6 +373,16 @@ class HKDevice:
if not self.unreliable_serial_numbers:
identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
connections: set[tuple[str, str]] = set()
if self.pairing.transport == Transport.BLE and (
discovery := self.pairing.controller.discoveries.get(
normalize_hkid(self.unique_id)
)
):
connections = {
(dr.CONNECTION_BLUETOOTH, cast(BleDiscovery, discovery).device.address),
}
device_info = DeviceInfo(
identifiers={
(
@ -379,6 +390,7 @@ class HKDevice:
f"{self.unique_id}:aid:{accessory.aid}",
)
},
connections=connections,
name=accessory.name,
manufacturer=accessory.manufacturer,
model=accessory.model,

View File

@ -5,6 +5,10 @@ from __future__ import annotations
from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.characteristics.const import (
TargetAirPurifierStateValues,
TargetFanStateValues,
)
from aiohomekit.model.services import Service, ServicesTypes
from propcache.api import cached_property
@ -35,6 +39,8 @@ DIRECTION_TO_HK = {
}
HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()}
PRESET_AUTO = "auto"
class BaseHomeKitFan(HomeKitEntity, FanEntity):
"""Representation of a Homekit fan."""
@ -42,6 +48,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
# This must be set in subclasses to the name of a boolean characteristic
# that controls whether the fan is on or off.
on_characteristic: str
preset_char = CharacteristicsTypes.FAN_STATE_TARGET
preset_manual_value: int = TargetFanStateValues.MANUAL
preset_automatic_value: int = TargetFanStateValues.AUTOMATIC
@callback
def _async_reconfigure(self) -> None:
@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
"_speed_range",
"_min_speed",
"_max_speed",
"preset_modes",
"speed_count",
"supported_features",
)
@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity cares about."""
return [
types = [
CharacteristicsTypes.SWING_MODE,
CharacteristicsTypes.ROTATION_DIRECTION,
CharacteristicsTypes.ROTATION_SPEED,
self.on_characteristic,
]
if self.service.has(self.preset_char):
types.append(self.preset_char)
return types
@property
def is_on(self) -> bool:
@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
if self.service.has(CharacteristicsTypes.SWING_MODE):
features |= FanEntityFeature.OSCILLATE
if self.service.has(self.preset_char):
features |= FanEntityFeature.PRESET_MODE
return features
@cached_property
@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
/ max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0)
)
@cached_property
def preset_modes(self) -> list[str]:
"""Return the preset modes."""
return [PRESET_AUTO] if self.service.has(self.preset_char) else []
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if (
self.service.has(self.preset_char)
and self.service.value(self.preset_char) == self.preset_automatic_value
):
return PRESET_AUTO
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
if self.service.has(self.preset_char):
await self.async_put_characteristics(
{
self.preset_char: self.preset_automatic_value
if preset_mode == PRESET_AUTO
else self.preset_manual_value
}
)
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self.async_put_characteristics(
@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
await self.async_turn_off()
return
await self.async_put_characteristics(
{
CharacteristicsTypes.ROTATION_SPEED: round(
percentage_to_ranged_value(self._speed_range, percentage)
)
}
)
characteristics = {
CharacteristicsTypes.ROTATION_SPEED: round(
percentage_to_ranged_value(self._speed_range, percentage)
)
}
if FanEntityFeature.PRESET_MODE in self.supported_features:
characteristics[self.preset_char] = self.preset_manual_value
await self.async_put_characteristics(characteristics)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
if not self.is_on:
characteristics[self.on_characteristic] = True
if (
if preset_mode == PRESET_AUTO:
characteristics[self.preset_char] = self.preset_automatic_value
elif (
percentage is not None
and FanEntityFeature.SET_SPEED in self.supported_features
):
characteristics[CharacteristicsTypes.ROTATION_SPEED] = round(
percentage_to_ranged_value(self._speed_range, percentage)
)
if FanEntityFeature.PRESET_MODE in self.supported_features:
characteristics[self.preset_char] = self.preset_manual_value
if characteristics:
await self.async_put_characteristics(characteristics)
@ -200,10 +249,18 @@ class HomeKitFanV2(BaseHomeKitFan):
on_characteristic = CharacteristicsTypes.ACTIVE
class HomeKitAirPurifer(HomeKitFanV2):
"""Implement air purifier support for public.hap.service.airpurifier."""
preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET
preset_manual_value = TargetAirPurifierStateValues.MANUAL
preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC
ENTITY_TYPES = {
ServicesTypes.FAN: HomeKitFanV1,
ServicesTypes.FAN_V2: HomeKitFanV2,
ServicesTypes.AIR_PURIFIER: HomeKitFanV2,
ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer,
}

View File

@ -82,15 +82,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity):
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self._home.set_security_zones_activation(False, False)
await self._home.set_security_zones_activation_async(False, False)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
await self._home.set_security_zones_activation(False, True)
await self._home.set_security_zones_activation_async(False, True)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self._home.set_security_zones_activation(True, True)
await self._home.set_security_zones_activation_async(True, True)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""

View File

@ -4,31 +4,31 @@ from __future__ import annotations
from typing import Any
from homematicip.aio.device import (
AsyncAccelerationSensor,
AsyncContactInterface,
AsyncDevice,
AsyncFullFlushContactInterface,
AsyncFullFlushContactInterface6,
AsyncMotionDetectorIndoor,
AsyncMotionDetectorOutdoor,
AsyncMotionDetectorPushButton,
AsyncPluggableMainsFailureSurveillance,
AsyncPresenceDetectorIndoor,
AsyncRainSensor,
AsyncRotaryHandleSensor,
AsyncShutterContact,
AsyncShutterContactMagnetic,
AsyncSmokeDetector,
AsyncTiltVibrationSensor,
AsyncWaterSensor,
AsyncWeatherSensor,
AsyncWeatherSensorPlus,
AsyncWeatherSensorPro,
AsyncWiredInput32,
)
from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
from homematicip.device import (
AccelerationSensor,
ContactInterface,
Device,
FullFlushContactInterface,
FullFlushContactInterface6,
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
PluggableMainsFailureSurveillance,
PresenceDetectorIndoor,
RainSensor,
RotaryHandleSensor,
ShutterContact,
ShutterContactMagnetic,
SmokeDetector,
TiltVibrationSensor,
WaterSensor,
WeatherSensor,
WeatherSensorPlus,
WeatherSensorPro,
WiredInput32,
)
from homematicip.group import SecurityGroup, SecurityZoneGroup
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@ -82,66 +82,60 @@ async def async_setup_entry(
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)]
for device in hap.home.devices:
if isinstance(device, AsyncAccelerationSensor):
if isinstance(device, AccelerationSensor):
entities.append(HomematicipAccelerationSensor(hap, device))
if isinstance(device, AsyncTiltVibrationSensor):
if isinstance(device, TiltVibrationSensor):
entities.append(HomematicipTiltVibrationSensor(hap, device))
if isinstance(device, AsyncWiredInput32):
if isinstance(device, WiredInput32):
entities.extend(
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 33)
)
elif isinstance(device, AsyncFullFlushContactInterface6):
elif isinstance(device, FullFlushContactInterface6):
entities.extend(
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 7)
)
elif isinstance(
device, (AsyncContactInterface, AsyncFullFlushContactInterface)
):
elif isinstance(device, (ContactInterface, FullFlushContactInterface)):
entities.append(HomematicipContactInterface(hap, device))
if isinstance(
device,
(AsyncShutterContact, AsyncShutterContactMagnetic),
(ShutterContact, ShutterContactMagnetic),
):
entities.append(HomematicipShutterContact(hap, device))
if isinstance(device, AsyncRotaryHandleSensor):
if isinstance(device, RotaryHandleSensor):
entities.append(HomematicipShutterContact(hap, device, True))
if isinstance(
device,
(
AsyncMotionDetectorIndoor,
AsyncMotionDetectorOutdoor,
AsyncMotionDetectorPushButton,
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
),
):
entities.append(HomematicipMotionDetector(hap, device))
if isinstance(device, AsyncPluggableMainsFailureSurveillance):
if isinstance(device, PluggableMainsFailureSurveillance):
entities.append(
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
)
if isinstance(device, AsyncPresenceDetectorIndoor):
if isinstance(device, PresenceDetectorIndoor):
entities.append(HomematicipPresenceDetector(hap, device))
if isinstance(device, AsyncSmokeDetector):
if isinstance(device, SmokeDetector):
entities.append(HomematicipSmokeDetector(hap, device))
if isinstance(device, AsyncWaterSensor):
if isinstance(device, WaterSensor):
entities.append(HomematicipWaterDetector(hap, device))
if isinstance(
device, (AsyncRainSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
):
if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipRainSensor(hap, device))
if isinstance(
device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
):
if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipStormSensor(hap, device))
entities.append(HomematicipSunshineSensor(hap, device))
if isinstance(device, AsyncDevice) and device.lowBat is not None:
if isinstance(device, Device) and device.lowBat is not None:
entities.append(HomematicipBatterySensor(hap, device))
for group in hap.home.groups:
if isinstance(group, AsyncSecurityGroup):
if isinstance(group, SecurityGroup):
entities.append(HomematicipSecuritySensorGroup(hap, device=group))
elif isinstance(group, AsyncSecurityZoneGroup):
elif isinstance(group, SecurityZoneGroup):
entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group))
async_add_entities(entities)

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from homematicip.aio.device import AsyncWallMountedGarageDoorController
from homematicip.device import WallMountedGarageDoorController
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
@ -25,7 +25,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipGarageDoorControllerButton(hap, device)
for device in hap.home.devices
if isinstance(device, AsyncWallMountedGarageDoorController)
if isinstance(device, WallMountedGarageDoorController)
)
@ -39,4 +39,4 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
async def async_press(self) -> None:
"""Handle the button press."""
await self._device.send_start_impulse()
await self._device.send_start_impulse_async()

View File

@ -4,16 +4,15 @@ from __future__ import annotations
from typing import Any
from homematicip.aio.device import (
AsyncHeatingThermostat,
AsyncHeatingThermostatCompact,
AsyncHeatingThermostatEvo,
)
from homematicip.aio.group import AsyncHeatingGroup
from homematicip.base.enums import AbsenceType
from homematicip.device import Switch
from homematicip.device import (
HeatingThermostat,
HeatingThermostatCompact,
HeatingThermostatEvo,
Switch,
)
from homematicip.functionalHomes import IndoorClimateHome
from homematicip.group import HeatingCoolingProfile
from homematicip.group import HeatingCoolingProfile, HeatingGroup
from homeassistant.components.climate import (
PRESET_AWAY,
@ -65,7 +64,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipHeatingGroup(hap, device)
for device in hap.home.groups
if isinstance(device, AsyncHeatingGroup)
if isinstance(device, HeatingGroup)
)
@ -82,7 +81,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None:
def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None:
"""Initialize heating group."""
device.modelType = "HmIP-Heating-Group"
super().__init__(hap, device)
@ -214,7 +213,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return
if self.min_temp <= temperature <= self.max_temp:
await self._device.set_point_temperature(temperature)
await self._device.set_point_temperature_async(temperature)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@ -222,23 +221,23 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return
if hvac_mode == HVACMode.AUTO:
await self._device.set_control_mode(HMIP_AUTOMATIC_CM)
await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM)
else:
await self._device.set_control_mode(HMIP_MANUAL_CM)
await self._device.set_control_mode_async(HMIP_MANUAL_CM)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self._device.boostMode and preset_mode != PRESET_BOOST:
await self._device.set_boost(False)
await self._device.set_boost_async(False)
if preset_mode == PRESET_BOOST:
await self._device.set_boost()
await self._device.set_boost_async()
if preset_mode == PRESET_ECO:
await self._device.set_control_mode(HMIP_ECO_CM)
await self._device.set_control_mode_async(HMIP_ECO_CM)
if preset_mode in self._device_profile_names:
profile_idx = self._get_profile_idx_by_name(preset_mode)
if self._device.controlMode != HMIP_AUTOMATIC_CM:
await self.async_set_hvac_mode(HVACMode.AUTO)
await self._device.set_active_profile(profile_idx)
await self._device.set_active_profile_async(profile_idx)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@ -332,20 +331,15 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
@property
def _first_radiator_thermostat(
self,
) -> (
AsyncHeatingThermostat
| AsyncHeatingThermostatCompact
| AsyncHeatingThermostatEvo
| None
):
) -> HeatingThermostat | HeatingThermostatCompact | HeatingThermostatEvo | None:
"""Return the first radiator thermostat from the hmip heating group."""
for device in self._device.devices:
if isinstance(
device,
(
AsyncHeatingThermostat,
AsyncHeatingThermostatCompact,
AsyncHeatingThermostatEvo,
HeatingThermostat,
HeatingThermostatCompact,
HeatingThermostatEvo,
),
):
return device

View File

@ -4,16 +4,16 @@ from __future__ import annotations
from typing import Any
from homematicip.aio.device import (
AsyncBlindModule,
AsyncDinRailBlind4,
AsyncFullFlushBlind,
AsyncFullFlushShutter,
AsyncGarageDoorModuleTormatic,
AsyncHoermannDrivesModule,
)
from homematicip.aio.group import AsyncExtendedLinkedShutterGroup
from homematicip.base.enums import DoorCommand, DoorState
from homematicip.device import (
BlindModule,
DinRailBlind4,
FullFlushBlind,
FullFlushShutter,
GarageDoorModuleTormatic,
HoermannDrivesModule,
)
from homematicip.group import ExtendedLinkedShutterGroup
from homeassistant.components.cover import (
ATTR_POSITION,
@ -45,23 +45,21 @@ async def async_setup_entry(
entities: list[HomematicipGenericEntity] = [
HomematicipCoverShutterGroup(hap, group)
for group in hap.home.groups
if isinstance(group, AsyncExtendedLinkedShutterGroup)
if isinstance(group, ExtendedLinkedShutterGroup)
]
for device in hap.home.devices:
if isinstance(device, AsyncBlindModule):
if isinstance(device, BlindModule):
entities.append(HomematicipBlindModule(hap, device))
elif isinstance(device, AsyncDinRailBlind4):
elif isinstance(device, DinRailBlind4):
entities.extend(
HomematicipMultiCoverSlats(hap, device, channel=channel)
for channel in range(1, 5)
)
elif isinstance(device, AsyncFullFlushBlind):
elif isinstance(device, FullFlushBlind):
entities.append(HomematicipCoverSlats(hap, device))
elif isinstance(device, AsyncFullFlushShutter):
elif isinstance(device, FullFlushShutter):
entities.append(HomematicipCoverShutter(hap, device))
elif isinstance(
device, (AsyncHoermannDrivesModule, AsyncGarageDoorModuleTormatic)
):
elif isinstance(device, (HoermannDrivesModule, GarageDoorModuleTormatic)):
entities.append(HomematicipGarageDoorModule(hap, device))
async_add_entities(entities)
@ -91,14 +89,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_primary_shading_level(primaryShadingLevel=level)
await self._device.set_primary_shading_level_async(primaryShadingLevel=level)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific tilt position."""
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_secondary_shading_level(
await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=level,
)
@ -112,37 +110,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.set_primary_shading_level(
await self._device.set_primary_shading_level_async(
primaryShadingLevel=HMIP_COVER_OPEN
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.set_primary_shading_level(
await self._device.set_primary_shading_level_async(
primaryShadingLevel=HMIP_COVER_CLOSED
)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
await self._device.stop()
await self._device.stop_async()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
await self._device.set_secondary_shading_level(
await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=HMIP_SLATS_OPEN,
)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
await self._device.set_secondary_shading_level(
await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=HMIP_SLATS_CLOSED,
)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
await self._device.stop()
await self._device.stop_async()
class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
@ -176,7 +174,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_shutter_level(level, self._channel)
await self._device.set_shutter_level_async(level, self._channel)
@property
def is_closed(self) -> bool | None:
@ -190,15 +188,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel)
await self._device.set_shutter_level_async(HMIP_COVER_OPEN, self._channel)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel)
await self._device.set_shutter_level_async(HMIP_COVER_CLOSED, self._channel)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
await self._device.set_shutter_stop(self._channel)
await self._device.set_shutter_stop_async(self._channel)
class HomematicipCoverShutter(HomematicipMultiCoverShutter, CoverEntity):
@ -238,23 +236,25 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity):
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel)
await self._device.set_slats_level_async(
slatsLevel=level, channelIndex=self._channel
)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
await self._device.set_slats_level(
await self._device.set_slats_level_async(
slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel
)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
await self._device.set_slats_level(
await self._device.set_slats_level_async(
slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel
)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
await self._device.set_shutter_stop(self._channel)
await self._device.set_shutter_stop_async(self._channel)
class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity):
@ -288,15 +288,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.send_door_command(DoorCommand.OPEN)
await self._device.send_door_command_async(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.send_door_command(DoorCommand.CLOSE)
await self._device.send_door_command_async(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
await self._device.send_door_command(DoorCommand.STOP)
await self._device.send_door_command_async(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
@ -335,35 +335,35 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_shutter_level(level)
await self._device.set_shutter_level_async(level)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific tilt position."""
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
await self._device.set_slats_level(level)
await self._device.set_slats_level_async(level)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
await self._device.set_shutter_level(HMIP_COVER_OPEN)
await self._device.set_shutter_level_async(HMIP_COVER_OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
await self._device.set_shutter_level(HMIP_COVER_CLOSED)
await self._device.set_shutter_level_async(HMIP_COVER_CLOSED)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the group if in motion."""
await self._device.set_shutter_stop()
await self._device.set_shutter_stop_async()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
await self._device.set_slats_level(HMIP_SLATS_OPEN)
await self._device.set_slats_level_async(HMIP_SLATS_OPEN)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
await self._device.set_slats_level(HMIP_SLATS_CLOSED)
await self._device.set_slats_level_async(HMIP_SLATS_CLOSED)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the group if in motion."""
await self._device.set_shutter_stop()
await self._device.set_shutter_stop_async()

View File

@ -5,9 +5,9 @@ from __future__ import annotations
import logging
from typing import Any
from homematicip.aio.device import AsyncDevice
from homematicip.aio.group import AsyncGroup
from homematicip.base.functionalChannels import FunctionalChannel
from homematicip.device import Device
from homematicip.group import Group
from homeassistant.const import ATTR_ID
from homeassistant.core import callback
@ -100,7 +100,7 @@ class HomematicipGenericEntity(Entity):
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
# Only physical devices should be HA devices.
if isinstance(self._device, AsyncDevice):
if isinstance(self._device, Device):
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
@ -237,14 +237,14 @@ class HomematicipGenericEntity(Entity):
"""Return the state attributes of the generic entity."""
state_attr = {}
if isinstance(self._device, AsyncDevice):
if isinstance(self._device, Device):
for attr, attr_key in DEVICE_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value
state_attr[ATTR_IS_GROUP] = False
if isinstance(self._device, AsyncGroup):
if isinstance(self._device, Group):
for attr, attr_key in GROUP_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value

View File

@ -3,7 +3,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from homematicip.aio.device import Device
from homematicip.device import Device
from homeassistant.components.event import (
EventDeviceClass,

View File

@ -7,15 +7,18 @@ from collections.abc import Callable
import logging
from typing import Any
from homematicip.aio.auth import AsyncAuth
from homematicip.aio.home import AsyncHome
from homematicip.async_home import AsyncHome
from homematicip.auth import Auth
from homematicip.base.base_connection import HmipConnectionError
from homematicip.base.enums import EventType
from homematicip.connection.connection_context import ConnectionContextBuilder
from homematicip.connection.rest_connection import RestConnection
import homeassistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.httpx_client import get_async_client
from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS
from .errors import HmipcConnectionError
@ -23,10 +26,25 @@ from .errors import HmipcConnectionError
_LOGGER = logging.getLogger(__name__)
async def build_context_async(
hass: HomeAssistant, hapid: str | None, authtoken: str | None
):
"""Create a HomematicIP context object."""
ssl_ctx = homeassistant.util.ssl.get_default_context()
client_session = get_async_client(hass)
return await ConnectionContextBuilder.build_context_async(
accesspoint_id=hapid,
auth_token=authtoken,
ssl_ctx=ssl_ctx,
httpx_client_session=client_session,
)
class HomematicipAuth:
"""Manages HomematicIP client registration."""
auth: AsyncAuth
auth: Auth
def __init__(self, hass: HomeAssistant, config: dict[str, str]) -> None:
"""Initialize HomematicIP Cloud client registration."""
@ -46,27 +64,34 @@ class HomematicipAuth:
async def async_checkbutton(self) -> bool:
"""Check blue butten has been pressed."""
try:
return await self.auth.isRequestAcknowledged()
return await self.auth.is_request_acknowledged()
except HmipConnectionError:
return False
async def async_register(self):
"""Register client at HomematicIP."""
try:
authtoken = await self.auth.requestAuthToken()
await self.auth.confirmAuthToken(authtoken)
authtoken = await self.auth.request_auth_token()
await self.auth.confirm_auth_token(authtoken)
except HmipConnectionError:
return False
return authtoken
async def get_auth(self, hass: HomeAssistant, hapid, pin):
"""Create a HomematicIP access point object."""
auth = AsyncAuth(hass.loop, async_get_clientsession(hass))
context = await build_context_async(hass, hapid, None)
connection = RestConnection(
context,
log_status_exceptions=False,
httpx_client_session=get_async_client(hass),
)
# hass.loop
auth = Auth(connection, context.client_auth_token, hapid)
try:
await auth.init(hapid)
if pin:
auth.pin = pin
await auth.connectionRequest("HomeAssistant")
auth.set_pin(pin)
result = await auth.connection_request(hapid)
_LOGGER.debug("Connection request result: %s", result)
except HmipConnectionError:
return None
return auth
@ -156,7 +181,7 @@ class HomematicipHAP:
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
await self.home.get_current_state()
await self.home.get_current_state_async()
self.update_all()
def get_state_finished(self, future) -> None:
@ -187,8 +212,8 @@ class HomematicipHAP:
retry_delay = 2 ** min(tries, 8)
try:
await self.home.get_current_state()
hmip_events = await self.home.enable_events()
await self.home.get_current_state_async()
hmip_events = self.home.enable_events()
tries = 0
await hmip_events
except HmipConnectionError:
@ -219,7 +244,7 @@ class HomematicipHAP:
self._ws_close_requested = True
if self._retry_task is not None:
self._retry_task.cancel()
await self.home.disable_events()
await self.home.disable_events_async()
_LOGGER.debug("Closed connection to HomematicIP cloud server")
await self.hass.config_entries.async_unload_platforms(
self.config_entry, PLATFORMS
@ -246,17 +271,17 @@ class HomematicipHAP:
name: str | None,
) -> AsyncHome:
"""Create a HomematicIP access point object."""
home = AsyncHome(hass.loop, async_get_clientsession(hass))
home = AsyncHome()
home.name = name
# Use the title of the config entry as title for the home.
home.label = self.config_entry.title
home.modelType = "HomematicIP Cloud Home"
home.set_auth_token(authtoken)
try:
await home.init(hapid)
await home.get_current_state()
context = await build_context_async(hass, hapid, authtoken)
home.init_with_context(context, True, get_async_client(hass))
await home.get_current_state_async()
except HmipConnectionError as err:
raise HmipcConnectionError from err
home.on_update(self.async_update)

View File

@ -4,18 +4,18 @@ from __future__ import annotations
from typing import Any
from homematicip.aio.device import (
AsyncBrandDimmer,
AsyncBrandSwitchMeasuring,
AsyncBrandSwitchNotificationLight,
AsyncDimmer,
AsyncDinRailDimmer3,
AsyncFullFlushDimmer,
AsyncPluggableDimmer,
AsyncWiredDimmer3,
)
from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState
from homematicip.base.functionalChannels import NotificationLightChannel
from homematicip.device import (
BrandDimmer,
BrandSwitchMeasuring,
BrandSwitchNotificationLight,
Dimmer,
DinRailDimmer3,
FullFlushDimmer,
PluggableDimmer,
WiredDimmer3,
)
from packaging.version import Version
from homeassistant.components.light import (
@ -46,9 +46,9 @@ async def async_setup_entry(
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
if isinstance(device, AsyncBrandSwitchMeasuring):
if isinstance(device, BrandSwitchMeasuring):
entities.append(HomematicipLightMeasuring(hap, device))
elif isinstance(device, AsyncBrandSwitchNotificationLight):
elif isinstance(device, BrandSwitchNotificationLight):
device_version = Version(device.firmwareVersion)
entities.append(HomematicipLight(hap, device))
@ -65,14 +65,14 @@ async def async_setup_entry(
entity_class(hap, device, device.bottomLightChannelIndex, "Bottom")
)
elif isinstance(device, (AsyncWiredDimmer3, AsyncDinRailDimmer3)):
elif isinstance(device, (WiredDimmer3, DinRailDimmer3)):
entities.extend(
HomematicipMultiDimmer(hap, device, channel=channel)
for channel in range(1, 4)
)
elif isinstance(
device,
(AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer),
(Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer),
):
entities.append(HomematicipDimmer(hap, device))
@ -96,11 +96,11 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
await self._device.turn_on()
await self._device.turn_on_async()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._device.turn_off()
await self._device.turn_off_async()
class HomematicipLightMeasuring(HomematicipLight):
@ -141,15 +141,15 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the dimmer on."""
if ATTR_BRIGHTNESS in kwargs:
await self._device.set_dim_level(
await self._device.set_dim_level_async(
kwargs[ATTR_BRIGHTNESS] / 255.0, self._channel
)
else:
await self._device.set_dim_level(1, self._channel)
await self._device.set_dim_level_async(1, self._channel)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the dimmer off."""
await self._device.set_dim_level(0, self._channel)
await self._device.set_dim_level_async(0, self._channel)
class HomematicipDimmer(HomematicipMultiDimmer, LightEntity):
@ -239,7 +239,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
dim_level = brightness / 255.0
transition = kwargs.get(ATTR_TRANSITION, 0.5)
await self._device.set_rgb_dim_level_with_time(
await self._device.set_rgb_dim_level_with_time_async(
channelIndex=self._channel,
rgb=simple_rgb_color,
dimLevel=dim_level,
@ -252,7 +252,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
simple_rgb_color = self._func_channel.simpleRGBColorState
transition = kwargs.get(ATTR_TRANSITION, 0.5)
await self._device.set_rgb_dim_level_with_time(
await self._device.set_rgb_dim_level_with_time_async(
channelIndex=self._channel,
rgb=simple_rgb_color,
dimLevel=0.0,

View File

@ -5,8 +5,8 @@ from __future__ import annotations
import logging
from typing import Any
from homematicip.aio.device import AsyncDoorLockDrive
from homematicip.base.enums import LockState, MotorState
from homematicip.device import DoorLockDrive
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
@ -45,7 +45,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipDoorLockDrive(hap, device)
for device in hap.home.devices
if isinstance(device, AsyncDoorLockDrive)
if isinstance(device, DoorLockDrive)
)
@ -75,17 +75,17 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity):
@handle_errors
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
return await self._device.set_lock_state(LockState.LOCKED)
return await self._device.set_lock_state_async(LockState.LOCKED)
@handle_errors
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
return await self._device.set_lock_state(LockState.UNLOCKED)
return await self._device.set_lock_state_async(LockState.UNLOCKED)
@handle_errors
async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
return await self._device.set_lock_state(LockState.OPEN)
return await self._device.set_lock_state_async(LockState.OPEN)
@property
def extra_state_attributes(self) -> dict[str, Any]:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==1.1.7"]
"requirements": ["homematicip==2.0.0"]
}

View File

@ -5,39 +5,39 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homematicip.aio.device import (
AsyncBrandSwitchMeasuring,
AsyncEnergySensorsInterface,
AsyncFloorTerminalBlock6,
AsyncFloorTerminalBlock10,
AsyncFloorTerminalBlock12,
AsyncFullFlushSwitchMeasuring,
AsyncHeatingThermostat,
AsyncHeatingThermostatCompact,
AsyncHeatingThermostatEvo,
AsyncHomeControlAccessPoint,
AsyncLightSensor,
AsyncMotionDetectorIndoor,
AsyncMotionDetectorOutdoor,
AsyncMotionDetectorPushButton,
AsyncPassageDetector,
AsyncPlugableSwitchMeasuring,
AsyncPresenceDetectorIndoor,
AsyncRoomControlDeviceAnalog,
AsyncTemperatureDifferenceSensor2,
AsyncTemperatureHumiditySensorDisplay,
AsyncTemperatureHumiditySensorOutdoor,
AsyncTemperatureHumiditySensorWithoutDisplay,
AsyncWeatherSensor,
AsyncWeatherSensorPlus,
AsyncWeatherSensorPro,
AsyncWiredFloorTerminalBlock12,
)
from homematicip.base.enums import FunctionalChannelType, ValveState
from homematicip.base.functionalChannels import (
FloorTerminalBlockMechanicChannel,
FunctionalChannel,
)
from homematicip.device import (
BrandSwitchMeasuring,
EnergySensorsInterface,
FloorTerminalBlock6,
FloorTerminalBlock10,
FloorTerminalBlock12,
FullFlushSwitchMeasuring,
HeatingThermostat,
HeatingThermostatCompact,
HeatingThermostatEvo,
HomeControlAccessPoint,
LightSensor,
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
PassageDetector,
PlugableSwitchMeasuring,
PresenceDetectorIndoor,
RoomControlDeviceAnalog,
TemperatureDifferenceSensor2,
TemperatureHumiditySensorDisplay,
TemperatureHumiditySensorOutdoor,
TemperatureHumiditySensorWithoutDisplay,
WeatherSensor,
WeatherSensorPlus,
WeatherSensorPro,
WiredFloorTerminalBlock12,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -102,14 +102,14 @@ async def async_setup_entry(
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
if isinstance(device, AsyncHomeControlAccessPoint):
if isinstance(device, HomeControlAccessPoint):
entities.append(HomematicipAccesspointDutyCycle(hap, device))
if isinstance(
device,
(
AsyncHeatingThermostat,
AsyncHeatingThermostatCompact,
AsyncHeatingThermostatEvo,
HeatingThermostat,
HeatingThermostatCompact,
HeatingThermostatEvo,
),
):
entities.append(HomematicipHeatingThermostat(hap, device))
@ -117,55 +117,53 @@ async def async_setup_entry(
if isinstance(
device,
(
AsyncTemperatureHumiditySensorDisplay,
AsyncTemperatureHumiditySensorWithoutDisplay,
AsyncTemperatureHumiditySensorOutdoor,
AsyncWeatherSensor,
AsyncWeatherSensorPlus,
AsyncWeatherSensorPro,
TemperatureHumiditySensorDisplay,
TemperatureHumiditySensorWithoutDisplay,
TemperatureHumiditySensorOutdoor,
WeatherSensor,
WeatherSensorPlus,
WeatherSensorPro,
),
):
entities.append(HomematicipTemperatureSensor(hap, device))
entities.append(HomematicipHumiditySensor(hap, device))
elif isinstance(device, (AsyncRoomControlDeviceAnalog,)):
elif isinstance(device, (RoomControlDeviceAnalog,)):
entities.append(HomematicipTemperatureSensor(hap, device))
if isinstance(
device,
(
AsyncLightSensor,
AsyncMotionDetectorIndoor,
AsyncMotionDetectorOutdoor,
AsyncMotionDetectorPushButton,
AsyncPresenceDetectorIndoor,
AsyncWeatherSensor,
AsyncWeatherSensorPlus,
AsyncWeatherSensorPro,
LightSensor,
MotionDetectorIndoor,
MotionDetectorOutdoor,
MotionDetectorPushButton,
PresenceDetectorIndoor,
WeatherSensor,
WeatherSensorPlus,
WeatherSensorPro,
),
):
entities.append(HomematicipIlluminanceSensor(hap, device))
if isinstance(
device,
(
AsyncPlugableSwitchMeasuring,
AsyncBrandSwitchMeasuring,
AsyncFullFlushSwitchMeasuring,
PlugableSwitchMeasuring,
BrandSwitchMeasuring,
FullFlushSwitchMeasuring,
),
):
entities.append(HomematicipPowerSensor(hap, device))
entities.append(HomematicipEnergySensor(hap, device))
if isinstance(
device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
):
if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipWindspeedSensor(hap, device))
if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)):
if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipTodayRainSensor(hap, device))
if isinstance(device, AsyncPassageDetector):
if isinstance(device, PassageDetector):
entities.append(HomematicipPassageDetectorDeltaCounter(hap, device))
if isinstance(device, AsyncTemperatureDifferenceSensor2):
if isinstance(device, TemperatureDifferenceSensor2):
entities.append(HomematicpTemperatureExternalSensorCh1(hap, device))
entities.append(HomematicpTemperatureExternalSensorCh2(hap, device))
entities.append(HomematicpTemperatureExternalSensorDelta(hap, device))
if isinstance(device, AsyncEnergySensorsInterface):
if isinstance(device, EnergySensorsInterface):
for ch in get_channels_from_device(
device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL
):
@ -194,10 +192,10 @@ async def async_setup_entry(
if isinstance(
device,
(
AsyncFloorTerminalBlock6,
AsyncFloorTerminalBlock10,
AsyncFloorTerminalBlock12,
AsyncWiredFloorTerminalBlock12,
FloorTerminalBlock6,
FloorTerminalBlock10,
FloorTerminalBlock12,
WiredFloorTerminalBlock12,
),
):
entities.extend(

View File

@ -5,10 +5,10 @@ from __future__ import annotations
import logging
from pathlib import Path
from homematicip.aio.device import AsyncSwitchMeasuring
from homematicip.aio.group import AsyncHeatingGroup
from homematicip.aio.home import AsyncHome
from homematicip.async_home import AsyncHome
from homematicip.base.helpers import handle_config
from homematicip.device import SwitchMeasuring
from homematicip.group import HeatingGroup
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
@ -233,10 +233,10 @@ async def _async_activate_eco_mode_with_duration(
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.activate_absence_with_duration(duration)
await home.activate_absence_with_duration_async(duration)
else:
for hap in hass.data[DOMAIN].values():
await hap.home.activate_absence_with_duration(duration)
await hap.home.activate_absence_with_duration_async(duration)
async def _async_activate_eco_mode_with_period(
@ -247,10 +247,10 @@ async def _async_activate_eco_mode_with_period(
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.activate_absence_with_period(endtime)
await home.activate_absence_with_period_async(endtime)
else:
for hap in hass.data[DOMAIN].values():
await hap.home.activate_absence_with_period(endtime)
await hap.home.activate_absence_with_period_async(endtime)
async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
@ -260,30 +260,30 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) ->
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.activate_vacation(endtime, temperature)
await home.activate_vacation_async(endtime, temperature)
else:
for hap in hass.data[DOMAIN].values():
await hap.home.activate_vacation(endtime, temperature)
await hap.home.activate_vacation_async(endtime, temperature)
async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None:
"""Service to deactivate eco mode."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.deactivate_absence()
await home.deactivate_absence_async()
else:
for hap in hass.data[DOMAIN].values():
await hap.home.deactivate_absence()
await hap.home.deactivate_absence_async()
async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
"""Service to deactivate vacation."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.deactivate_vacation()
await home.deactivate_vacation_async()
else:
for hap in hass.data[DOMAIN].values():
await hap.home.deactivate_vacation()
await hap.home.deactivate_vacation_async()
async def _set_active_climate_profile(
@ -297,12 +297,12 @@ async def _set_active_climate_profile(
if entity_id_list != "all":
for entity_id in entity_id_list:
group = hap.hmip_device_by_entity_id.get(entity_id)
if group and isinstance(group, AsyncHeatingGroup):
await group.set_active_profile(climate_profile_index)
if group and isinstance(group, HeatingGroup):
await group.set_active_profile_async(climate_profile_index)
else:
for group in hap.home.groups:
if isinstance(group, AsyncHeatingGroup):
await group.set_active_profile(climate_profile_index)
if isinstance(group, HeatingGroup):
await group.set_active_profile_async(climate_profile_index)
async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None:
@ -323,7 +323,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N
path = Path(config_path)
config_file = path / file_name
json_state = await hap.home.download_configuration()
json_state = await hap.home.download_configuration_async()
json_state = handle_config(json_state, anonymize)
config_file.write_text(json_state, encoding="utf8")
@ -337,12 +337,12 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall)
if entity_id_list != "all":
for entity_id in entity_id_list:
device = hap.hmip_device_by_entity_id.get(entity_id)
if device and isinstance(device, AsyncSwitchMeasuring):
await device.reset_energy_counter()
if device and isinstance(device, SwitchMeasuring):
await device.reset_energy_counter_async()
else:
for device in hap.home.devices:
if isinstance(device, AsyncSwitchMeasuring):
await device.reset_energy_counter()
if isinstance(device, SwitchMeasuring):
await device.reset_energy_counter_async()
async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall):
@ -351,10 +351,10 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
await home.set_cooling(cooling)
await home.set_cooling_async(cooling)
else:
for hap in hass.data[DOMAIN].values():
await hap.home.set_cooling(cooling)
await hap.home.set_cooling_async(cooling)
def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None:

View File

@ -4,23 +4,23 @@ from __future__ import annotations
from typing import Any
from homematicip.aio.device import (
AsyncBrandSwitch2,
AsyncBrandSwitchMeasuring,
AsyncDinRailSwitch,
AsyncDinRailSwitch4,
AsyncFullFlushInputSwitch,
AsyncFullFlushSwitchMeasuring,
AsyncHeatingSwitch2,
AsyncMultiIOBox,
AsyncOpenCollector8Module,
AsyncPlugableSwitch,
AsyncPlugableSwitchMeasuring,
AsyncPrintedCircuitBoardSwitch2,
AsyncPrintedCircuitBoardSwitchBattery,
AsyncWiredSwitch8,
from homematicip.device import (
BrandSwitch2,
BrandSwitchMeasuring,
DinRailSwitch,
DinRailSwitch4,
FullFlushInputSwitch,
FullFlushSwitchMeasuring,
HeatingSwitch2,
MultiIOBox,
OpenCollector8Module,
PlugableSwitch,
PlugableSwitchMeasuring,
PrintedCircuitBoardSwitch2,
PrintedCircuitBoardSwitchBattery,
WiredSwitch8,
)
from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup
from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
@ -42,26 +42,24 @@ async def async_setup_entry(
entities: list[HomematicipGenericEntity] = [
HomematicipGroupSwitch(hap, group)
for group in hap.home.groups
if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup))
if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup))
]
for device in hap.home.devices:
if isinstance(device, AsyncBrandSwitchMeasuring):
if isinstance(device, BrandSwitchMeasuring):
# BrandSwitchMeasuring inherits PlugableSwitchMeasuring
# This entity is implemented in the light platform and will
# not be added in the switch platform
pass
elif isinstance(
device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring)
):
elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)):
entities.append(HomematicipSwitchMeasuring(hap, device))
elif isinstance(device, AsyncWiredSwitch8):
elif isinstance(device, WiredSwitch8):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9)
)
elif isinstance(device, AsyncDinRailSwitch):
elif isinstance(device, DinRailSwitch):
entities.append(HomematicipMultiSwitch(hap, device, channel=1))
elif isinstance(device, AsyncDinRailSwitch4):
elif isinstance(device, DinRailSwitch4):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 5)
@ -69,13 +67,13 @@ async def async_setup_entry(
elif isinstance(
device,
(
AsyncPlugableSwitch,
AsyncPrintedCircuitBoardSwitchBattery,
AsyncFullFlushInputSwitch,
PlugableSwitch,
PrintedCircuitBoardSwitchBattery,
FullFlushInputSwitch,
),
):
entities.append(HomematicipSwitch(hap, device))
elif isinstance(device, AsyncOpenCollector8Module):
elif isinstance(device, OpenCollector8Module):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9)
@ -83,10 +81,10 @@ async def async_setup_entry(
elif isinstance(
device,
(
AsyncBrandSwitch2,
AsyncPrintedCircuitBoardSwitch2,
AsyncHeatingSwitch2,
AsyncMultiIOBox,
BrandSwitch2,
PrintedCircuitBoardSwitch2,
HeatingSwitch2,
MultiIOBox,
),
):
entities.extend(
@ -119,11 +117,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self._device.turn_on(self._channel)
await self._device.turn_on_async(self._channel)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self._device.turn_off(self._channel)
await self._device.turn_off_async(self._channel)
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):
@ -168,11 +166,11 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the group on."""
await self._device.turn_on()
await self._device.turn_on_async()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the group off."""
await self._device.turn_off()
await self._device.turn_off_async()
class HomematicipSwitchMeasuring(HomematicipSwitch):

View File

@ -2,12 +2,8 @@
from __future__ import annotations
from homematicip.aio.device import (
AsyncWeatherSensor,
AsyncWeatherSensorPlus,
AsyncWeatherSensorPro,
)
from homematicip.base.enums import WeatherCondition
from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
@ -59,9 +55,9 @@ async def async_setup_entry(
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
if isinstance(device, AsyncWeatherSensorPro):
if isinstance(device, WeatherSensorPro):
entities.append(HomematicipWeatherSensorPro(hap, device))
elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)):
elif isinstance(device, (WeatherSensor, WeatherSensorPlus)):
entities.append(HomematicipWeatherSensor(hap, device))
entities.append(HomematicipHomeWeather(hap))

View File

@ -272,8 +272,8 @@
"operator_search_mode": {
"name": "Operator search mode",
"state": {
"0": "Auto",
"1": "Manual"
"0": "[%key:common::state::auto%]",
"1": "[%key:common::state::manual%]"
}
},
"preferred_network_mode": {

View File

@ -26,25 +26,25 @@
"name": "Current power in peak"
},
"current_power_off_peak": {
"name": "Current power in off peak"
"name": "Current power in off-peak"
},
"current_power_out_peak": {
"name": "Current power out peak"
},
"current_power_out_off_peak": {
"name": "Current power out off peak"
"name": "Current power out off-peak"
},
"energy_consumption_peak_today": {
"name": "Energy consumption peak today"
},
"energy_consumption_off_peak_today": {
"name": "Energy consumption off peak today"
"name": "Energy consumption off-peak today"
},
"energy_production_peak_today": {
"name": "Energy production peak today"
},
"energy_production_off_peak_today": {
"name": "Energy production off peak today"
"name": "Energy production off-peak today"
},
"energy_today": {
"name": "Energy today"

View File

@ -65,7 +65,7 @@
"normal": "[%key:common::state::normal%]",
"home": "[%key:common::state::home%]",
"away": "[%key:common::state::not_home%]",
"auto": "Auto",
"auto": "[%key:common::state::auto%]",
"baby": "Baby",
"boost": "Boost",
"comfort": "Comfort",

View File

@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2025.3.2"]
"requirements": ["aioautomower==2025.4.0"]
}

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
"requirements": ["iaqualink==0.5.3", "h2==4.1.0"],
"requirements": ["iaqualink==0.5.3", "h2==4.2.0"],
"single_config_entry": true
}

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==11.1.0"]
"requirements": ["Pillow==11.2.1"]
}

View File

@ -2,33 +2,36 @@
from __future__ import annotations
from inkbird_ble import INKBIRDBluetoothDeviceData
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .const import CONF_DEVICE_TYPE, DOMAIN
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
assert entry.unique_id is not None
device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
data = INKBIRDBluetoothDeviceData(device_type)
coordinator = INKBIRDActiveBluetoothProcessorCoordinator(hass, entry, data)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA)
coordinator = INKBIRDActiveBluetoothProcessorCoordinator(
hass, entry, device_type, device_data
)
await coordinator.async_init()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN
from .const import CONF_DEVICE_TYPE, DOMAIN
class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
self._discovered_devices: dict[str, str] = {}
self._discovered_devices: dict[str, tuple[str, str]] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
return self.async_create_entry(title=title, data={})
return self.async_create_entry(
title=title,
data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)},
)
self._set_confirm_only()
placeholders = {"name": title}
@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
title, device_type = self._discovered_devices[address]
return self.async_create_entry(
title=self._discovered_devices[address], data={}
title=title, data={CONF_DEVICE_TYPE: device_type}
)
current_addresses = self._async_current_ids(include_ignore=False)
@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
device.title or device.get_device_name() or discovery_info.name
device.title or device.get_device_name() or discovery_info.name,
str(device.device_type),
)
if not self._discovered_devices:

View File

@ -3,3 +3,4 @@
DOMAIN = "inkbird"
CONF_DEVICE_TYPE = "device_type"
CONF_DEVICE_DATA = "device_data"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import datetime, timedelta
import logging
from typing import Any
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
@ -12,40 +13,43 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_last_service_info,
)
from homeassistant.components.bluetooth.active_update_processor import (
ActiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_DEVICE_TYPE
from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN
_LOGGER = logging.getLogger(__name__)
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
class INKBIRDActiveBluetoothProcessorCoordinator(
ActiveBluetoothProcessorCoordinator[SensorUpdate]
):
"""Coordinator for INKBIRD Bluetooth devices."""
_data: INKBIRDBluetoothDeviceData
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
data: INKBIRDBluetoothDeviceData,
device_type: str | None,
device_data: dict[str, Any] | None,
) -> None:
"""Initialize the INKBIRD Bluetooth processor coordinator."""
self._data = data
self._entry = entry
self._device_type = device_type
self._device_data = device_data
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,
@ -56,6 +60,30 @@ class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordin
poll_method=self._async_poll_data,
)
async def async_init(self) -> None:
"""Initialize the coordinator."""
self._data = INKBIRDBluetoothDeviceData(
self._device_type,
self._device_data,
self.async_set_updated_data,
self._async_device_data_changed,
)
if not self._data.uses_notify:
self._entry.async_on_unload(
async_track_time_interval(
self.hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
)
)
return
if not (service_info := async_last_service_info(self.hass, self.address)):
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="no_advertisement",
translation_placeholders={"address": self.address},
)
await self._data.async_start(service_info, service_info.device)
self._entry.async_on_unload(self._data.async_stop)
async def _async_poll_data(
self, last_service_info: BluetoothServiceInfoBleak
) -> SensorUpdate:
@ -76,6 +104,13 @@ class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordin
)
)
@callback
def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None:
"""Handle device data changed."""
self.hass.config_entries.async_update_entry(
self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data}
)
@callback
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
"""Handle update callback from the passive BLE processor."""

View File

@ -33,6 +33,15 @@
{
"local_name": "ITH-21-B",
"connectable": false
},
{
"local_name": "Ink@IAM-T1",
"connectable": true
},
{
"manufacturer_id": 12628,
"manufacturer_data_start": [65, 67, 45],
"connectable": true
}
],
"codeowners": ["@bdraco"],
@ -40,5 +49,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
"requirements": ["inkbird-ble==0.10.1"]
"requirements": ["inkbird-ble==0.13.0"]
}

View File

@ -4,12 +4,10 @@ from __future__ import annotations
from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
@ -19,15 +17,17 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
from . import INKBIRDConfigEntry
SENSOR_DESCRIPTIONS = {
(DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription(
@ -58,6 +58,18 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
(DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription(
key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
}
@ -97,20 +109,17 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: INKBIRDConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the INKBIRD BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
INKBIRDBluetoothSensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class INKBIRDBluetoothSensorEntity(

View File

@ -17,5 +17,10 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"exceptions": {
"no_advertisement": {
"message": "The device with address {address} is not advertising; Make sure it is in range and powered on."
}
}
}

View File

@ -61,11 +61,14 @@ OPTIONS_SCHEMA = vol.Schema(
_LOGGER = logging.getLogger(__name__)
def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
async def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
default_location = {
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
}
get_timezones: list[str] = list(
await hass.async_add_executor_job(zoneinfo.available_timezones)
)
return vol.Schema(
{
vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(),
@ -75,9 +78,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(),
vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int,
vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector(
SelectSelectorConfig(
options=sorted(zoneinfo.available_timezones()),
)
SelectSelectorConfig(options=get_timezones, sort=True)
),
}
)
@ -109,7 +110,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
_get_data_schema(self.hass), user_input
await _get_data_schema(self.hass), user_input
),
)
@ -121,7 +122,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
if not user_input:
return self.async_show_form(
data_schema=self.add_suggested_values_to_schema(
_get_data_schema(self.hass),
await _get_data_schema(self.hass),
reconfigure_entry.data,
),
step_id="reconfigure",

View File

@ -145,7 +145,10 @@ class KrakenData:
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
def _get_websocket_name_asset_pairs(self) -> str:
return ",".join(wsname for wsname in self.tradable_asset_pairs.values())
return ",".join(
self.tradable_asset_pairs[tracked_pair]
for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
)
def set_update_interval(self, update_interval: int) -> None:
"""Set the coordinator update_interval to the supplied update_interval."""

View File

@ -1,21 +1,31 @@
"""Kuler Sky lights integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import logging
from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
from homeassistant.components.bluetooth import async_ble_device_from_address
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
PLATFORMS = [Platform.LIGHT]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Kuler Sky from a config entry."""
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
if DATA_ADDRESSES not in hass.data[DOMAIN]:
hass.data[DOMAIN][DATA_ADDRESSES] = set()
ble_device = async_ble_device_from_address(
hass, entry.data[CONF_ADDRESS], connectable=True
)
if not ble_device:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
# Stop discovery
unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
if unregister_discovery:
unregister_discovery()
hass.data.pop(DOMAIN, None)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
# Version 1 was a single entry instance that started a bluetooth discovery
# thread to add devices. Version 2 has one config entry per device, and
# supports core bluetooth discovery
if config_entry.version == 1:
dev_reg = dr.async_get(hass)
devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
if len(devices) == 0:
_LOGGER.error("Unable to migrate; No devices registered")
return False
first_device = devices[0]
domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
address = next(iter(domain_identifiers))[1]
hass.config_entries.async_update_entry(
config_entry,
title=first_device.name or address,
data={CONF_ADDRESS: address},
unique_id=address,
version=2,
)
# Create new config flows for the remaining devices
for device in devices[1:]:
domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN]
address = next(iter(domain_identifiers))[1]
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={CONF_ADDRESS: address},
)
)
_LOGGER.debug("Migration to version %s successful", config_entry.version)
return True

View File

@ -1,26 +1,143 @@
"""Config flow for Kuler Sky."""
import logging
from typing import Any
from bluetooth_data_tools import human_readable_name
import pykulersky
import voluptuous as vol
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_flow
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
async_last_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from .const import DOMAIN
from .const import DOMAIN, EXPECTED_SERVICE_UUID
_LOGGER = logging.getLogger(__name__)
async def _async_has_devices(hass: HomeAssistant) -> bool:
"""Return if there are devices that can be discovered."""
# Check if there are any devices that can be discovered in the network.
try:
devices = await pykulersky.discover()
except pykulersky.PykulerskyException as exc:
_LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
return False
return len(devices) > 0
class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Kulersky."""
VERSION = 2
config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_integration_discovery(
self, discovery_info: dict[str, str]
) -> ConfigFlowResult:
"""Handle the integration discovery step.
The old version of the integration used to have multiple
device in a single config entry. This is now deprecated.
The integration discovery step is used to create config
entries for each device beyond the first one.
"""
address: str = discovery_info[CONF_ADDRESS]
if service_info := async_last_service_info(self.hass, address):
title = human_readable_name(None, service_info.name, service_info.address)
else:
title = address
await self.async_set_unique_id(address)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=title,
data={CONF_ADDRESS: address},
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": human_readable_name(
None, discovery_info.name, discovery_info.address
)
}
return await self.async_step_user()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
local_name = human_readable_name(
None, discovery_info.name, discovery_info.address
)
await self.async_set_unique_id(
discovery_info.address, raise_on_progress=False
)
self._abort_if_unique_id_configured()
kulersky_light = None
try:
kulersky_light = pykulersky.Light(discovery_info.address)
await kulersky_light.connect()
except pykulersky.PykulerskyException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=local_name,
data={
CONF_ADDRESS: discovery_info.address,
},
)
finally:
if kulersky_light:
await kulersky_light.disconnect()
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids()
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
or discovery.address in self._discovered_devices
or EXPECTED_SERVICE_UUID not in discovery.service_uuids
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
if self._discovery_info:
data_schema = vol.Schema(
{vol.Required(CONF_ADDRESS): self._discovery_info.address}
)
else:
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: (
f"{service_info.name} ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

View File

@ -4,3 +4,5 @@ DOMAIN = "kulersky"
DATA_ADDRESSES = "addresses"
DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription"
EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c"

View File

@ -2,12 +2,12 @@
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
import pykulersky
from homeassistant.components import bluetooth
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGBW_COLOR,
@ -15,18 +15,15 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
DISCOVERY_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(
hass: HomeAssistant,
@ -34,32 +31,15 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Kuler sky light devices."""
async def discover(*args):
"""Attempt to discover new lights."""
lights = await pykulersky.discover()
# Filter out already discovered lights
new_lights = [
light
for light in lights
if light.address not in hass.data[DOMAIN][DATA_ADDRESSES]
]
new_entities = []
for light in new_lights:
hass.data[DOMAIN][DATA_ADDRESSES].add(light.address)
new_entities.append(KulerskyLight(light))
async_add_entities(new_entities, update_before_add=True)
# Start initial discovery
hass.async_create_task(discover())
# Perform recurring discovery of new devices
hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval(
hass, discover, DISCOVERY_INTERVAL
ble_device = bluetooth.async_ble_device_from_address(
hass, config_entry.data[CONF_ADDRESS], connectable=True
)
entity = KulerskyLight(
config_entry.title,
config_entry.data[CONF_ADDRESS],
pykulersky.Light(ble_device),
)
async_add_entities([entity], update_before_add=True)
class KulerskyLight(LightEntity):
@ -71,37 +51,30 @@ class KulerskyLight(LightEntity):
_attr_supported_color_modes = {ColorMode.RGBW}
_attr_color_mode = ColorMode.RGBW
def __init__(self, light: pykulersky.Light) -> None:
def __init__(self, name: str, address: str, light: pykulersky.Light) -> None:
"""Initialize a Kuler Sky light."""
self._light = light
self._attr_unique_id = light.address
self._attr_unique_id = address
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, light.address)},
identifiers={(DOMAIN, address)},
connections={(CONNECTION_BLUETOOTH, address)},
manufacturer="Brightech",
name=light.name,
name=name,
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.async_on_remove(
self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass
)
)
async def async_will_remove_from_hass(self, *args) -> None:
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
try:
await self._light.disconnect()
except pykulersky.PykulerskyException:
_LOGGER.debug(
"Exception disconnected from %s", self._light.address, exc_info=True
"Exception disconnected from %s", self._attr_unique_id, exc_info=True
)
@property
def is_on(self):
def is_on(self) -> bool | None:
"""Return true if light is on."""
return self.brightness > 0
return self.brightness is not None and self.brightness > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@ -133,11 +106,13 @@ class KulerskyLight(LightEntity):
rgbw = await self._light.get_color()
except pykulersky.PykulerskyException as exc:
if self._attr_available:
_LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc)
_LOGGER.warning(
"Unable to connect to %s: %s", self._attr_unique_id, exc
)
self._attr_available = False
return
if self._attr_available is False:
_LOGGER.warning("Reconnected to %s", self._light.address)
_LOGGER.info("Reconnected to %s", self._attr_unique_id)
self._attr_available = True
brightness = max(rgbw)

View File

@ -1,8 +1,14 @@
{
"domain": "kulersky",
"name": "Kuler Sky",
"bluetooth": [
{
"service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c"
}
],
"codeowners": ["@emlove"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kulersky",
"iot_class": "local_polling",
"loggers": ["bleak", "pykulersky"],

View File

@ -1,13 +1,23 @@
{
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
"user": {
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"exceptions": {
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
}
}
}

View File

@ -97,8 +97,7 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_ICS_FILE],
self.data[CONF_STORAGE_KEY],
)
except HomeAssistantError as err:
_LOGGER.debug("Error saving uploaded file: %s", err)
except InvalidIcsFile:
errors[CONF_ICS_FILE] = "invalid_ics_file"
else:
return self.async_create_entry(
@ -112,6 +111,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
)
class InvalidIcsFile(HomeAssistantError):
"""Error to indicate that the uploaded file is not a valid ICS file."""
def save_uploaded_ics_file(
hass: HomeAssistant, uploaded_file_id: str, storage_key: str
):
@ -122,6 +125,10 @@ def save_uploaded_ics_file(
try:
CalendarStream.from_ics(ics)
except CalendarParseError as err:
raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err
_LOGGER.error("Error reading the calendar information: %s", err.message)
_LOGGER.debug(
"Additional calendar error detail: %s", str(err.detailed_error)
)
raise InvalidIcsFile("Failed to upload file: Invalid ICS file") from err
dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key)))
shutil.move(file, dest_path)

View File

@ -17,7 +17,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"invalid_ics_file": "Invalid .ics file"
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
}
},
"selector": {

View File

@ -123,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
assets = None
try:
assets = await async_pair(self.data[CONF_HOST])
except (TimeoutError, OSError):
except (TimeoutError, OSError) as exc:
_LOGGER.debug("Pairing failed", exc_info=exc)
errors["base"] = "cannot_connect"
if not errors:

View File

@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"]
"requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"]
}

View File

@ -161,6 +161,11 @@ class MeteoFranceWeather(
"""Return the wind speed."""
return self.coordinator.data.current_forecast["wind"]["speed"]
@property
def native_wind_gust_speed(self):
"""Return the wind gust speed."""
return self.coordinator.data.current_forecast["wind"].get("gust")
@property
def wind_bearing(self):
"""Return the wind bearing."""

View File

@ -0,0 +1,70 @@
"""The Miele integration."""
from __future__ import annotations
from aiohttp import ClientError, ClientResponseError
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
"""Set up Miele from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
try:
await auth.async_get_access_token()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="config_entry_auth_failed",
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
except ClientError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
# Setup MieleAPI and coordinator for data fetch
coordinator = MieleDataUpdateCoordinator(hass, auth)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_create_background_task(
hass,
coordinator.api.listen_events(
data_callback=coordinator.callback_update_data,
actions_callback=coordinator.callback_update_actions,
),
"pymiele event listener",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

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