Compare commits

..

3 Commits

Author SHA1 Message Date
Michael
269e9e04c4 Merge branch 'dev' into number/add-crossed_threshold-trigger 2026-03-08 23:07:25 +01:00
Michael
7820c4327f Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-08 22:37:23 +01:00
mib1185
b08717b9bd add number.crossed_threshold trigger 2026-03-08 20:46:37 +00:00
186 changed files with 1864 additions and 9300 deletions

View File

@@ -212,7 +212,6 @@ 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,8 +551,6 @@ 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
@@ -571,8 +569,6 @@ 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
@@ -743,8 +739,6 @@ 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
@@ -794,8 +788,8 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh
/homeassistant/components/indevolt/ @xirt
/tests/components/indevolt/ @xirt
/homeassistant/components/indevolt/ @xirtnl
/tests/components/indevolt/ @xirtnl
/homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221

View File

@@ -242,8 +242,6 @@ 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 Executable file → Normal file
View File

@@ -168,57 +168,29 @@ 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
if self._attr_hvac_mode == HVACMode.HEAT:
await self._adax_data_handler.set_target_temperature(temperature)
await self._adax_data_handler.set_target_temperature(temperature)
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.
"""
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
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 self._attr_target_temperature is None:
if target_temp == 0:
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 bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE))
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
}

View File

@@ -144,13 +144,12 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"device_tracker",
"door",
"fan",
"garage_door",
"humidifier",
"humidity",
"lawn_mower",
"light",
"lock",
"media_player",
"number",
"person",
"remote",
"scene",

View File

@@ -14,7 +14,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -133,7 +132,6 @@ 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,7 +16,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -130,7 +129,6 @@ 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,7 +17,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -231,7 +230,6 @@ 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,7 +17,6 @@ from .agent import (
BackupAgentError,
BackupAgentPlatformProtocol,
LocalBackupAgent,
OnProgressCallback,
)
from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
@@ -42,7 +41,6 @@ from .manager import (
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
UploadBackupEvent,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
@@ -74,11 +72,9 @@ __all__ = [
"LocalBackupAgent",
"ManagerBackup",
"NewBackup",
"OnProgressCallback",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"UploadBackupEvent",
"WrittenBackup",
"async_get_manager",
"suggested_filename",

View File

@@ -14,13 +14,6 @@ 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."""
@@ -60,14 +53,12 @@ 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, OnProgressCallback
from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename
@@ -73,7 +73,6 @@ 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,15 +252,6 @@ 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."""
@@ -588,24 +579,9 @@ class BackupManager:
_backup = replace(
backup, protected=should_encrypt, size=streamer.size()
)
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(
await self.backup_agents[agent_id].async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
on_progress=on_upload_progress,
)
if streamer:
await streamer.wait()
@@ -1398,10 +1374,9 @@ 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)
if not isinstance(event, UploadBackupEvent):
self.last_event = event
if not isinstance(event, (BlockedEvent, IdleEvent)):
self.last_action_event = event
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

@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==2.1.1",
"bleak-retry-connector==4.6.0",
"bleak-retry-connector==4.4.3",
"bluetooth-adapters==2.1.0",
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.9.1"
"habluetooth==5.8.0"
]
}

View File

@@ -115,6 +115,18 @@
}
},
"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,6 +372,78 @@
},
"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,7 +17,15 @@ from homeassistant.helpers.trigger import (
make_entity_transition_trigger,
)
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
CONF_HVAC_MODE = "hvac_mode"
@@ -45,6 +53,18 @@ 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
@@ -53,16 +73,16 @@ TRIGGERS: dict[str, type[Trigger]] = {
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
DOMAIN, ATTR_HUMIDITY
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
DOMAIN, ATTR_HUMIDITY
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
DOMAIN, ATTR_TEMPERATURE
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
DOMAIN, ATTR_TEMPERATURE
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -66,6 +66,20 @@ 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:
@@ -80,6 +94,20 @@ 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,7 +18,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
@@ -107,7 +106,6 @@ 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,7 +14,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -130,7 +129,6 @@ 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,6 +4,7 @@ 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
@@ -32,20 +33,7 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
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
from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401
_LOGGER = logging.getLogger(__name__)
@@ -55,33 +43,57 @@ 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
__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",
]
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"
@bind_hass

View File

@@ -1,52 +1,6 @@
"""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,37 +108,5 @@
"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,8 +1,4 @@
{
"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}",
@@ -86,15 +82,6 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -149,107 +136,5 @@
"name": "Toggle tilt"
}
},
"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"
}
}
"title": "Cover"
}

