mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
Merge branch 'dev' into whirlpool_bronze
This commit is contained in:
commit
5567ffcb64
@ -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
2
CODEOWNERS
generated
@ -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
|
||||
|
@ -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
|
||||
|
@ -34,7 +34,7 @@
|
||||
"state": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]",
|
||||
"auto": "Auto"
|
||||
"auto": "[%key:common::state::auto%]"
|
||||
}
|
||||
},
|
||||
"modes": {
|
||||
|
@ -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": [
|
||||
|
136
homeassistant/components/backup/onboarding.py
Normal file
136
homeassistant/components/backup/onboarding.py
Normal 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)
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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]
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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()
|
||||
|
||||
|
29
homeassistant/components/comelit/entity.py
Normal file
29
homeassistant/components/comelit/entity.py
Normal 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)
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -7,5 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiocomelit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiocomelit==0.11.3"]
|
||||
}
|
||||
|
92
homeassistant/components/comelit/quality_scale.yaml
Normal file
92
homeassistant/components/comelit/quality_scale.yaml
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -45,6 +45,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"bed_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
"state": {
|
||||
"rainbow": "mdi:looks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"volume": {
|
||||
"default": "mdi:volume-high"
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
],
|
||||
|
@ -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}")
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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."""
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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:
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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)
|
||||
|
60
homeassistant/components/home_connect/repairs.py
Normal file
60
homeassistant/components/home_connect/repairs.py
Normal 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()
|
@ -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": {
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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]:
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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))
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2025.3.2"]
|
||||
"requirements": ["aioautomower==2025.4.0"]
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -3,3 +3,4 @@
|
||||
DOMAIN = "inkbird"
|
||||
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
CONF_DEVICE_DATA = "device_data"
|
||||
|
@ -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."""
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -4,3 +4,5 @@ DOMAIN = "kulersky"
|
||||
|
||||
DATA_ADDRESSES = "addresses"
|
||||
DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription"
|
||||
|
||||
EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c"
|
||||
|
@ -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)
|
||||
|
@ -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"],
|
||||
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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": {
|
||||
|
@ -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:
|
||||
|
@ -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"]
|
||||
}
|
||||
|
@ -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."""
|
||||
|
70
homeassistant/components/miele/__init__.py
Normal file
70
homeassistant/components/miele/__init__.py
Normal 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
Loading…
x
Reference in New Issue
Block a user