Compare commits

...

24 Commits

Author SHA1 Message Date
Erik Montnemery
a25300b8e1 Fix import in cover (#165199) 2026-03-09 15:27:12 +01:00
Leon Grave
6fa8e71b21 Add freshr integration, based on pyfreshr (#164538)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-09 15:26:03 +01:00
tronikos
c983978a10 Remove type: ignore in Android TV Remote (#165126) 2026-03-09 14:42:51 +01:00
Joost Lekkerkerker
68b8b6b675 Add fixture for Air Purifier to SmartThings (#165187) 2026-03-09 14:21:34 +01:00
Martin Hjelmare
ee4d313b10 Fix update tests for Python 3.14.3 (#165196) 2026-03-09 14:21:18 +01:00
Erik Montnemery
5e665093c9 Revert "Add number.changed trigger" (#165193) 2026-03-09 13:55:08 +01:00
A. Gideonse
9a5f509ab9 Fix missing Gen-2 sensor for the Indevolt integration (#165133) 2026-03-09 13:49:54 +01:00
Erik Montnemery
8d0cd5edaa Remove some climate and humidifier triggers (#165192) 2026-03-09 13:37:31 +01:00
epenet
71726272f5 Speed up SmartThings tests (#165184)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 13:25:14 +01:00
epenet
9c6c27ab56 Avoid duplicate id/label in smartthings device fixtures (#165190) 2026-03-09 12:40:11 +01:00
Joost Lekkerkerker
db20cf8161 Rename SmartThings devices to maintain uniqueness (#165189) 2026-03-09 12:16:07 +01:00
John O'Nolan
59b6270157 Add reconfigure flow to Ghost integration (#165131) 2026-03-09 11:57:40 +01:00
epenet
a65ba01bbe Mark climate type hints as mandatory (#164982)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-03-09 11:50:42 +01:00
Erik Montnemery
a5d0350560 Add garage_door triggers (#165144)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 11:42:09 +01:00
Shai Ungar
368993556f Bump pyseventeentrack to 1.1.2 (#165089)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 10:38:48 +01:00
Daniel Shneyder
23ea17eaef Bump kaiterra-async-client to 1.1.0 (#165166) 2026-03-09 09:59:55 +01:00
g4bri3lDev
6ace93e45b Bump py-opendisplay to 5.5.0 (#165138)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-09 09:29:57 +01:00
epenet
237a0ae03f Improve type hints in ecobee climate (#165178) 2026-03-09 09:16:43 +01:00
epenet
6067be6f49 Improve type hints in lightwave climate (#165179) 2026-03-09 09:16:29 +01:00
J. Nick Koston
a35c3d5de5 Bump yalexs-ble to 3.3.0 (#165168) 2026-03-08 16:39:30 -10:00
J. Nick Koston
e9c3634cb6 Bump habluetooth to 5.9.1 and bleak-retry-connector to 4.6.0 (#165022) 2026-03-08 16:16:53 -10:00
J. Nick Koston
2ba4544180 Bump yalexs-ble to 3.2.8 (#165018) 2026-03-09 03:07:49 +01:00
Artur Pragacz
5235ce7ae4 Lower ssdp discovery timeout log severity in Onkyo (#165156) 2026-03-09 02:19:42 +01:00
Oscar
56b601e577 Add basic auth support to remote_calendar (#158075) 2026-03-08 16:52:58 -07:00
113 changed files with 4633 additions and 1212 deletions

View File

@@ -212,6 +212,7 @@ homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.*
homeassistant.components.freshr.*
homeassistant.components.fritz.*
homeassistant.components.fritzbox.*
homeassistant.components.fritzbox_callmonitor.*

4
CODEOWNERS generated
View File

@@ -551,6 +551,8 @@ build.json @home-assistant/supervisor
/tests/components/freebox/ @hacf-fr @Quentame
/homeassistant/components/freedompro/ @stefano055415
/tests/components/freedompro/ @stefano055415
/homeassistant/components/freshr/ @SierraNL
/tests/components/freshr/ @SierraNL
/homeassistant/components/fressnapf_tracker/ @eifinger
/tests/components/fressnapf_tracker/ @eifinger
/homeassistant/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
@@ -569,6 +571,8 @@ build.json @home-assistant/supervisor
/tests/components/fully_kiosk/ @cgarwood
/homeassistant/components/fyta/ @dontinelli
/tests/components/fyta/ @dontinelli
/homeassistant/components/garage_door/ @home-assistant/core
/tests/components/garage_door/ @home-assistant/core
/homeassistant/components/garages_amsterdam/ @klaasnicolaas
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus

View File

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

View File

@@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem
def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool:
"""Get value of enable_ime option or its default value."""
return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return]
return bool(entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE))

View File

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

View File

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

View File

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

View File

@@ -115,18 +115,6 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"current_temperature_changed": {
"trigger": "mdi:thermometer"
},
"current_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},

View File

@@ -372,78 +372,6 @@
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {

View File

@@ -17,15 +17,7 @@ from homeassistant.helpers.trigger import (
make_entity_transition_trigger,
)
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
@@ -53,18 +45,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING

View File

@@ -66,20 +66,6 @@ hvac_mode_changed:
- unknown
multiple: true
current_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_humidity_changed:
target: *trigger_climate_target
fields:
@@ -94,20 +80,6 @@ target_humidity_crossed_threshold:
lower_limit: *number_or_entity
upper_limit: *number_or_entity
current_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_temperature_changed:
target: *trigger_climate_target
fields:

View File

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

View File

@@ -1,6 +1,52 @@
"""Constants for cover entity platform."""
from enum import IntFlag, StrEnum
DOMAIN = "cover"
ATTR_CURRENT_POSITION = "current_position"
ATTR_CURRENT_TILT_POSITION = "current_tilt_position"
ATTR_IS_CLOSED = "is_closed"
ATTR_POSITION = "position"
ATTR_TILT_POSITION = "tilt_position"
INTENT_OPEN_COVER = "HassOpenCover"
INTENT_CLOSE_COVER = "HassCloseCover"
class CoverEntityFeature(IntFlag):
"""Supported features of the cover entity."""
OPEN = 1
CLOSE = 2
SET_POSITION = 4
STOP = 8
OPEN_TILT = 16
CLOSE_TILT = 32
STOP_TILT = 64
SET_TILT_POSITION = 128
class CoverState(StrEnum):
"""State of Cover entities."""
CLOSED = "closed"
CLOSING = "closing"
OPEN = "open"
OPENING = "opening"
class CoverDeviceClass(StrEnum):
"""Device class for cover."""
# Refer to the cover dev docs for device class descriptions
AWNING = "awning"
BLIND = "blind"
CURTAIN = "curtain"
DAMPER = "damper"
DOOR = "door"
GARAGE = "garage"
GATE = "gate"
SHADE = "shade"
SHUTTER = "shutter"
WINDOW = "window"

View File

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

View File

@@ -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]] = {

View File

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

View File

@@ -0,0 +1,47 @@
"""The Fresh-r integration."""
import asyncio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import (
FreshrConfigEntry,
FreshrData,
FreshrDevicesCoordinator,
FreshrReadingsCoordinator,
)
_PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
"""Set up Fresh-r from a config entry."""
devices_coordinator = FreshrDevicesCoordinator(hass, entry)
await devices_coordinator.async_config_entry_first_refresh()
readings: dict[str, FreshrReadingsCoordinator] = {
device.id: FreshrReadingsCoordinator(
hass, entry, device, devices_coordinator.client
)
for device in devices_coordinator.data
}
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in readings.values()
)
)
entry.runtime_data = FreshrData(
devices=devices_coordinator,
readings=readings,
)
await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: FreshrConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,58 @@
"""Config flow for the Fresh-r integration."""
from __future__ import annotations
from typing import Any
from aiohttp import ClientError
from pyfreshr import FreshrClient
from pyfreshr.exceptions import LoginError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
)
class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Fresh-r."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Fresh-r ({user_input[CONF_USERNAME]})",
data=user_input,
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

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

View File

@@ -0,0 +1,116 @@
"""Coordinator for Fresh-r integration."""
from dataclasses import dataclass
from datetime import timedelta
from aiohttp import ClientError
from pyfreshr import FreshrClient
from pyfreshr.exceptions import ApiResponseError, LoginError
from pyfreshr.models import DeviceReadings, DeviceSummary
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
DEVICES_SCAN_INTERVAL = timedelta(hours=1)
READINGS_SCAN_INTERVAL = timedelta(minutes=10)
@dataclass
class FreshrData:
"""Runtime data stored on the config entry."""
devices: FreshrDevicesCoordinator
readings: dict[str, FreshrReadingsCoordinator]
type FreshrConfigEntry = ConfigEntry[FreshrData]
class FreshrDevicesCoordinator(DataUpdateCoordinator[list[DeviceSummary]]):
"""Coordinator that refreshes the device list once an hour."""
config_entry: FreshrConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: FreshrConfigEntry) -> None:
"""Initialize the device list coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_devices",
update_interval=DEVICES_SCAN_INTERVAL,
)
self.client = FreshrClient(session=async_create_clientsession(hass))
async def _async_update_data(self) -> list[DeviceSummary]:
"""Fetch the list of devices from the Fresh-r API."""
username = self.config_entry.data[CONF_USERNAME]
password = self.config_entry.data[CONF_PASSWORD]
try:
if not self.client.logged_in:
await self.client.login(username, password)
devices = await self.client.fetch_devices()
except LoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (ApiResponseError, ClientError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
else:
return devices
class FreshrReadingsCoordinator(DataUpdateCoordinator[DeviceReadings]):
"""Coordinator that refreshes readings for a single device every 10 minutes."""
config_entry: FreshrConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: FreshrConfigEntry,
device: DeviceSummary,
client: FreshrClient,
) -> None:
"""Initialize the readings coordinator for a single device."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=f"{DOMAIN}_readings_{device.id}",
update_interval=READINGS_SCAN_INTERVAL,
)
self._device = device
self._client = client
@property
def device_id(self) -> str:
"""Return the device ID."""
return self._device.id
async def _async_update_data(self) -> DeviceReadings:
"""Fetch current readings for this device from the Fresh-r API."""
try:
return await self._client.fetch_device_current(self._device)
except LoginError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
) from err
except (ApiResponseError, ClientError) as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err

View File

@@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"dew_point": {
"default": "mdi:thermometer-water"
},
"flow": {
"default": "mdi:fan"
},
"inside_temperature": {
"default": "mdi:home-thermometer"
},
"outside_temperature": {
"default": "mdi:thermometer"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "freshr",
"name": "Fresh-r",
"codeowners": ["@SierraNL"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/freshr",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyfreshr==1.2.0"]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register custom actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register custom actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration uses a polling coordinator, not event-driven updates.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible.
discovery: todo
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,158 @@
"""Sensor platform for the Fresh-r integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyfreshr.models import DeviceReadings, DeviceType
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UnitOfTemperature,
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FreshrConfigEntry, FreshrReadingsCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FreshrSensorEntityDescription(SensorEntityDescription):
"""Describes a Fresh-r sensor."""
value_fn: Callable[[DeviceReadings], StateType]
_T1 = FreshrSensorEntityDescription(
key="t1",
translation_key="inside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.t1,
)
_T2 = FreshrSensorEntityDescription(
key="t2",
translation_key="outside_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.t2,
)
_CO2 = FreshrSensorEntityDescription(
key="co2",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.co2,
)
_HUM = FreshrSensorEntityDescription(
key="hum",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.hum,
)
_FLOW = FreshrSensorEntityDescription(
key="flow",
translation_key="flow",
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.flow,
)
_DP = FreshrSensorEntityDescription(
key="dp",
translation_key="dew_point",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
value_fn=lambda r: r.dp,
)
_TEMP = FreshrSensorEntityDescription(
key="temp",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda r: r.temp,
)
_DEVICE_TYPE_NAMES: dict[DeviceType, str] = {
DeviceType.FRESH_R: "Fresh-r",
DeviceType.FORWARD: "Fresh-r Forward",
DeviceType.MONITOR: "Fresh-r Monitor",
}
SENSOR_TYPES: dict[DeviceType, tuple[FreshrSensorEntityDescription, ...]] = {
DeviceType.FRESH_R: (_T1, _T2, _CO2, _HUM, _FLOW, _DP),
DeviceType.FORWARD: (_T1, _T2, _CO2, _HUM, _FLOW, _DP, _TEMP),
DeviceType.MONITOR: (_CO2, _HUM, _DP, _TEMP),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FreshrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fresh-r sensors from a config entry."""
entities: list[FreshrSensor] = []
for device in config_entry.runtime_data.devices.data:
descriptions = SENSOR_TYPES.get(
device.device_type, SENSOR_TYPES[DeviceType.FRESH_R]
)
device_info = DeviceInfo(
identifiers={(DOMAIN, device.id)},
name=_DEVICE_TYPE_NAMES.get(device.device_type, "Fresh-r"),
serial_number=device.id,
manufacturer="Fresh-r",
)
entities.extend(
FreshrSensor(
config_entry.runtime_data.readings[device.id],
description,
device_info,
)
for description in descriptions
)
async_add_entities(entities)
class FreshrSensor(CoordinatorEntity[FreshrReadingsCoordinator], SensorEntity):
"""Representation of a Fresh-r sensor."""
_attr_has_entity_name = True
entity_description: FreshrSensorEntityDescription
def __init__(
self,
coordinator: FreshrReadingsCoordinator,
description: FreshrSensorEntityDescription,
device_info: DeviceInfo,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_device_info = device_info
self._attr_unique_id = f"{coordinator.device_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return the value from coordinator data."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,51 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_account": "Cannot change the account username."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "Your Fresh-r account password.",
"username": "Your Fresh-r account username (email address)."
}
}
}
},
"entity": {
"sensor": {
"dew_point": {
"name": "Dew point"
},
"flow": {
"name": "Air flow rate"
},
"inside_temperature": {
"name": "Inside temperature"
},
"outside_temperature": {
"name": "Outside temperature"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Authentication failed. Check your Fresh-r username and password."
},
"cannot_connect": {
"message": "Could not connect to the Fresh-r service."
}
}
}

View File

@@ -0,0 +1,15 @@
"""Integration for garage door triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "garage_door"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Garage door",
"triggers": {
"closed": {
"description": "Triggers after one or more garage doors close.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
}
},
"name": "Garage door closed"
},
"opened": {
"description": "Triggers after one or more garage doors open.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
}
},
"name": "Garage door opened"
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
closed:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage
opened:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: garage_door
- domain: cover
device_class: garage

View File

@@ -108,6 +108,50 @@ class GhostConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input is not None:
api_url = user_input[CONF_API_URL].rstrip("/")
admin_api_key = user_input[CONF_ADMIN_API_KEY]
if ":" not in admin_api_key:
errors["base"] = "invalid_api_key"
else:
try:
site = await self._validate_credentials(api_url, admin_api_key)
except GhostAuthError:
errors["base"] = "invalid_auth"
except GhostError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during Ghost reconfigure")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(site["site_uuid"])
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
CONF_API_URL: api_url,
CONF_ADMIN_API_KEY: admin_api_key,
},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA,
suggested_values=user_input or reconfigure_entry.data,
),
errors=errors,
description_placeholders={"setup_url": GHOST_INTEGRATION_SETUP_URL},
)
async def _validate_credentials(
self, api_url: str, admin_api_key: str
) -> dict[str, Any]:

View File

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

View File

@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "This Ghost site is already configured.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "The provided credentials belong to a different Ghost site."
},
"error": {
"cannot_connect": "Failed to connect to Ghost. Please check your URL.",
@@ -21,6 +23,17 @@
"description": "Your API key for {title} is invalid. [Create a new integration key]({setup_url}) to reauthenticate.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"reconfigure": {
"data": {
"admin_api_key": "[%key:component::ghost::config::step::user::data::admin_api_key%]",
"api_url": "[%key:component::ghost::config::step::user::data::api_url%]"
},
"data_description": {
"admin_api_key": "[%key:component::ghost::config::step::user::data_description::admin_api_key%]",
"api_url": "[%key:component::ghost::config::step::user::data_description::api_url%]"
},
"description": "Update the configuration for your Ghost integration. [Create a custom integration]({setup_url}) to get your API URL and Admin API key."
},
"user": {
"data": {
"admin_api_key": "Admin API key",

View File

@@ -64,12 +64,6 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},

View File

@@ -199,42 +199,6 @@
},
"title": "Humidifier",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more humidifiers changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Humidifier current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more humidifiers crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Humidifier current humidity crossed threshold"
},
"started_drying": {
"description": "Triggers after one or more humidifiers start drying.",
"fields": {

View File

@@ -4,21 +4,13 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
)
from .const import ATTR_ACTION, ATTR_CURRENT_HUMIDITY, DOMAIN, HumidifierAction
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_ACTION, HumidifierAction.DRYING
),

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target: &trigger_humidifier_target
target:
entity:
domain: humidifier
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
@@ -14,52 +14,7 @@
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
started_drying: *trigger_common
started_humidifying: *trigger_common
turned_on: *trigger_common
turned_off: *trigger_common
current_humidity_changed:
target: *trigger_humidifier_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_humidifier_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

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

View File

@@ -234,7 +234,6 @@ SENSORS: Final = (
),
IndevoltSensorEntityDescription(
key="1505",
generation=[1],
translation_key="cumulative_production",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +0,0 @@
"""Provides triggers for number entities."""
from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(
{DOMAIN, INPUT_NUMBER_DOMAIN}
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for number entities."""
return TRIGGERS

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_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,
)

View File

@@ -7,7 +7,7 @@ from httpx import HTTPError, InvalidURL, TimeoutException
from ical.calendar import Calendar
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_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__)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2208,6 +2208,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"freshr": {
"name": "Fresh-r",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
"fressnapf_tracker": {
"name": "Fressnapf Tracker",
"integration_type": "hub",

View File

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

View File

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

@@ -1876,6 +1876,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.freshr.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.fritz.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@@ -1193,14 +1193,17 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="current_humidity",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="target_humidity",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="hvac_mode",
return_type=["HVACMode", None],
mandatory=True,
),
TypeHintMatch(
function_name="hvac_modes",
@@ -1210,26 +1213,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="hvac_action",
return_type=["HVACAction", None],
mandatory=True,
),
TypeHintMatch(
function_name="current_temperature",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="target_temperature",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="target_temperature_step",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="target_temperature_high",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="target_temperature_low",
return_type=["float", None],
mandatory=True,
),
TypeHintMatch(
function_name="preset_mode",
@@ -1239,26 +1248,32 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
TypeHintMatch(
function_name="preset_modes",
return_type=["list[str]", None],
mandatory=True,
),
TypeHintMatch(
function_name="is_aux_heat",
return_type=["bool", None],
mandatory=True,
),
TypeHintMatch(
function_name="fan_mode",
return_type=["str", None],
mandatory=True,
),
TypeHintMatch(
function_name="fan_modes",
return_type=["list[str]", None],
mandatory=True,
),
TypeHintMatch(
function_name="swing_mode",
return_type=["str", None],
mandatory=True,
),
TypeHintMatch(
function_name="swing_modes",
return_type=["list[str]", None],
mandatory=True,
),
TypeHintMatch(
function_name="set_temperature",

15
requirements_all.txt generated
View File

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

View File

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

View File

@@ -76,6 +76,7 @@ NO_IOT_CLASS = [
"ffmpeg",
"file_upload",
"frontend",
"garage_door",
"hardkernel",
"hardware",
"history",

View File

@@ -2111,6 +2111,7 @@ NO_QUALITY_SCALE = [
"ffmpeg",
"file_upload",
"frontend",
"garage_door",
"hardkernel",
"hardware",
"history",

View File

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

View File

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

View File

@@ -0,0 +1 @@
"""Tests for the Fresh-r integration."""

View 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

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

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

View 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)
== []
)

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

View File

@@ -0,0 +1 @@
"""Tests for the garage_door integration."""

View 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

View File

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

View File

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

View File

@@ -15,6 +15,7 @@
"2105": 2000,
"11034": 100,
"1502": 0,
"1505": 553673,
"6004": 0.07,
"6005": 0,
"6006": 380.58,

View File

@@ -51,6 +51,7 @@
'142': 1.79,
'1501': 0,
'1502': 0,
'1505': 553673,
'1532': 150,
'1600': 48.5,
'1601': 48.3,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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