View File

@@ -1,107 +0,0 @@
"""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

@@ -1,81 +0,0 @@
.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,33 +1,80 @@
"""Provides triggers for 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
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
DEVICE_CLASSES_DOOR: dict[str, str] = {
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
COVER_DOMAIN: CoverDeviceClass.DOOR,
}
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
TRIGGERS: dict[str, type[Trigger]] = {
"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},
),
"opened": DoorOpenedTrigger,
"closed": DoorClosedTrigger,
}

View File

@@ -490,14 +490,14 @@ class Thermostat(ClimateEntity):
return None
@property
def fan(self) -> str:
def fan(self):
"""Return the current fan status."""
if "fan" in self.thermostat["equipmentStatus"]:
return STATE_ON
return STATE_OFF
@property
def fan_mode(self) -> str:
def fan_mode(self):
"""Return the fan setting."""
return self.thermostat["runtime"]["desiredFanMode"]
@@ -535,7 +535,7 @@ class Thermostat(ClimateEntity):
return None
@property
def hvac_mode(self) -> HVACMode:
def hvac_mode(self):
"""Return current operation."""
return ECOBEE_HVAC_TO_HASS[self.settings["hvacMode"]]
@@ -548,7 +548,7 @@ class Thermostat(ClimateEntity):
return None
@property
def hvac_action(self) -> HVACAction:
def hvac_action(self):
"""Return current HVAC action.
Ecobee returns a CSV string with different equipment that is active.

View File

@@ -1,47 +0,0 @@
"""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

@@ -1,58 +0,0 @@
"""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

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

View File

@@ -1,116 +0,0 @@
"""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

