mirror of
https://github.com/home-assistant/core.git
synced 2026-03-09 15:44:00 +01:00
Compare commits
24 Commits
number/add
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a25300b8e1 | ||
|
|
6fa8e71b21 | ||
|
|
c983978a10 | ||
|
|
68b8b6b675 | ||
|
|
ee4d313b10 | ||
|
|
5e665093c9 | ||
|
|
9a5f509ab9 | ||
|
|
8d0cd5edaa | ||
|
|
71726272f5 | ||
|
|
9c6c27ab56 | ||
|
|
db20cf8161 | ||
|
|
59b6270157 | ||
|
|
a65ba01bbe | ||
|
|
a5d0350560 | ||
|
|
368993556f | ||
|
|
23ea17eaef | ||
|
|
6ace93e45b | ||
|
|
237a0ae03f | ||
|
|
6067be6f49 | ||
|
|
a35c3d5de5 | ||
|
|
e9c3634cb6 | ||
|
|
2ba4544180 | ||
|
|
5235ce7ae4 | ||
|
|
56b601e577 |
@@ -212,6 +212,7 @@ homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.folder_watcher.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
homeassistant.components.freshr.*
|
||||
homeassistant.components.fritz.*
|
||||
homeassistant.components.fritzbox.*
|
||||
homeassistant.components.fritzbox_callmonitor.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -551,6 +551,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/freebox/ @hacf-fr @Quentame
|
||||
/homeassistant/components/freedompro/ @stefano055415
|
||||
/tests/components/freedompro/ @stefano055415
|
||||
/homeassistant/components/freshr/ @SierraNL
|
||||
/tests/components/freshr/ @SierraNL
|
||||
/homeassistant/components/fressnapf_tracker/ @eifinger
|
||||
/tests/components/fressnapf_tracker/ @eifinger
|
||||
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
|
||||
@@ -569,6 +571,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/fully_kiosk/ @cgarwood
|
||||
/homeassistant/components/fyta/ @dontinelli
|
||||
/tests/components/fyta/ @dontinelli
|
||||
/homeassistant/components/garage_door/ @home-assistant/core
|
||||
/tests/components/garage_door/ @home-assistant/core
|
||||
/homeassistant/components/garages_amsterdam/ @klaasnicolaas
|
||||
/tests/components/garages_amsterdam/ @klaasnicolaas
|
||||
/homeassistant/components/gardena_bluetooth/ @elupus
|
||||
|
||||
@@ -242,6 +242,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
#
|
||||
# Integrations providing triggers and conditions for base platforms:
|
||||
"door",
|
||||
"garage_door",
|
||||
}
|
||||
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
|
||||
# These integrations are set up if recovery mode is activated.
|
||||
|
||||
@@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
|
||||
|
||||
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
|
||||
"""Get value of enable_ime option or its default value."""
|
||||
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]
|
||||
return bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE))
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -144,12 +144,12 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"device_tracker",
|
||||
"door",
|
||||
"fan",
|
||||
"garage_door",
|
||||
"humidifier",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"person",
|
||||
"remote",
|
||||
"scene",
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==2.1.1",
|
||||
"bleak-retry-connector==4.4.3",
|
||||
"bleak-retry-connector==4.6.0",
|
||||
"bluetooth-adapters==2.1.0",
|
||||
"bluetooth-auto-recovery==1.5.3",
|
||||
"bluetooth-data-tools==1.28.4",
|
||||
"dbus-fast==3.1.2",
|
||||
"habluetooth==5.8.0"
|
||||
"habluetooth==5.9.1"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -115,18 +115,6 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"trigger": "mdi:thermometer"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"trigger": "mdi:thermostat"
|
||||
},
|
||||
|
||||
@@ -372,78 +372,6 @@
|
||||
},
|
||||
"title": "Climate",
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity changed"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current humidity crossed threshold"
|
||||
},
|
||||
"current_temperature_changed": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the temperature is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the temperature is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature changed"
|
||||
},
|
||||
"current_temperature_crossed_threshold": {
|
||||
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Climate-control device current temperature crossed threshold"
|
||||
},
|
||||
"hvac_mode_changed": {
|
||||
"description": "Triggers after the mode of one or more climate-control devices changes.",
|
||||
"fields": {
|
||||
|
||||
@@ -17,15 +17,7 @@ from homeassistant.helpers.trigger import (
|
||||
make_entity_transition_trigger,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
DOMAIN,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
|
||||
|
||||
CONF_HVAC_MODE = "hvac_mode"
|
||||
|
||||
@@ -53,18 +45,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_TEMPERATURE
|
||||
),
|
||||
"hvac_mode_changed": HVACModeChangedTrigger,
|
||||
"started_cooling": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
|
||||
|
||||
@@ -66,20 +66,6 @@ hvac_mode_changed:
|
||||
- unknown
|
||||
multiple: true
|
||||
|
||||
current_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_humidity_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_humidity_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
@@ -94,20 +80,6 @@ target_humidity_crossed_threshold:
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
current_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_temperature_crossed_threshold:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
target_temperature_changed:
|
||||
target: *trigger_climate_target
|
||||
fields:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from enum import IntFlag, StrEnum
|
||||
import functools as ft
|
||||
import logging
|
||||
from typing import Any, final
|
||||
@@ -33,7 +32,20 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN, INTENT_CLOSE_COVER, INTENT_OPEN_COVER # noqa: F401
|
||||
from .const import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
ATTR_IS_CLOSED,
|
||||
ATTR_POSITION,
|
||||
ATTR_TILT_POSITION,
|
||||
DOMAIN,
|
||||
INTENT_CLOSE_COVER,
|
||||
INTENT_OPEN_COVER,
|
||||
CoverDeviceClass,
|
||||
CoverEntityFeature,
|
||||
CoverState,
|
||||
)
|
||||
from .trigger import CoverClosedTriggerBase, CoverOpenedTriggerBase
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,57 +55,33 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
|
||||
class CoverState(StrEnum):
|
||||
"""State of Cover entities."""
|
||||
|
||||
CLOSED = "closed"
|
||||
CLOSING = "closing"
|
||||
OPEN = "open"
|
||||
OPENING = "opening"
|
||||
|
||||
|
||||
class CoverDeviceClass(StrEnum):
|
||||
"""Device class for cover."""
|
||||
|
||||
# Refer to the cover dev docs for device class descriptions
|
||||
AWNING = "awning"
|
||||
BLIND = "blind"
|
||||
CURTAIN = "curtain"
|
||||
DAMPER = "damper"
|
||||
DOOR = "door"
|
||||
GARAGE = "garage"
|
||||
GATE = "gate"
|
||||
SHADE = "shade"
|
||||
SHUTTER = "shutter"
|
||||
WINDOW = "window"
|
||||
|
||||
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.Coerce(CoverDeviceClass))
|
||||
DEVICE_CLASSES = [cls.value for cls in CoverDeviceClass]
|
||||
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
|
||||
class CoverEntityFeature(IntFlag):
|
||||
"""Supported features of the cover entity."""
|
||||
|
||||
OPEN = 1
|
||||
CLOSE = 2
|
||||
SET_POSITION = 4
|
||||
STOP = 8
|
||||
OPEN_TILT = 16
|
||||
CLOSE_TILT = 32
|
||||
STOP_TILT = 64
|
||||
SET_TILT_POSITION = 128
|
||||
|
||||
|
||||
ATTR_CURRENT_POSITION = "current_position"
|
||||
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
|
||||
ATTR_IS_CLOSED = "is_closed"
|
||||
ATTR_POSITION = "position"
|
||||
ATTR_TILT_POSITION = "tilt_position"
|
||||
__all__ = [
|
||||
"ATTR_CURRENT_POSITION",
|
||||
"ATTR_CURRENT_TILT_POSITION",
|
||||
"ATTR_IS_CLOSED",
|
||||
"ATTR_POSITION",
|
||||
"ATTR_TILT_POSITION",
|
||||
"DEVICE_CLASSES",
|
||||
"DEVICE_CLASSES_SCHEMA",
|
||||
"DOMAIN",
|
||||
"INTENT_CLOSE_COVER",
|
||||
"INTENT_OPEN_COVER",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"CoverClosedTriggerBase",
|
||||
"CoverDeviceClass",
|
||||
"CoverEntity",
|
||||
"CoverEntityDescription",
|
||||
"CoverEntityFeature",
|
||||
"CoverOpenedTriggerBase",
|
||||
"CoverState",
|
||||
]
|
||||
|
||||
|
||||
@bind_hass
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
"""Constants for cover entity platform."""
|
||||
|
||||
from enum import IntFlag, StrEnum
|
||||
|
||||
DOMAIN = "cover"
|
||||
|
||||
ATTR_CURRENT_POSITION = "current_position"
|
||||
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
|
||||
ATTR_IS_CLOSED = "is_closed"
|
||||
ATTR_POSITION = "position"
|
||||
ATTR_TILT_POSITION = "tilt_position"
|
||||
|
||||
INTENT_OPEN_COVER = "HassOpenCover"
|
||||
INTENT_CLOSE_COVER = "HassCloseCover"
|
||||
|
||||
|
||||
class CoverEntityFeature(IntFlag):
|
||||
"""Supported features of the cover entity."""
|
||||
|
||||
OPEN = 1
|
||||
CLOSE = 2
|
||||
SET_POSITION = 4
|
||||
STOP = 8
|
||||
OPEN_TILT = 16
|
||||
CLOSE_TILT = 32
|
||||
STOP_TILT = 64
|
||||
SET_TILT_POSITION = 128
|
||||
|
||||
|
||||
class CoverState(StrEnum):
|
||||
"""State of Cover entities."""
|
||||
|
||||
CLOSED = "closed"
|
||||
CLOSING = "closing"
|
||||
OPEN = "open"
|
||||
OPENING = "opening"
|
||||
|
||||
|
||||
class CoverDeviceClass(StrEnum):
|
||||
"""Device class for cover."""
|
||||
|
||||
# Refer to the cover dev docs for device class descriptions
|
||||
AWNING = "awning"
|
||||
BLIND = "blind"
|
||||
CURTAIN = "curtain"
|
||||
DAMPER = "damper"
|
||||
DOOR = "door"
|
||||
GARAGE = "garage"
|
||||
GATE = "gate"
|
||||
SHADE = "shade"
|
||||
SHUTTER = "shutter"
|
||||
WINDOW = "window"
|
||||
|
||||
73
homeassistant/components/cover/trigger.py
Normal file
73
homeassistant/components/cover/trigger.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Provides triggers for covers."""
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_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
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN
|
||||
|
||||
|
||||
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 CoverTriggerBase(EntityTriggerBase):
|
||||
"""Base trigger for cover state changes."""
|
||||
|
||||
_domains = {BINARY_SENSOR_DOMAIN, DOMAIN}
|
||||
_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
|
||||
|
||||
|
||||
class CoverOpenedTriggerBase(CoverTriggerBase):
|
||||
"""Base trigger for cover opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
|
||||
|
||||
class CoverClosedTriggerBase(CoverTriggerBase):
|
||||
"""Base trigger for cover closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
@@ -1,75 +1,34 @@
|
||||
"""Provides triggers for doors."""
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, DOMAIN as COVER_DOMAIN
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State, split_entity_id
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.cover import (
|
||||
DOMAIN as COVER_DOMAIN,
|
||||
CoverClosedTriggerBase,
|
||||
CoverDeviceClass,
|
||||
CoverOpenedTriggerBase,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import Trigger
|
||||
|
||||
DEVICE_CLASS_DOOR = "door"
|
||||
DEVICE_CLASSES_DOOR: dict[str, str] = {
|
||||
BINARY_SENSOR_DOMAIN: BinarySensorDeviceClass.DOOR,
|
||||
COVER_DOMAIN: CoverDeviceClass.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):
|
||||
class DoorOpenedTrigger(CoverOpenedTriggerBase):
|
||||
"""Trigger for door opened state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_ON
|
||||
_cover_is_closed_target_value = False
|
||||
_device_classes = DEVICE_CLASSES_DOOR
|
||||
|
||||
|
||||
class DoorClosedTrigger(DoorTriggerBase):
|
||||
class DoorClosedTrigger(CoverClosedTriggerBase):
|
||||
"""Trigger for door closed state changes."""
|
||||
|
||||
_binary_sensor_target_state = STATE_OFF
|
||||
_cover_is_closed_target_value = True
|
||||
_device_classes = DEVICE_CLASSES_DOOR
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
|
||||
@@ -490,14 +490,14 @@ class Thermostat(ClimateEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def fan(self):
|
||||
def fan(self) -> str:
|
||||
"""Return the current fan status."""
|
||||
if "fan" in self.thermostat["equipmentStatus"]:
|
||||
return STATE_ON
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def fan_mode(self):
|
||||
def fan_mode(self) -> str:
|
||||
"""Return the fan setting."""
|
||||
return self.thermostat["runtime"]["desiredFanMode"]
|
||||
|
||||
@@ -535,7 +535,7 @@ class Thermostat(ClimateEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def hvac_mode(self):
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""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):
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return current HVAC action.
|
||||
|
||||
Ecobee returns a CSV string with different equipment that is active.
|
||||
|
||||
47
homeassistant/components/freshr/__init__.py
Normal file
47
homeassistant/components/freshr/__init__.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""The Fresh-r integration."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import (
|
||||
FreshrConfigEntry,
|
||||
FreshrData,
|
||||
FreshrDevicesCoordinator,
|
||||
FreshrReadingsCoordinator,
|
||||
)
|
||||
|
||||
_PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
|
||||
"""Set up Fresh-r from a config entry."""
|
||||
devices_coordinator = FreshrDevicesCoordinator(hass, entry)
|
||||
await devices_coordinator.async_config_entry_first_refresh()
|
||||
|
||||
readings: dict[str, FreshrReadingsCoordinator] = {
|
||||
device.id: FreshrReadingsCoordinator(
|
||||
hass, entry, device, devices_coordinator.client
|
||||
)
|
||||
for device in devices_coordinator.data
|
||||
}
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in readings.values()
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = FreshrData(
|
||||
devices=devices_coordinator,
|
||||
readings=readings,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
|
||||
58
homeassistant/components/freshr/config_flow.py
Normal file
58
homeassistant/components/freshr/config_flow.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Config flow for the Fresh-r integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr import FreshrClient
|
||||
from pyfreshr.exceptions import LoginError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fresh-r."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
client = FreshrClient(session=async_get_clientsession(self.hass))
|
||||
try:
|
||||
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
|
||||
except LoginError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(
|
||||
title=f"Fresh-r ({user_input[CONF_USERNAME]})",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
7
homeassistant/components/freshr/const.py
Normal file
7
homeassistant/components/freshr/const.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Constants for the Fresh-r integration."""
|
||||
|
||||
import logging
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "freshr"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
116
homeassistant/components/freshr/coordinator.py
Normal file
116
homeassistant/components/freshr/coordinator.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Coordinator for Fresh-r integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr import FreshrClient
|
||||
from pyfreshr.exceptions import ApiResponseError, LoginError
|
||||
from pyfreshr.models import DeviceReadings, DeviceSummary
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
DEVICES_SCAN_INTERVAL = timedelta(hours=1)
|
||||
READINGS_SCAN_INTERVAL = timedelta(minutes=10)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FreshrData:
|
||||
"""Runtime data stored on the config entry."""
|
||||
|
||||
devices: FreshrDevicesCoordinator
|
||||
readings: dict[str, FreshrReadingsCoordinator]
|
||||
|
||||
|
||||
type FreshrConfigEntry = ConfigEntry[FreshrData]
|
||||
|
||||
|
||||
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
|
||||
"""Coordinator that refreshes the device list once an hour."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None:
|
||||
"""Initialize the device list coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_devices",
|
||||
update_interval=DEVICES_SCAN_INTERVAL,
|
||||
)
|
||||
self.client = FreshrClient(session=async_create_clientsession(hass))
|
||||
|
||||
async def _async_update_data(self) -> list[DeviceSummary]:
|
||||
"""Fetch the list of devices from the Fresh-r API."""
|
||||
username = self.config_entry.data[CONF_USERNAME]
|
||||
password = self.config_entry.data[CONF_PASSWORD]
|
||||
|
||||
try:
|
||||
if not self.client.logged_in:
|
||||
await self.client.login(username, password)
|
||||
|
||||
devices = await self.client.fetch_devices()
|
||||
except LoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
except (ApiResponseError, ClientError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
else:
|
||||
return devices
|
||||
|
||||
|
||||
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
|
||||
"""Coordinator that refreshes readings for a single device every 10 minutes."""
|
||||
|
||||
config_entry: FreshrConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: FreshrConfigEntry,
|
||||
device: DeviceSummary,
|
||||
client: FreshrClient,
|
||||
) -> None:
|
||||
"""Initialize the readings coordinator for a single device."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=f"{DOMAIN}_readings_{device.id}",
|
||||
update_interval=READINGS_SCAN_INTERVAL,
|
||||
)
|
||||
self._device = device
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def device_id(self) -> str:
|
||||
"""Return the device ID."""
|
||||
return self._device.id
|
||||
|
||||
async def _async_update_data(self) -> DeviceReadings:
|
||||
"""Fetch current readings for this device from the Fresh-r API."""
|
||||
try:
|
||||
return await self._client.fetch_device_current(self._device)
|
||||
except LoginError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="auth_failed",
|
||||
) from err
|
||||
except (ApiResponseError, ClientError) as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
18
homeassistant/components/freshr/icons.json
Normal file
18
homeassistant/components/freshr/icons.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dew_point": {
|
||||
"default": "mdi:thermometer-water"
|
||||
},
|
||||
"flow": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"inside_temperature": {
|
||||
"default": "mdi:home-thermometer"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"default": "mdi:thermometer"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/freshr/manifest.json
Normal file
11
homeassistant/components/freshr/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "freshr",
|
||||
"name": "Fresh-r",
|
||||
"codeowners": ["@SierraNL"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/freshr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfreshr==1.2.0"]
|
||||
}
|
||||
72
homeassistant/components/freshr/quality_scale.yaml
Normal file
72
homeassistant/components/freshr/quality_scale.yaml
Normal file
@@ -0,0 +1,72 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow: done
|
||||
config-flow-test-coverage: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration uses a polling coordinator, not event-driven updates.
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Integration connects to a cloud service; no local network discovery is possible.
|
||||
discovery: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
||||
158
homeassistant/components/freshr/sensor.py
Normal file
158
homeassistant/components/freshr/sensor.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Sensor platform for the Fresh-r integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyfreshr.models import DeviceReadings, DeviceType
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONCENTRATION_PARTS_PER_MILLION,
|
||||
PERCENTAGE,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolumeFlowRate,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class FreshrSensorEntityDescription(SensorEntityDescription):
|
||||
"""Describes a Fresh-r sensor."""
|
||||
|
||||
value_fn: Callable[[DeviceReadings], StateType]
|
||||
|
||||
|
||||
_T1 = FreshrSensorEntityDescription(
|
||||
key="t1",
|
||||
translation_key="inside_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.t1,
|
||||
)
|
||||
_T2 = FreshrSensorEntityDescription(
|
||||
key="t2",
|
||||
translation_key="outside_temperature",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.t2,
|
||||
)
|
||||
_CO2 = FreshrSensorEntityDescription(
|
||||
key="co2",
|
||||
device_class=SensorDeviceClass.CO2,
|
||||
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.co2,
|
||||
)
|
||||
_HUM = FreshrSensorEntityDescription(
|
||||
key="hum",
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.hum,
|
||||
)
|
||||
_FLOW = FreshrSensorEntityDescription(
|
||||
key="flow",
|
||||
translation_key="flow",
|
||||
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
|
||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.flow,
|
||||
)
|
||||
_DP = FreshrSensorEntityDescription(
|
||||
key="dp",
|
||||
translation_key="dew_point",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_registry_enabled_default=False,
|
||||
value_fn=lambda r: r.dp,
|
||||
)
|
||||
_TEMP = FreshrSensorEntityDescription(
|
||||
key="temp",
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda r: r.temp,
|
||||
)
|
||||
|
||||
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
|
||||
DeviceType.FRESH_R: "Fresh-r",
|
||||
DeviceType.FORWARD: "Fresh-r Forward",
|
||||
DeviceType.MONITOR: "Fresh-r Monitor",
|
||||
}
|
||||
|
||||
SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = {
|
||||
DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP),
|
||||
DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP),
|
||||
DeviceType.MONITOR: (_CO2, _HUM, _DP, _TEMP),
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: FreshrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Fresh-r sensors from a config entry."""
|
||||
entities: list[FreshrSensor] = []
|
||||
for device in config_entry.runtime_data.devices.data:
|
||||
descriptions = SENSOR_TYPES.get(
|
||||
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
|
||||
)
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
|
||||
serial_number=device.id,
|
||||
manufacturer="Fresh-r",
|
||||
)
|
||||
entities.extend(
|
||||
FreshrSensor(
|
||||
config_entry.runtime_data.readings[device.id],
|
||||
description,
|
||||
device_info,
|
||||
)
|
||||
for description in descriptions
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
|
||||
"""Representation of a Fresh-r sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: FreshrSensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: FreshrReadingsCoordinator,
|
||||
description: FreshrSensorEntityDescription,
|
||||
device_info: DeviceInfo,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator)
|
||||
self.entity_description = description
|
||||
self._attr_device_info = device_info
|
||||
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value from coordinator data."""
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
51
homeassistant/components/freshr/strings.json
Normal file
51
homeassistant/components/freshr/strings.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"wrong_account": "Cannot change the account username."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Your Fresh-r account password.",
|
||||
"username": "Your Fresh-r account username (email address)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dew_point": {
|
||||
"name": "Dew point"
|
||||
},
|
||||
"flow": {
|
||||
"name": "Air flow rate"
|
||||
},
|
||||
"inside_temperature": {
|
||||
"name": "Inside temperature"
|
||||
},
|
||||
"outside_temperature": {
|
||||
"name": "Outside temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"auth_failed": {
|
||||
"message": "Authentication failed. Check your Fresh-r username and password."
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Could not connect to the Fresh-r service."
|
||||
}
|
||||
}
|
||||
}
|
||||
15
homeassistant/components/garage_door/__init__.py
Normal file
15
homeassistant/components/garage_door/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Integration for garage door triggers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
DOMAIN = "garage_door"
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
return True
|
||||
10
homeassistant/components/garage_door/icons.json
Normal file
10
homeassistant/components/garage_door/icons.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:garage"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:garage-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/garage_door/manifest.json
Normal file
8
homeassistant/components/garage_door/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "garage_door",
|
||||
"name": "Garage door",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/garage_door",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
38
homeassistant/components/garage_door/strings.json
Normal file
38
homeassistant/components/garage_door/strings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"selector": {
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Garage door",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more garage doors close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more garage doors open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
42
homeassistant/components/garage_door/trigger.py
Normal file
42
homeassistant/components/garage_door/trigger.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""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,
|
||||
CoverClosedTriggerBase,
|
||||
CoverDeviceClass,
|
||||
CoverOpenedTriggerBase,
|
||||
)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
class GarageDoorOpenedTrigger(CoverOpenedTriggerBase):
|
||||
"""Trigger for garage door opened state changes."""
|
||||
|
||||
_device_classes = DEVICE_CLASSES_GARAGE_DOOR
|
||||
|
||||
|
||||
class GarageDoorClosedTrigger(CoverClosedTriggerBase):
|
||||
"""Trigger for garage door closed state changes."""
|
||||
|
||||
_device_classes = DEVICE_CLASSES_GARAGE_DOOR
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"opened": GarageDoorOpenedTrigger,
|
||||
"closed": GarageDoorClosedTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for garage doors."""
|
||||
return TRIGGERS
|
||||
29
homeassistant/components/garage_door/triggers.yaml
Normal file
29
homeassistant/components/garage_door/triggers.yaml
Normal file
@@ -0,0 +1,29 @@
|
||||
.trigger_common_fields: &trigger_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
closed:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
|
||||
opened:
|
||||
fields: *trigger_common_fields
|
||||
target:
|
||||
entity:
|
||||
- domain: binary_sensor
|
||||
device_class: garage_door
|
||||
- domain: cover
|
||||
device_class: garage
|
||||
@@ -108,6 +108,50 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration."""
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_url = user_input[CONF_API_URL].rstrip("/")
|
||||
admin_api_key = user_input[CONF_ADMIN_API_KEY]
|
||||
|
||||
if ":" not in admin_api_key:
|
||||
errors["base"] = "invalid_api_key"
|
||||
else:
|
||||
try:
|
||||
site = await self._validate_credentials(api_url, admin_api_key)
|
||||
except GhostAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except GhostError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error during Ghost reconfigure")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(site["site_uuid"])
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data_updates={
|
||||
CONF_API_URL: api_url,
|
||||
CONF_ADMIN_API_KEY: admin_api_key,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
suggested_values=user_input or reconfigure_entry.data,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
|
||||
)
|
||||
|
||||
async def _validate_credentials(
|
||||
self, api_url: str, admin_api_key: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -68,7 +68,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair scenarios identified for this integration.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Ghost site is already configured.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "The provided credentials belong to a different Ghost site."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to Ghost. Please check your URL.",
|
||||
@@ -21,6 +23,17 @@
|
||||
"description": "Your API key for {title} is invalid. [Create a new integration key]({setup_url}) to reauthenticate.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]",
|
||||
"api_url": "[%key:component::ghost::config::step::user::data::api_url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]",
|
||||
"api_url": "[%key:component::ghost::config::step::user::data_description::api_url%]"
|
||||
},
|
||||
"description": "Update the configuration for your Ghost integration. [Create a custom integration]({setup_url}) to get your API URL and Admin API key."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"admin_api_key": "Admin API key",
|
||||
|
||||
@@ -64,12 +64,6 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"started_drying": {
|
||||
"trigger": "mdi:arrow-down-bold"
|
||||
},
|
||||
|
||||
@@ -199,42 +199,6 @@
|
||||
},
|
||||
"title": "Humidifier",
|
||||
"triggers": {
|
||||
"current_humidity_changed": {
|
||||
"description": "Triggers after the humidity measured by one or more humidifiers changes.",
|
||||
"fields": {
|
||||
"above": {
|
||||
"description": "Trigger when the humidity is above this value.",
|
||||
"name": "Above"
|
||||
},
|
||||
"below": {
|
||||
"description": "Trigger when the humidity is below this value.",
|
||||
"name": "Below"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier current humidity changed"
|
||||
},
|
||||
"current_humidity_crossed_threshold": {
|
||||
"description": "Triggers after the humidity measured by one or more humidifiers crosses a threshold.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::climate::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::climate::common::trigger_behavior_name%]"
|
||||
},
|
||||
"lower_limit": {
|
||||
"description": "Lower threshold limit.",
|
||||
"name": "Lower threshold"
|
||||
},
|
||||
"threshold_type": {
|
||||
"description": "Type of threshold crossing to trigger on.",
|
||||
"name": "Threshold type"
|
||||
},
|
||||
"upper_limit": {
|
||||
"description": "Upper threshold limit.",
|
||||
"name": "Upper threshold"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier current humidity crossed threshold"
|
||||
},
|
||||
"started_drying": {
|
||||
"description": "Triggers after one or more humidifiers start drying.",
|
||||
"fields": {
|
||||
|
||||
@@ -4,21 +4,13 @@ from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_numerical_state_attribute_changed_trigger,
|
||||
make_entity_numerical_state_attribute_crossed_threshold_trigger,
|
||||
make_entity_target_state_attribute_trigger,
|
||||
make_entity_target_state_trigger,
|
||||
)
|
||||
|
||||
from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, DOMAIN, HumidifierAction
|
||||
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
DOMAIN, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
"started_drying": make_entity_target_state_attribute_trigger(
|
||||
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
|
||||
),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.trigger_common: &trigger_common
|
||||
target: &trigger_humidifier_target
|
||||
target:
|
||||
entity:
|
||||
domain: humidifier
|
||||
fields:
|
||||
behavior: &trigger_behavior
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
@@ -14,52 +14,7 @@
|
||||
- last
|
||||
- any
|
||||
|
||||
.number_or_entity: &number_or_entity
|
||||
required: false
|
||||
selector:
|
||||
choose:
|
||||
choices:
|
||||
number:
|
||||
selector:
|
||||
number:
|
||||
mode: box
|
||||
entity:
|
||||
selector:
|
||||
entity:
|
||||
filter:
|
||||
domain:
|
||||
- input_number
|
||||
- number
|
||||
- sensor
|
||||
translation_key: number_or_entity
|
||||
|
||||
.trigger_threshold_type: &trigger_threshold_type
|
||||
required: true
|
||||
default: above
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- above
|
||||
- below
|
||||
- between
|
||||
- outside
|
||||
translation_key: trigger_threshold_type
|
||||
|
||||
started_drying: *trigger_common
|
||||
started_humidifying: *trigger_common
|
||||
turned_on: *trigger_common
|
||||
turned_off: *trigger_common
|
||||
|
||||
current_humidity_changed:
|
||||
target: *trigger_humidifier_target
|
||||
fields:
|
||||
above: *number_or_entity
|
||||
below: *number_or_entity
|
||||
|
||||
current_humidity_crossed_threshold:
|
||||
target: *trigger_humidifier_target
|
||||
fields:
|
||||
behavior: *trigger_behavior
|
||||
threshold_type: *trigger_threshold_type
|
||||
lower_limit: *number_or_entity
|
||||
upper_limit: *number_or_entity
|
||||
|
||||
@@ -102,5 +102,6 @@ SENSOR_KEYS = {
|
||||
"11009",
|
||||
"11010",
|
||||
"6105",
|
||||
"1505",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -234,7 +234,6 @@ SENSORS: Final = (
|
||||
),
|
||||
IndevoltSensorEntityDescription(
|
||||
key="1505",
|
||||
generation=[1],
|
||||
translation_key="cumulative_production",
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["kaiterra_async_client"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["kaiterra-async-client==1.0.0"]
|
||||
"requirements": ["kaiterra-async-client==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ class LightwaveTrv(ClimateEntity):
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Target room temperature."""
|
||||
if self._inhibit > 0:
|
||||
# If we get an update before the new temp has
|
||||
|
||||
@@ -173,10 +173,5 @@
|
||||
"set_value": {
|
||||
"service": "mdi:numeric"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"trigger": "mdi:counter"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,11 +204,5 @@
|
||||
"name": "Set"
|
||||
}
|
||||
},
|
||||
"title": "Number",
|
||||
"triggers": {
|
||||
"changed": {
|
||||
"description": "Triggers when a number value changes.",
|
||||
"name": "Number changed"
|
||||
}
|
||||
}
|
||||
"title": "Number"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Provides triggers for number entities."""
|
||||
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.trigger import (
|
||||
Trigger,
|
||||
make_entity_numerical_state_changed_trigger,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"changed": make_entity_numerical_state_changed_trigger(
|
||||
{DOMAIN, INPUT_NUMBER_DOMAIN}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for number entities."""
|
||||
return TRIGGERS
|
||||
@@ -1,6 +0,0 @@
|
||||
changed:
|
||||
target:
|
||||
entity:
|
||||
domain:
|
||||
- number
|
||||
- input_number
|
||||
@@ -213,7 +213,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
try:
|
||||
info = await async_interview(host)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning("Timed out interviewing: %s", host)
|
||||
_LOGGER.info("Timed out interviewing: %s", host)
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except OSError:
|
||||
_LOGGER.exception("Unexpected exception interviewing: %s", host)
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["py-opendisplay==5.2.0"]
|
||||
"requirements": ["py-opendisplay==5.5.0"]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
"""Specifies the parameter for the httpx download."""
|
||||
"""HTTP client for fetching remote calendar data."""
|
||||
|
||||
from httpx import AsyncClient, Response, Timeout
|
||||
from httpx import AsyncClient, Auth, BasicAuth, Response, Timeout
|
||||
|
||||
|
||||
async def get_calendar(client: AsyncClient, url: str) -> Response:
|
||||
async def get_calendar(
|
||||
client: AsyncClient,
|
||||
url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
) -> 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),
|
||||
)
|
||||
|
||||
@@ -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_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .client import get_calendar
|
||||
@@ -25,12 +25,24 @@ 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:
|
||||
@@ -39,8 +51,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
errors: dict = {}
|
||||
_LOGGER.debug("User input: %s", user_input)
|
||||
errors: dict[str, str] = {}
|
||||
self._async_abort_entries_match(
|
||||
{CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]}
|
||||
)
|
||||
@@ -52,6 +63,11 @@ 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(
|
||||
@@ -83,3 +99,60 @@ 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,
|
||||
)
|
||||
|
||||
@@ -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_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, 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,11 +46,18 @@ 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)
|
||||
res = await get_calendar(
|
||||
self._client,
|
||||
self._url,
|
||||
username=self._username,
|
||||
password=self._password,
|
||||
)
|
||||
res.raise_for_status()
|
||||
except TimeoutException as err:
|
||||
_LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__)
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"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%]"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyseventeentrack"],
|
||||
"requirements": ["pyseventeentrack==1.1.1"]
|
||||
"requirements": ["pyseventeentrack==1.1.2"]
|
||||
}
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["socketio", "engineio", "yalexs"],
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.7"]
|
||||
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["yalexs-ble==3.2.7"]
|
||||
"requirements": ["yalexs-ble==3.3.0"]
|
||||
}
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -229,6 +229,7 @@ FLOWS = {
|
||||
"foscam",
|
||||
"freebox",
|
||||
"freedompro",
|
||||
"freshr",
|
||||
"fressnapf_tracker",
|
||||
"fritz",
|
||||
"fritzbox",
|
||||
|
||||
@@ -2208,6 +2208,12 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"freshr": {
|
||||
"name": "Fresh-r",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"fressnapf_tracker": {
|
||||
"name": "Fressnapf Tracker",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -657,24 +657,6 @@ class EntityNumericalStateAttributeChangedTriggerBase(EntityTriggerBase):
|
||||
return True
|
||||
|
||||
|
||||
class EntityNumericalStateChangedTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for numerical state changes."""
|
||||
|
||||
_schema = ENTITY_STATE_TRIGGER_SCHEMA
|
||||
|
||||
def is_valid_state(self, state: State) -> bool:
|
||||
"""Check if the new state matches the expected one."""
|
||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||
return False
|
||||
|
||||
try:
|
||||
float(state.state)
|
||||
except TypeError, ValueError:
|
||||
# State is not a valid number, don't trigger
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
CONF_LOWER_LIMIT = "lower_limit"
|
||||
CONF_UPPER_LIMIT = "upper_limit"
|
||||
CONF_THRESHOLD_TYPE = "threshold_type"
|
||||
@@ -873,19 +855,6 @@ def make_entity_numerical_state_attribute_crossed_threshold_trigger(
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_numerical_state_changed_trigger(
|
||||
domains: set[str],
|
||||
) -> type[EntityNumericalStateChangedTriggerBase]:
|
||||
"""Create a trigger for numerical state change."""
|
||||
|
||||
class CustomTrigger(EntityNumericalStateChangedTriggerBase):
|
||||
"""Trigger for numerical state changes."""
|
||||
|
||||
_domains = domains
|
||||
|
||||
return CustomTrigger
|
||||
|
||||
|
||||
def make_entity_target_state_attribute_trigger(
|
||||
domain: str, attribute: str, to_state: str
|
||||
) -> type[EntityTargetStateAttributeTriggerBase]:
|
||||
|
||||
@@ -21,7 +21,7 @@ audioop-lts==0.2.1
|
||||
av==16.0.1
|
||||
awesomeversion==25.8.0
|
||||
bcrypt==5.0.0
|
||||
bleak-retry-connector==4.4.3
|
||||
bleak-retry-connector==4.6.0
|
||||
bleak==2.1.1
|
||||
bluetooth-adapters==2.1.0
|
||||
bluetooth-auto-recovery==1.5.3
|
||||
@@ -36,7 +36,7 @@ file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.8.0
|
||||
habluetooth==5.9.1
|
||||
hass-nabucasa==1.15.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -1876,6 +1876,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.freshr.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.fritz.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -1193,14 +1193,17 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="current_humidity",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_humidity",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="hvac_mode",
|
||||
return_type=["HVACMode", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="hvac_modes",
|
||||
@@ -1210,26 +1213,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="hvac_action",
|
||||
return_type=["HVACAction", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="current_temperature",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_step",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_high",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="target_temperature_low",
|
||||
return_type=["float", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="preset_mode",
|
||||
@@ -1239,26 +1248,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="preset_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="is_aux_heat",
|
||||
return_type=["bool", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="fan_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="fan_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="swing_mode",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="swing_modes",
|
||||
return_type=["list[str]", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="set_temperature",
|
||||
|
||||
15
requirements_all.txt
generated
15
requirements_all.txt
generated
@@ -638,7 +638,7 @@ bizkaibus==0.1.1
|
||||
bleak-esphome==3.7.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.4.3
|
||||
bleak-retry-connector==4.6.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak==2.1.1
|
||||
@@ -1170,7 +1170,7 @@ ha-silabs-firmware-client==0.3.0
|
||||
habiticalib==0.4.6
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.8.0
|
||||
habluetooth==5.9.1
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1359,7 +1359,7 @@ jsonpath==0.82.2
|
||||
justnimbus==0.7.4
|
||||
|
||||
# homeassistant.components.kaiterra
|
||||
kaiterra-async-client==1.0.0
|
||||
kaiterra-async-client==1.1.0
|
||||
|
||||
# homeassistant.components.keba
|
||||
keba-kecontact==1.3.0
|
||||
@@ -1868,7 +1868,7 @@ py-nightscout==1.2.2
|
||||
py-nymta==0.4.0
|
||||
|
||||
# homeassistant.components.opendisplay
|
||||
py-opendisplay==5.2.0
|
||||
py-opendisplay==5.5.0
|
||||
|
||||
# homeassistant.components.schluter
|
||||
py-schluter==0.1.7
|
||||
@@ -2112,6 +2112,9 @@ pyforked-daapd==0.1.14
|
||||
# homeassistant.components.freedompro
|
||||
pyfreedompro==1.1.0
|
||||
|
||||
# homeassistant.components.freshr
|
||||
pyfreshr==1.2.0
|
||||
|
||||
# homeassistant.components.fritzbox
|
||||
pyfritzhome==0.6.20
|
||||
|
||||
@@ -2458,7 +2461,7 @@ pyserial==3.5
|
||||
pysesame2==1.0.1
|
||||
|
||||
# homeassistant.components.seventeentrack
|
||||
pyseventeentrack==1.1.1
|
||||
pyseventeentrack==1.1.2
|
||||
|
||||
# homeassistant.components.sia
|
||||
pysiaalarm==3.2.2
|
||||
@@ -3313,7 +3316,7 @@ yalesmartalarmclient==0.4.3
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==3.2.7
|
||||
yalexs-ble==3.3.0
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
|
||||
13
requirements_test_all.txt
generated
13
requirements_test_all.txt
generated
@@ -575,7 +575,7 @@ beautifulsoup4==4.13.3
|
||||
bleak-esphome==3.7.1
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.4.3
|
||||
bleak-retry-connector==4.6.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak==2.1.1
|
||||
@@ -1040,7 +1040,7 @@ ha-silabs-firmware-client==0.3.0
|
||||
habiticalib==0.4.6
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==5.8.0
|
||||
habluetooth==5.9.1
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
@@ -1620,7 +1620,7 @@ py-nightscout==1.2.2
|
||||
py-nymta==0.4.0
|
||||
|
||||
# homeassistant.components.opendisplay
|
||||
py-opendisplay==5.2.0
|
||||
py-opendisplay==5.5.0
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
py-sucks==0.9.11
|
||||
@@ -1807,6 +1807,9 @@ pyforked-daapd==0.1.14
|
||||
# homeassistant.components.freedompro
|
||||
pyfreedompro==1.1.0
|
||||
|
||||
# homeassistant.components.freshr
|
||||
pyfreshr==1.2.0
|
||||
|
||||
# homeassistant.components.fritzbox
|
||||
pyfritzhome==0.6.20
|
||||
|
||||
@@ -2093,7 +2096,7 @@ pysenz==1.0.2
|
||||
pyserial==3.5
|
||||
|
||||
# homeassistant.components.seventeentrack
|
||||
pyseventeentrack==1.1.1
|
||||
pyseventeentrack==1.1.2
|
||||
|
||||
# homeassistant.components.sia
|
||||
pysiaalarm==3.2.2
|
||||
@@ -2792,7 +2795,7 @@ yalesmartalarmclient==0.4.3
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==3.2.7
|
||||
yalexs-ble==3.3.0
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yale
|
||||
|
||||
@@ -76,6 +76,7 @@ NO_IOT_CLASS = [
|
||||
"ffmpeg",
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -2111,6 +2111,7 @@ NO_QUALITY_SCALE = [
|
||||
"ffmpeg",
|
||||
"file_upload",
|
||||
"frontend",
|
||||
"garage_door",
|
||||
"hardkernel",
|
||||
"hardware",
|
||||
"history",
|
||||
|
||||
@@ -7,8 +7,6 @@ import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate.const import (
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
ATTR_HUMIDITY,
|
||||
ATTR_HVAC_ACTION,
|
||||
HVACAction,
|
||||
@@ -47,10 +45,6 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"climate.current_humidity_changed",
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
"climate.current_temperature_changed",
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
"climate.hvac_mode_changed",
|
||||
"climate.target_humidity_changed",
|
||||
"climate.target_humidity_crossed_threshold",
|
||||
@@ -211,30 +205,12 @@ async def test_climate_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.current_humidity_changed", HVACMode.AUTO, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.current_temperature_changed",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.target_humidity_changed", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"climate.target_temperature_changed", HVACMode.AUTO, ATTR_TEMPERATURE
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
@@ -380,16 +356,6 @@ async def test_climate_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
@@ -535,16 +501,6 @@ async def test_climate_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_humidity_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.current_temperature_crossed_threshold",
|
||||
HVACMode.AUTO,
|
||||
ATTR_CURRENT_TEMPERATURE,
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"climate.target_humidity_crossed_threshold", HVACMode.AUTO, ATTR_HUMIDITY
|
||||
),
|
||||
|
||||
@@ -4,9 +4,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, CoverDeviceClass, CoverState
|
||||
from homeassistant.components.door.trigger import DEVICE_CLASS_DOOR
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_LABEL_ID,
|
||||
@@ -143,7 +141,6 @@ async def test_door_trigger_binary_sensor_behavior_any(
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -161,7 +158,6 @@ async def test_door_trigger_binary_sensor_behavior_any(
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -369,7 +365,6 @@ async def test_door_trigger_binary_sensor_behavior_last(
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -387,7 +382,6 @@ async def test_door_trigger_binary_sensor_behavior_last(
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -458,7 +452,6 @@ async def test_door_trigger_cover_behavior_first(
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -476,7 +469,6 @@ async def test_door_trigger_cover_behavior_first(
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
@@ -649,9 +641,3 @@ async def test_door_trigger_excludes_non_door_device_class(
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
def test_door_device_class() -> None:
|
||||
"""Test the door trigger device class."""
|
||||
assert BinarySensorDeviceClass.DOOR == DEVICE_CLASS_DOOR
|
||||
assert CoverDeviceClass.DOOR == DEVICE_CLASS_DOOR
|
||||
|
||||
1
tests/components/freshr/__init__.py
Normal file
1
tests/components/freshr/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Fresh-r integration."""
|
||||
75
tests/components/freshr/conftest.py
Normal file
75
tests/components/freshr/conftest.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Common fixtures for the Fresh-r tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pyfreshr.models import DeviceReadings, DeviceSummary
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.freshr.const import DOMAIN
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
DEVICE_ID = "SN001"
|
||||
|
||||
MOCK_DEVICE_CURRENT = DeviceReadings(
|
||||
t1=21.5,
|
||||
t2=5.3,
|
||||
co2=850,
|
||||
hum=45,
|
||||
flow=0.12,
|
||||
dp=10.2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.freshr.async_setup_entry", return_value=True
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"},
|
||||
unique_id="test-user",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_freshr_client() -> Generator[MagicMock]:
|
||||
"""Return a mocked FreshrClient."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.freshr.coordinator.FreshrClient", autospec=True
|
||||
) as mock_client_class,
|
||||
patch(
|
||||
"homeassistant.components.freshr.config_flow.FreshrClient",
|
||||
new=mock_client_class,
|
||||
),
|
||||
):
|
||||
client = mock_client_class.return_value
|
||||
client.logged_in = False
|
||||
client.fetch_devices.return_value = [DeviceSummary(id=DEVICE_ID)]
|
||||
client.fetch_device_current.return_value = MOCK_DEVICE_CURRENT
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Fresh-r integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
337
tests/components/freshr/snapshots/test_sensor.ambr
Normal file
337
tests/components/freshr/snapshots/test_sensor.ambr
Normal file
@@ -0,0 +1,337 @@
|
||||
# serializer version: 1
|
||||
# name: test_entities[sensor.fresh_r_air_flow_rate-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_air_flow_rate',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Air flow rate',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 0,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME_FLOW_RATE: 'volume_flow_rate'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Air flow rate',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'flow',
|
||||
'unique_id': 'SN001_flow',
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_air_flow_rate-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'volume_flow_rate',
|
||||
'friendly_name': 'Fresh-r Air flow rate',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR: 'm³/h'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_air_flow_rate',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '0.12',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_carbon_dioxide-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_carbon_dioxide',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Carbon dioxide',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.CO2: 'carbon_dioxide'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Carbon dioxide',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'SN001_co2',
|
||||
'unit_of_measurement': 'ppm',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_carbon_dioxide-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'carbon_dioxide',
|
||||
'friendly_name': 'Fresh-r Carbon dioxide',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': 'ppm',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_carbon_dioxide',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '850',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_dew_point-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_dew_point',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Dew point',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Dew point',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'dew_point',
|
||||
'unique_id': 'SN001_dp',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_dew_point-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Fresh-r Dew point',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_dew_point',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '10.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_humidity-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_humidity',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Humidity',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Humidity',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': 'SN001_hum',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_humidity-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'humidity',
|
||||
'friendly_name': 'Fresh-r Humidity',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_humidity',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '45',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_inside_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_inside_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Inside temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Inside temperature',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'inside_temperature',
|
||||
'unique_id': 'SN001_t1',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_inside_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Fresh-r Inside temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_inside_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '21.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_outside_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.fresh_r_outside_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Outside temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Outside temperature',
|
||||
'platform': 'freshr',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'outside_temperature',
|
||||
'unique_id': 'SN001_t2',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_entities[sensor.fresh_r_outside_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Fresh-r Outside temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.fresh_r_outside_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '5.3',
|
||||
})
|
||||
# ---
|
||||
121
tests/components/freshr/test_config_flow.py
Normal file
121
tests/components/freshr/test_config_flow.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Test the Fresh-r config flow."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr.exceptions import LoginError
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.freshr.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
USER_INPUT = {CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_freshr_client")
|
||||
async def test_form_success(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Fresh-r (test-user)"
|
||||
assert result["data"] == USER_INPUT
|
||||
assert result["result"].unique_id == "test-user"
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "expected_error"),
|
||||
[
|
||||
(LoginError("bad credentials"), "invalid_auth"),
|
||||
(RuntimeError("unexpected"), "unknown"),
|
||||
(ClientError("network"), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
async def test_form_error(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_freshr_client: MagicMock,
|
||||
exception: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test config flow handles login errors and recovers correctly."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_freshr_client.login.side_effect = exception
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": expected_error}
|
||||
|
||||
# Ensure the flow can recover after providing correct credentials
|
||||
mock_freshr_client.login.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=USER_INPUT
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_freshr_client")
|
||||
async def test_form_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config flow aborts when the account is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=USER_INPUT
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_freshr_client")
|
||||
async def test_form_already_configured_case_insensitive(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test config flow aborts when the same account is configured with different casing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={**USER_INPUT, CONF_USERNAME: USER_INPUT[CONF_USERNAME].upper()},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
61
tests/components/freshr/test_init.py
Normal file
61
tests/components/freshr/test_init.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Test the Fresh-r initialization."""
|
||||
|
||||
from aiohttp import ClientError
|
||||
from pyfreshr.exceptions import ApiResponseError
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MagicMock, MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test unloading the config entry."""
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[ApiResponseError("parse error"), ClientError("network error")],
|
||||
)
|
||||
async def test_setup_fetch_devices_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test that a fetch_devices error during setup triggers a retry."""
|
||||
mock_freshr_client.fetch_devices.side_effect = exception
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_no_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that an empty device list sets up successfully with no entities."""
|
||||
mock_freshr_client.fetch_devices.return_value = []
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert (
|
||||
er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id)
|
||||
== []
|
||||
)
|
||||
84
tests/components/freshr/test_sensor.py
Normal file
84
tests/components/freshr/test_sensor.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Test the Fresh-r sensor platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aiohttp import ClientError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyfreshr.exceptions import ApiResponseError
|
||||
from pyfreshr.models import DeviceReadings
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.freshr.const import DOMAIN
|
||||
from homeassistant.components.freshr.coordinator import READINGS_SCAN_INTERVAL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .conftest import DEVICE_ID
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, DEVICE_ID)})
|
||||
assert device_entry
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_entry.device_id == device_entry.id
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_sensor_none_values(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_freshr_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test sensors return unknown when all readings are None."""
|
||||
mock_freshr_client.fetch_device_current.return_value = DeviceReadings(
|
||||
t1=None, t2=None, co2=None, hum=None, flow=None, dp=None
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for key in ("t1", "t2", "co2", "hum", "flow", "dp"):
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
"sensor", DOMAIN, f"{DEVICE_ID}_{key}"
|
||||
)
|
||||
assert entity_id is not None
|
||||
assert hass.states.get(entity_id).state == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[ApiResponseError("api error"), ClientError("network error")],
|
||||
)
|
||||
async def test_readings_connection_error_makes_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_freshr_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
error: Exception,
|
||||
) -> None:
|
||||
"""Test that connection errors during readings refresh mark entities unavailable."""
|
||||
mock_freshr_client.fetch_device_current.side_effect = error
|
||||
freezer.tick(READINGS_SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.fresh_r_inside_temperature")
|
||||
assert state is not None
|
||||
assert state.state == "unavailable"
|
||||
1
tests/components/garage_door/__init__.py
Normal file
1
tests/components/garage_door/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the garage_door integration."""
|
||||
647
tests/components/garage_door/test_trigger.py
Normal file
647
tests/components/garage_door/test_trigger.py
Normal file
@@ -0,0 +1,647 @@
|
||||
"""Test garage door trigger."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.cover import ATTR_IS_CLOSED, CoverState
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
TriggerStateDescription,
|
||||
arm_trigger,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_binary_sensors(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple binary sensor entities associated with different targets."""
|
||||
return await target_entities(hass, "binary_sensor")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_covers(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple cover entities associated with different targets."""
|
||||
return await target_entities(hass, "cover")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"garage_door.opened",
|
||||
"garage_door.closed",
|
||||
],
|
||||
)
|
||||
async def test_garage_door_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the garage door triggers are gated by the labs flag."""
|
||||
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("binary_sensor"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_binary_sensor_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires for binary_sensor entities with device_class garage_door."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_binary_sensors["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_cover_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires for cover entities with device_class garage."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("binary_sensor"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_binary_sensor_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires on the first binary_sensor state change."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_binary_sensors["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("binary_sensor"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage_door"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_binary_sensor_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_binary_sensors: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires when the last binary_sensor changes state."""
|
||||
other_entity_ids = set(target_binary_sensors["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_binary_sensors["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_binary_sensors["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_cover_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires on the first cover state change."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("cover"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.opened",
|
||||
target_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="garage_door.closed",
|
||||
target_states=[
|
||||
(CoverState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(CoverState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(CoverState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(CoverState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(CoverState.OPEN, {}),
|
||||
],
|
||||
additional_attributes={ATTR_DEVICE_CLASS: "garage"},
|
||||
trigger_from_none=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_cover_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_covers: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test garage door trigger fires when the last cover changes state."""
|
||||
other_entity_ids = set(target_covers["included"]) - {entity_id}
|
||||
excluded_entity_ids = set(target_covers["excluded"]) - {entity_id}
|
||||
|
||||
for eid in target_covers["included"]:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
for eid in excluded_entity_ids:
|
||||
set_or_remove_state(hass, eid, states[0]["excluded"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
excluded_state = state["excluded"]
|
||||
included_state = state["included"]
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
for excluded_entity_id in excluded_entity_ids:
|
||||
set_or_remove_state(hass, excluded_entity_id, excluded_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"trigger_key",
|
||||
"binary_sensor_initial",
|
||||
"binary_sensor_target",
|
||||
"cover_initial",
|
||||
"cover_initial_is_closed",
|
||||
"cover_target",
|
||||
"cover_target_is_closed",
|
||||
),
|
||||
[
|
||||
(
|
||||
"garage_door.opened",
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
CoverState.CLOSED,
|
||||
True,
|
||||
CoverState.OPEN,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"garage_door.closed",
|
||||
STATE_ON,
|
||||
STATE_OFF,
|
||||
CoverState.OPEN,
|
||||
False,
|
||||
CoverState.CLOSED,
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_garage_door_trigger_excludes_non_garage_door_device_class(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger_key: str,
|
||||
binary_sensor_initial: str,
|
||||
binary_sensor_target: str,
|
||||
cover_initial: str,
|
||||
cover_initial_is_closed: bool,
|
||||
cover_target: str,
|
||||
cover_target_is_closed: bool,
|
||||
) -> None:
|
||||
"""Test garage door trigger does not fire for entities without device_class garage_door."""
|
||||
entity_id_garage_door = "binary_sensor.test_garage_door"
|
||||
entity_id_door = "binary_sensor.test_door"
|
||||
entity_id_cover_garage_door = "cover.test_garage_door"
|
||||
entity_id_cover_door = "cover.test_door"
|
||||
|
||||
# Set initial states
|
||||
hass.states.async_set(
|
||||
entity_id_garage_door,
|
||||
binary_sensor_initial,
|
||||
{ATTR_DEVICE_CLASS: "garage_door"},
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_id_door, binary_sensor_initial, {ATTR_DEVICE_CLASS: "door"}
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_id_cover_garage_door,
|
||||
cover_initial,
|
||||
{ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_initial_is_closed},
|
||||
)
|
||||
hass.states.async_set(
|
||||
entity_id_cover_door,
|
||||
cover_initial,
|
||||
{ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_initial_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(
|
||||
hass,
|
||||
trigger_key,
|
||||
{},
|
||||
{
|
||||
CONF_ENTITY_ID: [
|
||||
entity_id_garage_door,
|
||||
entity_id_door,
|
||||
entity_id_cover_garage_door,
|
||||
entity_id_cover_door,
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
# Garage door binary_sensor changes - should trigger
|
||||
hass.states.async_set(
|
||||
entity_id_garage_door,
|
||||
binary_sensor_target,
|
||||
{ATTR_DEVICE_CLASS: "garage_door"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_garage_door
|
||||
service_calls.clear()
|
||||
|
||||
# Door binary_sensor changes - should NOT trigger (wrong device class)
|
||||
hass.states.async_set(
|
||||
entity_id_door, binary_sensor_target, {ATTR_DEVICE_CLASS: "door"}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
# Cover garage door changes - should trigger
|
||||
hass.states.async_set(
|
||||
entity_id_cover_garage_door,
|
||||
cover_target,
|
||||
{ATTR_DEVICE_CLASS: "garage", ATTR_IS_CLOSED: cover_target_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id_cover_garage_door
|
||||
service_calls.clear()
|
||||
|
||||
# Door cover changes - should NOT trigger (wrong device class)
|
||||
hass.states.async_set(
|
||||
entity_id_cover_door,
|
||||
cover_target,
|
||||
{ATTR_DEVICE_CLASS: "door", ATTR_IS_CLOSED: cover_target_is_closed},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
@@ -19,6 +19,7 @@ from .conftest import API_KEY, API_URL, SITE_UUID
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
NEW_API_KEY = "new_key_id:new_key_secret"
|
||||
NEW_API_URL = "https://new.ghost.io"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@@ -227,3 +228,143 @@ async def test_reauth_flow_invalid_api_key_format(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_api_key"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ghost_api", "mock_setup_entry")
|
||||
async def test_reconfigure_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfigure flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: NEW_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data[CONF_API_URL] == NEW_API_URL
|
||||
assert mock_config_entry.data[CONF_ADMIN_API_KEY] == NEW_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error_key"),
|
||||
[
|
||||
(GhostAuthError("Invalid API key"), "invalid_auth"),
|
||||
(GhostConnectionError("Connection failed"), "cannot_connect"),
|
||||
(RuntimeError("Unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_reconfigure_flow_errors_can_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ghost_api: AsyncMock,
|
||||
side_effect: Exception,
|
||||
error_key: str,
|
||||
) -> None:
|
||||
"""Test reconfigure flow errors and recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
mock_ghost_api.get_site.side_effect = side_effect
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: NEW_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error_key}
|
||||
|
||||
mock_ghost_api.get_site.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: NEW_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data[CONF_API_URL] == NEW_API_URL
|
||||
assert mock_config_entry.data[CONF_ADMIN_API_KEY] == NEW_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_ghost_api", "mock_setup_entry")
|
||||
async def test_reconfigure_flow_invalid_api_key_format(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfigure flow with invalid API key format."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: "invalid-no-colon",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "invalid_api_key"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: NEW_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert mock_config_entry.data[CONF_API_URL] == NEW_API_URL
|
||||
assert mock_config_entry.data[CONF_ADMIN_API_KEY] == NEW_API_KEY
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_reconfigure_flow_unique_id_mismatch(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_ghost_api: AsyncMock,
|
||||
) -> None:
|
||||
"""Test reconfigure flow aborts on unique ID mismatch."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_ghost_api.get_site.return_value = {
|
||||
"title": "Different Ghost",
|
||||
"url": NEW_API_URL,
|
||||
"site_uuid": "different-uuid",
|
||||
}
|
||||
|
||||
result = await mock_config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_API_URL: NEW_API_URL,
|
||||
CONF_ADMIN_API_KEY: NEW_API_KEY,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unique_id_mismatch"
|
||||
|
||||
@@ -4,19 +4,13 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.humidifier.const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
HumidifierAction,
|
||||
)
|
||||
from homeassistant.components.humidifier.const import ATTR_ACTION, HumidifierAction
|
||||
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
TriggerStateDescription,
|
||||
arm_trigger,
|
||||
parametrize_numerical_attribute_changed_trigger_states,
|
||||
parametrize_numerical_attribute_crossed_threshold_trigger_states,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
set_or_remove_state,
|
||||
@@ -33,8 +27,6 @@ async def target_humidifiers(hass: HomeAssistant) -> list[str]:
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"humidifier.current_humidity_changed",
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
"humidifier.started_drying",
|
||||
"humidifier.started_humidifying",
|
||||
"humidifier.turned_off",
|
||||
@@ -120,14 +112,6 @@ async def test_humidifier_state_trigger_behavior_any(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_changed_trigger_states(
|
||||
"humidifier.current_humidity_changed", STATE_ON, ATTR_CURRENT_HUMIDITY
|
||||
),
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
@@ -243,11 +227,6 @@ async def test_humidifier_state_trigger_behavior_first(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
@@ -363,11 +342,6 @@ async def test_humidifier_state_trigger_behavior_last(
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "trigger_options", "states"),
|
||||
[
|
||||
*parametrize_numerical_attribute_crossed_threshold_trigger_states(
|
||||
"humidifier.current_humidity_crossed_threshold",
|
||||
STATE_ON,
|
||||
ATTR_CURRENT_HUMIDITY,
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="humidifier.started_drying",
|
||||
target_states=[(STATE_ON, {ATTR_ACTION: HumidifierAction.DRYING})],
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"2105": 2000,
|
||||
"11034": 100,
|
||||
"1502": 0,
|
||||
"1505": 553673,
|
||||
"6004": 0.07,
|
||||
"6005": 0,
|
||||
"6006": 380.58,
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
'142': 1.79,
|
||||
'1501': 0,
|
||||
'1502': 0,
|
||||
'1505': 553673,
|
||||
'1532': 150,
|
||||
'1600': 48.5,
|
||||
'1601': 48.3,
|
||||
|
||||
@@ -2866,6 +2866,66 @@
|
||||
'state': '0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_cumulative_production-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.cms_sf2000_cumulative_production',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Cumulative production',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 2,
|
||||
}),
|
||||
'sensor.private': dict({
|
||||
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Cumulative production',
|
||||
'platform': 'indevolt',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'cumulative_production',
|
||||
'unique_id': 'SolidFlex2000-87654321_1505',
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_cumulative_production-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'energy',
|
||||
'friendly_name': 'CMS-SF2000 Cumulative production',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.cms_sf2000_cumulative_production',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '553.673',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor[2][sensor.cms_sf2000_daily_production-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
"""Test number entity trigger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
|
||||
from homeassistant.components.number.const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
TriggerStateDescription,
|
||||
arm_trigger,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_numbers(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple number entities associated with different targets."""
|
||||
return (await target_entities(hass, DOMAIN))["included"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_input_numbers(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple input number entities associated with different targets."""
|
||||
return (await target_entities(hass, INPUT_NUMBER_DOMAIN))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"number.changed",
|
||||
],
|
||||
)
|
||||
async def test_number_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the number entity triggers are gated by the labs flag."""
|
||||
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
|
||||
assert (
|
||||
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
|
||||
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
|
||||
"feature to be enabled in Home Assistant Labs settings (feature flag: "
|
||||
"'new_triggers_conditions')"
|
||||
) in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1.1", "attributes": {}}, "count": 1},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 1},
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1.5", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "not a number", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
{
|
||||
"included": {"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
"count": 0,
|
||||
},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
{"included": {"state": STATE_UNKNOWN, "attributes": {}}, "count": 0},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_number_changed_trigger_behavior(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_numbers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the number changed trigger behaves correctly."""
|
||||
other_entity_ids = set(target_numbers) - {entity_id}
|
||||
|
||||
# Set all numbers, including the tested number, to the initial state
|
||||
for eid in target_numbers:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, None, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check that changing other numbers also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(INPUT_NUMBER_DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("trigger", "states"),
|
||||
[
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1.1", "attributes": {}}, "count": 1},
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 1},
|
||||
{"included": {"state": None, "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "1.5", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
(
|
||||
"number.changed",
|
||||
[
|
||||
{"included": {"state": "1", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "not a number", "attributes": {}}, "count": 0},
|
||||
{"included": {"state": "2", "attributes": {}}, "count": 1},
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_input_number_changed_trigger_behavior(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_input_numbers: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the input_number changed trigger behaves correctly."""
|
||||
other_entity_ids = set(target_input_numbers) - {entity_id}
|
||||
|
||||
# Set all input_numbers, including the tested input_number, to the initial state
|
||||
for eid in target_input_numbers:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await arm_trigger(hass, trigger, None, trigger_target_config)
|
||||
|
||||
for state in states[1:]:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == state["count"]
|
||||
for service_call in service_calls:
|
||||
assert service_call.data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Check that changing other input_numbers also triggers
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == (entities_in_target - 1) * state["count"]
|
||||
service_calls.clear()
|
||||
@@ -44,7 +44,7 @@ DEVICE_CONFIG = GlobalConfig(
|
||||
),
|
||||
power=PowerOption(
|
||||
power_mode=0,
|
||||
battery_capacity_mah=0,
|
||||
battery_capacity_mah=b"\x00" * 3,
|
||||
sleep_timeout_ms=0,
|
||||
tx_power=0,
|
||||
sleep_flags=0,
|
||||
@@ -78,7 +78,8 @@ DEVICE_CONFIG = GlobalConfig(
|
||||
transmission_modes=0x01,
|
||||
clk_pin=0,
|
||||
reserved_pins=b"\x00" * 7,
|
||||
reserved=b"\x00" * 35,
|
||||
full_update_mC=0,
|
||||
reserved=b"\x00" * 33,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import respx
|
||||
|
||||
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_URL, CONF_VERIFY_SSL
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -326,3 +326,249 @@ async def test_duplicate_url(
|
||||
|
||||
assert result2["type"] is FlowResultType.ABORT
|
||||
assert result2["reason"] == "already_configured"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_form_unauthorized_basic_auth(
|
||||
hass: HomeAssistant, ics_content: str
|
||||
) -> None:
|
||||
"""Test 401 with WWW-Authenticate: Basic triggers auth step and succeeds."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=401,
|
||||
headers={"www-authenticate": 'Basic realm="test"'},
|
||||
)
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "auth"
|
||||
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=ics_content,
|
||||
)
|
||||
)
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result3["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result3["title"] == CALENDAR_NAME
|
||||
assert result3["data"] == {
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
}
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_form_auth_invalid_credentials(
|
||||
hass: HomeAssistant, ics_content: str
|
||||
) -> None:
|
||||
"""Test wrong credentials in auth step shows invalid_auth error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=401,
|
||||
headers={"www-authenticate": 'Basic realm="test"'},
|
||||
)
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "auth"
|
||||
|
||||
# Wrong credentials - server still returns 401
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "wrong",
|
||||
CONF_PASSWORD: "wrong",
|
||||
},
|
||||
)
|
||||
assert result3["type"] is FlowResultType.FORM
|
||||
assert result3["step_id"] == "auth"
|
||||
assert result3["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
# Correct credentials
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=ics_content,
|
||||
)
|
||||
)
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result4["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result4["title"] == CALENDAR_NAME
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_form_auth_forbidden_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test 403 in auth step aborts the flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=401,
|
||||
headers={"www-authenticate": 'Basic realm="test"'},
|
||||
)
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "auth"
|
||||
|
||||
respx.get(CALENDER_URL).mock(return_value=Response(status_code=403))
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "forbidden"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_form_auth_invalid_ics_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test invalid ICS in auth step aborts the flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=401,
|
||||
headers={"www-authenticate": 'Basic realm="test"'},
|
||||
)
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "auth"
|
||||
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text="not valid ics",
|
||||
)
|
||||
)
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "invalid_ics_file"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "base_error"),
|
||||
[
|
||||
(TimeoutException("Connection timed out"), "timeout_connect"),
|
||||
(HTTPError("Connection failed"), "cannot_connect"),
|
||||
],
|
||||
)
|
||||
@respx.mock
|
||||
async def test_form_auth_connection_errors(
|
||||
hass: HomeAssistant,
|
||||
side_effect: Exception,
|
||||
ics_content: str,
|
||||
base_error: str,
|
||||
) -> None:
|
||||
"""Test connection errors in auth step show retryable errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=401,
|
||||
headers={"www-authenticate": 'Basic realm="test"'},
|
||||
)
|
||||
)
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
},
|
||||
)
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "auth"
|
||||
|
||||
# Connection error during auth
|
||||
respx.get(CALENDER_URL).mock(side_effect=side_effect)
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result3["type"] is FlowResultType.FORM
|
||||
assert result3["step_id"] == "auth"
|
||||
assert result3["errors"] == {"base": base_error}
|
||||
|
||||
# Retry with success
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=ics_content,
|
||||
)
|
||||
)
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
assert result4["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
@@ -4,12 +4,19 @@ from httpx import HTTPError, InvalidURL, Response, TimeoutException
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from homeassistant.components.remote_calendar.const import CONF_CALENDAR_NAME, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
STATE_OFF,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
from .conftest import CALENDER_URL, TEST_ENTITY
|
||||
from .conftest import CALENDAR_NAME, CALENDER_URL, TEST_ENTITY
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -85,3 +92,30 @@ async def test_calendar_parse_error(
|
||||
)
|
||||
await setup_integration(hass, config_entry)
|
||||
assert config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_load_with_auth(hass: HomeAssistant, ics_content: str) -> None:
|
||||
"""Test loading a config entry with basic auth credentials."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_CALENDAR_NAME: CALENDAR_NAME,
|
||||
CONF_URL: CALENDER_URL,
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
},
|
||||
)
|
||||
respx.get(CALENDER_URL).mock(
|
||||
return_value=Response(
|
||||
status_code=200,
|
||||
text=ics_content,
|
||||
)
|
||||
)
|
||||
await setup_integration(hass, config_entry)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(TEST_ENTITY)
|
||||
assert state
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -1,18 +1,134 @@
|
||||
"""Tests for the SmartThings integration."""
|
||||
|
||||
from functools import cache
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pysmartthings import Attribute, Capability, DeviceEvent, DeviceHealthEvent
|
||||
from pysmartthings import (
|
||||
Attribute,
|
||||
Capability,
|
||||
DeviceEvent,
|
||||
DeviceHealthEvent,
|
||||
DeviceResponse,
|
||||
DeviceStatus,
|
||||
)
|
||||
from pysmartthings.models import HealthStatus
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.smartthings.const import MAIN
|
||||
from homeassistant.components.smartthings.const import DOMAIN, MAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
DEVICE_FIXTURES = [
|
||||
"aq_sensor_3_ikea",
|
||||
"aeotec_ms6",
|
||||
"da_ac_air_000001",
|
||||
"da_ac_airsensor_01001",
|
||||
"da_ac_rac_000001",
|
||||
"da_ac_rac_000003",
|
||||
"da_ac_rac_100001",
|
||||
"da_ac_rac_01001",
|
||||
"da_ac_cac_01001",
|
||||
"multipurpose_sensor",
|
||||
"contact_sensor",
|
||||
"base_electric_meter",
|
||||
"smart_plug",
|
||||
"vd_stv_2017_k",
|
||||
"c2c_arlo_pro_3_switch",
|
||||
"yale_push_button_deadbolt_lock",
|
||||
"ge_in_wall_smart_dimmer",
|
||||
"centralite",
|
||||
"da_ref_normal_000001",
|
||||
"da_ref_normal_01011",
|
||||
"da_ref_normal_01011_onedoor",
|
||||
"da_ref_normal_01001",
|
||||
"vd_network_audio_002s",
|
||||
"vd_network_audio_003s",
|
||||
"vd_sensor_light_2023",
|
||||
"iphone",
|
||||
"da_sac_ehs_000001_sub",
|
||||
"da_sac_ehs_000001_sub_1",
|
||||
"da_sac_ehs_000002_sub",
|
||||
"da_ac_ehs_01001",
|
||||
"da_wm_dw_000001",
|
||||
"da_wm_wd_01011",
|
||||
"da_wm_wd_000001",
|
||||
"da_wm_wd_000001_1",
|
||||
"da_wm_wm_01011",
|
||||
"da_wm_wm_100001",
|
||||
"da_wm_wm_100002",
|
||||
"da_wm_wm_000001",
|
||||
"da_wm_wm_000001_1",
|
||||
"da_wm_sc_000001",
|
||||
"da_wm_dw_01011",
|
||||
"da_rvc_normal_000001",
|
||||
"da_rvc_map_01011",
|
||||
"da_ks_microwave_0101x",
|
||||
"da_ks_cooktop_000001",
|
||||
"da_ks_cooktop_31001",
|
||||
"da_ks_range_0101x",
|
||||
"da_ks_oven_01061",
|
||||
"da_ks_oven_0107x",
|
||||
"da_ks_walloven_0107x",
|
||||
"da_ks_hood_01001",
|
||||
"hue_color_temperature_bulb",
|
||||
"hue_rgbw_color_bulb",
|
||||
"c2c_shade",
|
||||
"sonos_player",
|
||||
"aeotec_home_energy_meter_gen5",
|
||||
"virtual_water_sensor",
|
||||
"virtual_thermostat",
|
||||
"virtual_valve",
|
||||
"sensibo_airconditioner_1",
|
||||
"ecobee_sensor",
|
||||
"ecobee_thermostat",
|
||||
"ecobee_thermostat_offline",
|
||||
"sensi_thermostat",
|
||||
"siemens_washer",
|
||||
"fake_fan",
|
||||
"generic_fan_3_speed",
|
||||
"heatit_ztrm3_thermostat",
|
||||
"heatit_zpushwall",
|
||||
"generic_ef00_v1",
|
||||
"gas_detector",
|
||||
"bosch_radiator_thermostat_ii",
|
||||
"im_speaker_ai_0001",
|
||||
"im_smarttag2_ble_uwb",
|
||||
"abl_light_b_001",
|
||||
"tplink_p110",
|
||||
"ikea_kadrilj",
|
||||
"aux_ac",
|
||||
"hw_q80r_soundbar",
|
||||
"gas_meter",
|
||||
"lumi",
|
||||
"tesla_powerwall",
|
||||
]
|
||||
|
||||
|
||||
def get_device_status(device_name: str) -> DeviceStatus:
|
||||
"""Load a DeviceStatus object from a fixture for the given device name."""
|
||||
return DeviceStatus.from_json(
|
||||
load_fixture(f"device_status/{device_name}.json", DOMAIN)
|
||||
)
|
||||
|
||||
|
||||
def get_device_response(device_name: str) -> DeviceResponse:
|
||||
"""Load a DeviceResponse object from a fixture for the given device name."""
|
||||
return DeviceResponse.from_json(load_fixture(f"devices/{device_name}.json", DOMAIN))
|
||||
|
||||
|
||||
@cache
|
||||
def get_fixture_name(device_id: str) -> str:
|
||||
"""Get the fixture name for a given device ID."""
|
||||
for fixture_name in DEVICE_FIXTURES:
|
||||
for device in get_device_response(fixture_name).items:
|
||||
if device.device_id == device_id:
|
||||
return fixture_name
|
||||
|
||||
raise KeyError(f"Fixture for device_id {device_id} not found")
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
@@ -33,8 +149,13 @@ def snapshot_smartthings_entities(
|
||||
entities = hass.states.async_all(platform)
|
||||
for entity_state in entities:
|
||||
entity_entry = entity_registry.async_get(entity_state.entity_id)
|
||||
assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry")
|
||||
assert entity_state == snapshot(name=f"{entity_entry.entity_id}-state")
|
||||
prefix = ""
|
||||
if platform != Platform.SCENE:
|
||||
# SCENE unique id is not based on device fixture
|
||||
device_id = entity_entry.unique_id[:36]
|
||||
prefix = f"{get_fixture_name(device_id)}]["
|
||||
assert entity_entry == snapshot(name=f"{prefix}{entity_entry.entity_id}-entry")
|
||||
assert entity_state == snapshot(name=f"{prefix}{entity_entry.entity_id}-state")
|
||||
|
||||
|
||||
def set_attribute_value(
|
||||
|
||||
@@ -7,8 +7,6 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pysmartthings import (
|
||||
DeviceHealth,
|
||||
DeviceResponse,
|
||||
DeviceStatus,
|
||||
LocationResponse,
|
||||
RoomResponse,
|
||||
SceneResponse,
|
||||
@@ -33,6 +31,8 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_S
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import DEVICE_FIXTURES, get_device_response, get_device_status, get_fixture_name
|
||||
|
||||
from tests.common import MockConfigEntry, load_fixture
|
||||
|
||||
|
||||
@@ -99,107 +99,33 @@ def mock_smartthings() -> Generator[AsyncMock]:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture(
|
||||
params=[
|
||||
"aq_sensor_3_ikea",
|
||||
"aeotec_ms6",
|
||||
"da_ac_airsensor_01001",
|
||||
"da_ac_rac_000001",
|
||||
"da_ac_rac_000003",
|
||||
"da_ac_rac_100001",
|
||||
"da_ac_rac_01001",
|
||||
"da_ac_cac_01001",
|
||||
"multipurpose_sensor",
|
||||
"contact_sensor",
|
||||
"base_electric_meter",
|
||||
"smart_plug",
|
||||
"vd_stv_2017_k",
|
||||
"c2c_arlo_pro_3_switch",
|
||||
"yale_push_button_deadbolt_lock",
|
||||
"ge_in_wall_smart_dimmer",
|
||||
"centralite",
|
||||
"da_ref_normal_000001",
|
||||
"da_ref_normal_01011",
|
||||
"da_ref_normal_01011_onedoor",
|
||||
"da_ref_normal_01001",
|
||||
"vd_network_audio_002s",
|
||||
"vd_network_audio_003s",
|
||||
"vd_sensor_light_2023",
|
||||
"iphone",
|
||||
"da_sac_ehs_000001_sub",
|
||||
"da_sac_ehs_000001_sub_1",
|
||||
"da_sac_ehs_000002_sub",
|
||||
"da_ac_ehs_01001",
|
||||
"da_wm_dw_000001",
|
||||
"da_wm_wd_01011",
|
||||
"da_wm_wd_000001",
|
||||
"da_wm_wd_000001_1",
|
||||
"da_wm_wm_01011",
|
||||
"da_wm_wm_100001",
|
||||
"da_wm_wm_100002",
|
||||
"da_wm_wm_000001",
|
||||
"da_wm_wm_000001_1",
|
||||
"da_wm_sc_000001",
|
||||
"da_wm_dw_01011",
|
||||
"da_rvc_normal_000001",
|
||||
"da_rvc_map_01011",
|
||||
"da_ks_microwave_0101x",
|
||||
"da_ks_cooktop_000001",
|
||||
"da_ks_cooktop_31001",
|
||||
"da_ks_range_0101x",
|
||||
"da_ks_oven_01061",
|
||||
"da_ks_oven_0107x",
|
||||
"da_ks_walloven_0107x",
|
||||
"da_ks_hood_01001",
|
||||
"hue_color_temperature_bulb",
|
||||
"hue_rgbw_color_bulb",
|
||||
"c2c_shade",
|
||||
"sonos_player",
|
||||
"aeotec_home_energy_meter_gen5",
|
||||
"virtual_water_sensor",
|
||||
"virtual_thermostat",
|
||||
"virtual_valve",
|
||||
"sensibo_airconditioner_1",
|
||||
"ecobee_sensor",
|
||||
"ecobee_thermostat",
|
||||
"ecobee_thermostat_offline",
|
||||
"sensi_thermostat",
|
||||
"siemens_washer",
|
||||
"fake_fan",
|
||||
"generic_fan_3_speed",
|
||||
"heatit_ztrm3_thermostat",
|
||||
"heatit_zpushwall",
|
||||
"generic_ef00_v1",
|
||||
"gas_detector",
|
||||
"bosch_radiator_thermostat_ii",
|
||||
"im_speaker_ai_0001",
|
||||
"im_smarttag2_ble_uwb",
|
||||
"abl_light_b_001",
|
||||
"tplink_p110",
|
||||
"ikea_kadrilj",
|
||||
"aux_ac",
|
||||
"hw_q80r_soundbar",
|
||||
"gas_meter",
|
||||
"lumi",
|
||||
"tesla_powerwall",
|
||||
]
|
||||
)
|
||||
def device_fixture(
|
||||
mock_smartthings: AsyncMock, request: pytest.FixtureRequest
|
||||
) -> Generator[str]:
|
||||
@pytest.fixture
|
||||
def device_fixture() -> str | None:
|
||||
"""Return every device."""
|
||||
return request.param
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def devices(mock_smartthings: AsyncMock, device_fixture: str) -> Generator[AsyncMock]:
|
||||
def devices(mock_smartthings: AsyncMock, device_fixture: str | None) -> AsyncMock:
|
||||
"""Return a specific device."""
|
||||
mock_smartthings.get_devices.return_value = DeviceResponse.from_json(
|
||||
load_fixture(f"devices/{device_fixture}.json", DOMAIN)
|
||||
).items
|
||||
mock_smartthings.get_device_status.return_value = DeviceStatus.from_json(
|
||||
load_fixture(f"device_status/{device_fixture}.json", DOMAIN)
|
||||
).components
|
||||
if device_fixture is not None:
|
||||
mock_smartthings.get_devices.return_value = get_device_response(
|
||||
device_fixture
|
||||
).items
|
||||
mock_smartthings.get_device_status.return_value = get_device_status(
|
||||
device_fixture
|
||||
).components
|
||||
else:
|
||||
devices = []
|
||||
for device_name in DEVICE_FIXTURES:
|
||||
devices.extend(get_device_response(device_name).items)
|
||||
mock_smartthings.get_devices.return_value = devices
|
||||
|
||||
async def _get_device_status(device_id: str):
|
||||
return get_device_status(get_fixture_name(device_id)).components
|
||||
|
||||
mock_smartthings.get_device_status.side_effect = _get_device_status
|
||||
|
||||
return mock_smartthings
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"components": {
|
||||
"main": {
|
||||
"samsungce.dongleSoftwareInstallation": {
|
||||
"status": {
|
||||
"value": "completed",
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
}
|
||||
},
|
||||
"samsungce.deviceIdentification": {
|
||||
"micomAssayCode": {
|
||||
"value": "10244541",
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
},
|
||||
"modelName": {
|
||||
"value": null
|
||||
},
|
||||
"serialNumber": {
|
||||
"value": null
|
||||
},
|
||||
"serialNumberExtra": {
|
||||
"value": null
|
||||
},
|
||||
"releaseCountry": {
|
||||
"value": null
|
||||
},
|
||||
"modelClassificationCode": {
|
||||
"value": "7000035A001111C40100000000000000",
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
},
|
||||
"description": {
|
||||
"value": "ARTIK051_TVTL_18K",
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
},
|
||||
"releaseYear": {
|
||||
"value": 17,
|
||||
"timestamp": "2024-10-24T10:37:09.369Z"
|
||||
},
|
||||
"binaryId": {
|
||||
"value": "ARTIK051_TVTL_18K",
|
||||
"timestamp": "2026-03-08T00:02:58.554Z"
|
||||
}
|
||||
},
|
||||
"airQualitySensor": {
|
||||
"airQuality": {
|
||||
"value": 1,
|
||||
"unit": "CAQI",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"switch": {
|
||||
"value": "on",
|
||||
"timestamp": "2026-03-07T23:37:39.892Z"
|
||||
}
|
||||
},
|
||||
"fineDustHealthConcern": {
|
||||
"fineDustHealthConcern": {
|
||||
"value": "good",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
},
|
||||
"supportedFineDustValues": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"ocf": {
|
||||
"st": {
|
||||
"value": null
|
||||
},
|
||||
"mndt": {
|
||||
"value": null
|
||||
},
|
||||
"mnfv": {
|
||||
"value": "ARTIK051_TVTL_18K_12200115",
|
||||
"timestamp": "2025-06-16T09:58:48.824Z"
|
||||
},
|
||||
"mnhw": {
|
||||
"value": "1.0",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"di": {
|
||||
"value": "c02e8cfa-94ba-86f3-59a0-04a280950f2b",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnsl": {
|
||||
"value": null
|
||||
},
|
||||
"dmv": {
|
||||
"value": "1.2.1",
|
||||
"timestamp": "2025-06-16T09:58:48.824Z"
|
||||
},
|
||||
"n": {
|
||||
"value": "[air purifier] Samsung",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnmo": {
|
||||
"value": "ARTIK051_TVTL_18K|10244541|7000035A001111C40100000000000000",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"vid": {
|
||||
"value": "DA-AC-AIR-000001",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnmn": {
|
||||
"value": "Samsung Electronics",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnml": {
|
||||
"value": "http://www.samsung.com",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnpv": {
|
||||
"value": "BEAPP9AT507146H",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"mnos": {
|
||||
"value": "TizenRT2.0",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"pi": {
|
||||
"value": "c02e8cfa-94ba-86f3-59a0-04a280950f2b",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
},
|
||||
"icv": {
|
||||
"value": "core.1.1.0",
|
||||
"timestamp": "2025-06-16T09:58:48.045Z"
|
||||
}
|
||||
},
|
||||
"veryFineDustHealthConcern": {
|
||||
"supportedVeryFineDustValues": {
|
||||
"value": null
|
||||
},
|
||||
"veryFineDustHealthConcern": {
|
||||
"value": "good",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"airConditionerFanMode": {
|
||||
"fanMode": {
|
||||
"value": "auto",
|
||||
"timestamp": "2026-03-07T23:37:41.517Z"
|
||||
},
|
||||
"supportedAcFanModes": {
|
||||
"value": ["auto", "low", "medium", "high", "sleep"],
|
||||
"timestamp": "2026-03-07T10:39:52.927Z"
|
||||
},
|
||||
"availableAcFanModes": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"custom.disabledCapabilities": {
|
||||
"disabledCapabilities": {
|
||||
"value": [
|
||||
"samsungce.dongleSoftwareInstallation",
|
||||
"custom.virusDoctorMode",
|
||||
"custom.welcomeCareMode"
|
||||
],
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
}
|
||||
},
|
||||
"samsungce.driverVersion": {
|
||||
"versionNumber": {
|
||||
"value": 25040101,
|
||||
"timestamp": "2025-06-12T06:22:07.198Z"
|
||||
}
|
||||
},
|
||||
"dustSensor": {
|
||||
"dustLevel": {
|
||||
"value": 5,
|
||||
"unit": "\u03bcg/m^3",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
},
|
||||
"fineDustLevel": {
|
||||
"value": 5,
|
||||
"unit": "\u03bcg/m^3",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"custom.airPurifierOperationMode": {
|
||||
"apOperationMode": {
|
||||
"value": "off",
|
||||
"timestamp": "2026-03-07T23:37:40.011Z"
|
||||
},
|
||||
"supportedApOperationMode": {
|
||||
"value": ["off"],
|
||||
"timestamp": "2026-03-07T10:39:52.927Z"
|
||||
}
|
||||
},
|
||||
"custom.deviceReportStateConfiguration": {
|
||||
"reportStateRealtimePeriod": {
|
||||
"value": "disabled",
|
||||
"timestamp": "2026-03-07T23:53:20.670Z"
|
||||
},
|
||||
"reportStateRealtime": {
|
||||
"value": {
|
||||
"state": "disabled"
|
||||
},
|
||||
"timestamp": "2026-03-07T23:53:20.670Z"
|
||||
},
|
||||
"reportStatePeriod": {
|
||||
"value": "disabled",
|
||||
"timestamp": "2026-03-07T23:53:20.670Z"
|
||||
}
|
||||
},
|
||||
"custom.virusDoctorMode": {
|
||||
"virusDoctorMode": {
|
||||
"value": "off",
|
||||
"timestamp": "2023-03-31T04:07:27.093Z"
|
||||
}
|
||||
},
|
||||
"dustHealthConcern": {
|
||||
"supportedDustValues": {
|
||||
"value": null
|
||||
},
|
||||
"dustHealthConcern": {
|
||||
"value": "good",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"custom.lowerDevicePower": {
|
||||
"powerState": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"refresh": {},
|
||||
"execute": {
|
||||
"data": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"samsungce.softwareVersion": {
|
||||
"versions": {
|
||||
"value": [
|
||||
{
|
||||
"id": "0",
|
||||
"swType": "Software",
|
||||
"versionNumber": "02059A200115",
|
||||
"description": "Version"
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"swType": "Firmware",
|
||||
"versionNumber": "22030800,22041907",
|
||||
"description": "Version"
|
||||
}
|
||||
],
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
}
|
||||
},
|
||||
"odorSensor": {
|
||||
"odorLevel": {
|
||||
"value": 1,
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"custom.deviceDependencyStatus": {
|
||||
"subDeviceActive": {
|
||||
"value": true,
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
},
|
||||
"dependencyStatus": {
|
||||
"value": "single",
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
},
|
||||
"numberOfSubDevices": {
|
||||
"value": 0,
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
}
|
||||
},
|
||||
"samsungce.airQualityHealthConcern": {
|
||||
"supportedAirQualityHealthConcerns": {
|
||||
"value": null
|
||||
},
|
||||
"airQualityHealthConcern": {
|
||||
"value": "good",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"samsungce.softwareUpdate": {
|
||||
"targetModule": {
|
||||
"value": null
|
||||
},
|
||||
"otnDUID": {
|
||||
"value": "H3CFUCFWKAQR2",
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
},
|
||||
"lastUpdatedDate": {
|
||||
"value": null
|
||||
},
|
||||
"availableModules": {
|
||||
"value": [],
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
},
|
||||
"newVersionAvailable": {
|
||||
"value": false,
|
||||
"timestamp": "2026-03-07T23:53:20.689Z"
|
||||
},
|
||||
"operatingState": {
|
||||
"value": null
|
||||
},
|
||||
"progress": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"custom.welcomeCareMode": {
|
||||
"welcomeCareMode": {
|
||||
"value": null
|
||||
}
|
||||
},
|
||||
"custom.filterUsageTime": {
|
||||
"usageTime": {
|
||||
"value": 17,
|
||||
"timestamp": "2026-03-07T23:53:20.593Z"
|
||||
}
|
||||
},
|
||||
"veryFineDustSensor": {
|
||||
"veryFineDustLevel": {
|
||||
"value": 5,
|
||||
"unit": "\u03bcg/m^3",
|
||||
"timestamp": "2026-03-07T23:53:20.755Z"
|
||||
}
|
||||
},
|
||||
"custom.airQualityMaxLevel": {
|
||||
"airQualityMaxLevel": {
|
||||
"value": 4,
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
}
|
||||
},
|
||||
"samsungce.lamp": {
|
||||
"brightnessLevel": {
|
||||
"value": "off",
|
||||
"timestamp": "2026-03-07T23:37:40.011Z"
|
||||
},
|
||||
"supportedBrightnessLevel": {
|
||||
"value": ["off", "high"],
|
||||
"timestamp": "2022-11-10T22:51:36.262Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"deviceId": "c02e8cfa-94ba-86f3-59a0-04a280950f2b",
|
||||
"name": "[air purifier] Samsung",
|
||||
"label": "Air purifier",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "DA-AC-AIR-000001",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
"locationId": "0ab793d8-ef93-4c27-b1c5-602c5a90ba6a",
|
||||
"ownerId": "862a9721-9b24-59f0-aac2-d56c6d76eaf2",
|
||||
"roomId": "32fe7236-bed2-469b-8fc3-9ed30bb47050",
|
||||
"deviceTypeName": "Samsung OCF Air Purifier",
|
||||
"components": [
|
||||
{
|
||||
"id": "main",
|
||||
"label": "main",
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "ocf",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "refresh",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "execute",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "switch",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "airQualitySensor",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "odorSensor",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "dustSensor",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "veryFineDustSensor",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "dustHealthConcern",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "fineDustHealthConcern",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "veryFineDustHealthConcern",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "airConditionerFanMode",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.airQualityMaxLevel",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.welcomeCareMode",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.airPurifierOperationMode",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.filterUsageTime",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.lowerDevicePower",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.deviceDependencyStatus",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.deviceReportStateConfiguration",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.disabledCapabilities",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "custom.virusDoctorMode",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.airQualityHealthConcern",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.deviceIdentification",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.dongleSoftwareInstallation",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.lamp",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.softwareUpdate",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.softwareVersion",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"id": "samsungce.driverVersion",
|
||||
"version": 1
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
{
|
||||
"name": "AirPurifier",
|
||||
"categoryType": "manufacturer"
|
||||
}
|
||||
],
|
||||
"optional": false
|
||||
}
|
||||
],
|
||||
"createTime": "2022-11-10T22:51:33.462Z",
|
||||
"profile": {
|
||||
"id": "a38afb02-0fa5-353e-948e-2dda14030cef"
|
||||
},
|
||||
"ocf": {
|
||||
"ocfDeviceType": "oic.d.airpurifier",
|
||||
"name": "[air purifier] Samsung",
|
||||
"specVersion": "core.1.1.0",
|
||||
"verticalDomainSpecVersion": "1.2.1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"modelNumber": "ARTIK051_TVTL_18K|10244541|7000035A001111C40100000000000000",
|
||||
"platformVersion": "BEAPP9AT507146H",
|
||||
"platformOS": "TizenRT2.0",
|
||||
"hwVersion": "1.0",
|
||||
"firmwareVersion": "ARTIK051_TVTL_18K_12200115",
|
||||
"vendorId": "DA-AC-AIR-000001",
|
||||
"lastSignupTime": "2022-11-10T22:51:22.368610Z",
|
||||
"transferCandidate": false,
|
||||
"additionalAuthCodeRequired": false
|
||||
},
|
||||
"type": "OCF",
|
||||
"restrictionTier": 0,
|
||||
"allowed": null,
|
||||
"executionContext": "CLOUD",
|
||||
"relationships": []
|
||||
}
|
||||
],
|
||||
"_links": {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "7d3feb98-8a36-4351-c362-5e21ad3a78dd",
|
||||
"name": "Family Hub",
|
||||
"label": "Refrigerator",
|
||||
"label": "Refrigerator 1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "DA-REF-NORMAL-01001",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "3442dfc6-17c0-a65f-dae0-4c6e01786f44",
|
||||
"name": "[robot vacuum] Samsung",
|
||||
"label": "Robot vacuum",
|
||||
"label": "Robot vacuum 1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "DA-RVC-NORMAL-000001",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "7ff318f3-3772-524d-3c9f-72fcd26413ed",
|
||||
"name": "[dishwasher] Samsung",
|
||||
"label": "Dishwasher",
|
||||
"label": "Dishwasher 1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "DA-WM-DW-01011",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"name": "Washer",
|
||||
"label": "Washer",
|
||||
"label": "Washer 1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "C097276D-C8D4-0000-0000-000000000000",
|
||||
"name": "Washer",
|
||||
"label": "Washer",
|
||||
"label": "Washer 2",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "DA-WM-WM-100002",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6",
|
||||
"name": "Soundbar",
|
||||
"label": "Soundbar",
|
||||
"label": "Soundbar 1",
|
||||
"manufacturerName": "Samsung Electronics",
|
||||
"presentationId": "VD-NetworkAudio-003S",
|
||||
"deviceManufacturerCode": "Samsung Electronics",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "2894dc93-0f11-49cc-8a81-3a684cebebf6",
|
||||
"name": "asd",
|
||||
"label": "asd",
|
||||
"label": "virtual thermostat",
|
||||
"manufacturerName": "SmartThingsCommunity",
|
||||
"presentationId": "78906115-bf23-3c43-9cd6-f42ca3d5517a",
|
||||
"locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"deviceId": "a2a6018b-2663-4727-9d1d-8f56953b5116",
|
||||
"name": "asd",
|
||||
"label": "asd",
|
||||
"label": "virtual water sensor",
|
||||
"manufacturerName": "SmartThingsCommunity",
|
||||
"presentationId": "838ae989-b832-3610-968c-2940491600f6",
|
||||
"locationId": "88a3a314-f0c8-40b4-bb44-44ba06c9c42f",
|
||||
|
||||
@@ -1436,7 +1436,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-entry]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_coolselect_door-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1449,7 +1449,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.refrigerator_coolselect_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_coolselect_door',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1472,21 +1472,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-state]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_coolselect_door-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'door',
|
||||
'friendly_name': 'Refrigerator CoolSelect+ door',
|
||||
'friendly_name': 'Refrigerator 1 CoolSelect+ door',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.refrigerator_coolselect_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_coolselect_door',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_filter_status-entry]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_filter_status-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1499,7 +1499,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.refrigerator_filter_status',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_filter_status',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1522,21 +1522,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_filter_status-state]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_filter_status-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'problem',
|
||||
'friendly_name': 'Refrigerator Filter status',
|
||||
'friendly_name': 'Refrigerator 1 Filter status',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.refrigerator_filter_status',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_filter_status',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-entry]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_freezer_door-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1549,7 +1549,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.refrigerator_freezer_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_freezer_door',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1572,21 +1572,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-state]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_freezer_door-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'door',
|
||||
'friendly_name': 'Refrigerator Freezer door',
|
||||
'friendly_name': 'Refrigerator 1 Freezer door',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.refrigerator_freezer_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_freezer_door',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_fridge_door-entry]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_fridge_door-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1599,7 +1599,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.refrigerator_fridge_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_fridge_door',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1622,14 +1622,14 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_fridge_door-state]
|
||||
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_1_fridge_door-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'door',
|
||||
'friendly_name': 'Refrigerator Fridge door',
|
||||
'friendly_name': 'Refrigerator 1 Fridge door',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.refrigerator_fridge_door',
|
||||
'entity_id': 'binary_sensor.refrigerator_1_fridge_door',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
@@ -2033,7 +2033,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_child_lock-entry]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_child_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -2046,7 +2046,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.dishwasher_child_lock',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_child_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -2069,20 +2069,20 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_child_lock-state]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_child_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Child lock',
|
||||
'friendly_name': 'Dishwasher 1 Child lock',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.dishwasher_child_lock',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_child_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_power-entry]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_power-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -2095,7 +2095,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.dishwasher_power',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_power',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -2118,21 +2118,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_power-state]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_power-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'Dishwasher Power',
|
||||
'friendly_name': 'Dishwasher 1 Power',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.dishwasher_power',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_power',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_remote_control-entry]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_remote_control-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -2145,7 +2145,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.dishwasher_remote_control',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_remote_control',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -2168,13 +2168,13 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_remote_control-state]
|
||||
# name: test_all_entities[da_wm_dw_01011][binary_sensor.dishwasher_1_remote_control-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Dishwasher Remote control',
|
||||
'friendly_name': 'Dishwasher 1 Remote control',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.dishwasher_remote_control',
|
||||
'entity_id': 'binary_sensor.dishwasher_1_remote_control',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
@@ -3413,7 +3413,7 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-entry]
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_1_power-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -3426,7 +3426,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.washer_power',
|
||||
'entity_id': 'binary_sensor.washer_1_power',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -3449,21 +3449,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_power-state]
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_1_power-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'Washer Power',
|
||||
'friendly_name': 'Washer 1 Power',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.washer_power',
|
||||
'entity_id': 'binary_sensor.washer_1_power',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-entry]
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_1_remote_control-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -3476,7 +3476,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_1_remote_control',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -3499,20 +3499,20 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_remote_control-state]
|
||||
# name: test_all_entities[da_wm_wm_100001][binary_sensor.washer_1_remote_control-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Washer Remote control',
|
||||
'friendly_name': 'Washer 1 Remote control',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_1_remote_control',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_power-entry]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_power-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -3525,7 +3525,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.washer_power',
|
||||
'entity_id': 'binary_sensor.washer_2_power',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -3548,21 +3548,21 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_power-state]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_power-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'power',
|
||||
'friendly_name': 'Washer Power',
|
||||
'friendly_name': 'Washer 2 Power',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.washer_power',
|
||||
'entity_id': 'binary_sensor.washer_2_power',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_remote_control-entry]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_remote_control-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -3575,7 +3575,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_2_remote_control',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -3598,20 +3598,20 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_remote_control-state]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_remote_control-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Washer Remote control',
|
||||
'friendly_name': 'Washer 2 Remote control',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_2_remote_control',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_upper_washer_remote_control-entry]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_upper_washer_remote_control-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -3624,7 +3624,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.washer_upper_washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_2_upper_washer_remote_control',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -3647,13 +3647,13 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_upper_washer_remote_control-state]
|
||||
# name: test_all_entities[da_wm_wm_100002][binary_sensor.washer_2_upper_washer_remote_control-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Washer Upper washer remote control',
|
||||
'friendly_name': 'Washer 2 Upper washer remote control',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.washer_upper_washer_remote_control',
|
||||
'entity_id': 'binary_sensor.washer_2_upper_washer_remote_control',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
@@ -4010,7 +4010,7 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-entry]
|
||||
# name: test_all_entities[virtual_water_sensor][binary_sensor.virtual_water_sensor_moisture-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -4023,7 +4023,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.asd_moisture',
|
||||
'entity_id': 'binary_sensor.virtual_water_sensor_moisture',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -4046,14 +4046,14 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[virtual_water_sensor][binary_sensor.asd_moisture-state]
|
||||
# name: test_all_entities[virtual_water_sensor][binary_sensor.virtual_water_sensor_moisture-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'moisture',
|
||||
'friendly_name': 'asd Moisture',
|
||||
'friendly_name': 'virtual water sensor Moisture',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.asd_moisture',
|
||||
'entity_id': 'binary_sensor.virtual_water_sensor_moisture',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -342,7 +342,7 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-entry]
|
||||
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_1_reset_water_filter-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -355,7 +355,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.refrigerator_reset_water_filter',
|
||||
'entity_id': 'button.refrigerator_1_reset_water_filter',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -378,13 +378,13 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-state]
|
||||
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_1_reset_water_filter-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Refrigerator Reset water filter',
|
||||
'friendly_name': 'Refrigerator 1 Reset water filter',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.refrigerator_reset_water_filter',
|
||||
'entity_id': 'button.refrigerator_1_reset_water_filter',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -1400,7 +1400,7 @@
|
||||
'state': 'heat_cool',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[virtual_thermostat][climate.asd-entry]
|
||||
# name: test_all_entities[virtual_thermostat][climate.virtual_thermostat-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1422,7 +1422,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'climate',
|
||||
'entity_category': None,
|
||||
'entity_id': 'climate.asd',
|
||||
'entity_id': 'climate.virtual_thermostat',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1445,7 +1445,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[virtual_thermostat][climate.asd-state]
|
||||
# name: test_all_entities[virtual_thermostat][climate.virtual_thermostat-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 4734.6,
|
||||
@@ -1453,7 +1453,7 @@
|
||||
'fan_modes': list([
|
||||
'on',
|
||||
]),
|
||||
'friendly_name': 'asd',
|
||||
'friendly_name': 'virtual thermostat',
|
||||
'hvac_action': <HVACAction.COOLING: 'cooling'>,
|
||||
'hvac_modes': list([
|
||||
<HVACMode.AUTO: 'auto'>,
|
||||
@@ -1466,7 +1466,7 @@
|
||||
'temperature': None,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.asd',
|
||||
'entity_id': 'climate.virtual_thermostat',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -1,4 +1,70 @@
|
||||
# serializer version: 1
|
||||
# name: test_all_entities[da_ac_air_000001][fan.air_purifier-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'sleep',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'fan',
|
||||
'entity_category': None,
|
||||
'entity_id': 'fan.air_purifier',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <FanEntityFeature: 56>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'c02e8cfa-94ba-86f3-59a0-04a280950f2b_main',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ac_air_000001][fan.air_purifier-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Air purifier',
|
||||
'preset_mode': 'auto',
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'sleep',
|
||||
]),
|
||||
'supported_features': <FanEntityFeature: 56>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.air_purifier',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_all_entities[da_ks_hood_01001][fan.range_hood-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user