Compare commits

...

30 Commits

Author SHA1 Message Date
Paul Bottein
df06a5878c Add windows 98 labs feature 2026-03-10 09:41:49 +01:00
Panda-NZ
a36733c4dc Add ambient temperature range controls to ToGrill integration (#165235) 2026-03-09 23:40:30 +01:00
Bram Kragten
bf846e0756 Validate reorder is only used when multiple is true (#165216) 2026-03-09 22:32:02 +01:00
Erik Montnemery
c037dad093 Add humidity triggers (#165197) 2026-03-09 20:34:26 +01:00
Erik Montnemery
ce11e66e1f Add cover triggers (#165188)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-09 19:37:36 +01:00
David Bishop
f38ca7b04a Add unique_id to Whisker (Litter-Robot) config entries (#164766)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-09 19:35:34 +01:00
Tor André Roland
01200ef0a8 Optimizations to Adax local device control (#162109)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-09 19:29:43 +01:00
mettolen
c5e0c78cbc Minor Saunum integration improvements (#164705) 2026-03-09 19:22:27 +01:00
g4bri3lDev
7681caa936 Add diagnostics to OpenDisplay integration (#165222) 2026-03-09 19:05:52 +01:00
Bram Kragten
230a2ff045 Add reorder support to area selector (#165211) 2026-03-09 17:40:34 +01:00
A. Gideonse
9d828502a3 Fix code owner for indevolt integration (#165214) 2026-03-09 17:40:00 +01:00
Samuel Xiao
28088a7e1a Switchbot Cloud: Compatible with new device types (#165191) 2026-03-09 17:12:39 +01:00
epenet
9e8171fb77 Improve test coverage in Tuya light (#164954) 2026-03-09 17:11:26 +01:00
John O'Nolan
1660d3b28a Add stale device removal to Ghost integration (#165134) 2026-03-09 17:10:13 +01:00
Josef Zweck
2ef81a54a5 Allow backups to report the upload progress (#163608)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-09 17:12:49 +02:00
Samuel Xiao
ce6154839e Switchbot Cloud: Fixed light mode settings error (#164723) 2026-03-09 15:50:02 +01:00
Erik Montnemery
a25300b8e1 Fix import in cover (#165199) 2026-03-09 15:27:12 +01:00
Leon Grave
6fa8e71b21 Add freshr integration, based on pyfreshr (#164538)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-09 15:26:03 +01:00
tronikos
c983978a10 Remove type: ignore in Android TV Remote (#165126) 2026-03-09 14:42:51 +01:00
Joost Lekkerkerker
68b8b6b675 Add fixture for Air Purifier to SmartThings (#165187) 2026-03-09 14:21:34 +01:00
Martin Hjelmare
ee4d313b10 Fix update tests for Python 3.14.3 (#165196) 2026-03-09 14:21:18 +01:00
Erik Montnemery
5e665093c9 Revert "Add number.changed trigger" (#165193) 2026-03-09 13:55:08 +01:00
A. Gideonse
9a5f509ab9 Fix missing Gen-2 sensor for the Indevolt integration (#165133) 2026-03-09 13:49:54 +01:00
Erik Montnemery
8d0cd5edaa Remove some climate and humidifier triggers (#165192) 2026-03-09 13:37:31 +01:00
epenet
71726272f5 Speed up SmartThings tests (#165184)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 13:25:14 +01:00
epenet
9c6c27ab56 Avoid duplicate id/label in smartthings device fixtures (#165190) 2026-03-09 12:40:11 +01:00
Joost Lekkerkerker
db20cf8161 Rename SmartThings devices to maintain uniqueness (#165189) 2026-03-09 12:16:07 +01:00
John O'Nolan
59b6270157 Add reconfigure flow to Ghost integration (#165131) 2026-03-09 11:57:40 +01:00
epenet
a65ba01bbe Mark climate type hints as mandatory (#164982)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-09 11:50:42 +01:00
Erik Montnemery
a5d0350560 Add garage_door triggers (#165144)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 11:42:09 +01:00
169 changed files with 8953 additions and 1354 deletions

View File

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

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

View File

@@ -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
View 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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View 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)

View 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
)

View File

@@ -0,0 +1,7 @@
"""Constants for the Fresh-r integration."""
import logging
from typing import Final
DOMAIN: Final = "freshr"
LOGGER = logging.getLogger(__package__)

View 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

View 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"
}
}
}
}

View 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"]
}

View 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

View 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)

View 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."
}
}
}

View File

@@ -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"]
}

View File

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

View 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

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"closed": {
"trigger": "mdi:garage"
},
"opened": {
"trigger": "mdi:garage-open"
}
}
}

View 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"
}

View 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"
}
}
}

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"changed": {
"trigger": "mdi:water-percent"
},
"crossed_threshold": {
"trigger": "mdi:water-percent"
}
}
}

View 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"
}

View 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"
}
}
}

View 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

View 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

View File

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

View File

@@ -102,5 +102,6 @@ SENSOR_KEYS = {
"11009",
"11010",
"6105",
"1505",
],
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -173,10 +173,5 @@
"set_value": {
"service": "mdi:numeric"
}
},
"triggers": {
"changed": {
"trigger": "mdi:counter"
}
}
}

View File

@@ -204,11 +204,5 @@
"name": "Set"
}
},
"title": "Number",
"triggers": {
"changed": {
"description": "Triggers when a number value changes.",
"name": "Number changed"
}
}
"title": "Number"
}

View File

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

View File

@@ -1,6 +0,0 @@
changed:
target:
entity:
domain:
- number
- input_number

View File

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

View File

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

View 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),
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,7 @@ clean_area:
selector:
area:
multiple: true
reorder: true
send_command:
target:

View File

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

View File

@@ -229,6 +229,7 @@ FLOWS = {
"foscam",
"freebox",
"freedompro",
"freshr",
"fressnapf_tracker",
"fritz",
"fritzbox",

View File

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

View File

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

View File

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

View File

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

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

View File

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

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