mirror of
https://github.com/home-assistant/core.git
synced 2025-08-06 22:25:13 +02:00
Merge branch 'dev' into subscribe_config_flow_init_remove
This commit is contained in:
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.13
|
||||
uses: github/codeql-action/init@v3.28.15
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.13
|
||||
uses: github/codeql-action/analyze@v3.28.15
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
@@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
self._api.send_key_command(key_code, direction)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
||||
def _send_launch_app_command(self, app_link: str) -> None:
|
||||
@@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
|
||||
self._api.send_launch_app_command(app_link)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
@@ -21,7 +21,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AndroidTVRemoteConfigEntry
|
||||
from .const import CONF_APP_ICON, CONF_APP_NAME
|
||||
from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
|
||||
from .entity import AndroidTVRemoteBaseEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
|
||||
await asyncio.sleep(delay_secs)
|
||||
except ConnectionClosed as exc:
|
||||
raise HomeAssistantError(
|
||||
"Connection to Android TV device is closed"
|
||||
translation_domain=DOMAIN, translation_key="connection_closed"
|
||||
) from exc
|
||||
|
@@ -54,5 +54,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_closed": {
|
||||
"message": "Connection to the Android TV device is closed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL]
|
||||
PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
|
||||
|
||||
type BoschAlarmConfigEntry = ConfigEntry[Panel]
|
||||
|
||||
|
@@ -10,11 +10,10 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -35,7 +34,7 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
||||
class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
|
||||
"""An alarm control panel entity for a bosch alarm panel."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -48,19 +47,8 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
||||
|
||||
def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
|
||||
"""Initialise a Bosch Alarm control panel entity."""
|
||||
self.panel = panel
|
||||
self._area = panel.areas[area_id]
|
||||
self._area_id = area_id
|
||||
self._attr_unique_id = f"{unique_id}_area_{area_id}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._attr_unique_id)},
|
||||
name=self._area.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
unique_id,
|
||||
),
|
||||
)
|
||||
super().__init__(panel, area_id, unique_id, False, False, True)
|
||||
self._attr_unique_id = self._area_unique_id
|
||||
|
||||
@property
|
||||
def alarm_state(self) -> AlarmControlPanelState | None:
|
||||
@@ -90,20 +78,3 @@ class AreaAlarmControlPanel(AlarmControlPanelEntity):
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.panel.area_arm_all(self._area_id)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.panel.connection_status()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity attached to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._area.status_observer.attach(self.schedule_update_ha_state)
|
||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Run when entity removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
||||
|
@@ -11,7 +11,12 @@ from typing import Any
|
||||
from bosch_alarm_mode2 import Panel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_CODE,
|
||||
CONF_HOST,
|
||||
@@ -108,6 +113,13 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
self._data = user_input
|
||||
self._data[CONF_MODEL] = model
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
if (
|
||||
self._get_reconfigure_entry().data[CONF_MODEL]
|
||||
!= self._data[CONF_MODEL]
|
||||
):
|
||||
return self.async_abort(reason="device_mismatch")
|
||||
return await self.async_step_auth()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -117,6 +129,12 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the reconfigure step."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_auth(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -154,10 +172,22 @@ class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
if serial_number:
|
||||
await self.async_set_unique_id(str(serial_number))
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
self._async_abort_entries_match({CONF_HOST: self._data[CONF_HOST]})
|
||||
return self.async_create_entry(title=f"Bosch {model}", data=self._data)
|
||||
if self.source == SOURCE_USER:
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_configured()
|
||||
else:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_HOST: self._data[CONF_HOST]}
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=f"Bosch {model}", data=self._data
|
||||
)
|
||||
if serial_number:
|
||||
self._abort_if_unique_id_mismatch(reason="device_mismatch")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reconfigure_entry(),
|
||||
data=self._data,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="auth",
|
||||
|
88
homeassistant/components/bosch_alarm/entity.py
Normal file
88
homeassistant/components/bosch_alarm/entity.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Support for Bosch Alarm Panel History as a sensor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
|
||||
from homeassistant.components.sensor import Entity
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class BoschAlarmEntity(Entity):
|
||||
"""A base entity for a bosch alarm panel."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, panel: Panel, unique_id: str) -> None:
|
||||
"""Set up a entity for a bosch alarm panel."""
|
||||
self.panel = panel
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=f"Bosch {panel.model}",
|
||||
manufacturer="Bosch Security Systems",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self.panel.connection_status()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
|
||||
|
||||
|
||||
class BoschAlarmAreaEntity(BoschAlarmEntity):
|
||||
"""A base entity for area related entities within a bosch alarm panel."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
panel: Panel,
|
||||
area_id: int,
|
||||
unique_id: str,
|
||||
observe_alarms: bool,
|
||||
observe_ready: bool,
|
||||
observe_status: bool,
|
||||
) -> None:
|
||||
"""Set up a area related entity for a bosch alarm panel."""
|
||||
super().__init__(panel, unique_id)
|
||||
self._area_id = area_id
|
||||
self._area_unique_id = f"{unique_id}_area_{area_id}"
|
||||
self._observe_alarms = observe_alarms
|
||||
self._observe_ready = observe_ready
|
||||
self._observe_status = observe_status
|
||||
self._area = panel.areas[area_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self._area_unique_id)},
|
||||
name=self._area.name,
|
||||
manufacturer="Bosch Security Systems",
|
||||
via_device=(DOMAIN, unique_id),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Observe state changes."""
|
||||
await super().async_added_to_hass()
|
||||
if self._observe_alarms:
|
||||
self._area.alarm_observer.attach(self.schedule_update_ha_state)
|
||||
if self._observe_ready:
|
||||
self._area.ready_observer.attach(self.schedule_update_ha_state)
|
||||
if self._observe_status:
|
||||
self._area.status_observer.attach(self.schedule_update_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Stop observing state changes."""
|
||||
await super().async_added_to_hass()
|
||||
if self._observe_alarms:
|
||||
self._area.alarm_observer.detach(self.schedule_update_ha_state)
|
||||
if self._observe_ready:
|
||||
self._area.ready_observer.detach(self.schedule_update_ha_state)
|
||||
if self._observe_status:
|
||||
self._area.status_observer.detach(self.schedule_update_ha_state)
|
9
homeassistant/components/bosch_alarm/icons.json
Normal file
9
homeassistant/components/bosch_alarm/icons.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"faulting_points": {
|
||||
"default": "mdi:alert-circle-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["bosch-alarm-mode2==0.4.3"]
|
||||
"requirements": ["bosch-alarm-mode2==0.4.6"]
|
||||
}
|
||||
|
@@ -62,9 +62,9 @@ rules:
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
|
86
homeassistant/components/bosch_alarm/sensor.py
Normal file
86
homeassistant/components/bosch_alarm/sensor.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Support for Bosch Alarm Panel History as a sensor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bosch_alarm_mode2 import Panel
|
||||
from bosch_alarm_mode2.panel import Area
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import BoschAlarmConfigEntry
|
||||
from .entity import BoschAlarmAreaEntity
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class BoschAlarmSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes Bosch Alarm sensor entity."""
|
||||
|
||||
value_fn: Callable[[Area], int]
|
||||
observe_alarms: bool = False
|
||||
observe_ready: bool = False
|
||||
observe_status: bool = False
|
||||
|
||||
|
||||
SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
|
||||
BoschAlarmSensorEntityDescription(
|
||||
key="faulting_points",
|
||||
translation_key="faulting_points",
|
||||
value_fn=lambda area: area.faults,
|
||||
observe_ready=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: BoschAlarmConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up bosch alarm sensors."""
|
||||
|
||||
panel = config_entry.runtime_data
|
||||
unique_id = config_entry.unique_id or config_entry.entry_id
|
||||
|
||||
async_add_entities(
|
||||
BoschAreaSensor(panel, area_id, unique_id, template)
|
||||
for area_id in panel.areas
|
||||
for template in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
|
||||
"""An area sensor entity for a bosch alarm panel."""
|
||||
|
||||
entity_description: BoschAlarmSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
panel: Panel,
|
||||
area_id: int,
|
||||
unique_id: str,
|
||||
entity_description: BoschAlarmSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Set up an area sensor entity for a bosch alarm panel."""
|
||||
super().__init__(
|
||||
panel,
|
||||
area_id,
|
||||
unique_id,
|
||||
entity_description.observe_alarms,
|
||||
entity_description.observe_ready,
|
||||
entity_description.observe_status,
|
||||
)
|
||||
self.entity_description = entity_description
|
||||
self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
"""Return the state of the sensor."""
|
||||
return self.entity_description.value_fn(self._area)
|
@@ -43,7 +43,9 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"device_mismatch": "Please ensure you reconfigure against the same device."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -53,5 +55,13 @@
|
||||
"authentication_failed": {
|
||||
"message": "Incorrect credentials for panel."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"faulting_points": {
|
||||
"name": "Faulting points",
|
||||
"unit_of_measurement": "points"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"email": "The email address associated with your Bring! account.",
|
||||
"password": "The password to login to your Bring! account."
|
||||
"password": "The password to log in to your Bring! account."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
|
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util.ssl import client_context_no_verify
|
||||
|
||||
from .const import DOMAIN, KEY_MAC, TIMEOUT
|
||||
|
||||
@@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
key=key,
|
||||
uuid=uuid,
|
||||
password=password,
|
||||
ssl_context=client_context_no_verify(),
|
||||
)
|
||||
except (TimeoutError, ClientError):
|
||||
self.host = None
|
||||
|
@@ -55,7 +55,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to create the vacation."
|
||||
"description": "ecobee thermostat on which to create the vacation."
|
||||
},
|
||||
"vacation_name": {
|
||||
"name": "Vacation name",
|
||||
@@ -101,7 +101,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to delete the vacation."
|
||||
"description": "ecobee thermostat on which to delete the vacation."
|
||||
},
|
||||
"vacation_name": {
|
||||
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
|
||||
@@ -149,7 +149,7 @@
|
||||
},
|
||||
"set_mic_mode": {
|
||||
"name": "Set mic mode",
|
||||
"description": "Enables/disables Alexa microphone (only for Ecobee 4).",
|
||||
"description": "Enables/disables Alexa microphone (only for ecobee 4).",
|
||||
"fields": {
|
||||
"mic_enabled": {
|
||||
"name": "Mic enabled",
|
||||
@@ -177,7 +177,7 @@
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Ecobee thermostat on which to set active sensors."
|
||||
"description": "ecobee thermostat on which to set active sensors."
|
||||
},
|
||||
"preset_mode": {
|
||||
"name": "Climate Name",
|
||||
@@ -203,12 +203,12 @@
|
||||
},
|
||||
"issues": {
|
||||
"migrate_aux_heat": {
|
||||
"title": "Migration of Ecobee set_aux_heat action",
|
||||
"title": "Migration of ecobee set_aux_heat action",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
|
||||
"title": "Disable legacy Ecobee set_aux_heat action"
|
||||
"description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
|
||||
"title": "Disable legacy ecobee set_aux_heat action"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -16,7 +16,13 @@ from homeassistant.config_entries import (
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_TOKEN,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
|
||||
|
||||
INSTALLER_AUTH_USERNAME = "installer"
|
||||
|
||||
AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
|
||||
|
||||
|
||||
def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Return a dictionary without AVOID_REFLECT_KEYS."""
|
||||
return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS}
|
||||
|
||||
|
||||
async def validate_input(
|
||||
hass: HomeAssistant,
|
||||
@@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders["serial"] = serial
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=self._async_generate_schema(),
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
self._async_generate_schema(),
|
||||
without_avoid_reflect_keys(user_input or reauth_entry.data),
|
||||
),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SERIAL: self.unique_id,
|
||||
CONF_HOST: host,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self._async_generate_schema(),
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
self._async_generate_schema(),
|
||||
without_avoid_reflect_keys(user_input or {}),
|
||||
),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
@@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
description_placeholders["serial"] = serial
|
||||
|
||||
suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
self._async_generate_schema(), suggested_values
|
||||
self._async_generate_schema(),
|
||||
without_avoid_reflect_keys(user_input or reconfigure_entry.data),
|
||||
),
|
||||
description_placeholders=description_placeholders,
|
||||
errors=errors,
|
||||
|
@@ -14,9 +14,10 @@ from pyfibaro.fibaro_client import (
|
||||
)
|
||||
from pyfibaro.fibaro_data_helper import read_rooms
|
||||
from pyfibaro.fibaro_device import DeviceModel
|
||||
from pyfibaro.fibaro_device_manager import FibaroDeviceManager
|
||||
from pyfibaro.fibaro_info import InfoModel
|
||||
from pyfibaro.fibaro_scene import SceneModel
|
||||
from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver
|
||||
from pyfibaro.fibaro_state_resolver import FibaroEvent
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
|
||||
@@ -81,8 +82,8 @@ class FibaroController:
|
||||
self._client = fibaro_client
|
||||
self._fibaro_info = info
|
||||
|
||||
# Whether to import devices from plugins
|
||||
self._import_plugins = import_plugins
|
||||
# The fibaro device manager exposes higher level API to access fibaro devices
|
||||
self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins)
|
||||
# Mapping roomId to room object
|
||||
self._room_map = read_rooms(fibaro_client)
|
||||
self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object
|
||||
@@ -91,79 +92,30 @@ class FibaroController:
|
||||
) # List of devices by entity platform
|
||||
# All scenes
|
||||
self._scenes = self._client.read_scenes()
|
||||
self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId
|
||||
# Event callbacks by device id
|
||||
self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {}
|
||||
# Unique serial number of the hub
|
||||
self.hub_serial = info.serial_number
|
||||
# Device infos by fibaro device id
|
||||
self._device_infos: dict[int, DeviceInfo] = {}
|
||||
self._read_devices()
|
||||
|
||||
def enable_state_handler(self) -> None:
|
||||
"""Start StateHandler thread for monitoring updates."""
|
||||
self._client.register_update_handler(self._on_state_change)
|
||||
def disconnect(self) -> None:
|
||||
"""Close push channel."""
|
||||
self._fibaro_device_manager.close()
|
||||
|
||||
def disable_state_handler(self) -> None:
|
||||
"""Stop StateHandler thread used for monitoring updates."""
|
||||
self._client.unregister_update_handler()
|
||||
|
||||
def _on_state_change(self, state: Any) -> None:
|
||||
"""Handle change report received from the HomeCenter."""
|
||||
callback_set = set()
|
||||
for change in state.get("changes", []):
|
||||
try:
|
||||
dev_id = change.pop("id")
|
||||
if dev_id not in self._device_map:
|
||||
continue
|
||||
device = self._device_map[dev_id]
|
||||
for property_name, value in change.items():
|
||||
if property_name == "log":
|
||||
if value and value != "transfer OK":
|
||||
_LOGGER.debug("LOG %s: %s", device.friendly_name, value)
|
||||
continue
|
||||
if property_name == "logTemp":
|
||||
continue
|
||||
if property_name in device.properties:
|
||||
device.properties[property_name] = value
|
||||
_LOGGER.debug(
|
||||
"<- %s.%s = %s", device.ha_id, property_name, str(value)
|
||||
)
|
||||
else:
|
||||
_LOGGER.warning("%s.%s not found", device.ha_id, property_name)
|
||||
if dev_id in self._callbacks:
|
||||
callback_set.add(dev_id)
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
for item in callback_set:
|
||||
for callback in self._callbacks[item]:
|
||||
callback()
|
||||
|
||||
resolver = FibaroStateResolver(state)
|
||||
for event in resolver.get_events():
|
||||
# event does not always have a fibaro id, therefore it is
|
||||
# essential that we first check for relevant event type
|
||||
if (
|
||||
event.event_type.lower() == "centralsceneevent"
|
||||
and event.fibaro_id in self._event_callbacks
|
||||
):
|
||||
for callback in self._event_callbacks[event.fibaro_id]:
|
||||
callback(event)
|
||||
|
||||
def register(self, device_id: int, callback: Any) -> None:
|
||||
def register(
|
||||
self, device_id: int, callback: Callable[[DeviceModel], None]
|
||||
) -> Callable[[], None]:
|
||||
"""Register device with a callback for updates."""
|
||||
device_callbacks = self._callbacks.setdefault(device_id, [])
|
||||
device_callbacks.append(callback)
|
||||
return self._fibaro_device_manager.add_change_listener(device_id, callback)
|
||||
|
||||
def register_event(
|
||||
self, device_id: int, callback: Callable[[FibaroEvent], None]
|
||||
) -> None:
|
||||
) -> Callable[[], None]:
|
||||
"""Register device with a callback for central scene events.
|
||||
|
||||
The callback receives one parameter with the event.
|
||||
"""
|
||||
device_callbacks = self._event_callbacks.setdefault(device_id, [])
|
||||
device_callbacks.append(callback)
|
||||
return self._fibaro_device_manager.add_event_listener(device_id, callback)
|
||||
|
||||
def get_children(self, device_id: int) -> list[DeviceModel]:
|
||||
"""Get a list of child devices."""
|
||||
@@ -286,7 +238,7 @@ class FibaroController:
|
||||
|
||||
def _read_devices(self) -> None:
|
||||
"""Read and process the device list."""
|
||||
devices = self._client.read_devices()
|
||||
devices = self._fibaro_device_manager.get_devices()
|
||||
self._device_map = {}
|
||||
last_climate_parent = None
|
||||
last_endpoint = None
|
||||
@@ -301,9 +253,8 @@ class FibaroController:
|
||||
device.ha_id = (
|
||||
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
|
||||
)
|
||||
platform = None
|
||||
if device.enabled and (not device.is_plugin or self._import_plugins):
|
||||
platform = self._map_device_to_platform(device)
|
||||
|
||||
platform = self._map_device_to_platform(device)
|
||||
if platform is None:
|
||||
continue
|
||||
device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}"
|
||||
@@ -393,8 +344,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
controller.enable_state_handler()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -403,8 +352,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b
|
||||
_LOGGER.debug("Shutting down Fibaro connection")
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
entry.runtime_data.disable_state_handler()
|
||||
|
||||
entry.runtime_data.disconnect()
|
||||
return unload_ok
|
||||
|
||||
|
||||
|
@@ -36,9 +36,13 @@ class FibaroEntity(Entity):
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
self.controller.register(self.fibaro_device.fibaro_id, self._update_callback)
|
||||
self.async_on_remove(
|
||||
self.controller.register(
|
||||
self.fibaro_device.fibaro_id, self._update_callback
|
||||
)
|
||||
)
|
||||
|
||||
def _update_callback(self) -> None:
|
||||
def _update_callback(self, fibaro_device: DeviceModel) -> None:
|
||||
"""Update the state."""
|
||||
self.schedule_update_ha_state(True)
|
||||
|
||||
|
@@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity):
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Register event callback
|
||||
self.controller.register_event(
|
||||
self.fibaro_device.fibaro_id, self._event_callback
|
||||
self.async_on_remove(
|
||||
self.controller.register_event(
|
||||
self.fibaro_device.fibaro_id, self._event_callback
|
||||
)
|
||||
)
|
||||
|
||||
def _event_callback(self, event: FibaroEvent) -> None:
|
||||
if event.key_id == self._button:
|
||||
if (
|
||||
event.event_type.lower() == "centralsceneevent"
|
||||
and event.key_id == self._button
|
||||
):
|
||||
self._trigger_event(event.key_event_type)
|
||||
self.schedule_update_ha_state()
|
||||
|
@@ -4,12 +4,8 @@ rules:
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: one coverage miss in line 110
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: data_description are missing
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
|
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"common": {
|
||||
"data_description_host": "The hostname or IP address of your FRITZ!Box router.",
|
||||
"data_description_port": "Leave empty to use the default port.",
|
||||
"data_description_username": "Username for the FRITZ!Box.",
|
||||
"data_description_password": "Password for the FRITZ!Box.",
|
||||
"data_description_ssl": "Use SSL to connect to the FRITZ!Box."
|
||||
},
|
||||
"config": {
|
||||
"flow_title": "{name}",
|
||||
"step": {
|
||||
@@ -9,6 +16,11 @@
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::fritz::common::data_description_username%]",
|
||||
"password": "[%key:component::fritz::common::data_description_password%]",
|
||||
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -17,6 +29,10 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::fritz::common::data_description_username%]",
|
||||
"password": "[%key:component::fritz::common::data_description_password%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
@@ -28,8 +44,9 @@
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your FRITZ!Box router.",
|
||||
"port": "Leave it empty to use the default port."
|
||||
"host": "[%key:component::fritz::common::data_description_host%]",
|
||||
"port": "[%key:component::fritz::common::data_description_port%]",
|
||||
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -43,8 +60,11 @@
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your FRITZ!Box router.",
|
||||
"port": "Leave it empty to use the default port."
|
||||
"host": "[%key:component::fritz::common::data_description_host%]",
|
||||
"port": "[%key:component::fritz::common::data_description_port%]",
|
||||
"username": "[%key:component::fritz::common::data_description_username%]",
|
||||
"password": "[%key:component::fritz::common::data_description_password%]",
|
||||
"ssl": "[%key:component::fritz::common::data_description_ssl%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -70,6 +90,10 @@
|
||||
"data": {
|
||||
"consider_home": "Seconds to consider a device at 'home'",
|
||||
"old_discovery": "Enable old discovery method"
|
||||
},
|
||||
"data_description": {
|
||||
"consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.",
|
||||
"old_discovery": "Enable old discovery method. This is needed for some scenarios."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,8 +193,12 @@
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
},
|
||||
"service_parameter_unknown": { "message": "Action or parameter unknown" },
|
||||
"service_not_supported": { "message": "Action not supported" },
|
||||
"service_parameter_unknown": {
|
||||
"message": "Action or parameter unknown"
|
||||
},
|
||||
"service_not_supported": {
|
||||
"message": "Action not supported"
|
||||
},
|
||||
"error_refresh_hosts_info": {
|
||||
"message": "Error refreshing hosts info"
|
||||
},
|
||||
|
@@ -9,8 +9,8 @@
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The email address to login to your FYTA account.",
|
||||
"password": "The password to login to your FYTA account."
|
||||
"username": "The email address to log in to your FYTA account.",
|
||||
"password": "The password to log in to your FYTA account."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
|
@@ -303,7 +303,7 @@ async def google_generative_ai_config_option_schema(
|
||||
CONF_TEMPERATURE,
|
||||
description={"suggested_value": options.get(CONF_TEMPERATURE)},
|
||||
default=RECOMMENDED_TEMPERATURE,
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
|
||||
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
|
||||
vol.Optional(
|
||||
CONF_TOP_P,
|
||||
description={"suggested_value": options.get(CONF_TOP_P)},
|
||||
|
@@ -55,6 +55,10 @@ from .const import (
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
ERROR_GETTING_RESPONSE = (
|
||||
"Sorry, I had a problem getting a response from Google Generative AI."
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -429,6 +433,12 @@ class GoogleGenerativeAIConversationEntity(
|
||||
raise HomeAssistantError(
|
||||
f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
|
||||
)
|
||||
if not chat_response.candidates:
|
||||
LOGGER.error(
|
||||
"No candidates found in the response: %s",
|
||||
chat_response,
|
||||
)
|
||||
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
|
||||
|
||||
except (
|
||||
APIError,
|
||||
@@ -452,9 +462,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
response_parts = chat_response.candidates[0].content.parts
|
||||
if not response_parts:
|
||||
raise HomeAssistantError(
|
||||
"Sorry, I had a problem getting a response from Google Generative AI."
|
||||
)
|
||||
raise HomeAssistantError(ERROR_GETTING_RESPONSE)
|
||||
content = " ".join(
|
||||
[part.text.strip() for part in response_parts if part.text]
|
||||
)
|
||||
|
@@ -182,6 +182,6 @@ async def websocket_update_addon(
|
||||
async def websocket_update_core(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Websocket handler to update an addon."""
|
||||
"""Websocket handler to update Home Assistant Core."""
|
||||
await update_core(hass, None, msg["backup"])
|
||||
connection.send_result(msg[WS_ID])
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyheos==1.0.4"],
|
||||
"requirements": ["pyheos==1.0.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||
|
@@ -71,6 +71,7 @@ BASE_SUPPORTED_FEATURES = (
|
||||
|
||||
PLAY_STATE_TO_STATE = {
|
||||
None: MediaPlayerState.IDLE,
|
||||
PlayState.UNKNOWN: MediaPlayerState.IDLE,
|
||||
PlayState.PLAY: MediaPlayerState.PLAYING,
|
||||
PlayState.STOP: MediaPlayerState.IDLE,
|
||||
PlayState.PAUSE: MediaPlayerState.PAUSED,
|
||||
|
@@ -5,37 +5,18 @@ from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, StatusKey
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import (
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
DOMAIN,
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -173,8 +154,6 @@ def _get_entities_for_appliance(
|
||||
for description in BINARY_SENSORS
|
||||
if description.key in appliance.status
|
||||
)
|
||||
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
|
||||
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
|
||||
return entities
|
||||
|
||||
|
||||
@@ -220,83 +199,3 @@ class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity)
|
||||
def available(self) -> bool:
|
||||
"""Return the availability."""
|
||||
return self.coordinator.last_update_success
|
||||
|
||||
|
||||
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
"""Binary sensor for Home Connect Generic Door."""
|
||||
|
||||
_attr_has_entity_name = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=StatusKey.BSH_COMMON_DOOR_STATE,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
boolean_map={
|
||||
BSH_DOOR_STATE_CLOSED: False,
|
||||
BSH_DOOR_STATE_LOCKED: False,
|
||||
BSH_DOOR_STATE_OPEN: True,
|
||||
},
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
|
||||
self._attr_name = f"{appliance.info.name} Door"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
automations = automations_with_entity(self.hass, self.entity_id)
|
||||
scripts = scripts_with_entity(self.hass, self.entity_id)
|
||||
items = automations + scripts
|
||||
if not items:
|
||||
return
|
||||
|
||||
entity_reg: er.EntityRegistry = er.async_get(self.hass)
|
||||
entity_automations = [
|
||||
automation_entity
|
||||
for automation_id in automations
|
||||
if (automation_entity := entity_reg.async_get(automation_id))
|
||||
]
|
||||
entity_scripts = [
|
||||
script_entity
|
||||
for script_id in scripts
|
||||
if (script_entity := entity_reg.async_get(script_id))
|
||||
]
|
||||
|
||||
items_list = [
|
||||
f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
|
||||
for item in entity_automations
|
||||
] + [
|
||||
f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
|
||||
for item in entity_scripts
|
||||
]
|
||||
|
||||
async_create_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
f"deprecated_binary_common_door_sensor_{self.entity_id}",
|
||||
breaks_in_ha_version="2025.5.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_binary_common_door_sensor",
|
||||
translation_placeholders={
|
||||
"entity": self.entity_id,
|
||||
"items": "\n".join(items_list),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
|
||||
)
|
||||
|
@@ -132,17 +132,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_binary_common_door_sensor": {
|
||||
"title": "Deprecated binary door sensor detected in some automations or scripts",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::deprecated_binary_common_door_sensor::title%]",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"fix_flow": {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"config": {
|
||||
"flow_title": "Homee {name} ({host})",
|
||||
"flow_title": "homee {name} ({host})",
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
@@ -18,9 +18,9 @@
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The IP address of your Homee.",
|
||||
"username": "The username for your Homee.",
|
||||
"password": "The password for your Homee."
|
||||
"host": "The IP address of your homee.",
|
||||
"username": "The username for your homee.",
|
||||
"password": "The password for your homee."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
"load_alarm": {
|
||||
"name": "Load",
|
||||
"state": {
|
||||
"off": "Normal",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"on": "Overload"
|
||||
}
|
||||
},
|
||||
@@ -352,7 +352,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"connection_closed": {
|
||||
"message": "Could not connect to Homee while setting attribute."
|
||||
"message": "Could not connect to homee while setting attribute."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import (
|
||||
async_validate_trigger_config,
|
||||
)
|
||||
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
|
||||
from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView
|
||||
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
|
||||
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
|
||||
@@ -49,6 +50,7 @@ from homeassistant.const import (
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_PORT,
|
||||
CONF_TYPE,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
@@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
|
||||
from . import ( # noqa: F401
|
||||
type_air_purifiers,
|
||||
type_cameras,
|
||||
type_covers,
|
||||
type_fans,
|
||||
@@ -113,6 +116,8 @@ from .const import (
|
||||
CONF_LINKED_DOORBELL_SENSOR,
|
||||
CONF_LINKED_HUMIDITY_SENSOR,
|
||||
CONF_LINKED_MOTION_SENSOR,
|
||||
CONF_LINKED_PM25_SENSOR,
|
||||
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||
CONFIG_OPTIONS,
|
||||
DEFAULT_EXCLUDE_ACCESSORY_MODE,
|
||||
DEFAULT_HOMEKIT_MODE,
|
||||
@@ -126,6 +131,7 @@ from .const import (
|
||||
SERVICE_HOMEKIT_UNPAIR,
|
||||
SHUTDOWN_TIMEOUT,
|
||||
SIGNAL_RELOAD_ENTITIES,
|
||||
TYPE_AIR_PURIFIER,
|
||||
)
|
||||
from .iidmanager import AccessoryIIDStorage
|
||||
from .models import HomeKitConfigEntry, HomeKitEntryData
|
||||
@@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION)
|
||||
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
|
||||
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
|
||||
HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)
|
||||
TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE)
|
||||
PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25)
|
||||
|
||||
|
||||
def _has_all_unique_names_and_ports(
|
||||
@@ -1136,6 +1144,21 @@ class HomeKit:
|
||||
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
|
||||
)
|
||||
|
||||
if domain == FAN_DOMAIN:
|
||||
if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR):
|
||||
config[entity_id].setdefault(
|
||||
CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id
|
||||
)
|
||||
if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR):
|
||||
config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER)
|
||||
config[entity_id].setdefault(
|
||||
CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id
|
||||
)
|
||||
if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR):
|
||||
config[entity_id].setdefault(
|
||||
CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id
|
||||
)
|
||||
|
||||
if domain == HUMIDIFIER_DOMAIN and (
|
||||
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
|
||||
):
|
||||
|
@@ -85,6 +85,8 @@ from .const import (
|
||||
SERV_ACCESSORY_INFO,
|
||||
SERV_BATTERY_SERVICE,
|
||||
SIGNAL_RELOAD_ENTITIES,
|
||||
TYPE_AIR_PURIFIER,
|
||||
TYPE_FAN,
|
||||
TYPE_FAUCET,
|
||||
TYPE_OUTLET,
|
||||
TYPE_SHOWER,
|
||||
@@ -112,6 +114,10 @@ SWITCH_TYPES = {
|
||||
TYPE_SWITCH: "Switch",
|
||||
TYPE_VALVE: "ValveSwitch",
|
||||
}
|
||||
FAN_TYPES = {
|
||||
TYPE_AIR_PURIFIER: "AirPurifier",
|
||||
TYPE_FAN: "Fan",
|
||||
}
|
||||
TYPES: Registry[str, type[HomeAccessory]] = Registry()
|
||||
|
||||
RELOAD_ON_CHANGE_ATTRS = (
|
||||
@@ -178,7 +184,10 @@ def get_accessory( # noqa: C901
|
||||
a_type = "WindowCovering"
|
||||
|
||||
elif state.domain == "fan":
|
||||
a_type = "Fan"
|
||||
if fan_type := config.get(CONF_TYPE):
|
||||
a_type = FAN_TYPES[fan_type]
|
||||
else:
|
||||
a_type = "Fan"
|
||||
|
||||
elif state.domain == "humidifier":
|
||||
a_type = "HumidifierDehumidifier"
|
||||
|
@@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode"
|
||||
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
|
||||
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
|
||||
CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor"
|
||||
CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor"
|
||||
CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor"
|
||||
CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor"
|
||||
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
|
||||
CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
|
||||
CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
|
||||
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
|
||||
CONF_MAX_FPS = "max_fps"
|
||||
CONF_MAX_HEIGHT = "max_height"
|
||||
@@ -120,12 +124,15 @@ TYPE_SHOWER = "shower"
|
||||
TYPE_SPRINKLER = "sprinkler"
|
||||
TYPE_SWITCH = "switch"
|
||||
TYPE_VALVE = "valve"
|
||||
TYPE_FAN = "fan"
|
||||
TYPE_AIR_PURIFIER = "air_purifier"
|
||||
|
||||
# #### Categories ####
|
||||
CATEGORY_RECEIVER = 34
|
||||
|
||||
# #### Services ####
|
||||
SERV_ACCESSORY_INFO = "AccessoryInformation"
|
||||
SERV_AIR_PURIFIER = "AirPurifier"
|
||||
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
|
||||
SERV_BATTERY_SERVICE = "BatteryService"
|
||||
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
|
||||
@@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor"
|
||||
SERV_DOOR = "Door"
|
||||
SERV_DOORBELL = "Doorbell"
|
||||
SERV_FANV2 = "Fanv2"
|
||||
SERV_FILTER_MAINTENANCE = "FilterMaintenance"
|
||||
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
|
||||
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
|
||||
SERV_HUMIDITY_SENSOR = "HumiditySensor"
|
||||
@@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName"
|
||||
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
|
||||
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
|
||||
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
|
||||
CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
|
||||
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
|
||||
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
|
||||
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
|
||||
@@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
|
||||
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
|
||||
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
|
||||
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
|
||||
CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication"
|
||||
CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel"
|
||||
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
|
||||
CHAR_HARDWARE_REVISION = "HardwareRevision"
|
||||
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
|
||||
@@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected"
|
||||
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
|
||||
CHAR_STREAMING_STRATUS = "StreamingStatus"
|
||||
CHAR_SWING_MODE = "SwingMode"
|
||||
CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
|
||||
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
|
||||
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
|
||||
CHAR_TARGET_POSITION = "TargetPosition"
|
||||
@@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues"
|
||||
# #### Thresholds ####
|
||||
THRESHOLD_CO = 25
|
||||
THRESHOLD_CO2 = 1000
|
||||
THRESHOLD_FILTER_CHANGE_NEEDED = 10
|
||||
|
||||
# #### Default values ####
|
||||
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C
|
||||
|
469
homeassistant/components/homekit/type_air_purifiers.py
Normal file
469
homeassistant/components/homekit/type_air_purifiers.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""Class to hold all air purifier accessories."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyhap.characteristic import Characteristic
|
||||
from pyhap.const import CATEGORY_AIR_PURIFIER
|
||||
from pyhap.service import Service
|
||||
from pyhap.util import callback as pyhap_callback
|
||||
|
||||
from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
EventStateChangedData,
|
||||
HassJobType,
|
||||
State,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .accessories import TYPES
|
||||
from .const import (
|
||||
CHAR_ACTIVE,
|
||||
CHAR_AIR_QUALITY,
|
||||
CHAR_CURRENT_AIR_PURIFIER_STATE,
|
||||
CHAR_CURRENT_HUMIDITY,
|
||||
CHAR_CURRENT_TEMPERATURE,
|
||||
CHAR_FILTER_CHANGE_INDICATION,
|
||||
CHAR_FILTER_LIFE_LEVEL,
|
||||
CHAR_NAME,
|
||||
CHAR_PM25_DENSITY,
|
||||
CHAR_TARGET_AIR_PURIFIER_STATE,
|
||||
CONF_LINKED_FILTER_CHANGE_INDICATION,
|
||||
CONF_LINKED_FILTER_LIFE_LEVEL,
|
||||
CONF_LINKED_HUMIDITY_SENSOR,
|
||||
CONF_LINKED_PM25_SENSOR,
|
||||
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||
SERV_AIR_PURIFIER,
|
||||
SERV_AIR_QUALITY_SENSOR,
|
||||
SERV_FILTER_MAINTENANCE,
|
||||
SERV_HUMIDITY_SENSOR,
|
||||
SERV_TEMPERATURE_SENSOR,
|
||||
THRESHOLD_FILTER_CHANGE_NEEDED,
|
||||
)
|
||||
from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan
|
||||
from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CURRENT_STATE_INACTIVE = 0
|
||||
CURRENT_STATE_IDLE = 1
|
||||
CURRENT_STATE_PURIFYING_AIR = 2
|
||||
TARGET_STATE_MANUAL = 0
|
||||
TARGET_STATE_AUTO = 1
|
||||
FILTER_CHANGE_FILTER = 1
|
||||
FILTER_OK = 0
|
||||
|
||||
IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
|
||||
|
||||
|
||||
@TYPES.register("AirPurifier")
|
||||
class AirPurifier(Fan):
|
||||
"""Generate an AirPurifier accessory for an air purifier entity.
|
||||
|
||||
Currently supports, in addition to Fan properties:
|
||||
temperature; humidity; PM2.5; auto mode.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any) -> None:
|
||||
"""Initialize a new AirPurifier accessory object."""
|
||||
super().__init__(*args, category=CATEGORY_AIR_PURIFIER)
|
||||
|
||||
self.auto_preset: str | None = None
|
||||
if self.preset_modes is not None:
|
||||
for preset in self.preset_modes:
|
||||
if str(preset).lower() == "auto":
|
||||
self.auto_preset = preset
|
||||
break
|
||||
|
||||
def create_services(self) -> Service:
|
||||
"""Create and configure the primary service for this accessory."""
|
||||
self.chars.append(CHAR_ACTIVE)
|
||||
self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE)
|
||||
self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE)
|
||||
serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars)
|
||||
self.set_primary_service(serv_air_purifier)
|
||||
|
||||
self.char_active: Characteristic = serv_air_purifier.configure_char(
|
||||
CHAR_ACTIVE, value=0
|
||||
)
|
||||
|
||||
self.preset_mode_chars: dict[str, Characteristic]
|
||||
self.char_current_humidity: Characteristic | None = None
|
||||
self.char_pm25_density: Characteristic | None = None
|
||||
self.char_current_temperature: Characteristic | None = None
|
||||
self.char_filter_change_indication: Characteristic | None = None
|
||||
self.char_filter_life_level: Characteristic | None = None
|
||||
|
||||
self.char_target_air_purifier_state: Characteristic = (
|
||||
serv_air_purifier.configure_char(
|
||||
CHAR_TARGET_AIR_PURIFIER_STATE,
|
||||
value=0,
|
||||
)
|
||||
)
|
||||
|
||||
self.char_current_air_purifier_state: Characteristic = (
|
||||
serv_air_purifier.configure_char(
|
||||
CHAR_CURRENT_AIR_PURIFIER_STATE,
|
||||
value=0,
|
||||
)
|
||||
)
|
||||
|
||||
self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR)
|
||||
if self.linked_humidity_sensor:
|
||||
humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME)
|
||||
serv_air_purifier.add_linked_service(humidity_serv)
|
||||
self.char_current_humidity = humidity_serv.configure_char(
|
||||
CHAR_CURRENT_HUMIDITY, value=0
|
||||
)
|
||||
|
||||
humidity_state = self.hass.states.get(self.linked_humidity_sensor)
|
||||
if humidity_state:
|
||||
self._async_update_current_humidity(humidity_state)
|
||||
|
||||
self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR)
|
||||
if self.linked_pm25_sensor:
|
||||
pm25_serv = self.add_preload_service(
|
||||
SERV_AIR_QUALITY_SENSOR,
|
||||
[CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY],
|
||||
)
|
||||
serv_air_purifier.add_linked_service(pm25_serv)
|
||||
self.char_pm25_density = pm25_serv.configure_char(
|
||||
CHAR_PM25_DENSITY, value=0
|
||||
)
|
||||
|
||||
self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY)
|
||||
|
||||
pm25_state = self.hass.states.get(self.linked_pm25_sensor)
|
||||
if pm25_state:
|
||||
self._async_update_current_pm25(pm25_state)
|
||||
|
||||
self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR)
|
||||
if self.linked_temperature_sensor:
|
||||
temperature_serv = self.add_preload_service(
|
||||
SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE]
|
||||
)
|
||||
serv_air_purifier.add_linked_service(temperature_serv)
|
||||
self.char_current_temperature = temperature_serv.configure_char(
|
||||
CHAR_CURRENT_TEMPERATURE, value=0
|
||||
)
|
||||
|
||||
temperature_state = self.hass.states.get(self.linked_temperature_sensor)
|
||||
if temperature_state:
|
||||
self._async_update_current_temperature(temperature_state)
|
||||
|
||||
self.linked_filter_change_indicator_binary_sensor = self.config.get(
|
||||
CONF_LINKED_FILTER_CHANGE_INDICATION
|
||||
)
|
||||
self.linked_filter_life_level_sensor = self.config.get(
|
||||
CONF_LINKED_FILTER_LIFE_LEVEL
|
||||
)
|
||||
if (
|
||||
self.linked_filter_change_indicator_binary_sensor
|
||||
or self.linked_filter_life_level_sensor
|
||||
):
|
||||
chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION]
|
||||
if self.linked_filter_life_level_sensor:
|
||||
chars.append(CHAR_FILTER_LIFE_LEVEL)
|
||||
serv_filter_maintenance = self.add_preload_service(
|
||||
SERV_FILTER_MAINTENANCE, chars
|
||||
)
|
||||
serv_air_purifier.add_linked_service(serv_filter_maintenance)
|
||||
serv_filter_maintenance.configure_char(
|
||||
CHAR_NAME,
|
||||
value=cleanup_name_for_homekit(f"{self.display_name} Filter"),
|
||||
)
|
||||
|
||||
self.char_filter_change_indication = serv_filter_maintenance.configure_char(
|
||||
CHAR_FILTER_CHANGE_INDICATION,
|
||||
value=0,
|
||||
)
|
||||
|
||||
if self.linked_filter_change_indicator_binary_sensor:
|
||||
filter_change_indicator_state = self.hass.states.get(
|
||||
self.linked_filter_change_indicator_binary_sensor
|
||||
)
|
||||
if filter_change_indicator_state:
|
||||
self._async_update_filter_change_indicator(
|
||||
filter_change_indicator_state
|
||||
)
|
||||
|
||||
if self.linked_filter_life_level_sensor:
|
||||
self.char_filter_life_level = serv_filter_maintenance.configure_char(
|
||||
CHAR_FILTER_LIFE_LEVEL,
|
||||
value=0,
|
||||
)
|
||||
|
||||
filter_life_level_state = self.hass.states.get(
|
||||
self.linked_filter_life_level_sensor
|
||||
)
|
||||
if filter_life_level_state:
|
||||
self._async_update_filter_life_level(filter_life_level_state)
|
||||
|
||||
return serv_air_purifier
|
||||
|
||||
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
|
||||
"""Check if a preset mode switch should be added."""
|
||||
return preset_mode.lower() != "auto"
|
||||
|
||||
@callback
|
||||
@pyhap_callback # type: ignore[misc]
|
||||
def run(self) -> None:
|
||||
"""Handle accessory driver started event.
|
||||
|
||||
Run inside the Home Assistant event loop.
|
||||
"""
|
||||
if self.linked_humidity_sensor:
|
||||
self._subscriptions.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.linked_humidity_sensor],
|
||||
self._async_update_current_humidity_event,
|
||||
job_type=HassJobType.Callback,
|
||||
)
|
||||
)
|
||||
|
||||
if self.linked_pm25_sensor:
|
||||
self._subscriptions.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.linked_pm25_sensor],
|
||||
self._async_update_current_pm25_event,
|
||||
job_type=HassJobType.Callback,
|
||||
)
|
||||
)
|
||||
|
||||
if self.linked_temperature_sensor:
|
||||
self._subscriptions.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.linked_temperature_sensor],
|
||||
self._async_update_current_temperature_event,
|
||||
job_type=HassJobType.Callback,
|
||||
)
|
||||
)
|
||||
|
||||
if self.linked_filter_change_indicator_binary_sensor:
|
||||
self._subscriptions.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.linked_filter_change_indicator_binary_sensor],
|
||||
self._async_update_filter_change_indicator_event,
|
||||
job_type=HassJobType.Callback,
|
||||
)
|
||||
)
|
||||
|
||||
if self.linked_filter_life_level_sensor:
|
||||
self._subscriptions.append(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[self.linked_filter_life_level_sensor],
|
||||
self._async_update_filter_life_level_event,
|
||||
job_type=HassJobType.Callback,
|
||||
)
|
||||
)
|
||||
|
||||
super().run()
|
||||
|
||||
@callback
|
||||
def _async_update_current_humidity_event(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle state change event listener callback."""
|
||||
self._async_update_current_humidity(event.data["new_state"])
|
||||
|
||||
@callback
|
||||
def _async_update_current_humidity(self, new_state: State | None) -> None:
|
||||
"""Handle linked humidity sensor state change to update HomeKit value."""
|
||||
if new_state is None or new_state.state in IGNORED_STATES:
|
||||
return
|
||||
|
||||
if (
|
||||
(current_humidity := convert_to_float(new_state.state)) is None
|
||||
or not self.char_current_humidity
|
||||
or self.char_current_humidity.value == current_humidity
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linked humidity sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_humidity_sensor,
|
||||
current_humidity,
|
||||
)
|
||||
self.char_current_humidity.set_value(current_humidity)
|
||||
|
||||
@callback
|
||||
def _async_update_current_pm25_event(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle state change event listener callback."""
|
||||
self._async_update_current_pm25(event.data["new_state"])
|
||||
|
||||
@callback
|
||||
def _async_update_current_pm25(self, new_state: State | None) -> None:
|
||||
"""Handle linked pm25 sensor state change to update HomeKit value."""
|
||||
if new_state is None or new_state.state in IGNORED_STATES:
|
||||
return
|
||||
|
||||
if (
|
||||
(current_pm25 := convert_to_float(new_state.state)) is None
|
||||
or not self.char_pm25_density
|
||||
or self.char_pm25_density.value == current_pm25
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linked pm25 sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_pm25_sensor,
|
||||
current_pm25,
|
||||
)
|
||||
self.char_pm25_density.set_value(current_pm25)
|
||||
air_quality = density_to_air_quality(current_pm25)
|
||||
self.char_air_quality.set_value(air_quality)
|
||||
_LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality)
|
||||
|
||||
@callback
|
||||
def _async_update_current_temperature_event(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle state change event listener callback."""
|
||||
self._async_update_current_temperature(event.data["new_state"])
|
||||
|
||||
@callback
|
||||
def _async_update_current_temperature(self, new_state: State | None) -> None:
|
||||
"""Handle linked temperature sensor state change to update HomeKit value."""
|
||||
if new_state is None or new_state.state in IGNORED_STATES:
|
||||
return
|
||||
|
||||
if (
|
||||
(current_temperature := convert_to_float(new_state.state)) is None
|
||||
or not self.char_current_temperature
|
||||
or self.char_current_temperature.value == current_temperature
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linked temperature sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_temperature_sensor,
|
||||
current_temperature,
|
||||
)
|
||||
self.char_current_temperature.set_value(current_temperature)
|
||||
|
||||
@callback
|
||||
def _async_update_filter_change_indicator_event(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle state change event listener callback."""
|
||||
self._async_update_filter_change_indicator(event.data.get("new_state"))
|
||||
|
||||
@callback
|
||||
def _async_update_filter_change_indicator(self, new_state: State | None) -> None:
|
||||
"""Handle linked filter change indicator binary sensor state change to update HomeKit value."""
|
||||
if new_state is None or new_state.state in IGNORED_STATES:
|
||||
return
|
||||
|
||||
current_change_indicator = (
|
||||
FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK
|
||||
)
|
||||
if (
|
||||
not self.char_filter_change_indication
|
||||
or self.char_filter_change_indication.value == current_change_indicator
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linked filter change indicator binary sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_filter_change_indicator_binary_sensor,
|
||||
current_change_indicator,
|
||||
)
|
||||
self.char_filter_change_indication.set_value(current_change_indicator)
|
||||
|
||||
@callback
|
||||
def _async_update_filter_life_level_event(
|
||||
self, event: Event[EventStateChangedData]
|
||||
) -> None:
|
||||
"""Handle state change event listener callback."""
|
||||
self._async_update_filter_life_level(event.data.get("new_state"))
|
||||
|
||||
@callback
|
||||
def _async_update_filter_life_level(self, new_state: State | None) -> None:
|
||||
"""Handle linked filter life level sensor state change to update HomeKit value."""
|
||||
if new_state is None or new_state.state in IGNORED_STATES:
|
||||
return
|
||||
|
||||
if (
|
||||
(current_life_level := convert_to_float(new_state.state)) is not None
|
||||
and self.char_filter_life_level
|
||||
and self.char_filter_life_level.value != current_life_level
|
||||
):
|
||||
_LOGGER.debug(
|
||||
"%s: Linked filter life level sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_filter_life_level_sensor,
|
||||
current_life_level,
|
||||
)
|
||||
self.char_filter_life_level.set_value(current_life_level)
|
||||
|
||||
if self.linked_filter_change_indicator_binary_sensor or not current_life_level:
|
||||
# Handled by its own event listener
|
||||
return
|
||||
|
||||
current_change_indicator = (
|
||||
FILTER_CHANGE_FILTER
|
||||
if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED)
|
||||
else FILTER_OK
|
||||
)
|
||||
if (
|
||||
not self.char_filter_change_indication
|
||||
or self.char_filter_change_indication.value == current_change_indicator
|
||||
):
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"%s: Linked filter life level sensor %s changed to %d",
|
||||
self.entity_id,
|
||||
self.linked_filter_life_level_sensor,
|
||||
current_change_indicator,
|
||||
)
|
||||
self.char_filter_change_indication.set_value(current_change_indicator)
|
||||
|
||||
@callback
|
||||
def async_update_state(self, new_state: State) -> None:
|
||||
"""Update fan after state change."""
|
||||
super().async_update_state(new_state)
|
||||
# Handle State
|
||||
state = new_state.state
|
||||
|
||||
if self.char_current_air_purifier_state is not None:
|
||||
self.char_current_air_purifier_state.set_value(
|
||||
CURRENT_STATE_PURIFYING_AIR
|
||||
if state == STATE_ON
|
||||
else CURRENT_STATE_INACTIVE
|
||||
)
|
||||
|
||||
# Automatic mode is represented in HASS by a preset called Auto or auto
|
||||
attributes = new_state.attributes
|
||||
if ATTR_PRESET_MODE in attributes:
|
||||
current_preset_mode = attributes.get(ATTR_PRESET_MODE)
|
||||
self.char_target_air_purifier_state.set_value(
|
||||
TARGET_STATE_AUTO
|
||||
if current_preset_mode and current_preset_mode.lower() == "auto"
|
||||
else TARGET_STATE_MANUAL
|
||||
)
|
||||
|
||||
def set_chars(self, char_values: dict[str, Any]) -> None:
|
||||
"""Handle automatic mode after state change."""
|
||||
super().set_chars(char_values)
|
||||
if (
|
||||
CHAR_TARGET_AIR_PURIFIER_STATE in char_values
|
||||
and self.auto_preset is not None
|
||||
):
|
||||
if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO:
|
||||
super().set_preset_mode(True, self.auto_preset)
|
||||
elif self.char_speed is not None:
|
||||
super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()})
|
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from pyhap.const import CATEGORY_FAN
|
||||
from pyhap.service import Service
|
||||
|
||||
from homeassistant.components.fan import (
|
||||
ATTR_DIRECTION,
|
||||
@@ -56,9 +57,9 @@ class Fan(HomeAccessory):
|
||||
Currently supports: state, speed, oscillate, direction.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any) -> None:
|
||||
def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None:
|
||||
"""Initialize a new Fan accessory object."""
|
||||
super().__init__(*args, category=CATEGORY_FAN)
|
||||
super().__init__(*args, category=category)
|
||||
self.chars: list[str] = []
|
||||
state = self.hass.states.get(self.entity_id)
|
||||
assert state
|
||||
@@ -79,12 +80,8 @@ class Fan(HomeAccessory):
|
||||
self.chars.append(CHAR_SWING_MODE)
|
||||
if features & FanEntityFeature.SET_SPEED:
|
||||
self.chars.append(CHAR_ROTATION_SPEED)
|
||||
if self.preset_modes and len(self.preset_modes) == 1:
|
||||
self.chars.append(CHAR_TARGET_FAN_STATE)
|
||||
|
||||
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
|
||||
self.set_primary_service(serv_fan)
|
||||
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
|
||||
serv_fan = self.create_services()
|
||||
|
||||
self.char_direction = None
|
||||
self.char_speed = None
|
||||
@@ -107,13 +104,21 @@ class Fan(HomeAccessory):
|
||||
properties={PROP_MIN_STEP: percentage_step},
|
||||
)
|
||||
|
||||
if self.preset_modes and len(self.preset_modes) == 1:
|
||||
if (
|
||||
self.preset_modes
|
||||
and len(self.preset_modes) == 1
|
||||
# NOTE: This would be missing for air purifiers
|
||||
and CHAR_TARGET_FAN_STATE in self.chars
|
||||
):
|
||||
self.char_target_fan_state = serv_fan.configure_char(
|
||||
CHAR_TARGET_FAN_STATE,
|
||||
value=0,
|
||||
)
|
||||
elif self.preset_modes:
|
||||
for preset_mode in self.preset_modes:
|
||||
if not self.should_add_preset_mode_switch(preset_mode):
|
||||
continue
|
||||
|
||||
preset_serv = self.add_preload_service(
|
||||
SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
|
||||
)
|
||||
@@ -126,7 +131,7 @@ class Fan(HomeAccessory):
|
||||
)
|
||||
|
||||
def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
|
||||
return self.set_preset_mode(value, preset_mode)
|
||||
self.set_preset_mode(value, preset_mode)
|
||||
|
||||
self.preset_mode_chars[preset_mode] = preset_serv.configure_char(
|
||||
CHAR_ON,
|
||||
@@ -137,10 +142,27 @@ class Fan(HomeAccessory):
|
||||
if CHAR_SWING_MODE in self.chars:
|
||||
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
|
||||
self.async_update_state(state)
|
||||
serv_fan.setter_callback = self._set_chars
|
||||
serv_fan.setter_callback = self.set_chars
|
||||
|
||||
def _set_chars(self, char_values: dict[str, Any]) -> None:
|
||||
_LOGGER.debug("Fan _set_chars: %s", char_values)
|
||||
def create_services(self) -> Service:
|
||||
"""Create and configure the primary service for this accessory."""
|
||||
if self.preset_modes and len(self.preset_modes) == 1:
|
||||
self.chars.append(CHAR_TARGET_FAN_STATE)
|
||||
serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
|
||||
self.set_primary_service(serv_fan)
|
||||
self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
|
||||
return serv_fan
|
||||
|
||||
def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
|
||||
"""Check if a preset mode switch should be added.
|
||||
|
||||
Always true for fans, but can be overridden by subclasses.
|
||||
"""
|
||||
return True
|
||||
|
||||
def set_chars(self, char_values: dict[str, Any]) -> None:
|
||||
"""Set characteristic values."""
|
||||
_LOGGER.debug("Fan set_chars: %s", char_values)
|
||||
if CHAR_ACTIVE in char_values:
|
||||
if char_values[CHAR_ACTIVE]:
|
||||
# If the device supports set speed we
|
||||
|
@@ -62,9 +62,13 @@ from .const import (
|
||||
CONF_LINKED_BATTERY_CHARGING_SENSOR,
|
||||
CONF_LINKED_BATTERY_SENSOR,
|
||||
CONF_LINKED_DOORBELL_SENSOR,
|
||||
CONF_LINKED_FILTER_CHANGE_INDICATION,
|
||||
CONF_LINKED_FILTER_LIFE_LEVEL,
|
||||
CONF_LINKED_HUMIDITY_SENSOR,
|
||||
CONF_LINKED_MOTION_SENSOR,
|
||||
CONF_LINKED_OBSTRUCTION_SENSOR,
|
||||
CONF_LINKED_PM25_SENSOR,
|
||||
CONF_LINKED_TEMPERATURE_SENSOR,
|
||||
CONF_LOW_BATTERY_THRESHOLD,
|
||||
CONF_MAX_FPS,
|
||||
CONF_MAX_HEIGHT,
|
||||
@@ -98,6 +102,8 @@ from .const import (
|
||||
FEATURE_PLAY_STOP,
|
||||
FEATURE_TOGGLE_MUTE,
|
||||
MAX_NAME_LENGTH,
|
||||
TYPE_AIR_PURIFIER,
|
||||
TYPE_FAN,
|
||||
TYPE_FAUCET,
|
||||
TYPE_OUTLET,
|
||||
TYPE_SHOWER,
|
||||
@@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
|
||||
)
|
||||
|
||||
FAN_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All(
|
||||
cv.string,
|
||||
vol.In(
|
||||
(
|
||||
TYPE_FAN,
|
||||
TYPE_AIR_PURIFIER,
|
||||
)
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN),
|
||||
vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain(
|
||||
binary_sensor.DOMAIN
|
||||
),
|
||||
vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN),
|
||||
}
|
||||
)
|
||||
|
||||
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
|
||||
@@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
|
||||
elif domain == "cover":
|
||||
config = COVER_SCHEMA(config)
|
||||
|
||||
elif domain == "fan":
|
||||
config = FAN_SCHEMA(config)
|
||||
|
||||
elif domain == "sensor":
|
||||
config = SENSOR_SCHEMA(config)
|
||||
|
||||
|
@@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
NOTIFY_SCHEMA = vol.Any(
|
||||
None,
|
||||
|
@@ -62,7 +62,7 @@
|
||||
"mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"home": "[%key:common::state::home%]",
|
||||
"away": "[%key:common::state::not_home%]",
|
||||
"auto": "Auto",
|
||||
|
@@ -136,6 +136,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
# Process new device
|
||||
new_devices = current_devices - self._devices_last_update
|
||||
if new_devices:
|
||||
self.data = data
|
||||
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
|
||||
self._add_new_devices(new_devices)
|
||||
|
||||
|
@@ -2,106 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData
|
||||
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth.active_update_processor import (
|
||||
ActiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, DOMAIN
|
||||
from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
|
||||
|
||||
|
||||
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
|
||||
"""Coordinator for INKBIRD Bluetooth devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
data: INKBIRDBluetoothDeviceData,
|
||||
) -> None:
|
||||
"""Initialize the INKBIRD Bluetooth processor coordinator."""
|
||||
self._data = data
|
||||
self._entry = entry
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=self._async_on_update,
|
||||
needs_poll_method=self._async_needs_poll,
|
||||
poll_method=self._async_poll_data,
|
||||
)
|
||||
|
||||
async def _async_poll_data(
|
||||
self, last_service_info: BluetoothServiceInfoBleak
|
||||
) -> SensorUpdate:
|
||||
"""Poll the device."""
|
||||
return await self._data.async_poll(last_service_info.device)
|
||||
|
||||
@callback
|
||||
def _async_needs_poll(
|
||||
self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
|
||||
) -> bool:
|
||||
return (
|
||||
not self.hass.is_stopping
|
||||
and self._data.poll_needed(service_info, last_poll)
|
||||
and bool(
|
||||
async_ble_device_from_address(
|
||||
self.hass, service_info.device.address, connectable=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
|
||||
"""Handle update callback from the passive BLE processor."""
|
||||
update = self._data.update(service_info)
|
||||
if (
|
||||
self._entry.data.get(CONF_DEVICE_TYPE) is None
|
||||
and self._data.device_type is not None
|
||||
):
|
||||
device_type_str = str(self._data.device_type)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._entry,
|
||||
data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
|
||||
)
|
||||
return update
|
||||
|
||||
@callback
|
||||
def _async_schedule_poll(self, _: datetime) -> None:
|
||||
"""Schedule a poll of the device."""
|
||||
if self._last_service_info and self._async_needs_poll(
|
||||
self._last_service_info, self._last_poll
|
||||
):
|
||||
self._debounced_poll.async_schedule_call()
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up INKBIRD BLE device from a config entry."""
|
||||
|
100
homeassistant/components/inkbird/coordinator.py
Normal file
100
homeassistant/components/inkbird/coordinator.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""The INKBIRD Bluetooth integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_ble_device_from_address,
|
||||
)
|
||||
from homeassistant.components.bluetooth.active_update_processor import (
|
||||
ActiveBluetoothProcessorCoordinator,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import CONF_DEVICE_TYPE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
|
||||
|
||||
|
||||
class INKBIRDActiveBluetoothProcessorCoordinator(ActiveBluetoothProcessorCoordinator):
|
||||
"""Coordinator for INKBIRD Bluetooth devices."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
data: INKBIRDBluetoothDeviceData,
|
||||
) -> None:
|
||||
"""Initialize the INKBIRD Bluetooth processor coordinator."""
|
||||
self._data = data
|
||||
self._entry = entry
|
||||
address = entry.unique_id
|
||||
assert address is not None
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
|
||||
)
|
||||
)
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
logger=_LOGGER,
|
||||
address=address,
|
||||
mode=BluetoothScanningMode.ACTIVE,
|
||||
update_method=self._async_on_update,
|
||||
needs_poll_method=self._async_needs_poll,
|
||||
poll_method=self._async_poll_data,
|
||||
)
|
||||
|
||||
async def _async_poll_data(
|
||||
self, last_service_info: BluetoothServiceInfoBleak
|
||||
) -> SensorUpdate:
|
||||
"""Poll the device."""
|
||||
return await self._data.async_poll(last_service_info.device)
|
||||
|
||||
@callback
|
||||
def _async_needs_poll(
|
||||
self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
|
||||
) -> bool:
|
||||
return (
|
||||
not self.hass.is_stopping
|
||||
and self._data.poll_needed(service_info, last_poll)
|
||||
and bool(
|
||||
async_ble_device_from_address(
|
||||
self.hass, service_info.device.address, connectable=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
|
||||
"""Handle update callback from the passive BLE processor."""
|
||||
update = self._data.update(service_info)
|
||||
if (
|
||||
self._entry.data.get(CONF_DEVICE_TYPE) is None
|
||||
and self._data.device_type is not None
|
||||
):
|
||||
device_type_str = str(self._data.device_type)
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self._entry,
|
||||
data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
|
||||
)
|
||||
return update
|
||||
|
||||
@callback
|
||||
def _async_schedule_poll(self, _: datetime) -> None:
|
||||
"""Schedule a poll of the device."""
|
||||
if self._last_service_info and self._async_needs_poll(
|
||||
self._last_service_info, self._last_poll
|
||||
):
|
||||
self._debounced_poll.async_schedule_call()
|
@@ -35,5 +35,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.6"]
|
||||
"requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.7"]
|
||||
}
|
||||
|
@@ -199,7 +199,7 @@ turn_on:
|
||||
example: "[255, 100, 100]"
|
||||
selector:
|
||||
color_rgb:
|
||||
kelvin: &kelvin
|
||||
color_temp_kelvin: &color_temp_kelvin
|
||||
filter: *color_temp_support
|
||||
selector:
|
||||
color_temp:
|
||||
@@ -316,7 +316,7 @@ toggle:
|
||||
fields:
|
||||
transition: *transition
|
||||
rgb_color: *rgb_color
|
||||
kelvin: *kelvin
|
||||
color_temp_kelvin: *color_temp_kelvin
|
||||
brightness_pct: *brightness_pct
|
||||
effect: *effect
|
||||
advanced_fields:
|
||||
|
@@ -19,8 +19,8 @@
|
||||
"field_flash_name": "Flash",
|
||||
"field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.",
|
||||
"field_hs_color_name": "Hue/Sat color",
|
||||
"field_kelvin_description": "Color temperature in Kelvin.",
|
||||
"field_kelvin_name": "Color temperature",
|
||||
"field_color_temp_kelvin_description": "Color temperature in Kelvin.",
|
||||
"field_color_temp_kelvin_name": "Color temperature",
|
||||
"field_profile_description": "Name of a light profile to use.",
|
||||
"field_profile_name": "Profile",
|
||||
"field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.",
|
||||
@@ -328,9 +328,9 @@
|
||||
"name": "[%key:component::light::common::field_color_temp_name%]",
|
||||
"description": "[%key:component::light::common::field_color_temp_description%]"
|
||||
},
|
||||
"kelvin": {
|
||||
"name": "[%key:component::light::common::field_kelvin_name%]",
|
||||
"description": "[%key:component::light::common::field_kelvin_description%]"
|
||||
"color_temp_kelvin": {
|
||||
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
|
||||
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
|
||||
},
|
||||
"brightness": {
|
||||
"name": "[%key:component::light::common::field_brightness_name%]",
|
||||
@@ -426,9 +426,9 @@
|
||||
"name": "[%key:component::light::common::field_color_temp_name%]",
|
||||
"description": "[%key:component::light::common::field_color_temp_description%]"
|
||||
},
|
||||
"kelvin": {
|
||||
"name": "[%key:component::light::common::field_kelvin_name%]",
|
||||
"description": "[%key:component::light::common::field_kelvin_description%]"
|
||||
"color_temp_kelvin": {
|
||||
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
|
||||
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
|
||||
},
|
||||
"brightness": {
|
||||
"name": "[%key:component::light::common::field_brightness_name%]",
|
||||
|
@@ -46,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
|
||||
super().__init__(mm, config_entry)
|
||||
self._attr_unique_id = f"{self._base_unique_id}-preset"
|
||||
self._presets: list[motionmount.Preset] = []
|
||||
self._attr_current_option = None
|
||||
|
||||
def _update_options(self, presets: list[motionmount.Preset]) -> None:
|
||||
"""Convert presets to select options."""
|
||||
|
@@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
|
||||
|
||||
def validate_config(config: ConfigType) -> ConfigType:
|
||||
"""Validate that the configuration is valid, throws if it isn't."""
|
||||
if config[CONF_MIN] >= config[CONF_MAX]:
|
||||
raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'")
|
||||
if config[CONF_MIN] > config[CONF_MAX]:
|
||||
raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}")
|
||||
|
||||
return config
|
||||
|
||||
|
@@ -43,8 +43,8 @@
|
||||
"data_description": {
|
||||
"broker": "The hostname or IP address of your MQTT broker.",
|
||||
"port": "The port your MQTT broker listens to. For example 1883.",
|
||||
"username": "The username to login to your MQTT broker.",
|
||||
"password": "The password to login to your MQTT broker.",
|
||||
"username": "The username to log in to your MQTT broker.",
|
||||
"password": "The password to log in to your MQTT broker.",
|
||||
"advanced_options": "Enable and select **Next** to set advanced options.",
|
||||
"certificate": "The custom CA certificate file to validate your MQTT brokers certificate.",
|
||||
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
|
||||
|
@@ -151,6 +151,9 @@ async def async_setup_entry(
|
||||
assert event.object_id is not None
|
||||
if event.object_id in added_ids:
|
||||
return
|
||||
player = mass.players.get(event.object_id)
|
||||
if TYPE_CHECKING:
|
||||
assert player is not None
|
||||
if not player.expose_to_ha:
|
||||
return
|
||||
added_ids.add(event.object_id)
|
||||
|
@@ -241,10 +241,10 @@
|
||||
"name": "Reachability"
|
||||
},
|
||||
"rf_strength": {
|
||||
"name": "Radio"
|
||||
"name": "RF strength"
|
||||
},
|
||||
"wifi_strength": {
|
||||
"name": "Wi-Fi"
|
||||
"name": "Wi-Fi strength"
|
||||
},
|
||||
"health_idx": {
|
||||
"name": "Health index",
|
||||
|
@@ -12,5 +12,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nexia",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nexia"],
|
||||
"requirements": ["nexia==2.4.0"]
|
||||
"requirements": ["nexia==2.7.0"]
|
||||
}
|
||||
|
@@ -0,0 +1,84 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require polling.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Be more specific in the config flow with catching exceptions.
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: todo
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: No options to configure
|
||||
docs-installation-parameters:
|
||||
status: exempt
|
||||
comment: No options to configure
|
||||
entity-unavailable: todo
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require authentication.
|
||||
test-coverage: todo
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require a websession.
|
||||
strict-typing: todo
|
@@ -323,7 +323,7 @@ class NumberDeviceClass(StrEnum):
|
||||
REACTIVE_POWER = "reactive_power"
|
||||
"""Reactive power.
|
||||
|
||||
Unit of measurement: `var`
|
||||
Unit of measurement: `var`, `kvar`
|
||||
"""
|
||||
|
||||
SIGNAL_STRENGTH = "signal_strength"
|
||||
@@ -497,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
|
||||
NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
|
||||
NumberDeviceClass.PRESSURE: set(UnitOfPressure),
|
||||
NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
|
||||
NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
|
||||
NumberDeviceClass.SIGNAL_STRENGTH: {
|
||||
SIGNAL_STRENGTH_DECIBELS,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
|
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Unable to login to Ohme"
|
||||
"message": "Unable to log in to Ohme"
|
||||
},
|
||||
"device_info_failed": {
|
||||
"message": "Unable to get Ohme device information"
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/opower",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"requirements": ["opower==0.10.0"]
|
||||
"requirements": ["opower==0.11.1"]
|
||||
}
|
||||
|
@@ -63,15 +63,24 @@ class PterodactylAPI:
|
||||
self.pterodactyl = None
|
||||
self.identifiers = []
|
||||
|
||||
def get_game_servers(self) -> list[str]:
|
||||
"""Get all game servers."""
|
||||
paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr]
|
||||
|
||||
return paginated_response.collect()
|
||||
|
||||
async def async_init(self):
|
||||
"""Initialize the Pterodactyl API."""
|
||||
self.pterodactyl = PterodactylClient(self.host, self.api_key)
|
||||
|
||||
try:
|
||||
paginated_response = await self.hass.async_add_executor_job(
|
||||
self.pterodactyl.client.servers.list_servers
|
||||
)
|
||||
except (BadRequestError, PterodactylApiError, ConnectionError) as error:
|
||||
game_servers = await self.hass.async_add_executor_job(self.get_game_servers)
|
||||
except (
|
||||
BadRequestError,
|
||||
PterodactylApiError,
|
||||
ConnectionError,
|
||||
StopIteration,
|
||||
) as error:
|
||||
raise PterodactylConnectionError(error) from error
|
||||
except HTTPError as error:
|
||||
if error.response.status_code == 401:
|
||||
@@ -79,7 +88,6 @@ class PterodactylAPI:
|
||||
|
||||
raise PterodactylConnectionError(error) from error
|
||||
else:
|
||||
game_servers = paginated_response.collect()
|
||||
for game_server in game_servers:
|
||||
self.identifiers.append(game_server["attributes"]["identifier"])
|
||||
|
||||
|
@@ -17,8 +17,8 @@
|
||||
"port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.",
|
||||
"use_https": "Use an HTTPS (SSL) connection to the Reolink device.",
|
||||
"baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.",
|
||||
"username": "Username to login to the Reolink device itself. Not the Reolink cloud account.",
|
||||
"password": "Password to login to the Reolink device itself. Not the Reolink cloud account."
|
||||
"username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.",
|
||||
"password": "Password to log in to the Reolink device itself. Not the Reolink cloud account."
|
||||
}
|
||||
},
|
||||
"privacy": {
|
||||
@@ -33,7 +33,7 @@
|
||||
"not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"",
|
||||
"password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
|
||||
"update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
|
||||
"webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}"
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -36,11 +36,11 @@
|
||||
"fan_speed": {
|
||||
"state": {
|
||||
"default": "Default",
|
||||
"normal": "Normal",
|
||||
"silent": "Silent",
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"intensive": "Intensive",
|
||||
"silent": "Silent",
|
||||
"super_silent": "Super silent",
|
||||
"high": "High",
|
||||
"auto": "Auto"
|
||||
}
|
||||
}
|
||||
|
@@ -352,7 +352,7 @@ class SensorDeviceClass(StrEnum):
|
||||
REACTIVE_POWER = "reactive_power"
|
||||
"""Reactive power.
|
||||
|
||||
Unit of measurement: `var`
|
||||
Unit of measurement: `var`, `kvar`
|
||||
"""
|
||||
|
||||
SIGNAL_STRENGTH = "signal_strength"
|
||||
@@ -596,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
|
||||
SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
|
||||
SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
|
||||
SensorDeviceClass.PRESSURE: set(UnitOfPressure),
|
||||
SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
|
||||
SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
|
||||
SensorDeviceClass.SIGNAL_STRENGTH: {
|
||||
SIGNAL_STRENGTH_DECIBELS,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
|
@@ -12,6 +12,7 @@ from aioshelly.exceptions import (
|
||||
CustomPortNotSupported,
|
||||
DeviceConnectionError,
|
||||
InvalidAuthError,
|
||||
InvalidHostError,
|
||||
MacAddressMismatchError,
|
||||
)
|
||||
from aioshelly.rpc_device import RpcDevice
|
||||
@@ -157,6 +158,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.info = await self._async_get_info(host, port)
|
||||
except DeviceConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidHostError:
|
||||
errors["base"] = "invalid_host"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
@@ -277,3 +277,7 @@ ROLE_TO_DEVICE_CLASS_MAP = {
|
||||
"current_humidity": SensorDeviceClass.HUMIDITY,
|
||||
"current_temperature": SensorDeviceClass.TEMPERATURE,
|
||||
}
|
||||
|
||||
# We want to check only the first 5 KB of the script if it contains emitEvent()
|
||||
# so that the integration startup remains fast.
|
||||
MAX_SCRIPT_SIZE = 5120
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"requirements": ["aioshelly==13.4.0"],
|
||||
"requirements": ["aioshelly==13.4.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@@ -51,6 +51,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
|
||||
"custom_port_not_supported": "Gen1 device does not support custom port.",
|
||||
|
@@ -58,6 +58,7 @@ from .const import (
|
||||
GEN2_BETA_RELEASE_URL,
|
||||
GEN2_RELEASE_URL,
|
||||
LOGGER,
|
||||
MAX_SCRIPT_SIZE,
|
||||
RPC_INPUTS_EVENTS_TYPES,
|
||||
SHAIR_MAX_WORK_HOURS,
|
||||
SHBTN_INPUTS_EVENTS_TYPES,
|
||||
@@ -642,7 +643,7 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None:
|
||||
|
||||
async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]:
|
||||
"""Return a list of event types for a specific script."""
|
||||
code_response = await device.script_getcode(id)
|
||||
code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE)
|
||||
matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"])
|
||||
return sorted([*{str(event_type.group(1)) for event_type in matches}])
|
||||
|
||||
|
@@ -10,7 +10,9 @@ import pysma
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_SSL,
|
||||
@@ -19,6 +21,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
serial_number=sma_device_info["serial"],
|
||||
)
|
||||
|
||||
# Add the MAC address to connections, if it comes via DHCP
|
||||
if CONF_MAC in entry.data:
|
||||
device_info[ATTR_CONNECTIONS] = {
|
||||
(dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])
|
||||
}
|
||||
|
||||
# Define the coordinator
|
||||
async def async_update_data():
|
||||
"""Update the used SMA sensors."""
|
||||
|
@@ -7,26 +7,43 @@ from typing import Any
|
||||
|
||||
import pysma
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_MAC,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import CONF_GROUP, DOMAIN, GROUPS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async def validate_input(
|
||||
hass: HomeAssistant,
|
||||
user_input: dict[str, Any],
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect."""
|
||||
session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL])
|
||||
session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL])
|
||||
|
||||
protocol = "https" if data[CONF_SSL] else "http"
|
||||
url = f"{protocol}://{data[CONF_HOST]}"
|
||||
protocol = "https" if user_input[CONF_SSL] else "http"
|
||||
host = data[CONF_HOST] if data is not None else user_input[CONF_HOST]
|
||||
url = URL.build(scheme=protocol, host=host)
|
||||
|
||||
sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP])
|
||||
sma = pysma.SMA(
|
||||
session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP]
|
||||
)
|
||||
|
||||
# new_session raises SmaAuthenticationException on failure
|
||||
await sma.new_session()
|
||||
@@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_GROUP: GROUPS[0],
|
||||
CONF_PASSWORD: vol.UNDEFINED,
|
||||
}
|
||||
self._discovery_data: dict[str, Any] = {}
|
||||
|
||||
async def _handle_user_input(
|
||||
self, user_input: dict[str, Any], discovery: bool = False
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
"""Handle the user input."""
|
||||
errors: dict[str, str] = {}
|
||||
device_info: dict[str, str] = {}
|
||||
|
||||
if not discovery:
|
||||
self._data[CONF_HOST] = user_input[CONF_HOST]
|
||||
|
||||
self._data[CONF_SSL] = user_input[CONF_SSL]
|
||||
self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
|
||||
self._data[CONF_GROUP] = user_input[CONF_GROUP]
|
||||
self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
|
||||
|
||||
try:
|
||||
device_info = await validate_input(
|
||||
self.hass, user_input=user_input, data=self._data
|
||||
)
|
||||
except pysma.exceptions.SmaConnectionException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except pysma.exceptions.SmaAuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except pysma.exceptions.SmaReadException:
|
||||
errors["base"] = "cannot_retrieve_device_info"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
|
||||
return errors, device_info
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""First step in config flow."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
self._data[CONF_HOST] = user_input[CONF_HOST]
|
||||
self._data[CONF_SSL] = user_input[CONF_SSL]
|
||||
self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
|
||||
self._data[CONF_GROUP] = user_input[CONF_GROUP]
|
||||
self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
|
||||
|
||||
try:
|
||||
device_info = await validate_input(self.hass, user_input)
|
||||
except pysma.exceptions.SmaConnectionException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except pysma.exceptions.SmaAuthenticationException:
|
||||
errors["base"] = "invalid_auth"
|
||||
except pysma.exceptions.SmaReadException:
|
||||
errors["base"] = "cannot_retrieve_device_info"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
errors, device_info = await self._handle_user_input(user_input=user_input)
|
||||
|
||||
if not errors:
|
||||
await self.async_set_unique_id(str(device_info["serial"]))
|
||||
await self.async_set_unique_id(
|
||||
str(device_info["serial"]), raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=self._data)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self._data[CONF_HOST], data=self._data
|
||||
)
|
||||
@@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_dhcp(
|
||||
self, discovery_info: DhcpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle DHCP discovery."""
|
||||
self._discovery_data[CONF_HOST] = discovery_info.ip
|
||||
self._discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress)
|
||||
self._discovery_data[CONF_NAME] = discovery_info.hostname
|
||||
self._data[CONF_HOST] = discovery_info.ip
|
||||
self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC])
|
||||
|
||||
await self.async_set_unique_id(discovery_info.hostname.replace("SMA", ""))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
errors, device_info = await self._handle_user_input(
|
||||
user_input=user_input, discovery=True
|
||||
)
|
||||
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=self._data[CONF_HOST], data=self._data
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean,
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL]
|
||||
): cv.boolean,
|
||||
vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In(
|
||||
GROUPS
|
||||
),
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
@@ -3,6 +3,13 @@
|
||||
"name": "SMA Solar",
|
||||
"codeowners": ["@kellerza", "@rklomp", "@erwindouna"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "sma*",
|
||||
"macaddress": "0015BB*"
|
||||
},
|
||||
{ "registered_devices": true }
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/sma",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pysma"],
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Login",
|
||||
"description": "Enter your SmartTub email address and password to login",
|
||||
"description": "Enter your SmartTub email address and password to log in",
|
||||
"data": {
|
||||
"email": "[%key:common::config_flow::data::email%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
|
@@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
raise ConfigEntryAuthFailed from err
|
||||
|
||||
except SmlightConnectionError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_device",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
@abstractmethod
|
||||
async def _internal_update_data(self) -> _DataT:
|
||||
|
@@ -11,6 +11,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smlight",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pysmlight==0.2.4"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
85
homeassistant/components/smlight/quality_scale.yaml
Normal file
85
homeassistant/components/smlight/quality_scale.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
Entities subscribe to SSE events from pysmlight library.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: done
|
||||
comment: Handled implicitly within coordinator
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide an option flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Handled by coordinator
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Device type integration.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
@@ -15,6 +15,10 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "Username for the device's web login.",
|
||||
"password": "Password for the device's web login."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -23,6 +27,10 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::smlight::config::step::auth::data_description::username%]",
|
||||
"password": "[%key:component::smlight::config::step::auth::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"confirm_discovery": {
|
||||
@@ -137,6 +145,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"firmware_update_failed": {
|
||||
"message": "Firmware update failed for {device_name}."
|
||||
},
|
||||
"cannot_connect_device": {
|
||||
"message": "An error occurred while connecting to the SMLIGHT device: {error}."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"unsupported_firmware": {
|
||||
"title": "SLZB core firmware update required",
|
||||
|
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData
|
||||
from .entity import SmEntity
|
||||
|
||||
@@ -210,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity):
|
||||
def _update_failed(self, event: MessageEvent) -> None:
|
||||
self._update_done()
|
||||
self.coordinator.in_progress = False
|
||||
raise HomeAssistantError(f"Update failed for {self.name}")
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="firmware_update_failed",
|
||||
translation_placeholders={
|
||||
"device_name": str(self.name),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
|
@@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = {
|
||||
SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH],
|
||||
SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
|
||||
SupportedModels.REMOTE.value: [Platform.SENSOR],
|
||||
SupportedModels.ROLLER_SHADE.value: [
|
||||
Platform.COVER,
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.SENSOR,
|
||||
],
|
||||
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
|
||||
}
|
||||
CLASS_BY_DEVICE = {
|
||||
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
|
||||
@@ -80,6 +86,7 @@ CLASS_BY_DEVICE = {
|
||||
SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt,
|
||||
SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
|
||||
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
|
||||
}
|
||||
|
||||
|
||||
|
@@ -35,6 +35,8 @@ class SupportedModels(StrEnum):
|
||||
RELAY_SWITCH_1 = "relay_switch_1"
|
||||
LEAK = "leak"
|
||||
REMOTE = "remote"
|
||||
ROLLER_SHADE = "roller_shade"
|
||||
HUBMINI_MATTER = "hubmini_matter"
|
||||
|
||||
|
||||
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.HUB2: SupportedModels.HUB2,
|
||||
SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM,
|
||||
SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1,
|
||||
SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE,
|
||||
}
|
||||
|
||||
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
@@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
|
||||
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
|
||||
SwitchbotModel.LEAK: SupportedModels.LEAK,
|
||||
SwitchbotModel.REMOTE: SupportedModels.REMOTE,
|
||||
SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
|
||||
}
|
||||
|
||||
SUPPORTED_MODEL_TYPES = (
|
||||
|
@@ -37,6 +37,8 @@ async def async_setup_entry(
|
||||
coordinator = entry.runtime_data
|
||||
if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt):
|
||||
async_add_entities([SwitchBotBlindTiltEntity(coordinator)])
|
||||
elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade):
|
||||
async_add_entities([SwitchBotRollerShadeEntity(coordinator)])
|
||||
else:
|
||||
async_add_entities([SwitchBotCurtainEntity(coordinator)])
|
||||
|
||||
@@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
|
||||
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
|
||||
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
|
||||
"""Representation of a Switchbot."""
|
||||
|
||||
_device: switchbot.SwitchbotRollerShade
|
||||
_attr_device_class = CoverDeviceClass.SHADE
|
||||
_attr_supported_features = (
|
||||
CoverEntityFeature.OPEN
|
||||
| CoverEntityFeature.CLOSE
|
||||
| CoverEntityFeature.STOP
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
_attr_translation_key = "cover"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
|
||||
"""Initialize the switchbot."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_is_closed = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
last_state = await self.async_get_last_state()
|
||||
if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes:
|
||||
return
|
||||
|
||||
self._attr_current_cover_position = last_state.attributes.get(
|
||||
ATTR_CURRENT_POSITION
|
||||
)
|
||||
self._last_run_success = last_state.attributes.get("last_run_success")
|
||||
if self._attr_current_cover_position is not None:
|
||||
self._attr_is_closed = self._attr_current_cover_position <= 20
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Open the roller shade."""
|
||||
|
||||
_LOGGER.debug("Switchbot to open roller shade %s", self._address)
|
||||
self._last_run_success = bool(await self._device.open())
|
||||
self._attr_is_opening = self._device.is_opening()
|
||||
self._attr_is_closing = self._device.is_closing()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Close the roller shade."""
|
||||
|
||||
_LOGGER.debug("Switchbot to close roller shade %s", self._address)
|
||||
self._last_run_success = bool(await self._device.close())
|
||||
self._attr_is_opening = self._device.is_opening()
|
||||
self._attr_is_closing = self._device.is_closing()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||
"""Stop the moving of roller shade."""
|
||||
|
||||
_LOGGER.debug("Switchbot to stop roller shade %s", self._address)
|
||||
self._last_run_success = bool(await self._device.stop())
|
||||
self._attr_is_opening = self._device.is_opening()
|
||||
self._attr_is_closing = self._device.is_closing()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||
"""Move the cover to a specific position."""
|
||||
|
||||
position = kwargs.get(ATTR_POSITION)
|
||||
_LOGGER.debug("Switchbot to move at %d %s", position, self._address)
|
||||
self._last_run_success = bool(await self._device.set_position(position))
|
||||
self._attr_is_opening = self._device.is_opening()
|
||||
self._attr_is_closing = self._device.is_closing()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._attr_is_closing = self._device.is_closing()
|
||||
self._attr_is_opening = self._device.is_opening()
|
||||
self._attr_current_cover_position = self.parsed_data["position"]
|
||||
self._attr_is_closed = self.parsed_data["position"] <= 20
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
@@ -141,7 +141,7 @@
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"off": "Normal",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"keep": "Keep mode",
|
||||
"dog": "Dog mode",
|
||||
"camp": "Camp mode"
|
||||
|
@@ -221,7 +221,7 @@
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"off": "Normal",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"keep": "Keep mode",
|
||||
"dog": "Dog mode",
|
||||
"camp": "Camp mode"
|
||||
|
@@ -48,7 +48,7 @@
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"off": "Normal",
|
||||
"off": "[%key:common::state::normal%]",
|
||||
"on": "Keep mode",
|
||||
"dog": "Dog mode",
|
||||
"camp": "Camp mode"
|
||||
|
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Traccar Server",
|
||||
"username": "The username (email) you use to login to your Traccar Server"
|
||||
"username": "The username (email) you use to log in to your Traccar Server"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -321,9 +321,9 @@
|
||||
"vacuum_cistern": {
|
||||
"name": "Water tank adjustment",
|
||||
"state": {
|
||||
"low": "Low",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"middle": "Middle",
|
||||
"high": "High",
|
||||
"high": "[%key:common::state::high%]",
|
||||
"closed": "[%key:common::state::closed%]"
|
||||
}
|
||||
},
|
||||
|
@@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity):
|
||||
|
||||
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
_attr_translation_key = "ventilation"
|
||||
_attributes: dict[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity):
|
||||
self._attr_supported_features |= FanEntityFeature.SET_SPEED
|
||||
|
||||
# evaluate quickmodes
|
||||
quickmodes: list[str] = (
|
||||
self._attributes["vicare_quickmodes"] = quickmodes = list[str](
|
||||
device.getVentilationQuickmodes()
|
||||
if is_supported(
|
||||
"getVentilationQuickmodes",
|
||||
@@ -196,26 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity):
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the entity is on."""
|
||||
if (
|
||||
self._attr_supported_features & FanEntityFeature.TURN_OFF
|
||||
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
|
||||
):
|
||||
if VentilationQuickmode.STANDBY in self._attributes[
|
||||
"vicare_quickmodes"
|
||||
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
|
||||
return False
|
||||
|
||||
return self.percentage is not None and self.percentage > 0
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
|
||||
self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Return the icon to use in the frontend."""
|
||||
if (
|
||||
self._attr_supported_features & FanEntityFeature.TURN_OFF
|
||||
and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY)
|
||||
):
|
||||
if VentilationQuickmode.STANDBY in self._attributes[
|
||||
"vicare_quickmodes"
|
||||
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
|
||||
return "mdi:fan-off"
|
||||
if hasattr(self, "_attr_preset_mode"):
|
||||
if self._attr_preset_mode == VentilationMode.VENTILATION:
|
||||
@@ -242,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity):
|
||||
"""Set the speed of the fan, as a percentage."""
|
||||
if self._attr_preset_mode != str(VentilationMode.PERMANENT):
|
||||
self.set_preset_mode(VentilationMode.PERMANENT)
|
||||
elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
|
||||
elif VentilationQuickmode.STANDBY in self._attributes[
|
||||
"vicare_quickmodes"
|
||||
] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
|
||||
self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
|
||||
|
||||
level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
|
||||
@@ -254,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity):
|
||||
target_mode = VentilationMode.to_vicare_mode(preset_mode)
|
||||
_LOGGER.debug("changing ventilation mode to %s", target_mode)
|
||||
self._api.activateVentilationMode(target_mode)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Show Device Attributes."""
|
||||
return self._attributes
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/weheat",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["weheat==2025.2.26"]
|
||||
"requirements": ["weheat==2025.3.7"]
|
||||
}
|
||||
|
@@ -2,13 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ENTITY_ID_FORMAT,
|
||||
FAN_AUTO,
|
||||
FAN_HIGH,
|
||||
FAN_LOW,
|
||||
@@ -22,15 +20,10 @@ from homeassistant.components.climate import (
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import WhirlpoolConfigEntry
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from .entity import WhirlpoolEntity
|
||||
|
||||
AIRCON_MODE_MAP = {
|
||||
AirconMode.Cool: HVACMode.COOL,
|
||||
@@ -71,14 +64,13 @@ async def async_setup_entry(
|
||||
"""Set up entry."""
|
||||
appliances_manager = config_entry.runtime_data
|
||||
aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons]
|
||||
async_add_entities(aircons, True)
|
||||
async_add_entities(aircons)
|
||||
|
||||
|
||||
class AirConEntity(ClimateEntity):
|
||||
class AirConEntity(WhirlpoolEntity, ClimateEntity):
|
||||
"""Representation of an air conditioner."""
|
||||
|
||||
_attr_fan_modes = SUPPORTED_FAN_MODES
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_hvac_modes = SUPPORTED_HVAC_MODES
|
||||
_attr_max_temp = SUPPORTED_MAX_TEMP
|
||||
@@ -97,29 +89,8 @@ class AirConEntity(ClimateEntity):
|
||||
|
||||
def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(aircon)
|
||||
self._aircon = aircon
|
||||
self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, aircon.said, hass=hass)
|
||||
self._attr_unique_id = aircon.said
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, aircon.said)},
|
||||
name=aircon.name if aircon.name is not None else aircon.said,
|
||||
manufacturer="Whirlpool",
|
||||
model="Sixth Sense",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register updates callback."""
|
||||
self._aircon.register_attr_callback(self.async_write_ha_state)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unregister updates callback."""
|
||||
self._aircon.unregister_attr_callback(self.async_write_ha_state)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return self._aircon.get_online()
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
|
@@ -19,8 +19,9 @@ class WhirlpoolEntity(Entity):
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, appliance.said)},
|
||||
name=appliance.name.capitalize(),
|
||||
name=appliance.name.capitalize() if appliance.name else appliance.said,
|
||||
manufacturer="Whirlpool",
|
||||
model_id=appliance.appliance_info.model_number,
|
||||
)
|
||||
self._attr_unique_id = f"{appliance.said}{unique_id_suffix}"
|
||||
|
||||
|
@@ -26,6 +26,7 @@ from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
@@ -79,10 +80,19 @@ def add_province_and_language_to_schema(
|
||||
}
|
||||
|
||||
if provinces := all_countries.get(country):
|
||||
if _country.subdivisions_aliases and (
|
||||
subdiv_aliases := _country.get_subdivision_aliases()
|
||||
):
|
||||
province_options: list[Any] = [
|
||||
SelectOptionDict(value=k, label=", ".join(v))
|
||||
for k, v in subdiv_aliases.items()
|
||||
]
|
||||
else:
|
||||
province_options = provinces
|
||||
province_schema = {
|
||||
vol.Optional(CONF_PROVINCE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=provinces,
|
||||
options=province_options,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_PROVINCE,
|
||||
)
|
||||
|
@@ -61,8 +61,8 @@
|
||||
"power_failure_alarm": {
|
||||
"name": "Power failure alarm",
|
||||
"state": {
|
||||
"normal": "Normal",
|
||||
"alert": "Alert",
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"off": "[%key:common::state::off%]"
|
||||
}
|
||||
},
|
||||
|
@@ -514,6 +514,7 @@ class ZHAGatewayProxy(EventBase):
|
||||
self._log_queue_handler.listener = logging.handlers.QueueListener(
|
||||
log_simple_queue, log_relay_handler
|
||||
)
|
||||
self._log_queue_handler_count: int = 0
|
||||
|
||||
self._unsubs: list[Callable[[], None]] = []
|
||||
self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol))
|
||||
@@ -747,7 +748,10 @@ class ZHAGatewayProxy(EventBase):
|
||||
if filterer:
|
||||
self._log_queue_handler.addFilter(filterer)
|
||||
|
||||
if self._log_queue_handler.listener:
|
||||
# Only start a new log queue handler if the old one is no longer running
|
||||
self._log_queue_handler_count += 1
|
||||
|
||||
if self._log_queue_handler.listener and self._log_queue_handler_count == 1:
|
||||
self._log_queue_handler.listener.start()
|
||||
|
||||
for logger_name in DEBUG_RELAY_LOGGERS:
|
||||
@@ -763,7 +767,10 @@ class ZHAGatewayProxy(EventBase):
|
||||
for logger_name in DEBUG_RELAY_LOGGERS:
|
||||
logging.getLogger(logger_name).removeHandler(self._log_queue_handler)
|
||||
|
||||
if self._log_queue_handler.listener:
|
||||
# Only stop the log queue handler if nothing else is using it
|
||||
self._log_queue_handler_count -= 1
|
||||
|
||||
if self._log_queue_handler.listener and self._log_queue_handler_count == 0:
|
||||
self._log_queue_handler.listener.stop()
|
||||
|
||||
if filterer:
|
||||
|
@@ -420,17 +420,20 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
dev_path = discovery_info.device
|
||||
self.usb_path = dev_path
|
||||
self._title = usb.human_readable_device_name(
|
||||
dev_path,
|
||||
serial_number,
|
||||
manufacturer,
|
||||
description,
|
||||
vid,
|
||||
pid,
|
||||
)
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_NAME: self._title.split(" - ")[0].strip()
|
||||
}
|
||||
if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2":
|
||||
title = "Home Assistant Connect ZWA-2"
|
||||
else:
|
||||
human_name = usb.human_readable_device_name(
|
||||
dev_path,
|
||||
serial_number,
|
||||
manufacturer,
|
||||
description,
|
||||
vid,
|
||||
pid,
|
||||
)
|
||||
title = human_name.split(" - ")[0].strip()
|
||||
self.context["title_placeholders"] = {CONF_NAME: title}
|
||||
self._title = title
|
||||
return await self.async_step_usb_confirm()
|
||||
|
||||
async def async_step_usb_confirm(
|
||||
|
@@ -21,6 +21,13 @@
|
||||
"pid": "8A2A",
|
||||
"description": "*z-wave*",
|
||||
"known_devices": ["Nortek HUSBZB-1"]
|
||||
},
|
||||
{
|
||||
"vid": "303A",
|
||||
"pid": "4001",
|
||||
"description": "*nabu casa zwa-2*",
|
||||
"manufacturer": "nabu casa",
|
||||
"known_devices": ["Nabu Casa Connect ZWA-2"]
|
||||
}
|
||||
],
|
||||
"zeroconf": ["_zwave-js-server._tcp.local."]
|
||||
|
@@ -72,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment
|
||||
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
|
||||
from .loader import async_suggest_report_issue
|
||||
from .setup import (
|
||||
DATA_SETUP_DONE,
|
||||
SetupPhases,
|
||||
async_pause_setup,
|
||||
async_process_deps_reqs,
|
||||
async_setup_component,
|
||||
async_start_setup,
|
||||
async_wait_component,
|
||||
)
|
||||
from .util import ulid as ulid_util
|
||||
from .util.async_ import create_eager_task
|
||||
@@ -1511,6 +1511,22 @@ class ConfigEntriesFlowManager(
|
||||
future.set_result(None)
|
||||
self._discovery_event_debouncer.async_shutdown()
|
||||
|
||||
@callback
|
||||
def async_flow_removed(
|
||||
self,
|
||||
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
|
||||
) -> None:
|
||||
"""Handle a removed config flow."""
|
||||
flow = cast(ConfigFlow, flow)
|
||||
|
||||
# Clean up issue if this is a reauth flow
|
||||
if flow.context["source"] == SOURCE_REAUTH:
|
||||
if (entry_id := flow.context.get("entry_id")) is not None and (
|
||||
entry := self.config_entries.async_get_entry(entry_id)
|
||||
) is not None:
|
||||
issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
|
||||
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
|
||||
|
||||
async def async_finish_flow(
|
||||
self,
|
||||
flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
|
||||
@@ -1523,20 +1539,6 @@ class ConfigEntriesFlowManager(
|
||||
"""
|
||||
flow = cast(ConfigFlow, flow)
|
||||
|
||||
# Mark the step as done.
|
||||
# We do this to avoid a circular dependency where async_finish_flow sets up a
|
||||
# new entry, which needs the integration to be set up, which is waiting for
|
||||
# init to be done.
|
||||
self._set_pending_import_done(flow)
|
||||
|
||||
# Clean up issue if this is a reauth flow
|
||||
if flow.context["source"] == SOURCE_REAUTH:
|
||||
if (entry_id := flow.context.get("entry_id")) is not None and (
|
||||
entry := self.config_entries.async_get_entry(entry_id)
|
||||
) is not None:
|
||||
issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
|
||||
ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
|
||||
|
||||
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
|
||||
# If there's a config entry with a matching unique ID,
|
||||
# update the discovery key.
|
||||
@@ -1575,6 +1577,12 @@ class ConfigEntriesFlowManager(
|
||||
)
|
||||
return result
|
||||
|
||||
# Mark the step as done.
|
||||
# We do this to avoid a circular dependency where async_finish_flow sets up a
|
||||
# new entry, which needs the integration to be set up, which is waiting for
|
||||
# init to be done.
|
||||
self._set_pending_import_done(flow)
|
||||
|
||||
# Avoid adding a config entry for a integration
|
||||
# that only supports a single config entry, but already has an entry
|
||||
if (
|
||||
@@ -2732,11 +2740,7 @@ class ConfigEntries:
|
||||
Config entries which are created after Home Assistant is started can't be waited
|
||||
for, the function will just return if the config entry is loaded or not.
|
||||
"""
|
||||
setup_done = self.hass.data.get(DATA_SETUP_DONE, {})
|
||||
if setup_future := setup_done.get(entry.domain):
|
||||
await setup_future
|
||||
# The component was not loaded.
|
||||
if entry.domain not in self.hass.config.components:
|
||||
if not await async_wait_component(self.hass, entry.domain):
|
||||
return False
|
||||
return entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
@@ -603,6 +603,7 @@ class UnitOfReactivePower(StrEnum):
|
||||
"""Reactive power units."""
|
||||
|
||||
VOLT_AMPERE_REACTIVE = "var"
|
||||
KILO_VOLT_AMPERE_REACTIVE = "kvar"
|
||||
|
||||
|
||||
_DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum(
|
||||
|
@@ -207,6 +207,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
Handler key is the domain of the component that we want to set up.
|
||||
"""
|
||||
|
||||
@callback
|
||||
def async_flow_removed(
|
||||
self,
|
||||
flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT],
|
||||
) -> None:
|
||||
"""Handle a removed data entry flow."""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def async_finish_flow(
|
||||
self,
|
||||
@@ -457,6 +464,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
"""Remove a flow from in progress."""
|
||||
if (flow := self._progress.pop(flow_id, None)) is None:
|
||||
raise UnknownFlow
|
||||
self.async_flow_removed(flow)
|
||||
self._async_remove_flow_from_index(flow)
|
||||
flow.async_cancel_progress_task()
|
||||
try:
|
||||
@@ -485,6 +493,10 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
description_placeholders=err.description_placeholders,
|
||||
)
|
||||
|
||||
if flow.flow_id not in self._progress:
|
||||
# The flow was removed during the step
|
||||
raise UnknownFlow
|
||||
|
||||
# Setup the flow handler's preview if needed
|
||||
if result.get("preview") is not None:
|
||||
await self._async_setup_preview(flow)
|
||||
|
9
homeassistant/generated/dhcp.py
generated
9
homeassistant/generated/dhcp.py
generated
@@ -613,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "sleepiq",
|
||||
"macaddress": "64DBA0*",
|
||||
},
|
||||
{
|
||||
"domain": "sma",
|
||||
"hostname": "sma*",
|
||||
"macaddress": "0015BB*",
|
||||
},
|
||||
{
|
||||
"domain": "sma",
|
||||
"registered_devices": True,
|
||||
},
|
||||
{
|
||||
"domain": "smartthings",
|
||||
"hostname": "st*",
|
||||
|
7
homeassistant/generated/usb.py
generated
7
homeassistant/generated/usb.py
generated
@@ -148,4 +148,11 @@ USB = [
|
||||
"pid": "8A2A",
|
||||
"vid": "10C4",
|
||||
},
|
||||
{
|
||||
"description": "*nabu casa zwa-2*",
|
||||
"domain": "zwave_js",
|
||||
"manufacturer": "nabu casa",
|
||||
"pid": "4001",
|
||||
"vid": "303A",
|
||||
},
|
||||
]
|
||||
|
@@ -130,7 +130,7 @@ multidict>=6.0.2
|
||||
backoff>=2.0
|
||||
|
||||
# ensure pydantic version does not float since it might have breaking changes
|
||||
pydantic==2.11.2
|
||||
pydantic==2.11.3
|
||||
|
||||
# Required for Python 3.12.4 compatibility (#119223).
|
||||
mashumaro>=3.13.1
|
||||
@@ -212,3 +212,8 @@ async-timeout==4.0.3
|
||||
# https://github.com/home-assistant/core/issues/122508
|
||||
# https://github.com/home-assistant/core/issues/118004
|
||||
aiofiles>=24.1.0
|
||||
|
||||
# multidict < 6.4.0 has memory leaks
|
||||
# https://github.com/aio-libs/multidict/issues/1134
|
||||
# https://github.com/aio-libs/multidict/issues/1131
|
||||
multidict>=6.4.2
|
||||
|
@@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__)
|
||||
ATTR_COMPONENT: Final = "component"
|
||||
|
||||
|
||||
# DATA_SETUP is a dict, indicating domains which are currently
|
||||
# _DATA_SETUP is a dict, indicating domains which are currently
|
||||
# being setup or which failed to setup:
|
||||
# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain
|
||||
# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain
|
||||
# being setup and the Task is the `_async_setup_component` helper.
|
||||
# - Tasks are removed from DATA_SETUP if setup was successful, that is,
|
||||
# - Tasks are removed from _DATA_SETUP if setup was successful, that is,
|
||||
# the task returned True.
|
||||
DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
|
||||
_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
|
||||
|
||||
# DATA_SETUP_DONE is a dict, indicating components which will be setup:
|
||||
# - Events are added to DATA_SETUP_DONE during bootstrap by
|
||||
# _DATA_SETUP_DONE is a dict, indicating components which will be setup:
|
||||
# - Events are added to _DATA_SETUP_DONE during bootstrap by
|
||||
# async_set_domains_to_be_loaded, the key is the domain which will be loaded.
|
||||
# - Events are set and removed from DATA_SETUP_DONE when async_setup_component
|
||||
# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component
|
||||
# is finished, regardless of if the setup was successful or not.
|
||||
DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
|
||||
_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
|
||||
|
||||
# DATA_SETUP_STARTED is a dict, indicating when an attempt
|
||||
# _DATA_SETUP_STARTED is a dict, indicating when an attempt
|
||||
# to setup a component started.
|
||||
DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
|
||||
_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
|
||||
"setup_started"
|
||||
)
|
||||
|
||||
# DATA_SETUP_TIME is a defaultdict, indicating how time was spent
|
||||
# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent
|
||||
# setting up a component.
|
||||
DATA_SETUP_TIME: HassKey[
|
||||
_DATA_SETUP_TIME: HassKey[
|
||||
defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]
|
||||
] = HassKey("setup_time")
|
||||
|
||||
DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
|
||||
_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
|
||||
|
||||
DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
|
||||
_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
|
||||
"bootstrap_persistent_errors"
|
||||
)
|
||||
|
||||
@@ -104,8 +104,8 @@ def async_notify_setup_error(
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from .components import persistent_notification
|
||||
|
||||
if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None:
|
||||
errors = hass.data[DATA_PERSISTENT_ERRORS] = {}
|
||||
if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None:
|
||||
errors = hass.data[_DATA_PERSISTENT_ERRORS] = {}
|
||||
|
||||
errors[component] = errors.get(component) or display_link
|
||||
|
||||
@@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str])
|
||||
- Properly handle after_dependencies.
|
||||
- Keep track of domains which will load but have not yet finished loading
|
||||
"""
|
||||
setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
|
||||
setup_futures = hass.data.setdefault(DATA_SETUP, {})
|
||||
setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
|
||||
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
|
||||
old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components
|
||||
if overlap := old_domains & domains:
|
||||
_LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap)
|
||||
@@ -158,8 +158,8 @@ async def async_setup_component(
|
||||
if domain in hass.config.components:
|
||||
return True
|
||||
|
||||
setup_futures = hass.data.setdefault(DATA_SETUP, {})
|
||||
setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
|
||||
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
|
||||
setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
|
||||
|
||||
if existing_setup_future := setup_futures.get(domain):
|
||||
return await existing_setup_future
|
||||
@@ -200,35 +200,40 @@ async def _async_process_dependencies(
|
||||
|
||||
Returns a list of dependencies which failed to set up.
|
||||
"""
|
||||
setup_futures = hass.data.setdefault(DATA_SETUP, {})
|
||||
setup_futures = hass.data.setdefault(_DATA_SETUP, {})
|
||||
|
||||
dependencies_tasks = {
|
||||
dep: setup_futures.get(dep)
|
||||
or create_eager_task(
|
||||
async_setup_component(hass, dep, config),
|
||||
name=f"setup {dep} as dependency of {integration.domain}",
|
||||
loop=hass.loop,
|
||||
)
|
||||
for dep in integration.dependencies
|
||||
if dep not in hass.config.components
|
||||
}
|
||||
dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
|
||||
|
||||
to_be_loaded = hass.data.get(DATA_SETUP_DONE, {})
|
||||
for dep in integration.dependencies:
|
||||
fut = setup_futures.get(dep)
|
||||
if fut is None:
|
||||
if dep in hass.config.components:
|
||||
continue
|
||||
fut = create_eager_task(
|
||||
async_setup_component(hass, dep, config),
|
||||
name=f"setup {dep} as dependency of {integration.domain}",
|
||||
loop=hass.loop,
|
||||
)
|
||||
dependencies_tasks[dep] = fut
|
||||
|
||||
to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {})
|
||||
# We don't want to just wait for the futures from `to_be_loaded` here.
|
||||
# We want to ensure that our after_dependencies are always actually
|
||||
# scheduled to be set up, as if for whatever reason they had not been,
|
||||
# we would deadlock waiting for them here.
|
||||
for dep in integration.after_dependencies:
|
||||
if (
|
||||
dep not in dependencies_tasks
|
||||
and dep in to_be_loaded
|
||||
and dep not in hass.config.components
|
||||
):
|
||||
dependencies_tasks[dep] = setup_futures.get(dep) or create_eager_task(
|
||||
if dep not in to_be_loaded or dep in dependencies_tasks:
|
||||
continue
|
||||
fut = setup_futures.get(dep)
|
||||
if fut is None:
|
||||
if dep in hass.config.components:
|
||||
continue
|
||||
fut = create_eager_task(
|
||||
async_setup_component(hass, dep, config),
|
||||
name=f"setup {dep} as after dependency of {integration.domain}",
|
||||
loop=hass.loop,
|
||||
)
|
||||
dependencies_tasks[dep] = fut
|
||||
|
||||
if not dependencies_tasks:
|
||||
return []
|
||||
@@ -478,7 +483,7 @@ async def _async_setup_component(
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
hass.data[DATA_SETUP].pop(domain, None)
|
||||
hass.data[_DATA_SETUP].pop(domain, None)
|
||||
|
||||
hass.bus.async_fire_internal(
|
||||
EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain)
|
||||
@@ -568,8 +573,8 @@ async def async_process_deps_reqs(
|
||||
|
||||
Module is a Python module of either a component or platform.
|
||||
"""
|
||||
if (processed := hass.data.get(DATA_DEPS_REQS)) is None:
|
||||
processed = hass.data[DATA_DEPS_REQS] = set()
|
||||
if (processed := hass.data.get(_DATA_DEPS_REQS)) is None:
|
||||
processed = hass.data[_DATA_DEPS_REQS] = set()
|
||||
elif integration.domain in processed:
|
||||
return
|
||||
|
||||
@@ -684,7 +689,7 @@ class SetupPhases(StrEnum):
|
||||
"""Wait time for the packages to import."""
|
||||
|
||||
|
||||
@singleton.singleton(DATA_SETUP_STARTED)
|
||||
@singleton.singleton(_DATA_SETUP_STARTED)
|
||||
def _setup_started(
|
||||
hass: core.HomeAssistant,
|
||||
) -> dict[tuple[str, str | None], float]:
|
||||
@@ -727,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator
|
||||
)
|
||||
|
||||
|
||||
@singleton.singleton(DATA_SETUP_TIME)
|
||||
@singleton.singleton(_DATA_SETUP_TIME)
|
||||
def _setup_times(
|
||||
hass: core.HomeAssistant,
|
||||
) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]:
|
||||
@@ -827,3 +832,11 @@ def async_get_domain_setup_times(
|
||||
) -> Mapping[str | None, dict[SetupPhases, float]]:
|
||||
"""Return timing data for each integration."""
|
||||
return _setup_times(hass).get(domain, {})
|
||||
|
||||
|
||||
async def async_wait_component(hass: HomeAssistant, domain: str) -> bool:
|
||||
"""Wait until a component is set up if pending, then return if it is set up."""
|
||||
setup_done = hass.data.get(_DATA_SETUP_DONE, {})
|
||||
if setup_future := setup_done.get(domain):
|
||||
await setup_future
|
||||
return domain in hass.config.components
|
||||
|
14
requirements_all.txt
generated
14
requirements_all.txt
generated
@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.4.0
|
||||
aioshelly==13.4.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -645,7 +645,7 @@ bluetooth-data-tools==1.27.0
|
||||
bond-async==0.2.1
|
||||
|
||||
# homeassistant.components.bosch_alarm
|
||||
bosch-alarm-mode2==0.4.3
|
||||
bosch-alarm-mode2==0.4.6
|
||||
|
||||
# homeassistant.components.bosch_shc
|
||||
boschshcpy==0.2.91
|
||||
@@ -1314,7 +1314,7 @@ ld2410-ble==0.1.1
|
||||
leaone-ble==0.1.0
|
||||
|
||||
# homeassistant.components.led_ble
|
||||
led-ble==1.1.6
|
||||
led-ble==1.1.7
|
||||
|
||||
# homeassistant.components.lektrico
|
||||
lektricowifi==0.0.43
|
||||
@@ -1489,7 +1489,7 @@ nettigo-air-monitor==4.1.0
|
||||
neurio==0.3.1
|
||||
|
||||
# homeassistant.components.nexia
|
||||
nexia==2.4.0
|
||||
nexia==2.7.0
|
||||
|
||||
# homeassistant.components.nextcloud
|
||||
nextcloudmonitor==1.5.1
|
||||
@@ -1607,7 +1607,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.10.0
|
||||
opower==0.11.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -2005,7 +2005,7 @@ pygti==0.9.4
|
||||
pyhaversion==22.8.0
|
||||
|
||||
# homeassistant.components.heos
|
||||
pyheos==1.0.4
|
||||
pyheos==1.0.5
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.2
|
||||
@@ -3064,7 +3064,7 @@ webio-api==0.1.11
|
||||
webmin-xmlrpc==0.0.2
|
||||
|
||||
# homeassistant.components.weheat
|
||||
weheat==2025.2.26
|
||||
weheat==2025.3.7
|
||||
|
||||
# homeassistant.components.whirlpool
|
||||
whirlpool-sixth-sense==0.20.0
|
||||
|
@@ -14,7 +14,7 @@ license-expression==30.4.1
|
||||
mock-open==1.4.0
|
||||
mypy-dev==1.16.0a7
|
||||
pre-commit==4.0.0
|
||||
pydantic==2.11.2
|
||||
pydantic==2.11.3
|
||||
pylint==3.3.6
|
||||
pylint-per-file-ignores==1.4.0
|
||||
pipdeptree==2.25.1
|
||||
|
14
requirements_test_all.txt
generated
14
requirements_test_all.txt
generated
@@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.4.0
|
||||
aioshelly==13.4.1
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@@ -570,7 +570,7 @@ bluetooth-data-tools==1.27.0
|
||||
bond-async==0.2.1
|
||||
|
||||
# homeassistant.components.bosch_alarm
|
||||
bosch-alarm-mode2==0.4.3
|
||||
bosch-alarm-mode2==0.4.6
|
||||
|
||||
# homeassistant.components.bosch_shc
|
||||
boschshcpy==0.2.91
|
||||
@@ -1114,7 +1114,7 @@ ld2410-ble==0.1.1
|
||||
leaone-ble==0.1.0
|
||||
|
||||
# homeassistant.components.led_ble
|
||||
led-ble==1.1.6
|
||||
led-ble==1.1.7
|
||||
|
||||
# homeassistant.components.lektrico
|
||||
lektricowifi==0.0.43
|
||||
@@ -1253,7 +1253,7 @@ netmap==0.7.0.2
|
||||
nettigo-air-monitor==4.1.0
|
||||
|
||||
# homeassistant.components.nexia
|
||||
nexia==2.4.0
|
||||
nexia==2.7.0
|
||||
|
||||
# homeassistant.components.nextcloud
|
||||
nextcloudmonitor==1.5.1
|
||||
@@ -1344,7 +1344,7 @@ openhomedevice==2.2.0
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.10.0
|
||||
opower==0.11.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -1635,7 +1635,7 @@ pygti==0.9.4
|
||||
pyhaversion==22.8.0
|
||||
|
||||
# homeassistant.components.heos
|
||||
pyheos==1.0.4
|
||||
pyheos==1.0.5
|
||||
|
||||
# homeassistant.components.hive
|
||||
pyhive-integration==1.0.2
|
||||
@@ -2472,7 +2472,7 @@ webio-api==0.1.11
|
||||
webmin-xmlrpc==0.0.2
|
||||
|
||||
# homeassistant.components.weheat
|
||||
weheat==2025.2.26
|
||||
weheat==2025.3.7
|
||||
|
||||
# homeassistant.components.whirlpool
|
||||
whirlpool-sixth-sense==0.20.0
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user