mirror of
https://github.com/home-assistant/core.git
synced 2026-03-12 05:51:59 +01:00
Compare commits
30 Commits
homekit-au
...
windows-98
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df06a5878c | ||
|
|
a36733c4dc | ||
|
|
bf846e0756 | ||
|
|
c037dad093 | ||
|
|
ce11e66e1f | ||
|
|
f38ca7b04a | ||
|
|
01200ef0a8 | ||
|
|
c5e0c78cbc | ||
|
|
7681caa936 | ||
|
|
230a2ff045 | ||
|
|
9d828502a3 | ||
|
|
28088a7e1a | ||
|
|
9e8171fb77 | ||
|
|
1660d3b28a | ||
|
|
2ef81a54a5 | ||
|
|
ce6154839e | ||
|
|
a25300b8e1 | ||
|
|
6fa8e71b21 | ||
|
|
c983978a10 | ||
|
|
68b8b6b675 | ||
|
|
ee4d313b10 | ||
|
|
5e665093c9 | ||
|
|
9a5f509ab9 | ||
|
|
8d0cd5edaa | ||
|
|
71726272f5 | ||
|
|
9c6c27ab56 | ||
|
|
db20cf8161 | ||
|
|
59b6270157 | ||
|
|
a65ba01bbe | ||
|
|
a5d0350560 |
@@ -212,6 +212,7 @@ homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.folder_watcher.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.freshr.*
|
||||
homeassistant.components.fritz.*
|
||||
homeassistant.components.fritzbox.*
|
||||
homeassistant.components.fritzbox_callmonitor.*
|
||||
|
||||
10
CODEOWNERS
generated
10
CODEOWNERS
generated
@@ -551,6 +551,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/freshr/ @SierraNL
|
||||
/tests/components/freshr/ @SierraNL
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
@@ -569,6 +571,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fully_kiosk/ @cgarwood
|
||||
/homeassistant/components/fyta/ @dontinelli
|
||||
/tests/components/fyta/ @dontinelli
|
||||
/homeassistant/components/garage_door/ @home-assistant/core
|
||||
/tests/components/garage_door/ @home-assistant/core
|
||||
/homeassistant/components/garages_amsterdam/ @klaasnicolaas
|
||||
/tests/components/garages_amsterdam/ @klaasnicolaas
|
||||
/homeassistant/components/gardena_bluetooth/ @elupus
|
||||
@@ -739,6 +743,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/huisbaasje/ @dennisschroer
|
||||
/homeassistant/components/humidifier/ @home-assistant/core @Shulyaka
|
||||
/tests/components/humidifier/ @home-assistant/core @Shulyaka
|
||||
/homeassistant/components/humidity/ @home-assistant/core
|
||||
/tests/components/humidity/ @home-assistant/core
|
||||
/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||
/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock
|
||||
/homeassistant/components/husqvarna_automower/ @Thomas55555
|
||||
@@ -788,8 +794,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/improv_ble/ @emontnemery
|
||||
/homeassistant/components/incomfort/ @jbouwh
|
||||
/tests/components/incomfort/ @jbouwh
|
||||
/homeassistant/components/indevolt/ @xirtnl
|
||||
/tests/components/indevolt/ @xirtnl
|
||||
/homeassistant/components/indevolt/ @xirt
|
||||
/tests/components/indevolt/ @xirt
|
||||
/homeassistant/components/inels/ @epdevlab
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221
|
||||
|
||||
@@ -242,6 +242,8 @@ DEFAULT_INTEGRATIONS = {
|
||||
#
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
"humidity",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
40
homeassistant/components/adax/climate.py
Normal file → Executable file
40
homeassistant/components/adax/climate.py
Normal file → Executable file
@@ -168,29 +168,57 @@ class LocalAdaxDevice(CoordinatorEntity[AdaxLocalCoordinator], ClimateEntity):
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
temperature = self._attr_target_temperature or self._attr_min_temp
|
||||
await self._adax_data_handler.set_target_temperature(temperature)
|
||||
self._attr_target_temperature = temperature
|
||||
self._attr_icon = "mdi:radiator"
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
await self._adax_data_handler.set_target_temperature(0)
|
||||
self._attr_icon = "mdi:radiator-off"
|
||||
else:
|
||||
# Ignore unsupported HVAC modes to avoid desynchronizing entity state
|
||||
# from the physical device.
|
||||
return
|
||||
|
||||
self._attr_hvac_mode = hvac_mode
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
return
|
||||
await self._adax_data_handler.set_target_temperature(temperature)
|
||||
if self._attr_hvac_mode == HVACMode.HEAT:
|
||||
await self._adax_data_handler.set_target_temperature(temperature)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._attr_target_temperature = temperature
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _update_hvac_attributes(self) -> None:
|
||||
"""Update hvac mode and temperatures from coordinator data.
|
||||
|
||||
The coordinator reports a target temperature of 0 when the heater is
|
||||
turned off. In that case, only the hvac mode and icon are updated and
|
||||
the previous non-zero target temperature is preserved. When the
|
||||
reported target temperature is non-zero, the stored target temperature
|
||||
is updated to match the coordinator value.
|
||||
"""
|
||||
if data := self.coordinator.data:
|
||||
self._attr_current_temperature = data["current_temperature"]
|
||||
self._attr_available = self._attr_current_temperature is not None
|
||||
if (target_temp := data["target_temperature"]) == 0:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_icon = "mdi:radiator-off"
|
||||
if target_temp == 0:
|
||||
if self._attr_target_temperature is None:
|
||||
self._attr_target_temperature = self._attr_min_temp
|
||||
else:
|
||||
self._attr_hvac_mode = HVACMode.HEAT
|
||||
self._attr_icon = "mdi:radiator"
|
||||
self._attr_target_temperature = target_temp
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_hvac_attributes()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self._update_hvac_attributes()
|
||||
|
||||
@@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
|
||||
|
||||
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
|
||||
"""Get value of enable_ime option or its default value."""
|
||||
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]
|
||||
return bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE))
|
||||
|
||||
@@ -144,12 +144,13 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"device_tracker",
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"humidifier",
|
||||
"humidity",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -132,6 +133,7 @@ class S3BackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -129,6 +130,7 @@ class AzureStorageBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -230,6 +231,7 @@ class BackblazeBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup to Backblaze B2.
|
||||
|
||||
@@ -17,6 +17,7 @@ from .agent import (
|
||||
BackupAgentError,
|
||||
BackupAgentPlatformProtocol,
|
||||
LocalBackupAgent,
|
||||
OnProgressCallback,
|
||||
)
|
||||
from .config import BackupConfig, CreateBackupParametersDict
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
@@ -41,6 +42,7 @@ from .manager import (
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
UploadBackupEvent,
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||
@@ -72,9 +74,11 @@ __all__ = [
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"NewBackup",
|
||||
"OnProgressCallback",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"UploadBackupEvent",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
|
||||
@@ -14,6 +14,13 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from .models import AgentBackup, BackupAgentError
|
||||
|
||||
|
||||
class OnProgressCallback(Protocol):
|
||||
"""Protocol for on_progress callback."""
|
||||
|
||||
def __call__(self, *, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Report upload progress."""
|
||||
|
||||
|
||||
class BackupAgentUnreachableError(BackupAgentError):
|
||||
"""Raised when the agent can't reach its API."""
|
||||
|
||||
@@ -53,12 +60,14 @@ class BackupAgent(abc.ABC):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
:param open_stream: A function returning an async iterator that yields bytes.
|
||||
:param backup: Metadata about the backup that should be uploaded.
|
||||
:param on_progress: A callback to report the number of uploaded bytes.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent
|
||||
from .agent import BackupAgent, LocalBackupAgent, OnProgressCallback
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .util import read_backup, suggested_filename
|
||||
@@ -73,6 +73,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
@@ -252,6 +252,15 @@ class BlockedEvent(ManagerStateEvent):
|
||||
manager_state: BackupManagerState = BackupManagerState.BLOCKED
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True, slots=True)
|
||||
class UploadBackupEvent(ManagerStateEvent):
|
||||
"""Backup agent upload progress event."""
|
||||
|
||||
agent_id: str
|
||||
uploaded_bytes: int
|
||||
total_bytes: int
|
||||
|
||||
|
||||
class BackupPlatformProtocol(Protocol):
|
||||
"""Define the format that backup platforms can have."""
|
||||
|
||||
@@ -579,9 +588,24 @@ class BackupManager:
|
||||
_backup = replace(
|
||||
backup, protected=should_encrypt, size=streamer.size()
|
||||
)
|
||||
await self.backup_agents[agent_id].async_upload_backup(
|
||||
agent = self.backup_agents[agent_id]
|
||||
|
||||
@callback
|
||||
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
|
||||
"""Handle upload progress."""
|
||||
self.async_on_backup_event(
|
||||
UploadBackupEvent(
|
||||
manager_state=self.state,
|
||||
agent_id=agent_id,
|
||||
uploaded_bytes=bytes_uploaded,
|
||||
total_bytes=_backup.size,
|
||||
)
|
||||
)
|
||||
|
||||
await agent.async_upload_backup(
|
||||
open_stream=open_stream_func,
|
||||
backup=_backup,
|
||||
on_progress=on_upload_progress,
|
||||
)
|
||||
if streamer:
|
||||
await streamer.wait()
|
||||
@@ -1374,9 +1398,10 @@ class BackupManager:
|
||||
"""Forward event to subscribers."""
|
||||
if (current_state := self.state) != (new_state := event.manager_state):
|
||||
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
|
||||
self.last_event = event
|
||||
if not isinstance(event, (BlockedEvent, IdleEvent)):
|
||||
self.last_action_event = event
|
||||
if not isinstance(event, UploadBackupEvent):
|
||||
self.last_event = event
|
||||
if not isinstance(event, (BlockedEvent, IdleEvent)):
|
||||
self.last_action_event = event
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
|
||||
@@ -115,18 +115,6 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"trigger": "mdi:thermostat"
|
||||
},
|
||||
|
||||
@@ -372,78 +372,6 @@
|
||||
},
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity changed"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity crossed threshold"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the temperature is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the temperature is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature changed"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature crossed threshold"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"description": "Triggers after the mode of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
|
||||
@@ -17,15 +17,7 @@ from homeassistant.helpers.trigger import (
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
DOMAIN,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
@@ -53,18 +45,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
@@ -73,16 +53,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
|
||||
),
|
||||
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_HUMIDITY
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_HUMIDITY
|
||||
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
|
||||
),
|
||||
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_TEMPERATURE
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_TEMPERATURE
|
||||
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
|
||||
),
|
||||
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
|
||||
"turned_on": make_entity_transition_trigger(
|
||||
|
||||
@@ -66,20 +66,6 @@ hvac_mode_changed:
|
||||
- unknown
|
||||
multiple: true
|
||||
|
||||
current_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_humidity_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
@@ -94,20 +80,6 @@ target_humidity_crossed_threshold:
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
current_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_temperature_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
|
||||
@@ -18,6 +18,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
|
||||
@@ -106,6 +107,7 @@ class CloudBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -129,6 +130,7 @@ class R2BackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from enum import IntFlag, StrEnum
|
||||
import functools as ft
|
||||
import logging
|
||||
from typing import Any, final
|
||||
@@ -33,7 +32,20 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401
|
||||
from .const import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
ATTR_IS_CLOSED,
|
||||
ATTR_POSITION,
|
||||
ATTR_TILT_POSITION,
|
||||
DOMAIN,
|
||||
INTENT_CLOSE_COVER,
|
||||
INTENT_OPEN_COVER,
|
||||
CoverDeviceClass,
|
||||
CoverEntityFeature,
|
||||
CoverState,
|
||||
)
|
||||
from .trigger import make_cover_closed_trigger, make_cover_opened_trigger
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,57 +55,33 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class CoverState(StrEnum):
|
||||
"""State of Cover entities."""
|
||||
|
||||
CLOSED = "closed"
|
||||
CLOSING = "closing"
|
||||
OPEN = "open"
|
||||
OPENING = "opening"
|
||||
|
||||
|
||||
class CoverDeviceClass(StrEnum):
|
||||
"""Device class for cover."""
|
||||
|
||||
# Refer to the cover dev docs for device class descriptions
|
||||
AWNING = "awning"
|
||||
BLIND = "blind"
|
||||
CURTAIN = "curtain"
|
||||
DAMPER = "damper"
|
||||
DOOR = "door"
|
||||
GARAGE = "garage"
|
||||
GATE = "gate"
|
||||
SHADE = "shade"
|
||||
SHUTTER = "shutter"
|
||||
WINDOW = "window"
|
||||
|
||||
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass))
|
||||
DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass]
|
||||
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
|
||||
class CoverEntityFeature(IntFlag):
|
||||
"""Supported features of the cover entity."""
|
||||
|
||||
OPEN = 1
|
||||
CLOSE = 2
|
||||
SET_POSITION = 4
|
||||
STOP = 8
|
||||
OPEN_TILT = 16
|
||||
CLOSE_TILT = 32
|
||||
STOP_TILT = 64
|
||||
SET_TILT_POSITION = 128
|
||||
|
||||
|
||||
ATTR_CURRENT_POSITION = "current_position"
|
||||
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
|
||||
ATTR_IS_CLOSED = "is_closed"
|
||||
ATTR_POSITION = "position"
|
||||
ATTR_TILT_POSITION = "tilt_position"
|
||||
__all__ = [
|
||||
"ATTR_CURRENT_POSITION",
|
||||
"ATTR_CURRENT_TILT_POSITION",
|
||||
"ATTR_IS_CLOSED",
|
||||
"ATTR_POSITION",
|
||||
"ATTR_TILT_POSITION",
|
||||
"DEVICE_CLASSES",
|
||||
"DEVICE_CLASSES_SCHEMA",
|
||||
"DOMAIN",
|
||||
"INTENT_CLOSE_COVER",
|
||||
"INTENT_OPEN_COVER",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"CoverDeviceClass",
|
||||
"CoverEntity",
|
||||
"CoverEntityDescription",
|
||||
"CoverEntityFeature",
|
||||
"CoverState",
|
||||
"make_cover_closed_trigger",
|
||||
"make_cover_opened_trigger",
|
||||
]
|
||||
|
||||
|
||||
@bind_hass
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
"""Constants for cover entity platform."""
|
||||
|
||||
from enum import IntFlag, StrEnum
|
||||
|
||||
DOMAIN = "cover"
|
||||
|
||||
ATTR_CURRENT_POSITION = "current_position"
|
||||
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
|
||||
ATTR_IS_CLOSED = "is_closed"
|
||||
ATTR_POSITION = "position"
|
||||
ATTR_TILT_POSITION = "tilt_position"
|
||||
|
||||
INTENT_OPEN_COVER = "HassOpenCover"
|
||||
INTENT_CLOSE_COVER = "HassCloseCover"
|
||||
|
||||
|
||||
class CoverEntityFeature(IntFlag):
|
||||
"""Supported features of the cover entity."""
|
||||
|
||||
OPEN = 1
|
||||
CLOSE = 2
|
||||
SET_POSITION = 4
|
||||
STOP = 8
|
||||
OPEN_TILT = 16
|
||||
CLOSE_TILT = 32
|
||||
STOP_TILT = 64
|
||||
SET_TILT_POSITION = 128
|
||||
|
||||
|
||||
class CoverState(StrEnum):
|
||||
"""State of Cover entities."""
|
||||
|
||||
CLOSED = "closed"
|
||||
CLOSING = "closing"
|
||||
OPEN = "open"
|
||||
OPENING = "opening"
|
||||
|
||||
|
||||
class CoverDeviceClass(StrEnum):
|
||||
"""Device class for cover."""
|
||||
|
||||
# Refer to the cover dev docs for device class descriptions
|
||||
AWNING = "awning"
|
||||
BLIND = "blind"
|
||||
CURTAIN = "curtain"
|
||||
DAMPER = "damper"
|
||||
DOOR = "door"
|
||||
GARAGE = "garage"
|
||||
GATE = "gate"
|
||||
SHADE = "shade"
|
||||
SHUTTER = "shutter"
|
||||
WINDOW = "window"
|
||||
|
||||
@@ -108,5 +108,37 @@
|
||||
"toggle_cover_tilt": {
|
||||
"service": "mdi:arrow-top-right-bottom-left"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"awning_closed": {
|
||||
"trigger": "mdi:storefront-outline"
|
||||
},
|
||||
"awning_opened": {
|
||||
"trigger": "mdi:storefront-outline"
|
||||
},
|
||||
"blind_closed": {
|
||||
"trigger": "mdi:blinds-horizontal-closed"
|
||||
},
|
||||
"blind_opened": {
|
||||
"trigger": "mdi:blinds-horizontal"
|
||||
},
|
||||
"curtain_closed": {
|
||||
"trigger": "mdi:curtains-closed"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"trigger": "mdi:curtains"
|
||||
},
|
||||
"shade_closed": {
|
||||
"trigger": "mdi:roller-shade-closed"
|
||||
},
|
||||
"shade_opened": {
|
||||
"trigger": "mdi:roller-shade"
|
||||
},
|
||||
"shutter_closed": {
|
||||
"trigger": "mdi:window-shutter"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"trigger": "mdi:window-shutter-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"close": "Close {entity_name}",
|
||||
@@ -82,6 +86,15 @@
|
||||
"name": "Window"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"close_cover": {
|
||||
"description": "Closes a cover.",
|
||||
@@ -136,5 +149,107 @@
|
||||
"name": "Toggle tilt"
|
||||
}
|
||||
},
|
||||
"title": "Cover"
|
||||
"title": "Cover",
|
||||
"triggers": {
|
||||
"awning_closed": {
|
||||
"description": "Triggers after one or more awnings close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning closed"
|
||||
},
|
||||
"awning_opened": {
|
||||
"description": "Triggers after one or more awnings open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning opened"
|
||||
},
|
||||
"blind_closed": {
|
||||
"description": "Triggers after one or more blinds close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind closed"
|
||||
},
|
||||
"blind_opened": {
|
||||
"description": "Triggers after one or more blinds open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind opened"
|
||||
},
|
||||
"curtain_closed": {
|
||||
"description": "Triggers after one or more curtains close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain closed"
|
||||
},
|
||||
"curtain_opened": {
|
||||
"description": "Triggers after one or more curtains open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain opened"
|
||||
},
|
||||
"shade_closed": {
|
||||
"description": "Triggers after one or more shades close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade closed"
|
||||
},
|
||||
"shade_opened": {
|
||||
"description": "Triggers after one or more shades open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade opened"
|
||||
},
|
||||
"shutter_closed": {
|
||||
"description": "Triggers after one or more shutters close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter closed"
|
||||
},
|
||||
"shutter_opened": {
|
||||
"description": "Triggers after one or more shutters open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::cover::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::cover::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
107
homeassistant/components/cover/trigger.py
Normal file
107
homeassistant/components/cover/trigger.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
|
||||
|
||||
class CoverTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for cover state changes."""
|
||||
|
||||
_binary_sensor_target_state: str
|
||||
_cover_is_closed_target_value: bool
|
||||
_device_classes: dict[str, str]
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities by cover device class."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id)
|
||||
== self._device_classes[split_entity_id(entity_id)[0]]
|
||||
}
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target cover state."""
|
||||
if split_entity_id(state.entity_id)[0] == DOMAIN:
|
||||
return (
|
||||
state.attributes.get(ATTR_IS_CLOSED)
|
||||
== self._cover_is_closed_target_value
|
||||
)
|
||||
return state.state == self._binary_sensor_target_state
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the transition is valid for a cover state change."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
|
||||
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
|
||||
return False
|
||||
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
def make_cover_opened_trigger(
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_opened."""
|
||||
|
||||
class CoverOpenedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverOpenedTrigger
|
||||
|
||||
|
||||
def make_cover_closed_trigger(
|
||||
*, device_classes: dict[str, str], domains: set[str] | None = None
|
||||
) -> type[CoverTriggerBase]:
|
||||
"""Create a trigger cover_closed."""
|
||||
|
||||
class CoverClosedTrigger(CoverTriggerBase):
|
||||
"""Trigger for cover closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
_domains = domains or {DOMAIN}
|
||||
_device_classes = device_classes
|
||||
|
||||
return CoverClosedTrigger
|
||||
|
||||
|
||||
# Concrete triggers for cover device classes (cover-only, no binary sensor)
|
||||
|
||||
DEVICE_CLASSES_AWNING: dict[str, str] = {DOMAIN: CoverDeviceClass.AWNING}
|
||||
DEVICE_CLASSES_BLIND: dict[str, str] = {DOMAIN: CoverDeviceClass.BLIND}
|
||||
DEVICE_CLASSES_CURTAIN: dict[str, str] = {DOMAIN: CoverDeviceClass.CURTAIN}
|
||||
DEVICE_CLASSES_SHADE: dict[str, str] = {DOMAIN: CoverDeviceClass.SHADE}
|
||||
DEVICE_CLASSES_SHUTTER: dict[str, str] = {DOMAIN: CoverDeviceClass.SHUTTER}
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"awning_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_AWNING),
|
||||
"awning_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_AWNING),
|
||||
"blind_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_BLIND),
|
||||
"blind_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_BLIND),
|
||||
"curtain_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
|
||||
"curtain_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_CURTAIN),
|
||||
"shade_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHADE),
|
||||
"shade_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHADE),
|
||||
"shutter_opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
|
||||
"shutter_closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_SHUTTER),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for covers."""
|
||||
return TRIGGERS
|
||||
81
homeassistant/components/cover/triggers.yaml
Normal file
81
homeassistant/components/cover/triggers.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
awning_closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
awning_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: awning
|
||||
|
||||
blind_closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
blind_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: blind
|
||||
|
||||
curtain_closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
curtain_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: curtain
|
||||
|
||||
shade_closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shade_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shade
|
||||
|
||||
shutter_closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
|
||||
shutter_opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: cover
|
||||
device_class: shutter
|
||||
@@ -1,80 +1,33 @@
|
||||
"""Provides triggers for doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_closed_trigger,
|
||||
make_cover_opened_trigger,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASS_DOOR = "door"
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class DoorTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for door state changes."""
|
||||
|
||||
_domains = {BINARY_SENSOR_DOMAIN, COVER_DOMAIN}
|
||||
_binary_sensor_target_state: str
|
||||
_cover_is_closed_target_value: bool
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities by door device class."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if get_device_class_or_undefined(self._hass, entity_id) == DEVICE_CLASS_DOOR
|
||||
}
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the state matches the target door state."""
|
||||
if split_entity_id(state.entity_id)[0] == COVER_DOMAIN:
|
||||
return (
|
||||
state.attributes.get(ATTR_IS_CLOSED)
|
||||
== self._cover_is_closed_target_value
|
||||
)
|
||||
return state.state == self._binary_sensor_target_state
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the transition is valid for a door state change."""
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
if split_entity_id(from_state.entity_id)[0] == COVER_DOMAIN:
|
||||
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
|
||||
return False
|
||||
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED)
|
||||
return from_state.state != to_state.state
|
||||
|
||||
|
||||
class DoorOpenedTrigger(DoorTriggerBase):
|
||||
"""Trigger for door opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
|
||||
|
||||
class DoorClosedTrigger(DoorTriggerBase):
|
||||
"""Trigger for door closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
|
||||
COVER_DOMAIN: CoverDeviceClass.DOOR,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": DoorOpenedTrigger,
|
||||
"closed": DoorClosedTrigger,
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
homeassistant/components/freshr/__init__.py
Normal file
47
homeassistant/components/freshr/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""The Fresh-r integration."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import (
|
||||
FreshrConfigEntry,
|
||||
FreshrData,
|
||||
FreshrDevicesCoordinator,
|
||||
FreshrReadingsCoordinator,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
|
||||
"""Set up Fresh-r from a config entry."""
|
||||
devices_coordinator = FreshrDevicesCoordinator(hass, entry)
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
readings: dict[str, FreshrReadingsCoordinator] = {
|
||||
device.id: FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
for device in devices_coordinator.data
|
||||
}
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in readings.values()
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = FreshrData(
|
||||
devices=devices_coordinator,
|
||||
readings=readings,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
58
homeassistant/components/freshr/config_flow.py
Normal file
58
homeassistant/components/freshr/config_flow.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Config flow for the Fresh-r integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr import FreshrClient
|
||||
from pyfreshr.exceptions import LoginError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fresh-r."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"Fresh-r ({user_input[CONF_USERNAME]})",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
7
homeassistant/components/freshr/const.py
Normal file
7
homeassistant/components/freshr/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the Fresh-r integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "freshr"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
116
homeassistant/components/freshr/coordinator.py
Normal file
116
homeassistant/components/freshr/coordinator.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Coordinator for Fresh-r integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr import FreshrClient
|
||||
from pyfreshr.exceptions import ApiResponseError, LoginError
|
||||
from pyfreshr.models import DeviceReadings, DeviceSummary
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEVICES_SCAN_INTERVAL = timedelta(hours=1)
|
||||
READINGS_SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FreshrData:
|
||||
"""Runtime data stored on the config entry."""
|
||||
|
||||
devices: FreshrDevicesCoordinator
|
||||
readings: dict[str, FreshrReadingsCoordinator]
|
||||
|
||||
|
||||
type FreshrConfigEntry = ConfigEntry[FreshrData]
|
||||
|
||||
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
"""Coordinator that refreshes the device list once an hour."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None:
|
||||
"""Initialize the device list coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_devices",
|
||||
update_interval=DEVICES_SCAN_INTERVAL,
|
||||
)
|
||||
self.client = FreshrClient(session=async_create_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> list[DeviceSummary]:
|
||||
"""Fetch the list of devices from the Fresh-r API."""
|
||||
username = self.config_entry.data[CONF_USERNAME]
|
||||
password = self.config_entry.data[CONF_PASSWORD]
|
||||
|
||||
try:
|
||||
if not self.client.logged_in:
|
||||
await self.client.login(username, password)
|
||||
|
||||
devices = await self.client.fetch_devices()
|
||||
except LoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
except (ApiResponseError, ClientError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
else:
|
||||
return devices
|
||||
|
||||
|
||||
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
|
||||
"""Coordinator that refreshes readings for a single device every 10 minutes."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: FreshrConfigEntry,
|
||||
device: DeviceSummary,
|
||||
client: FreshrClient,
|
||||
) -> None:
|
||||
"""Initialize the readings coordinator for a single device."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_readings_{device.id}",
|
||||
update_interval=READINGS_SCAN_INTERVAL,
|
||||
)
|
||||
self._device = device
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the device ID."""
|
||||
return self._device.id
|
||||
|
||||
async def _async_update_data(self) -> DeviceReadings:
|
||||
"""Fetch current readings for this device from the Fresh-r API."""
|
||||
try:
|
||||
return await self._client.fetch_device_current(self._device)
|
||||
except LoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
except (ApiResponseError, ClientError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
18
homeassistant/components/freshr/icons.json
Normal file
18
homeassistant/components/freshr/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dew_point": {
|
||||
"default": "mdi:thermometer-water"
|
||||
},
|
||||
"flow": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"inside_temperature": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/freshr/manifest.json
Normal file
11
homeassistant/components/freshr/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "freshr",
|
||||
"name": "Fresh-r",
|
||||
"codeowners": ["@SierraNL"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/freshr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfreshr==1.2.0"]
|
||||
}
|
||||
72
homeassistant/components/freshr/quality_scale.yaml
Normal file
72
homeassistant/components/freshr/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration uses a polling coordinator, not event-driven updates.
|
||||
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: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
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:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
158
homeassistant/components/freshr/sensor.py
Normal file
158
homeassistant/components/freshr/sensor.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Sensor platform for the Fresh-r integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyfreshr.models import DeviceReadings, DeviceType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FreshrSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a Fresh-r sensor."""
|
||||
|
||||
value_fn: Callable[[DeviceReadings], StateType]
|
||||
|
||||
|
||||
_T1 = FreshrSensorEntityDescription(
|
||||
key="t1",
|
||||
translation_key="inside_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.t1,
|
||||
)
|
||||
_T2 = FreshrSensorEntityDescription(
|
||||
key="t2",
|
||||
translation_key="outside_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.t2,
|
||||
)
|
||||
_CO2 = FreshrSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.co2,
|
||||
)
|
||||
_HUM = FreshrSensorEntityDescription(
|
||||
key="hum",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.hum,
|
||||
)
|
||||
_FLOW = FreshrSensorEntityDescription(
|
||||
key="flow",
|
||||
translation_key="flow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.flow,
|
||||
)
|
||||
_DP = FreshrSensorEntityDescription(
|
||||
key="dp",
|
||||
translation_key="dew_point",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda r: r.dp,
|
||||
)
|
||||
_TEMP = FreshrSensorEntityDescription(
|
||||
key="temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.temp,
|
||||
)
|
||||
|
||||
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
|
||||
DeviceType.FRESH_R: "Fresh-r",
|
||||
DeviceType.FORWARD: "Fresh-r Forward",
|
||||
DeviceType.MONITOR: "Fresh-r Monitor",
|
||||
}
|
||||
|
||||
SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = {
|
||||
DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP),
|
||||
DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP),
|
||||
DeviceType.MONITOR: (_CO2, _HUM, _DP, _TEMP),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FreshrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fresh-r sensors from a config entry."""
|
||||
entities: list[FreshrSensor] = []
|
||||
for device in config_entry.runtime_data.devices.data:
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device.id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device.id],
|
||||
description,
|
||||
device_info,
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
|
||||
"""Representation of a Fresh-r sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: FreshrSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FreshrReadingsCoordinator,
|
||||
description: FreshrSensorEntityDescription,
|
||||
device_info: DeviceInfo,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_device_info = device_info
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value from coordinator data."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
51
homeassistant/components/freshr/strings.json
Normal file
51
homeassistant/components/freshr/strings.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Cannot change the account username."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Your Fresh-r account password.",
|
||||
"username": "Your Fresh-r account username (email address)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dew_point": {
|
||||
"name": "Dew point"
|
||||
},
|
||||
"flow": {
|
||||
"name": "Air flow rate"
|
||||
},
|
||||
"inside_temperature": {
|
||||
"name": "Inside temperature"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed. Check your Fresh-r username and password."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Could not connect to the Fresh-r service."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"preview_features": { "windows_98": {}, "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260304.0"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"windows_98": {
|
||||
"description": "Transforms your dashboard with a nostalgic Windows 98 look.",
|
||||
"disable_confirmation": "Your dashboard will return to its normal look. You can re-enable this at any time in Labs settings.",
|
||||
"enable_confirmation": "Your dashboard will be transformed with a Windows 98 theme. You can turn this off at any time in Labs settings.",
|
||||
"name": "Windows 98"
|
||||
},
|
||||
"winter_mode": {
|
||||
"description": "Adds falling snowflakes on your screen. Get your home ready for winter! ❄️\n\nIf you have animations disabled in your device accessibility settings, this feature will not work.",
|
||||
"disable_confirmation": "Snowflakes will no longer fall on your screen. You can re-enable this at any time in Labs settings.",
|
||||
|
||||
15
homeassistant/components/garage_door/__init__.py
Normal file
15
homeassistant/components/garage_door/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Integration for garage door triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "garage_door"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/garage_door/icons.json
Normal file
10
homeassistant/components/garage_door/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:garage"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:garage-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/garage_door/manifest.json
Normal file
8
homeassistant/components/garage_door/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "garage_door",
|
||||
"name": "Garage door",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/garage_door",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
38
homeassistant/components/garage_door/strings.json
Normal file
38
homeassistant/components/garage_door/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Garage door",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more garage doors close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more garage doors open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
36
homeassistant/components/garage_door/trigger.py
Normal file
36
homeassistant/components/garage_door/trigger.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Provides triggers for garage doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverDeviceClass,
|
||||
make_cover_closed_trigger,
|
||||
make_cover_opened_trigger,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.GARAGE_DOOR,
|
||||
COVER_DOMAIN: CoverDeviceClass.GARAGE,
|
||||
}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": make_cover_opened_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
"closed": make_cover_closed_trigger(
|
||||
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
|
||||
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for garage doors."""
|
||||
return TRIGGERS
|
||||
29
homeassistant/components/garage_door/triggers.yaml
Normal file
29
homeassistant/components/garage_door/triggers.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
@@ -108,6 +108,50 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_url = user_input[CONF_API_URL].rstrip("/")
|
||||
admin_api_key = user_input[CONF_ADMIN_API_KEY]
|
||||
|
||||
if ":" not in admin_api_key:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
try:
|
||||
site = await self._validate_credentials(api_url, admin_api_key)
|
||||
except GhostAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GhostError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error during Ghost reconfigure")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(site["site_uuid"])
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_API_URL: api_url,
|
||||
CONF_ADMIN_API_KEY: admin_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
suggested_values=user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
|
||||
)
|
||||
|
||||
async def _validate_credentials(
|
||||
self, api_url: str, admin_api_key: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -68,13 +68,11 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair scenarios identified for this integration.
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: Remove newsletter entities when newsletter is removed
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -12,7 +12,9 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -210,36 +212,67 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
# Remove stale newsletter entities left over from previous runs.
|
||||
entity_registry = er.async_get(hass)
|
||||
prefix = f"{entry.unique_id}_newsletter_"
|
||||
active_newsletters = {
|
||||
newsletter_id
|
||||
for newsletter_id, newsletter in coordinator.data.newsletters.items()
|
||||
if newsletter.get("status") == "active"
|
||||
}
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
):
|
||||
if (
|
||||
entity_entry.unique_id.startswith(prefix)
|
||||
and entity_entry.unique_id[len(prefix) :] not in active_newsletters
|
||||
):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
|
||||
newsletter_added: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_newsletter_entities() -> None:
|
||||
"""Add newsletter entities when new newsletters appear."""
|
||||
def _async_update_newsletter_entities() -> None:
|
||||
"""Add new and remove stale newsletter entities."""
|
||||
nonlocal newsletter_added
|
||||
|
||||
new_newsletters = {
|
||||
active_newsletters = {
|
||||
newsletter_id
|
||||
for newsletter_id, newsletter in coordinator.data.newsletters.items()
|
||||
if newsletter.get("status") == "active"
|
||||
} - newsletter_added
|
||||
}
|
||||
|
||||
if not new_newsletters:
|
||||
return
|
||||
new_newsletters = active_newsletters - newsletter_added
|
||||
|
||||
async_add_entities(
|
||||
GhostNewsletterSensorEntity(
|
||||
coordinator,
|
||||
entry,
|
||||
newsletter_id,
|
||||
coordinator.data.newsletters[newsletter_id].get("name", "Newsletter"),
|
||||
if new_newsletters:
|
||||
async_add_entities(
|
||||
GhostNewsletterSensorEntity(
|
||||
coordinator,
|
||||
entry,
|
||||
newsletter_id,
|
||||
coordinator.data.newsletters[newsletter_id].get(
|
||||
"name", "Newsletter"
|
||||
),
|
||||
)
|
||||
for newsletter_id in new_newsletters
|
||||
)
|
||||
for newsletter_id in new_newsletters
|
||||
)
|
||||
newsletter_added |= new_newsletters
|
||||
newsletter_added.update(new_newsletters)
|
||||
|
||||
_async_add_newsletter_entities()
|
||||
removed_newsletters = newsletter_added - active_newsletters
|
||||
if removed_newsletters:
|
||||
entity_registry = er.async_get(hass)
|
||||
for newsletter_id in removed_newsletters:
|
||||
unique_id = f"{entry.unique_id}_newsletter_{newsletter_id}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, DOMAIN, unique_id
|
||||
)
|
||||
if entity_id:
|
||||
entity_registry.async_remove(entity_id)
|
||||
newsletter_added -= removed_newsletters
|
||||
|
||||
_async_update_newsletter_entities()
|
||||
entry.async_on_unload(
|
||||
coordinator.async_add_listener(_async_add_newsletter_entities)
|
||||
coordinator.async_add_listener(_async_update_newsletter_entities)
|
||||
)
|
||||
|
||||
|
||||
@@ -310,9 +343,10 @@ class GhostNewsletterSensorEntity(
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the entity is available."""
|
||||
if not super().available or self.coordinator.data is None:
|
||||
return False
|
||||
return self._newsletter_id in self.coordinator.data.newsletters
|
||||
return (
|
||||
super().available
|
||||
and self._newsletter_id in self.coordinator.data.newsletters
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Ghost site is already configured.",
|
||||
"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%]",
|
||||
"unique_id_mismatch": "The provided credentials belong to a different Ghost site."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Ghost. Please check your URL.",
|
||||
@@ -21,6 +23,17 @@
|
||||
"description": "Your API key for {title} is invalid. [Create a new integration key]({setup_url}) to reauthenticate.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]",
|
||||
"api_url": "[%key:component::ghost::config::step::user::data::api_url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]",
|
||||
"api_url": "[%key:component::ghost::config::step::user::data_description::api_url%]"
|
||||
},
|
||||
"description": "Update the configuration for your Ghost integration. [Create a custom integration]({setup_url}) to get your API URL and Admin API key."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"admin_api_key": "Admin API key",
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -75,6 +76,7 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -44,6 +44,7 @@ from homeassistant.components.backup import (
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
NewBackup,
|
||||
OnProgressCallback,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
@@ -183,6 +184,7 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -64,12 +64,6 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"started_drying": {
|
||||
"trigger": "mdi:arrow-down-bold"
|
||||
},
|
||||
|
||||
@@ -199,42 +199,6 @@
|
||||
},
|
||||
"title": "Humidifier",
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers after the humidity measured by one or more humidifiers changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier current humidity changed"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity measured by one or more humidifiers crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier current humidity crossed threshold"
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers after one or more humidifiers start drying.",
|
||||
"fields": {
|
||||
|
||||
@@ -4,21 +4,13 @@ from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, DOMAIN, HumidifierAction
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.trigger_common: &trigger_common
|
||||
target: &trigger_humidifier_target
|
||||
target:
|
||||
entity:
|
||||
domain: humidifier
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
@@ -14,52 +14,7 @@
|
||||
- last
|
||||
- any
|
||||
|
||||
.number_or_entity: &number_or_entity
|
||||
required: false
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain:
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- above
|
||||
- below
|
||||
- between
|
||||
- outside
|
||||
translation_key: trigger_threshold_type
|
||||
|
||||
started_drying: *trigger_common
|
||||
started_humidifying: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
turned_off: *trigger_common
|
||||
|
||||
current_humidity_changed:
|
||||
target: *trigger_humidifier_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_humidity_crossed_threshold:
|
||||
target: *trigger_humidifier_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
17
homeassistant/components/humidity/__init__.py
Normal file
17
homeassistant/components/humidity/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Integration for humidity triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "humidity"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
__all__ = []
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/humidity/icons.json
Normal file
10
homeassistant/components/humidity/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/humidity/manifest.json
Normal file
8
homeassistant/components/humidity/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "humidity",
|
||||
"name": "Humidity",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/humidity",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
68
homeassistant/components/humidity/strings.json
Normal file
68
homeassistant/components/humidity/strings.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"number_or_entity": {
|
||||
"choices": {
|
||||
"entity": "Entity",
|
||||
"number": "Number"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
},
|
||||
"trigger_threshold_type": {
|
||||
"options": {
|
||||
"above": "Above",
|
||||
"below": "Below",
|
||||
"between": "Between",
|
||||
"outside": "Outside"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Humidity",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when the humidity changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Only trigger when humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Only trigger when humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Humidity changed"
|
||||
},
|
||||
"crossed_threshold": {
|
||||
"description": "Triggers when the humidity crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::humidity::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "The lower limit of the threshold.",
|
||||
"name": "Lower limit"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "The type of threshold to use.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "The upper limit of the threshold.",
|
||||
"name": "Upper limit"
|
||||
}
|
||||
},
|
||||
"name": "Humidity crossed threshold"
|
||||
}
|
||||
}
|
||||
}
|
||||
71
homeassistant/components/humidity/trigger.py
Normal file
71
homeassistant/components/humidity/trigger.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Provides triggers for humidity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_HUMIDITY as CLIMATE_ATTR_CURRENT_HUMIDITY,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.humidifier import (
|
||||
ATTR_CURRENT_HUMIDITY as HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
DOMAIN as HUMIDIFIER_DOMAIN,
|
||||
)
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_WEATHER_HUMIDITY,
|
||||
DOMAIN as WEATHER_DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers.trigger import (
|
||||
EntityNumericalStateAttributeChangedTriggerBase,
|
||||
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
|
||||
EntityTriggerBase,
|
||||
Trigger,
|
||||
get_device_class_or_undefined,
|
||||
)
|
||||
|
||||
|
||||
class _HumidityTriggerMixin(EntityTriggerBase):
|
||||
"""Mixin for humidity triggers providing entity filtering and value extraction."""
|
||||
|
||||
_attributes = {
|
||||
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
|
||||
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
|
||||
SENSOR_DOMAIN: None, # Use state.state
|
||||
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
|
||||
}
|
||||
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
|
||||
or get_device_class_or_undefined(self._hass, entity_id)
|
||||
== SensorDeviceClass.HUMIDITY
|
||||
}
|
||||
|
||||
|
||||
class HumidityChangedTrigger(
|
||||
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
|
||||
):
|
||||
"""Trigger for humidity value changes across multiple domains."""
|
||||
|
||||
|
||||
class HumidityCrossedThresholdTrigger(
|
||||
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
|
||||
):
|
||||
"""Trigger for humidity value crossing a threshold across multiple domains."""
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": HumidityChangedTrigger,
|
||||
"crossed_threshold": HumidityCrossedThresholdTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for humidity."""
|
||||
return TRIGGERS
|
||||
64
homeassistant/components/humidity/triggers.yaml
Normal file
64
homeassistant/components/humidity/triggers.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
.trigger_common_fields:
|
||||
behavior: &trigger_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
.number_or_entity: &number_or_entity
|
||||
required: false
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain:
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- above
|
||||
- below
|
||||
- between
|
||||
- outside
|
||||
translation_key: trigger_threshold_type
|
||||
|
||||
.trigger_target: &trigger_target
|
||||
entity:
|
||||
- domain: sensor
|
||||
device_class: humidity
|
||||
- domain: climate
|
||||
- domain: humidifier
|
||||
- domain: weather
|
||||
|
||||
changed:
|
||||
target: *trigger_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
crossed_threshold:
|
||||
target: *trigger_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
@@ -15,6 +15,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -127,6 +128,7 @@ class IDriveE2BackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -102,5 +102,6 @@ SENSOR_KEYS = {
|
||||
"11009",
|
||||
"11010",
|
||||
"6105",
|
||||
"1505",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "indevolt",
|
||||
"name": "Indevolt",
|
||||
"codeowners": ["@xirtnl"],
|
||||
"codeowners": ["@xirt"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/indevolt",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -234,7 +234,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key="1505",
|
||||
generation=[1],
|
||||
translation_key="cumulative_production",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupNotFound,
|
||||
Folder,
|
||||
OnProgressCallback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -91,6 +92,7 @@ class KitchenSinkBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
@@ -24,7 +24,7 @@ class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for brightness changed."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
|
||||
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
@@ -35,7 +35,7 @@ class BrightnessCrossedThresholdTrigger(
|
||||
"""Trigger for brightness crossed threshold."""
|
||||
|
||||
_domains = {DOMAIN}
|
||||
_attribute = ATTR_BRIGHTNESS
|
||||
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
|
||||
_converter = staticmethod(_convert_uint8_to_percentage)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from pylitterbot import Account
|
||||
from pylitterbot.exceptions import LitterRobotException
|
||||
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
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 DeviceEntry
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -14,6 +19,8 @@ from .const import DOMAIN
|
||||
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -33,6 +40,50 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: LitterRobotConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
_LOGGER.debug(
|
||||
"Migrating configuration from version %s.%s",
|
||||
entry.version,
|
||||
entry.minor_version,
|
||||
)
|
||||
|
||||
if entry.version > 1:
|
||||
return False
|
||||
|
||||
if entry.minor_version < 2:
|
||||
account = Account(websession=async_get_clientsession(hass))
|
||||
try:
|
||||
await account.connect(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
)
|
||||
user_id = account.user_id
|
||||
except LitterRobotException:
|
||||
_LOGGER.debug("Could not connect to set unique_id during migration")
|
||||
return False
|
||||
finally:
|
||||
await account.disconnect()
|
||||
|
||||
if user_id and not hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, user_id
|
||||
):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=user_id, minor_version=2
|
||||
)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to configuration version %s.%s successful",
|
||||
entry.version,
|
||||
entry.minor_version,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LitterRobotConfigEntry) -> bool:
|
||||
"""Set up Litter-Robot from a config entry."""
|
||||
coordinator = LitterRobotDataUpdateCoordinator(hass, entry)
|
||||
|
||||
@@ -27,8 +27,10 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Litter-Robot."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
username: str
|
||||
_account_user_id: str | None = None
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
@@ -45,6 +47,8 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input:
|
||||
user_input = user_input | {CONF_USERNAME: self.username}
|
||||
if not (error := await self._async_validate_input(user_input)):
|
||||
await self.async_set_unique_id(self._account_user_id)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(), data_updates=user_input
|
||||
)
|
||||
@@ -65,8 +69,9 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
|
||||
|
||||
if not (error := await self._async_validate_input(user_input)):
|
||||
await self.async_set_unique_id(self._account_user_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME], data=user_input
|
||||
)
|
||||
@@ -92,4 +97,7 @@ class LitterRobotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
self._account_user_id = account.user_id
|
||||
if not self._account_user_id:
|
||||
return "unknown"
|
||||
return ""
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "The Whisker account does not match the previously configured account. Please re-authenticate using the same account, or remove this integration and set it up again if you want to use a different account."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
||||
@@ -173,10 +173,5 @@
|
||||
"set_value": {
|
||||
"service": "mdi:numeric"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"trigger": "mdi:counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +204,5 @@
|
||||
"name": "Set"
|
||||
}
|
||||
},
|
||||
"title": "Number",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when a number value changes.",
|
||||
"name": "Number changed"
|
||||
}
|
||||
}
|
||||
"title": "Number"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Provides triggers for number entities."""
|
||||
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN, INPUT_NUMBER_DOMAIN}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for number entities."""
|
||||
return TRIGGERS
|
||||
@@ -1,6 +0,0 @@
|
||||
changed:
|
||||
target:
|
||||
entity:
|
||||
domain:
|
||||
- number
|
||||
- input_number
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -145,6 +146,7 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -145,6 +146,7 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
42
homeassistant/components/opendisplay/diagnostics.py
Normal file
42
homeassistant/components/opendisplay/diagnostics.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Diagnostics support for OpenDisplay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import OpenDisplayConfigEntry
|
||||
|
||||
TO_REDACT = {"ssid", "password", "server_url"}
|
||||
|
||||
|
||||
def _asdict(obj: Any) -> Any:
|
||||
"""Recursively convert a dataclass to a dict, encoding bytes as hex strings."""
|
||||
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
||||
return {f.name: _asdict(getattr(obj, f.name)) for f in dataclasses.fields(obj)}
|
||||
if isinstance(obj, bytes):
|
||||
return obj.hex()
|
||||
if isinstance(obj, list):
|
||||
return [_asdict(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: OpenDisplayConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
runtime = entry.runtime_data
|
||||
fw = runtime.firmware
|
||||
|
||||
return {
|
||||
"firmware": {
|
||||
"major": fw["major"],
|
||||
"minor": fw["minor"],
|
||||
"sha": fw["sha"],
|
||||
},
|
||||
"is_flex": runtime.is_flex,
|
||||
"device_config": async_redact_data(_asdict(runtime.device_config), TO_REDACT),
|
||||
}
|
||||
@@ -54,7 +54,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The device's BLE MAC address is both its unique identifier and does not change.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pysaunum import SaunumClient, SaunumConnectionError
|
||||
from pysaunum import SaunumClient, SaunumConnectionError, SaunumTimeoutError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
@@ -40,7 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LeilSaunaConfigEntry) ->
|
||||
|
||||
try:
|
||||
client = await SaunumClient.create(host)
|
||||
except SaunumConnectionError as exc:
|
||||
except (SaunumConnectionError, SaunumTimeoutError) as exc:
|
||||
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
|
||||
|
||||
entry.async_on_unload(client.async_close)
|
||||
|
||||
@@ -6,7 +6,14 @@ import asyncio
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
|
||||
from pysaunum import (
|
||||
DEFAULT_DURATION,
|
||||
DEFAULT_FAN_DURATION,
|
||||
DEFAULT_TEMPERATURE,
|
||||
MAX_TEMPERATURE,
|
||||
MIN_TEMPERATURE,
|
||||
SaunumException,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
FAN_HIGH,
|
||||
@@ -149,7 +156,7 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode."""
|
||||
sauna_type = self.coordinator.data.sauna_type
|
||||
if sauna_type is not None and sauna_type in self._preset_name_map:
|
||||
if sauna_type in self._preset_name_map:
|
||||
return self._preset_name_map[sauna_type]
|
||||
return self._preset_name_map[0]
|
||||
|
||||
@@ -242,9 +249,9 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
|
||||
|
||||
async def async_start_session(
|
||||
self,
|
||||
duration: timedelta = timedelta(minutes=120),
|
||||
target_temperature: int = 80,
|
||||
fan_duration: timedelta = timedelta(minutes=10),
|
||||
duration: timedelta = timedelta(minutes=DEFAULT_DURATION),
|
||||
target_temperature: int = DEFAULT_TEMPERATURE,
|
||||
fan_duration: timedelta = timedelta(minutes=DEFAULT_FAN_DURATION),
|
||||
) -> None:
|
||||
"""Start a sauna session with custom parameters."""
|
||||
if self.coordinator.data.door_open:
|
||||
|
||||
@@ -7,6 +7,8 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pysaunum import (
|
||||
DEFAULT_DURATION,
|
||||
DEFAULT_FAN_DURATION,
|
||||
MAX_DURATION,
|
||||
MAX_FAN_DURATION,
|
||||
MIN_DURATION,
|
||||
@@ -35,10 +37,6 @@ if TYPE_CHECKING:
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
# Default values when device returns None or invalid data
|
||||
DEFAULT_DURATION_MIN = 120
|
||||
DEFAULT_FAN_DURATION_MIN = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LeilSaunaNumberEntityDescription(NumberEntityDescription):
|
||||
@@ -59,8 +57,8 @@ NUMBERS: tuple[LeilSaunaNumberEntityDescription, ...] = (
|
||||
native_step=1,
|
||||
value_fn=lambda data: (
|
||||
duration
|
||||
if (duration := data.sauna_duration) is not None and duration > MIN_DURATION
|
||||
else DEFAULT_DURATION_MIN
|
||||
if (duration := data.sauna_duration) > MIN_DURATION
|
||||
else DEFAULT_DURATION
|
||||
),
|
||||
set_value_fn=lambda client, value: client.async_set_sauna_duration(int(value)),
|
||||
),
|
||||
@@ -74,8 +72,8 @@ NUMBERS: tuple[LeilSaunaNumberEntityDescription, ...] = (
|
||||
native_step=1,
|
||||
value_fn=lambda data: (
|
||||
fan_dur
|
||||
if (fan_dur := data.fan_duration) is not None and fan_dur > MIN_FAN_DURATION
|
||||
else DEFAULT_FAN_DURATION_MIN
|
||||
if (fan_dur := data.fan_duration) > MIN_FAN_DURATION
|
||||
else DEFAULT_FAN_DURATION
|
||||
),
|
||||
set_value_fn=lambda client, value: client.async_set_fan_duration(int(value)),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,15 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE
|
||||
from pysaunum import (
|
||||
DEFAULT_DURATION,
|
||||
DEFAULT_FAN_DURATION,
|
||||
DEFAULT_TEMPERATURE,
|
||||
MAX_DURATION,
|
||||
MAX_FAN_DURATION,
|
||||
MAX_TEMPERATURE,
|
||||
MIN_TEMPERATURE,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
|
||||
@@ -29,17 +37,21 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
SERVICE_START_SESSION,
|
||||
entity_domain=CLIMATE_DOMAIN,
|
||||
schema={
|
||||
vol.Optional(ATTR_DURATION, default=timedelta(minutes=120)): vol.All(
|
||||
vol.Optional(
|
||||
ATTR_DURATION, default=timedelta(minutes=DEFAULT_DURATION)
|
||||
): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(
|
||||
min=timedelta(minutes=1),
|
||||
max=timedelta(minutes=MAX_DURATION),
|
||||
),
|
||||
),
|
||||
vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All(
|
||||
vol.Optional(ATTR_TARGET_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All(
|
||||
cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
|
||||
),
|
||||
vol.Optional(ATTR_FAN_DURATION, default=timedelta(minutes=10)): vol.All(
|
||||
vol.Optional(
|
||||
ATTR_FAN_DURATION, default=timedelta(minutes=DEFAULT_FAN_DURATION)
|
||||
): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(
|
||||
min=timedelta(minutes=1),
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
@@ -85,6 +86,7 @@ class SFTPBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
@@ -190,6 +190,8 @@ async def make_device_data(
|
||||
"Smart Lock Vision",
|
||||
"Smart Lock Vision Pro",
|
||||
"Smart Lock Pro Wifi",
|
||||
"Lock Vision",
|
||||
"Lock Vision Pro",
|
||||
]:
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
|
||||
@@ -102,6 +102,14 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Lock Vision": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Lock Vision Pro": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Smart Lock Pro Wifi": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
|
||||
@@ -58,6 +58,8 @@ class SwitchBotCloudLight(SwitchBotCloudEntity, LightEntity):
|
||||
"""Return the default color mode."""
|
||||
if not self.supported_color_modes:
|
||||
return ColorMode.UNKNOWN
|
||||
if ColorMode.BRIGHTNESS in self.supported_color_modes:
|
||||
return ColorMode.BRIGHTNESS
|
||||
if ColorMode.RGB in self.supported_color_modes:
|
||||
return ColorMode.RGB
|
||||
if ColorMode.COLOR_TEMP in self.supported_color_modes:
|
||||
@@ -136,6 +138,7 @@ class SwitchBotCloudCandleWarmerLamp(SwitchBotCloudLight):
|
||||
# Brightness adjustment
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
|
||||
|
||||
class SwitchBotCloudStripLight(SwitchBotCloudLight):
|
||||
@@ -145,6 +148,7 @@ class SwitchBotCloudStripLight(SwitchBotCloudLight):
|
||||
# RGB color control
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.RGB}
|
||||
_attr_color_mode = ColorMode.RGB
|
||||
|
||||
|
||||
class SwitchBotCloudRGBICLight(SwitchBotCloudLight):
|
||||
@@ -154,6 +158,7 @@ class SwitchBotCloudRGBICLight(SwitchBotCloudLight):
|
||||
# RGB color control
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.RGB}
|
||||
_attr_color_mode = ColorMode.RGB
|
||||
|
||||
async def _send_rgb_color_command(self, rgb_color: tuple) -> None:
|
||||
"""Send an RGB command."""
|
||||
@@ -174,6 +179,7 @@ class SwitchBotCloudRGBWWLight(SwitchBotCloudLight):
|
||||
_attr_min_color_temp_kelvin = 2700
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.RGB, ColorMode.COLOR_TEMP}
|
||||
_attr_color_mode = ColorMode.RGB
|
||||
|
||||
async def _send_brightness_command(self, brightness: int) -> None:
|
||||
"""Send a brightness command."""
|
||||
@@ -200,6 +206,7 @@ class SwitchBotCloudCeilingLight(SwitchBotCloudLight):
|
||||
_attr_min_color_temp_kelvin = 2700
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.COLOR_TEMP}
|
||||
_attr_color_mode = ColorMode.COLOR_TEMP
|
||||
|
||||
async def _send_brightness_command(self, brightness: int) -> None:
|
||||
"""Send a brightness command."""
|
||||
|
||||
@@ -227,6 +227,8 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Vision": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Vision Pro": (BATTERY_DESCRIPTION,),
|
||||
"Lock Vision": (BATTERY_DESCRIPTION,),
|
||||
"Lock Vision Pro": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Pro Wifi": (BATTERY_DESCRIPTION,),
|
||||
"Relay Switch 2PM": (
|
||||
RELAY_SWITCH_2PM_POWER_DESCRIPTION,
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -155,6 +156,7 @@ class SynologyDSMBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
@@ -790,7 +790,6 @@ edit_message:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
@@ -843,7 +842,6 @@ edit_message_media:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
@@ -922,7 +920,6 @@ edit_caption:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
@@ -960,7 +957,6 @@ edit_replymarkup:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
@@ -1015,7 +1011,6 @@ delete_message:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: "{{ trigger.event.data.message.message_id }}"
|
||||
@@ -1042,7 +1037,6 @@ leave_chat:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
advanced:
|
||||
collapsed: true
|
||||
fields:
|
||||
@@ -1064,7 +1058,6 @@ set_message_reaction:
|
||||
filter:
|
||||
domain: notify
|
||||
integration: telegram_bot
|
||||
reorder: true
|
||||
message_id:
|
||||
required: true
|
||||
example: 54321
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import CONF_PROBE_COUNT, DOMAIN
|
||||
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, DOMAIN
|
||||
|
||||
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
|
||||
|
||||
@@ -213,6 +213,8 @@ class ToGrillCoordinator(DataUpdateCoordinator[dict[tuple[int, int | None], Pack
|
||||
await client.request(PacketA1Notify)
|
||||
for probe in range(1, self.config_entry.data[CONF_PROBE_COUNT] + 1):
|
||||
await client.write(PacketA8Write(probe=probe))
|
||||
if self.config_entry.data.get(CONF_HAS_AMBIENT):
|
||||
await client.write(PacketA8Write(probe=0))
|
||||
except BleakError as exc:
|
||||
raise DeviceFailed(f"Device failed {exc}") from exc
|
||||
return self.data
|
||||
|
||||
@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import ToGrillConfigEntry
|
||||
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
|
||||
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, MAX_PROBE_COUNT
|
||||
from .coordinator import ToGrillCoordinator
|
||||
from .entity import ToGrillEntity
|
||||
|
||||
@@ -123,12 +123,64 @@ def _get_temperature_descriptions(
|
||||
)
|
||||
|
||||
|
||||
def _get_ambient_temperatures(
|
||||
coordinator: ToGrillCoordinator, alarm_type: AlarmType
|
||||
) -> tuple[float | None, float | None]:
|
||||
if not (packet := coordinator.get_packet(PacketA8Notify, 0)):
|
||||
return None, None
|
||||
if packet.alarm_type != alarm_type:
|
||||
return None, None
|
||||
return packet.temperature_1, packet.temperature_2
|
||||
|
||||
|
||||
ENTITY_DESCRIPTIONS = (
|
||||
*[
|
||||
description
|
||||
for probe_number in range(1, MAX_PROBE_COUNT + 1)
|
||||
for description in _get_temperature_descriptions(probe_number)
|
||||
],
|
||||
ToGrillNumberEntityDescription(
|
||||
key="ambient_temperature_minimum",
|
||||
translation_key="ambient_temperature_minimum",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
native_min_value=0,
|
||||
native_max_value=400,
|
||||
mode=NumberMode.BOX,
|
||||
icon="mdi:thermometer-chevron-down",
|
||||
set_packet=lambda coordinator, value: PacketA300Write(
|
||||
probe=0,
|
||||
minimum=None if value == 0.0 else value,
|
||||
maximum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[
|
||||
1
|
||||
],
|
||||
),
|
||||
get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[
|
||||
0
|
||||
],
|
||||
entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False),
|
||||
),
|
||||
ToGrillNumberEntityDescription(
|
||||
key="ambient_temperature_maximum",
|
||||
translation_key="ambient_temperature_maximum",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
native_min_value=0,
|
||||
native_max_value=400,
|
||||
mode=NumberMode.BOX,
|
||||
icon="mdi:thermometer-chevron-up",
|
||||
set_packet=lambda coordinator, value: PacketA300Write(
|
||||
probe=0,
|
||||
minimum=_get_ambient_temperatures(coordinator, AlarmType.TEMPERATURE_RANGE)[
|
||||
0
|
||||
],
|
||||
maximum=None if value == 0.0 else value,
|
||||
),
|
||||
get_value=lambda x: _get_ambient_temperatures(x, AlarmType.TEMPERATURE_RANGE)[
|
||||
1
|
||||
],
|
||||
entity_supported=lambda x: x.get(CONF_HAS_AMBIENT, False),
|
||||
),
|
||||
ToGrillNumberEntityDescription(
|
||||
key="alarm_interval",
|
||||
translation_key="alarm_interval",
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
"alarm_interval": {
|
||||
"name": "Alarm interval"
|
||||
},
|
||||
"ambient_temperature_maximum": {
|
||||
"name": "Ambient maximum temperature"
|
||||
},
|
||||
"ambient_temperature_minimum": {
|
||||
"name": "Ambient minimum temperature"
|
||||
},
|
||||
"temperature_maximum": {
|
||||
"name": "Maximum temperature"
|
||||
},
|
||||
|
||||
@@ -81,6 +81,7 @@ clean_area:
|
||||
selector:
|
||||
area:
|
||||
multiple: true
|
||||
reorder: true
|
||||
|
||||
send_command:
|
||||
target:
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.backup import (
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
OnProgressCallback,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -140,6 +141,7 @@ class WebDavBackupAgent(BackupAgent):
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
on_progress: OnProgressCallback,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -229,6 +229,7 @@ FLOWS = {
|
||||
"foscam",
|
||||
"freebox",
|
||||
"freedompro",
|
||||
"freshr",
|
||||
"fressnapf_tracker",
|
||||
"fritz",
|
||||
"fritzbox",
|
||||
|
||||
@@ -2208,6 +2208,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"freshr": {
|
||||
"name": "Fresh-r",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"fressnapf_tracker": {
|
||||
"name": "Fressnapf Tracker",
|
||||
"integration_type": "hub",
|
||||
|
||||
5
homeassistant/generated/labs.py
generated
5
homeassistant/generated/labs.py
generated
@@ -19,6 +19,11 @@ LABS_PREVIEW_FEATURES = {
|
||||
},
|
||||
},
|
||||
"frontend": {
|
||||
"windows_98": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
"report_issue_url": "",
|
||||
},
|
||||
"winter_mode": {
|
||||
"feedback_url": "",
|
||||
"learn_more_url": "",
|
||||
|
||||
@@ -119,6 +119,13 @@ def _validate_supported_features(supported_features: list[str]) -> int:
|
||||
return feature_mask
|
||||
|
||||
|
||||
def _validate_selector_reorder_config(config: Any) -> Any:
|
||||
"""Validate selectors with reorder option."""
|
||||
if config.get("reorder") and not config.get("multiple"):
|
||||
raise vol.Invalid("reorder can only be used when multiple is true")
|
||||
return config
|
||||
|
||||
|
||||
def make_selector_config_schema(schema_dict: dict | None = None) -> vol.Schema:
|
||||
"""Make selector config schema."""
|
||||
if schema_dict is None:
|
||||
@@ -301,6 +308,7 @@ class AreaSelectorConfig(BaseSelectorConfig, total=False):
|
||||
entity: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig]
|
||||
device: DeviceFilterSelectorConfig | list[DeviceFilterSelectorConfig]
|
||||
multiple: bool
|
||||
reorder: bool
|
||||
|
||||
|
||||
@SELECTORS.register("area")
|
||||
@@ -309,18 +317,22 @@ class AreaSelector(Selector[AreaSelectorConfig]):
|
||||
|
||||
selector_type = "area"
|
||||
|
||||
CONFIG_SCHEMA = make_selector_config_schema(
|
||||
{
|
||||
vol.Optional("entity"): vol.All(
|
||||
cv.ensure_list,
|
||||
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
vol.Optional("device"): vol.All(
|
||||
cv.ensure_list,
|
||||
[DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
}
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
make_selector_config_schema(
|
||||
{
|
||||
vol.Optional("entity"): vol.All(
|
||||
cv.ensure_list,
|
||||
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
vol.Optional("device"): vol.All(
|
||||
cv.ensure_list,
|
||||
[DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
vol.Optional("reorder", default=False): cv.boolean,
|
||||
}
|
||||
),
|
||||
_validate_selector_reorder_config,
|
||||
)
|
||||
|
||||
def __init__(self, config: AreaSelectorConfig | None = None) -> None:
|
||||
@@ -892,18 +904,21 @@ class EntitySelector(Selector[EntitySelectorConfig]):
|
||||
|
||||
selector_type = "entity"
|
||||
|
||||
CONFIG_SCHEMA = make_selector_config_schema(
|
||||
{
|
||||
**_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT,
|
||||
vol.Optional("exclude_entities"): [str],
|
||||
vol.Optional("include_entities"): [str],
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
vol.Optional("reorder", default=False): cv.boolean,
|
||||
vol.Optional("filter"): vol.All(
|
||||
cv.ensure_list,
|
||||
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
}
|
||||
CONFIG_SCHEMA = vol.All(
|
||||
make_selector_config_schema(
|
||||
{
|
||||
**_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT,
|
||||
vol.Optional("exclude_entities"): [str],
|
||||
vol.Optional("include_entities"): [str],
|
||||
vol.Optional("multiple", default=False): cv.boolean,
|
||||
vol.Optional("reorder", default=False): cv.boolean,
|
||||
vol.Optional("filter"): vol.All(
|
||||
cv.ensure_list,
|
||||
[ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA],
|
||||
),
|
||||
}
|
||||
),
|
||||
_validate_selector_reorder_config,
|
||||
)
|
||||
|
||||
def __init__(self, config: EntitySelectorConfig | None = None) -> None:
|
||||
|
||||
@@ -73,6 +73,7 @@ from .automation import (
|
||||
get_relative_description_key,
|
||||
move_options_fields_to_top_level,
|
||||
)
|
||||
from .entity import get_device_class
|
||||
from .integration_platform import async_process_integration_platforms
|
||||
from .selector import TargetSelector
|
||||
from .target import (
|
||||
@@ -80,7 +81,7 @@ from .target import (
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from .template import Template
|
||||
from .typing import ConfigType, TemplateVarsType
|
||||
from .typing import UNDEFINED, ConfigType, TemplateVarsType, UndefinedType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -333,6 +334,16 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def get_device_class_or_undefined(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> str | None | UndefinedType:
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
return UNDEFINED
|
||||
|
||||
|
||||
class EntityTriggerBase(Trigger):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
@@ -600,17 +611,29 @@ def _get_numerical_value(
|
||||
return entity_or_float
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
class EntityNumericalStateBase(EntityTriggerBase):
|
||||
"""Base class for numerical state and state attribute triggers."""
|
||||
|
||||
_attributes: dict[str, str | None]
|
||||
_converter: Callable[[Any], float] = float
|
||||
|
||||
def _get_tracked_value(self, state: State) -> Any:
|
||||
"""Get the tracked numerical value from a state."""
|
||||
domain = split_entity_id(state.entity_id)[0]
|
||||
source = self._attributes[domain]
|
||||
if source is None:
|
||||
return state.state
|
||||
return state.attributes.get(source)
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeChangedTriggerBase(EntityNumericalStateBase):
|
||||
"""Trigger for numerical state and state attribute changes."""
|
||||
|
||||
_attribute: str
|
||||
_schema = NUMERICAL_ATTRIBUTE_CHANGED_TRIGGER_SCHEMA
|
||||
|
||||
_above: None | float | str
|
||||
_below: None | float | str
|
||||
|
||||
_converter: Callable[[Any], float] = float
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
@@ -622,20 +645,18 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
|
||||
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
return from_state.attributes.get(self._attribute) != to_state.attributes.get(
|
||||
self._attribute
|
||||
)
|
||||
return self._get_tracked_value(from_state) != self._get_tracked_value(to_state) # type: ignore[no-any-return]
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state attribute matches the expected one."""
|
||||
# Handle missing or None attribute case first to avoid expensive exceptions
|
||||
if (_attribute_value := state.attributes.get(self._attribute)) is None:
|
||||
"""Check if the new state or state attribute matches the expected one."""
|
||||
# Handle missing or None value case first to avoid expensive exceptions
|
||||
if (_attribute_value := self._get_tracked_value(state)) is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_value = self._converter(_attribute_value)
|
||||
except TypeError, ValueError:
|
||||
# Attribute is not a valid number, don't trigger
|
||||
# Value is not a valid number, don't trigger
|
||||
return False
|
||||
|
||||
if self._above is not None:
|
||||
@@ -657,24 +678,6 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
|
||||
return True
|
||||
|
||||
|
||||
class EntityNumericalStateChangedTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state changes."""
|
||||
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected one."""
|
||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
try:
|
||||
float(state.state)
|
||||
except TypeError, ValueError:
|
||||
# State is not a valid number, don't trigger
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
CONF_LOWER_LIMIT = "lower_limit"
|
||||
CONF_UPPER_LIMIT = "upper_limit"
|
||||
CONF_THRESHOLD_TYPE = "threshold_type"
|
||||
@@ -727,22 +730,21 @@ NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.exten
|
||||
)
|
||||
|
||||
|
||||
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state attribute changes.
|
||||
class EntityNumericalStateAttributeCrossedThresholdTriggerBase(
|
||||
EntityNumericalStateBase
|
||||
):
|
||||
"""Trigger for numerical state and state attribute changes.
|
||||
|
||||
This trigger only fires when the observed attribute changes from not within to within
|
||||
the defined threshold.
|
||||
"""
|
||||
|
||||
_attribute: str
|
||||
_schema = NUMERICAL_ATTRIBUTE_CROSSED_THRESHOLD_SCHEMA
|
||||
|
||||
_lower_limit: float | str | None = None
|
||||
_upper_limit: float | str | None = None
|
||||
_threshold_type: ThresholdType
|
||||
|
||||
_converter: Callable[[Any], float] = float
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
@@ -773,14 +775,14 @@ class EntityNumericalStateAttributeCrossedThresholdTriggerBase(EntityTriggerBase
|
||||
# Entity not found or invalid number, don't trigger
|
||||
return False
|
||||
|
||||
# Handle missing or None attribute case first to avoid expensive exceptions
|
||||
if (_attribute_value := state.attributes.get(self._attribute)) is None:
|
||||
# Handle missing or None value case first to avoid expensive exceptions
|
||||
if (_attribute_value := self._get_tracked_value(state)) is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
current_value = self._converter(_attribute_value)
|
||||
except TypeError, ValueError:
|
||||
# Attribute is not a valid number, don't trigger
|
||||
# Value is not a valid number, don't trigger
|
||||
return False
|
||||
|
||||
# Note: We do not need to check for lower_limit/upper_limit being None here
|
||||
@@ -846,42 +848,29 @@ def make_entity_origin_state_trigger(
|
||||
|
||||
|
||||
def make_entity_numerical_state_attribute_changed_trigger(
|
||||
domain: str, attribute: str
|
||||
domains: set[str], attributes: dict[str, str | None]
|
||||
) -> type[EntityNumericalStateAttributeChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state attribute change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeChangedTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
|
||||
_domains = {domain}
|
||||
_attribute = attribute
|
||||
_domains = domains
|
||||
_attributes = attributes
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
domain: str, attribute: str
|
||||
domains: set[str], attributes: dict[str, str | None]
|
||||
) -> type[EntityNumericalStateAttributeCrossedThresholdTriggerBase]:
|
||||
"""Create a trigger for numerical state attribute change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateAttributeCrossedThresholdTriggerBase):
|
||||
"""Trigger for numerical state attribute changes."""
|
||||
|
||||
_domains = {domain}
|
||||
_attribute = attribute
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_numerical_state_changed_trigger(
|
||||
domains: set[str],
|
||||
) -> type[EntityNumericalStateChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateChangedTriggerBase):
|
||||
"""Trigger for numerical state changes."""
|
||||
|
||||
_domains = domains
|
||||
_attributes = attributes
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -1876,6 +1876,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.freshr.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.fritz.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -1193,14 +1193,17 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="current_humidity",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_humidity",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="hvac_mode",
|
||||
return_type=["HVACMode", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="hvac_modes",
|
||||
@@ -1210,26 +1213,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="hvac_action",
|
||||
return_type=["HVACAction", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="current_temperature",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_step",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_high",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_low",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="preset_mode",
|
||||
@@ -1239,26 +1248,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="preset_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="is_aux_heat",
|
||||
return_type=["bool", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="fan_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="fan_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="swing_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="swing_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="set_temperature",
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -2112,6 +2112,9 @@ pyforked-daapd==0.1.14
|
||||
# homeassistant.components.freedompro
|
||||
pyfreedompro==1.1.0
|
||||
|
||||
# homeassistant.components.freshr
|
||||
pyfreshr==1.2.0
|
||||
|
||||
# homeassistant.components.fritzbox
|
||||
pyfritzhome==0.6.20
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user