@@ -1,18 +0,0 @@
{
"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

@@ -1,11 +0,0 @@
{
"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

@@ -1,72 +0,0 @@
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

@@ -1,158 +0,0 @@
"""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

@@ -1,51 +0,0 @@
{
"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": { "windows_98": {}, "winter_mode": {} },
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260304.0"]
}

View File

@@ -1,11 +1,5 @@
{
"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

@@ -1,15 +0,0 @@
"""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

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

View File

@@ -1,8 +0,0 @@
{
"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

@@ -1,38 +0,0 @@
{
"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

@@ -1,36 +0,0 @@
"""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

@@ -1,29 +0,0 @@
.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,50 +108,6 @@ 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,11 +68,13 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No repair scenarios identified for this integration.
stale-devices: done
stale-devices:
status: todo
comment: Remove newsletter entities when newsletter is removed
# Platinum
async-dependency: done

View File

@@ -12,9 +12,7 @@ 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
@@ -212,67 +210,36 @@ 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_update_newsletter_entities() -> None:
"""Add new and remove stale newsletter entities."""
def _async_add_newsletter_entities() -> None:
"""Add newsletter entities when new newsletters appear."""
nonlocal newsletter_added
active_newsletters = {
new_newsletters = {
newsletter_id
for newsletter_id, newsletter in coordinator.data.newsletters.items()
if newsletter.get("status") == "active"
}
} - newsletter_added
new_newsletters = active_newsletters - newsletter_added
if not new_newsletters:
return
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
async_add_entities(
GhostNewsletterSensorEntity(
coordinator,
entry,
newsletter_id,
coordinator.data.newsletters[newsletter_id].get("name", "Newsletter"),
)
newsletter_added.update(new_newsletters)
for newsletter_id in new_newsletters
)
newsletter_added |= new_newsletters
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()
_async_add_newsletter_entities()
entry.async_on_unload(
coordinator.async_add_listener(_async_update_newsletter_entities)
coordinator.async_add_listener(_async_add_newsletter_entities)
)
@@ -343,10 +310,9 @@ class GhostNewsletterSensorEntity(
@property
def available(self) -> bool:
"""Return True if the entity is available."""
return (
super().available
and self._newsletter_id in self.coordinator.data.newsletters
)
if not super().available or self.coordinator.data is None:
return False
return self._newsletter_id in self.coordinator.data.newsletters
@property
def native_value(self) -> int | None:

View File

@@ -2,9 +2,7 @@
"config": {
"abort": {
"already_configured": "This Ghost site is already configured.",
"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."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "Failed to connect to Ghost. Please check your URL.",
@@ -23,17 +21,6 @@
"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,7 +13,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -76,7 +75,6 @@ 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,7 +44,6 @@ from homeassistant.components.backup import (
IncorrectPasswordError,
ManagerBackup,
NewBackup,
OnProgressCallback,
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
@@ -184,7 +183,6 @@ 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,6 +64,12 @@
}
},
"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,6 +199,42 @@
},
"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,13 +4,21 @@ 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, DOMAIN, HumidifierAction
from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, 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:
target: &trigger_humidifier_target
entity:
domain: humidifier
fields:
behavior:
behavior: &trigger_behavior
required: true
default: any
selector:
@@ -14,7 +14,52 @@
- 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

@@ -1,17 +0,0 @@
"""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

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

View File

@@ -1,8 +0,0 @@
{
"domain": "humidity",
"name": "Humidity",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/humidity",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -1,68 +0,0 @@
{
"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

@@ -1,71 +0,0 @@
"""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

@@ -15,7 +15,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -128,7 +127,6 @@ 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,6 +102,5 @@ SENSOR_KEYS = {
"11009",
"11010",
"6105",
"1505",
],
}

View File

@@ -1,7 +1,7 @@
{
"domain": "indevolt",
"name": "Indevolt",
"codeowners": ["@xirt"],
"codeowners": ["@xirtnl"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/indevolt",
"integration_type": "device",

View File

@@ -234,6 +234,7 @@ 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

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["kaiterra_async_client"],
"quality_scale": "legacy",
"requirements": ["kaiterra-async-client==1.1.0"]
"requirements": ["kaiterra-async-client==1.0.0"]
}

View File

@@ -13,7 +13,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupNotFound,
Folder,
OnProgressCallback,
)
from homeassistant.core import HomeAssistant, callback
@@ -92,7 +91,6 @@ 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}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)
@@ -35,7 +35,7 @@ class BrightnessCrossedThresholdTrigger(
"""Trigger for brightness crossed threshold."""
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_attribute = ATTR_BRIGHTNESS
_converter = staticmethod(_convert_uint8_to_percentage)

View File

@@ -90,7 +90,7 @@ class LightwaveTrv(ClimateEntity):
self._attr_hvac_action = HVACAction.OFF
@property
def target_temperature(self) -> float | None:
def target_temperature(self):
"""Target room temperature."""
if self._inhibit > 0:
# If we get an update before the new temp has

View File

@@ -3,15 +3,10 @@
from __future__ import annotations
import itertools
import logging
from pylitterbot import Account
from pylitterbot.exceptions import LitterRobotException
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.const import 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
@@ -19,8 +14,6 @@ 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,
@@ -40,50 +33,6 @@ 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,10 +27,8 @@ 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]
@@ -47,8 +45,6 @@ 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
)
@@ -69,9 +65,8 @@ 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
)
@@ -97,7 +92,4 @@ 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,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"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."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

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

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted numbers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"set_value": "Set value for {entity_name}"
@@ -192,6 +196,29 @@
"message": "Value {value} for {entity_id} is outside valid range {min_value} - {max_value}."
}
},
"selector": {
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above a value",
"below": "Below a value",
"between": "In a range",
"outside": "Outside a range"
}
}
},
"services": {
"set_value": {
"description": "Sets the value of a number.",
@@ -204,5 +231,33 @@
"name": "Set"
}
},
"title": "Number"
"title": "Number",
"triggers": {
"changed": {
"description": "Triggers when a number value changes.",
"name": "Number changed"
},
"crossed_threshold": {
"description": "Triggers after the value of one or more numbers crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::number::common::trigger_behavior_description%]",
"name": "[%key:component::number::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": "Number crossed threshold"
}
}
}

View File

@@ -0,0 +1,25 @@
"""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,
make_entity_numerical_state_crossed_threshold_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(
{DOMAIN, INPUT_NUMBER_DOMAIN}
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_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,14 +1,20 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.trigger_common:
target: &trigger_number_target
entity:
domain:
- number
- input_number
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
@@ -41,22 +47,11 @@
- 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
target: *trigger_number_target
crossed_threshold:
target: *trigger_target
target: *trigger_number_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type

View File

@@ -22,7 +22,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -146,7 +145,6 @@ 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,7 +22,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -146,7 +145,6 @@ class OneDriveBackupAgent(BackupAgent):
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
on_progress: OnProgressCallback,
**kwargs: Any,
) -> None:
"""Upload a backup."""

View File

@@ -213,7 +213,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
try:
info = await async_interview(host)
except TimeoutError:
_LOGGER.info("Timed out interviewing: %s", host)
_LOGGER.warning("Timed out interviewing: %s", host)
return self.async_abort(reason="cannot_connect")
except OSError:
_LOGGER.exception("Unexpected exception interviewing: %s", host)

View File

@@ -1,42 +0,0 @@
"""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

@@ -14,5 +14,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "silver",
"requirements": ["py-opendisplay==5.5.0"]
"requirements": ["py-opendisplay==5.2.0"]
}

View File

@@ -54,7 +54,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The device's BLE MAC address is both its unique identifier and does not change.

View File

@@ -1,22 +1,12 @@
"""HTTP client for fetching remote calendar data."""
"""Specifies the parameter for the httpx download."""
from httpx import AsyncClient, Auth, BasicAuth, Response, Timeout
from httpx import AsyncClient, Response, Timeout
async def get_calendar(
client: AsyncClient,
url: str,
username: str | None = None,
password: str | None = None,
) -> Response:
async def get_calendar(client: AsyncClient, url: str) -> Response:
"""Make an HTTP GET request using Home Assistant's async HTTPX client with timeout."""
auth: Auth | None = None
if username is not None and password is not None:
auth = BasicAuth(username, password)
return await client.get(
url,
auth=auth,
follow_redirects=True,
timeout=Timeout(5, read=30, write=5, pool=5),
)

View File

@@ -8,7 +8,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.helpers.httpx_client import get_async_client
from .client import get_calendar
@@ -25,24 +25,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_AUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Remote Calendar."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -51,7 +39,8 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
)
errors: dict[str, str] = {}
errors: dict = {}
_LOGGER.debug("User input: %s", user_input)
self._async_abort_entries_match(
{CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]}
)
@@ -63,11 +52,6 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
client = get_async_client(self.hass, verify_ssl=user_input[CONF_VERIFY_SSL])
try:
res = await get_calendar(client, user_input[CONF_URL])
if res.status_code == HTTPStatus.UNAUTHORIZED:
www_auth = res.headers.get("www-authenticate", "").lower()
if "basic" in www_auth:
self.data = user_input
return await self.async_step_auth()
if res.status_code == HTTPStatus.FORBIDDEN:
errors["base"] = "forbidden"
return self.async_show_form(
@@ -99,60 +83,3 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the authentication step."""
if user_input is None:
return self.async_show_form(
step_id="auth",
data_schema=STEP_AUTH_DATA_SCHEMA,
)
errors: dict[str, str] = {}
client = get_async_client(self.hass, verify_ssl=self.data[CONF_VERIFY_SSL])
try:
res = await get_calendar(
client,
self.data[CONF_URL],
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
if res.status_code == HTTPStatus.UNAUTHORIZED:
errors["base"] = "invalid_auth"
elif res.status_code == HTTPStatus.FORBIDDEN:
return self.async_abort(reason="forbidden")
else:
res.raise_for_status()
except TimeoutException as err:
errors["base"] = "timeout_connect"
_LOGGER.debug(
"A timeout error occurred: %s", str(err) or type(err).__name__
)
except (HTTPError, InvalidURL) as err:
errors["base"] = "cannot_connect"
_LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__)
else:
if not errors:
try:
await parse_calendar(self.hass, res.text)
except InvalidIcsException:
return self.async_abort(reason="invalid_ics_file")
else:
return self.async_create_entry(
title=self.data[CONF_CALENDAR_NAME],
data={
**self.data,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
},
)
return self.async_show_form(
step_id="auth",
data_schema=self.add_suggested_values_to_schema(
STEP_AUTH_DATA_SCHEMA, user_input
),
errors=errors,
)

View File

@@ -7,7 +7,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
from ical.calendar import Calendar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -46,18 +46,11 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True)
)
self._url = config_entry.data[CONF_URL]
self._username: str | None = config_entry.data.get(CONF_USERNAME)
self._password: str | None = config_entry.data.get(CONF_PASSWORD)
async def _async_update_data(self) -> Calendar:
"""Update data from the url."""
try:
res = await get_calendar(
self._client,
self._url,
username=self._username,
password=self._password,
)
res = await get_calendar(self._client, self._url)
res.raise_for_status()
except TimeoutException as err:
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)

View File

@@ -1,29 +1,15 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"forbidden": "[%key:component::remote_calendar::config::error::forbidden%]",
"invalid_ics_file": "[%key:component::remote_calendar::config::error::invalid_ics_file%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"forbidden": "The server understood the request but refuses to authorize it.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details.",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for HTTP Basic Authentication.",
"username": "The username for HTTP Basic Authentication."
},
"description": "The calendar requires authentication."
},
"user": {
"data": {
"calendar_name": "Calendar name",

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from pysaunum import SaunumClient, SaunumConnectionError, SaunumTimeoutError
from pysaunum import SaunumClient, SaunumConnectionError
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, SaunumTimeoutError) as exc:
except SaunumConnectionError as exc:
raise ConfigEntryNotReady(f"Error connecting to {host}: {exc}") from exc
entry.async_on_unload(client.async_close)

View File

@@ -6,14 +6,7 @@ import asyncio
from datetime import timedelta
from typing import Any
from pysaunum import (
DEFAULT_DURATION,
DEFAULT_FAN_DURATION,
DEFAULT_TEMPERATURE,
MAX_TEMPERATURE,
MIN_TEMPERATURE,
SaunumException,
)
from pysaunum import MAX_TEMPERATURE, MIN_TEMPERATURE, SaunumException
from homeassistant.components.climate import (
FAN_HIGH,
@@ -156,7 +149,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 in self._preset_name_map:
if sauna_type is not None and sauna_type in self._preset_name_map:
return self._preset_name_map[sauna_type]
return self._preset_name_map[0]
@@ -249,9 +242,9 @@ class LeilSaunaClimate(LeilSaunaEntity, ClimateEntity):
async def async_start_session(
self,
duration: timedelta = timedelta(minutes=DEFAULT_DURATION),
target_temperature: int = DEFAULT_TEMPERATURE,
fan_duration: timedelta = timedelta(minutes=DEFAULT_FAN_DURATION),
duration: timedelta = timedelta(minutes=120),
target_temperature: int = 80,
fan_duration: timedelta = timedelta(minutes=10),
) -> None:
"""Start a sauna session with custom parameters."""
if self.coordinator.data.door_open:

View File

@@ -7,8 +7,6 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from pysaunum import (
DEFAULT_DURATION,
DEFAULT_FAN_DURATION,
MAX_DURATION,
MAX_FAN_DURATION,
MIN_DURATION,
@@ -37,6 +35,10 @@ 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):
@@ -57,8 +59,8 @@ NUMBERS: tuple[LeilSaunaNumberEntityDescription, ...] = (
native_step=1,
value_fn=lambda data: (
duration
if (duration := data.sauna_duration) > MIN_DURATION
else DEFAULT_DURATION
if (duration := data.sauna_duration) is not None and duration > MIN_DURATION
else DEFAULT_DURATION_MIN
),
set_value_fn=lambda client, value: client.async_set_sauna_duration(int(value)),
),
@@ -72,8 +74,8 @@ NUMBERS: tuple[LeilSaunaNumberEntityDescription, ...] = (
native_step=1,
value_fn=lambda data: (
fan_dur
if (fan_dur := data.fan_duration) > MIN_FAN_DURATION
else DEFAULT_FAN_DURATION
if (fan_dur := data.fan_duration) is not None and fan_dur > MIN_FAN_DURATION
else DEFAULT_FAN_DURATION_MIN
),
set_value_fn=lambda client, value: client.async_set_fan_duration(int(value)),
),

View File

@@ -4,15 +4,7 @@ from __future__ import annotations
from datetime import timedelta
from pysaunum import (
DEFAULT_DURATION,
DEFAULT_FAN_DURATION,
DEFAULT_TEMPERATURE,
MAX_DURATION,
MAX_FAN_DURATION,
MAX_TEMPERATURE,
MIN_TEMPERATURE,
)
from pysaunum import MAX_DURATION, MAX_FAN_DURATION, MAX_TEMPERATURE, MIN_TEMPERATURE
import voluptuous as vol
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
@@ -37,21 +29,17 @@ def async_setup_services(hass: HomeAssistant) -> None:
SERVICE_START_SESSION,
entity_domain=CLIMATE_DOMAIN,
schema={
vol.Optional(
ATTR_DURATION, default=timedelta(minutes=DEFAULT_DURATION)
): vol.All(
vol.Optional(ATTR_DURATION, default=timedelta(minutes=120)): vol.All(
cv.time_period,
vol.Range(
min=timedelta(minutes=1),
max=timedelta(minutes=MAX_DURATION),
),
),
vol.Optional(ATTR_TARGET_TEMPERATURE, default=DEFAULT_TEMPERATURE): vol.All(
vol.Optional(ATTR_TARGET_TEMPERATURE, default=80): vol.All(
cv.positive_int, vol.Range(min=MIN_TEMPERATURE, max=MAX_TEMPERATURE)
),
vol.Optional(
ATTR_FAN_DURATION, default=timedelta(minutes=DEFAULT_FAN_DURATION)
): vol.All(
vol.Optional(ATTR_FAN_DURATION, default=timedelta(minutes=10)): vol.All(
cv.time_period,
vol.Range(
min=timedelta(minutes=1),

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyseventeentrack"],
"requirements": ["pyseventeentrack==1.1.2"]
"requirements": ["pyseventeentrack==1.1.1"]
}

View File

@@ -12,7 +12,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
)
from homeassistant.core import HomeAssistant, callback
@@ -86,7 +85,6 @@ 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,8 +190,6 @@ 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,14 +102,6 @@ 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,8 +58,6 @@ 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:
@@ -138,7 +136,6 @@ class SwitchBotCloudCandleWarmerLamp(SwitchBotCloudLight):
# Brightness adjustment
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
class SwitchBotCloudStripLight(SwitchBotCloudLight):
@@ -148,7 +145,6 @@ class SwitchBotCloudStripLight(SwitchBotCloudLight):
# RGB color control
_attr_supported_color_modes = {ColorMode.RGB}
_attr_color_mode = ColorMode.RGB
class SwitchBotCloudRGBICLight(SwitchBotCloudLight):
@@ -158,7 +154,6 @@ 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."""
@@ -179,7 +174,6 @@ 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."""
@@ -206,7 +200,6 @@ 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,8 +227,6 @@ 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,7 +15,6 @@ from homeassistant.components.backup import (
BackupAgent,
BackupAgentError,
BackupNotFound,
OnProgressCallback,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
@@ -156,7 +155,6 @@ 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,6 +790,7 @@ edit_message:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -842,6 +843,7 @@ edit_message_media:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -920,6 +922,7 @@ edit_caption:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -957,6 +960,7 @@ edit_replymarkup:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -1011,6 +1015,7 @@ delete_message:
filter:
domain: notify
integration: telegram_bot
reorder: true
message_id:
required: true
example: "{{ trigger.event.data.message.message_id }}"
@@ -1037,6 +1042,7 @@ leave_chat:
filter:
domain: notify
integration: telegram_bot
reorder: true
advanced:
collapsed: true
fields:
@@ -1058,6 +1064,7 @@ 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_HAS_AMBIENT, CONF_PROBE_COUNT, DOMAIN
from .const import CONF_PROBE_COUNT, DOMAIN
type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator]
@@ -213,8 +213,6 @@ 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_HAS_AMBIENT, CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity
@@ -123,64 +123,12 @@ 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",

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