This commit is contained in:
Franck Nijhof 2025-01-20 18:04:03 +01:00 committed by GitHub
commit 3e1d13b6ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 1430 additions and 189 deletions

View File

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.7"] "requirements": ["aioairzone==0.9.9"]
} }

View File

@ -50,7 +50,7 @@ async def async_setup_entry(
descriptions: list[AprilaireHumidifierDescription] = [] descriptions: list[AprilaireHumidifierDescription] = []
if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (0, 1, 2): if coordinator.data.get(Attribute.HUMIDIFICATION_AVAILABLE) in (1, 2):
descriptions.append( descriptions.append(
AprilaireHumidifierDescription( AprilaireHumidifierDescription(
key="humidifier", key="humidifier",
@ -67,7 +67,7 @@ async def async_setup_entry(
) )
) )
if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) in (0, 1): if coordinator.data.get(Attribute.DEHUMIDIFICATION_AVAILABLE) == 1:
descriptions.append( descriptions.append(
AprilaireHumidifierDescription( AprilaireHumidifierDescription(
key="dehumidifier", key="dehumidifier",

View File

@ -1017,9 +1017,18 @@ class PipelineRun:
raise RuntimeError("Recognize intent was not prepared") raise RuntimeError("Recognize intent was not prepared")
if self.pipeline.conversation_language == MATCH_ALL: if self.pipeline.conversation_language == MATCH_ALL:
# LLMs support all languages ('*') so use pipeline language for # LLMs support all languages ('*') so use languages from the
# intent fallback. # pipeline for intent fallback.
input_language = self.pipeline.language #
# We prioritize the STT and TTS languages because they may be more
# specific, such as "zh-CN" instead of just "zh". This is necessary
# for languages whose intents are split out by region when
# preferring local intent matching.
input_language = (
self.pipeline.stt_language
or self.pipeline.tts_language
or self.pipeline.language
)
else: else:
input_language = self.pipeline.conversation_language input_language = self.pipeline.conversation_language

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/aussie_broadband", "documentation": "https://www.home-assistant.io/integrations/aussie_broadband",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aussiebb"], "loggers": ["aussiebb"],
"requirements": ["pyaussiebb==0.1.4"] "requirements": ["pyaussiebb==0.1.5"]
} }

View File

@ -111,7 +111,7 @@
"services": { "services": {
"send_message": { "send_message": {
"name": "[%key:component::notify::services::notify::name%]", "name": "[%key:component::notify::services::notify::name%]",
"description": "Send a mobile push notification to members of a shared Bring! list.", "description": "Sends a mobile push notification to members of a shared Bring! list.",
"fields": { "fields": {
"entity_id": { "entity_id": {
"name": "List", "name": "List",
@ -122,8 +122,8 @@
"description": "Type of push notification to send to list members." "description": "Type of push notification to send to list members."
}, },
"item": { "item": {
"name": "Article (Required if message type `Urgent Message` selected)", "name": "Article (Required if notification type `Urgent message` is selected)",
"description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`" "description": "Article name to include in an urgent message e.g. `Urgent message - Please buy Cilantro urgently`"
} }
} }
} }
@ -134,7 +134,7 @@
"going_shopping": "I'm going shopping! - Last chance to make changes", "going_shopping": "I'm going shopping! - Last chance to make changes",
"changed_list": "List updated - Take a look at the articles", "changed_list": "List updated - Take a look at the articles",
"shopping_done": "Shopping done - The fridge is well stocked", "shopping_done": "Shopping done - The fridge is well stocked",
"urgent_message": "Urgent Message - Please buy `Article name` urgently" "urgent_message": "Urgent message - Please buy `Article` urgently"
} }
} }
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==10.1.0"] "requirements": ["py-sucks==0.9.10", "deebot-client==11.0.0"]
} }

View File

@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/elkm1", "documentation": "https://www.home-assistant.io/integrations/elkm1",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["elkm1_lib"], "loggers": ["elkm1_lib"],
"requirements": ["elkm1-lib==2.2.10"] "requirements": ["elkm1-lib==2.2.11"]
} }

View File

@ -8,7 +8,7 @@ from pyezviz.exceptions import PyEzvizError
from pyezviz.utils import decrypt_image from pyezviz.utils import decrypt_image
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import SOURCE_IGNORE, ConfigEntry
from homeassistant.const import CONF_PASSWORD from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -57,7 +57,9 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
) )
camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) camera = hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
self.alarm_image_password = ( self.alarm_image_password = (
camera.data[CONF_PASSWORD] if camera is not None else None camera.data[CONF_PASSWORD]
if camera and camera.source != SOURCE_IGNORE
else None
) )
async def _async_load_image_from_url(self, url: str) -> Image | None: async def _async_load_image_from_url(self, url: str) -> Image | None:

View File

@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/freebox", "documentation": "https://www.home-assistant.io/integrations/freebox",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["freebox_api"], "loggers": ["freebox_api"],
"requirements": ["freebox-api==1.2.1"], "requirements": ["freebox-api==1.2.2"],
"zeroconf": ["_fbx-api._tcp.local."] "zeroconf": ["_fbx-api._tcp.local."]
} }

View File

@ -227,11 +227,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
include_addons_set = supervisor_backups.AddonSet.ALL include_addons_set = supervisor_backups.AddonSet.ALL
elif include_addons: elif include_addons:
include_addons_set = set(include_addons) include_addons_set = set(include_addons)
include_folders_set = ( include_folders_set = {
{supervisor_backups.Folder(folder) for folder in include_folders} supervisor_backups.Folder(folder) for folder in include_folders or []
if include_folders }
else None # Always include SSL if Home Assistant is included
) if include_homeassistant:
include_folders_set.add(supervisor_backups.Folder.SSL)
hassio_agents: list[SupervisorBackupAgent] = [ hassio_agents: list[SupervisorBackupAgent] = [
cast(SupervisorBackupAgent, manager.backup_agents[agent_id]) cast(SupervisorBackupAgent, manager.backup_agents[agent_id])

View File

@ -9,5 +9,5 @@
}, },
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["apyhiveapi"], "loggers": ["apyhiveapi"],
"requirements": ["pyhiveapi==0.5.16"] "requirements": ["pyhive-integration==1.0.1"]
} }

View File

@ -52,6 +52,7 @@ from .const import (
PROP_MIN_VALUE, PROP_MIN_VALUE,
SERV_LIGHTBULB, SERV_LIGHTBULB,
) )
from .util import get_min_max
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -120,12 +121,14 @@ class Light(HomeAccessory):
self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100) self.char_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100)
if CHAR_COLOR_TEMPERATURE in self.chars: if CHAR_COLOR_TEMPERATURE in self.chars:
self.min_mireds = color_temperature_kelvin_to_mired( min_mireds = color_temperature_kelvin_to_mired(
attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP) attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP)
) )
self.max_mireds = color_temperature_kelvin_to_mired( max_mireds = color_temperature_kelvin_to_mired(
attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP) attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP)
) )
# Ensure min is less than max
self.min_mireds, self.max_mireds = get_min_max(min_mireds, max_mireds)
if not self.color_temp_supported and not self.rgbww_supported: if not self.color_temp_supported and not self.rgbww_supported:
self.max_mireds = self.min_mireds self.max_mireds = self.min_mireds
self.char_color_temp = serv_light.configure_char( self.char_color_temp = serv_light.configure_char(
@ -282,7 +285,11 @@ class Light(HomeAccessory):
hue, saturation = color_temperature_to_hs(color_temp) hue, saturation = color_temperature_to_hs(color_temp)
elif color_mode == ColorMode.WHITE: elif color_mode == ColorMode.WHITE:
hue, saturation = 0, 0 hue, saturation = 0, 0
elif hue_sat := attributes.get(ATTR_HS_COLOR): elif (
(hue_sat := attributes.get(ATTR_HS_COLOR))
and isinstance(hue_sat, (list, tuple))
and len(hue_sat) == 2
):
hue, saturation = hue_sat hue, saturation = hue_sat
else: else:
hue = None hue = None

View File

@ -14,6 +14,7 @@ from homeassistant.components.climate import (
ATTR_HVAC_ACTION, ATTR_HVAC_ACTION,
ATTR_HVAC_MODE, ATTR_HVAC_MODE,
ATTR_HVAC_MODES, ATTR_HVAC_MODES,
ATTR_MAX_HUMIDITY,
ATTR_MAX_TEMP, ATTR_MAX_TEMP,
ATTR_MIN_HUMIDITY, ATTR_MIN_HUMIDITY,
ATTR_MIN_TEMP, ATTR_MIN_TEMP,
@ -21,6 +22,7 @@ from homeassistant.components.climate import (
ATTR_SWING_MODES, ATTR_SWING_MODES,
ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_LOW,
DEFAULT_MAX_HUMIDITY,
DEFAULT_MAX_TEMP, DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP, DEFAULT_MIN_TEMP,
@ -90,7 +92,7 @@ from .const import (
SERV_FANV2, SERV_FANV2,
SERV_THERMOSTAT, SERV_THERMOSTAT,
) )
from .util import temperature_to_homekit, temperature_to_states from .util import get_min_max, temperature_to_homekit, temperature_to_states
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -208,7 +210,10 @@ class Thermostat(HomeAccessory):
self.fan_chars: list[str] = [] self.fan_chars: list[str] = []
attributes = state.attributes attributes = state.attributes
min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) min_humidity, _ = get_min_max(
attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY),
attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY),
)
features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: if features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE:
@ -839,6 +844,9 @@ def _get_temperature_range_from_state(
else: else:
max_temp = default_max max_temp = default_max
# Handle reversed temperature range
min_temp, max_temp = get_min_max(min_temp, max_temp)
# Homekit only supports 10-38, overwriting # Homekit only supports 10-38, overwriting
# the max to appears to work, but less than 0 causes # the max to appears to work, but less than 0 causes
# a crash on the home app # a crash on the home app

View File

@ -655,3 +655,14 @@ def state_changed_event_is_same_state(event: Event[EventStateChangedData]) -> bo
old_state = event_data["old_state"] old_state = event_data["old_state"]
new_state = event_data["new_state"] new_state = event_data["new_state"]
return bool(new_state and old_state and new_state.state == old_state.state) return bool(new_state and old_state and new_state.state == old_state.state)
def get_min_max(value1: float, value2: float) -> tuple[float, float]:
"""Return the minimum and maximum of two values.
HomeKit will go unavailable if the min and max are reversed
so we make sure the min is always the min and the max is always the max
as any mistakes made in integrations will cause the entire
bridge to go unavailable.
"""
return min(value1, value2), max(value1, value2)

View File

@ -12,7 +12,7 @@
"requirements": [ "requirements": [
"xknx==3.4.0", "xknx==3.4.0",
"xknxproject==3.8.1", "xknxproject==3.8.1",
"knx-frontend==2024.12.26.233449" "knx-frontend==2025.1.18.164225"
], ],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -13,7 +13,7 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["demetriek"], "loggers": ["demetriek"],
"requirements": ["demetriek==1.1.1"], "requirements": ["demetriek==1.2.0"],
"ssdp": [ "ssdp": [
{ {
"deviceType": "urn:schemas-upnp-org:device:LaMetric:1" "deviceType": "urn:schemas-upnp-org:device:LaMetric:1"

View File

@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from demetriek import Device, LaMetricDevice from demetriek import Device, LaMetricDevice, Range
from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -25,6 +25,7 @@ class LaMetricNumberEntityDescription(NumberEntityDescription):
"""Class describing LaMetric number entities.""" """Class describing LaMetric number entities."""
value_fn: Callable[[Device], int | None] value_fn: Callable[[Device], int | None]
range_fn: Callable[[Device], Range | None]
has_fn: Callable[[Device], bool] = lambda device: True has_fn: Callable[[Device], bool] = lambda device: True
set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]] set_value_fn: Callable[[LaMetricDevice, float], Awaitable[Any]]
@ -33,11 +34,9 @@ NUMBERS = [
LaMetricNumberEntityDescription( LaMetricNumberEntityDescription(
key="brightness", key="brightness",
translation_key="brightness", translation_key="brightness",
name="Brightness",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
native_step=1, native_step=1,
native_min_value=0, range_fn=lambda device: device.display.brightness_limit,
native_max_value=100,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,
value_fn=lambda device: device.display.brightness, value_fn=lambda device: device.display.brightness,
set_value_fn=lambda device, bri: device.display(brightness=int(bri)), set_value_fn=lambda device, bri: device.display(brightness=int(bri)),
@ -45,11 +44,10 @@ NUMBERS = [
LaMetricNumberEntityDescription( LaMetricNumberEntityDescription(
key="volume", key="volume",
translation_key="volume", translation_key="volume",
name="Volume",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
native_step=1, native_step=1,
native_min_value=0, range_fn=lambda device: device.audio.volume_range if device.audio else None,
native_max_value=100, native_unit_of_measurement=PERCENTAGE,
has_fn=lambda device: bool(device.audio and device.audio.available), has_fn=lambda device: bool(device.audio and device.audio.available),
value_fn=lambda device: device.audio.volume if device.audio else 0, value_fn=lambda device: device.audio.volume if device.audio else 0,
set_value_fn=lambda api, volume: api.audio(volume=int(volume)), set_value_fn=lambda api, volume: api.audio(volume=int(volume)),
@ -93,6 +91,20 @@ class LaMetricNumberEntity(LaMetricEntity, NumberEntity):
"""Return the number value.""" """Return the number value."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.coordinator.data)
@property
def native_min_value(self) -> int:
"""Return the min range."""
if limits := self.entity_description.range_fn(self.coordinator.data):
return limits.range_min
return 0
@property
def native_max_value(self) -> int:
"""Return the max range."""
if limits := self.entity_description.range_fn(self.coordinator.data):
return limits.range_max
return 100
@lametric_exception_handler @lametric_exception_handler
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Change to new number value.""" """Change to new number value."""

View File

@ -66,6 +66,14 @@
"name": "Dismiss all notifications" "name": "Dismiss all notifications"
} }
}, },
"number": {
"brightness": {
"name": "Brightness"
},
"volume": {
"name": "Volume"
}
},
"sensor": { "sensor": {
"rssi": { "rssi": {
"name": "Wi-Fi signal" "name": "Wi-Fi signal"

View File

@ -8,6 +8,6 @@
"iot_class": "calculated", "iot_class": "calculated",
"loggers": ["yt_dlp"], "loggers": ["yt_dlp"],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["yt-dlp[default]==2024.12.23"], "requirements": ["yt-dlp[default]==2025.01.15"],
"single_config_entry": true "single_config_entry": true
} }

View File

@ -179,14 +179,14 @@ class MqttNumber(MqttEntity, RestoreNumber):
return return
if num_value is not None and ( if num_value is not None and (
num_value < self.min_value or num_value > self.max_value num_value < self.native_min_value or num_value > self.native_max_value
): ):
_LOGGER.error( _LOGGER.error(
"Invalid value for %s: %s (range %s - %s)", "Invalid value for %s: %s (range %s - %s)",
self.entity_id, self.entity_id,
num_value, num_value,
self.min_value, self.native_min_value,
self.max_value, self.native_max_value,
) )
return return

View File

@ -112,7 +112,7 @@ class NikoHomeControlLight(NikoHomeControlEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on.""" """Instruct the light to turn on."""
self._action.turn_on(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55) self._action.turn_on(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55))
def turn_off(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None:
"""Instruct the light to turn off.""" """Instruct the light to turn off."""

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/niko_home_control", "documentation": "https://www.home-assistant.io/integrations/niko_home_control",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["nikohomecontrol"], "loggers": ["nikohomecontrol"],
"requirements": ["nhc==0.3.2"] "requirements": ["nhc==0.3.4"]
} }

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/nmap_tracker", "documentation": "https://www.home-assistant.io/integrations/nmap_tracker",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["nmap"], "loggers": ["nmap"],
"requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.7"] "requirements": ["netmap==0.7.0.2", "getmac==0.9.4", "aiooui==0.1.9"]
} }

View File

@ -61,7 +61,8 @@ MODEL_NAMES = [ # https://ollama.com/library
"goliath", "goliath",
"granite-code", "granite-code",
"granite3-dense", "granite3-dense",
"granite3-guardian" "granite3-moe", "granite3-guardian",
"granite3-moe",
"hermes3", "hermes3",
"internlm2", "internlm2",
"llama-guard3", "llama-guard3",

View File

@ -263,16 +263,22 @@ class ONVIFDevice:
LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name) LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name)
return return
cam_date = dt.datetime( try:
cdate.Date.Year, cam_date = dt.datetime(
cdate.Date.Month, cdate.Date.Year,
cdate.Date.Day, cdate.Date.Month,
cdate.Time.Hour, cdate.Date.Day,
cdate.Time.Minute, cdate.Time.Hour,
cdate.Time.Second, cdate.Time.Minute,
0, cdate.Time.Second,
tzone, 0,
) tzone,
)
except ValueError as err:
LOGGER.warning(
"%s: Could not parse date/time from camera: %s", self.name, err
)
return
cam_date_utc = cam_date.astimezone(dt_util.UTC) cam_date_utc = cam_date.astimezone(dt_util.UTC)

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/onvif", "documentation": "https://www.home-assistant.io/integrations/onvif",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"], "loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": ["onvif-zeep-async==3.1.13", "WSDiscovery==2.0.0"] "requirements": ["onvif-zeep-async==3.2.3", "WSDiscovery==2.0.0"]
} }

View File

@ -119,5 +119,5 @@ class HitachiAirToWaterHeatingZone(OverkizEntity, ClimateEntity):
temperature = cast(float, kwargs.get(ATTR_TEMPERATURE)) temperature = cast(float, kwargs.get(ATTR_TEMPERATURE))
await self.executor.async_execute_command( await self.executor.async_execute_command(
OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, int(temperature) OverkizCommand.SET_THERMOSTAT_SETTING_CONTROL_ZONE_1, float(temperature)
) )

View File

@ -6,7 +6,7 @@
"dependencies": ["usb"], "dependencies": ["usb"],
"documentation": "https://www.home-assistant.io/integrations/rainforest_raven", "documentation": "https://www.home-assistant.io/integrations/rainforest_raven",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["aioraven==0.7.0"], "requirements": ["aioraven==0.7.1"],
"usb": [ "usb": [
{ {
"vid": "0403", "vid": "0403",

View File

@ -209,10 +209,7 @@ class RfxtrxOptionsFlow(OptionsFlow):
except ValueError: except ValueError:
errors[CONF_COMMAND_OFF] = "invalid_input_2262_off" errors[CONF_COMMAND_OFF] = "invalid_input_2262_off"
try: off_delay = user_input.get(CONF_OFF_DELAY)
off_delay = none_or_int(user_input.get(CONF_OFF_DELAY), 10)
except ValueError:
errors[CONF_OFF_DELAY] = "invalid_input_off_delay"
if not errors: if not errors:
devices = {} devices = {}
@ -252,11 +249,11 @@ class RfxtrxOptionsFlow(OptionsFlow):
vol.Optional( vol.Optional(
CONF_OFF_DELAY, CONF_OFF_DELAY,
description={"suggested_value": device_data[CONF_OFF_DELAY]}, description={"suggested_value": device_data[CONF_OFF_DELAY]},
): str, ): int,
} }
else: else:
off_delay_schema = { off_delay_schema = {
vol.Optional(CONF_OFF_DELAY): str, vol.Optional(CONF_OFF_DELAY): int,
} }
data_schema.update(off_delay_schema) data_schema.update(off_delay_schema)

View File

@ -68,7 +68,6 @@
"invalid_event_code": "Invalid event code", "invalid_event_code": "Invalid event code",
"invalid_input_2262_on": "Invalid input for command on", "invalid_input_2262_on": "Invalid input for command on",
"invalid_input_2262_off": "Invalid input for command off", "invalid_input_2262_off": "Invalid input for command off",
"invalid_input_off_delay": "Invalid input for off delay",
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },

View File

@ -10,12 +10,16 @@ import logging
from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
from pysmartapp.event import EVENT_TYPE_DEVICE from pysmartapp.event import EVENT_TYPE_DEVICE
from pysmartthings import Attribute, Capability, SmartThings from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
@ -106,7 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# to import the modules. # to import the modules.
await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS) await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS)
remove_entry = False
try: try:
# See if the app is already setup. This occurs when there are # See if the app is already setup. This occurs when there are
# installs in multiple SmartThings locations (valid use-case) # installs in multiple SmartThings locations (valid use-case)
@ -175,34 +178,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
broker.connect() broker.connect()
hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
except APIInvalidGrant as ex:
raise ConfigEntryAuthFailed from ex
except ClientResponseError as ex: except ClientResponseError as ex:
if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
_LOGGER.exception( raise ConfigEntryError(
( "The access token is no longer valid. Please remove the integration and set up again."
"Unable to setup configuration entry '%s' - please reconfigure the" ) from ex
" integration" _LOGGER.debug(ex, exc_info=True)
), raise ConfigEntryNotReady from ex
entry.title,
)
remove_entry = True
else:
_LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex
except (ClientConnectionError, RuntimeWarning) as ex: except (ClientConnectionError, RuntimeWarning) as ex:
_LOGGER.debug(ex, exc_info=True) _LOGGER.debug(ex, exc_info=True)
raise ConfigEntryNotReady from ex raise ConfigEntryNotReady from ex
if remove_entry:
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
# only create new flow if there isn't a pending one for SmartThings.
if not hass.config_entries.flow.async_progress_by_handler(DOMAIN):
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}
)
)
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@ -1,5 +1,6 @@
"""Config flow to configure SmartThings.""" """Config flow to configure SmartThings."""
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import logging import logging
from typing import Any from typing import Any
@ -9,7 +10,7 @@ from pysmartthings import APIResponseError, AppOAuth, SmartThings
from pysmartthings.installedapp import format_install_url from pysmartthings.installedapp import format_install_url
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -213,7 +214,10 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
url = format_install_url(self.app_id, self.location_id) url = format_install_url(self.app_id, self.location_id)
return self.async_external_step(step_id="authorize", url=url) return self.async_external_step(step_id="authorize", url=url)
return self.async_external_step_done(next_step_id="install") next_step_id = "install"
if self.source == SOURCE_REAUTH:
next_step_id = "update"
return self.async_external_step_done(next_step_id=next_step_id)
def _show_step_pat(self, errors): def _show_step_pat(self, errors):
if self.access_token is None: if self.access_token is None:
@ -240,6 +244,41 @@ class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
}, },
) )
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
self.app_id = self._get_reauth_entry().data[CONF_APP_ID]
self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID]
self._set_confirm_only()
return await self.async_step_authorize()
async def async_step_update(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
return await self.async_step_update_confirm()
async def async_step_update_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication of an existing config entry."""
if user_input is None:
self._set_confirm_only()
return self.async_show_form(step_id="update_confirm")
entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token}
)
async def async_step_install( async def async_step_install(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@ -1,5 +1,7 @@
"""SmartApp functionality to receive cloud-push notifications.""" """SmartApp functionality to receive cloud-push notifications."""
from __future__ import annotations
import asyncio import asyncio
import functools import functools
import logging import logging
@ -27,6 +29,7 @@ from pysmartthings import (
) )
from homeassistant.components import cloud, webhook from homeassistant.components import cloud, webhook
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
@ -400,7 +403,7 @@ async def smartapp_sync_subscriptions(
_LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id) _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id)
async def _continue_flow( async def _find_and_continue_flow(
hass: HomeAssistant, hass: HomeAssistant,
app_id: str, app_id: str,
location_id: str, location_id: str,
@ -418,24 +421,34 @@ async def _continue_flow(
None, None,
) )
if flow is not None: if flow is not None:
await hass.config_entries.flow.async_configure( await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow)
flow["flow_id"],
{
CONF_INSTALLED_APP_ID: installed_app_id, async def _continue_flow(
CONF_REFRESH_TOKEN: refresh_token, hass: HomeAssistant,
}, app_id: str,
) installed_app_id: str,
_LOGGER.debug( refresh_token: str,
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'", flow: ConfigFlowResult,
flow["flow_id"], ) -> None:
installed_app_id, await hass.config_entries.flow.async_configure(
app_id, flow["flow_id"],
) {
CONF_INSTALLED_APP_ID: installed_app_id,
CONF_REFRESH_TOKEN: refresh_token,
},
)
_LOGGER.debug(
"Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
installed_app_id,
app_id,
)
async def smartapp_install(hass: HomeAssistant, req, resp, app): async def smartapp_install(hass: HomeAssistant, req, resp, app):
"""Handle a SmartApp installation and continue the config flow.""" """Handle a SmartApp installation and continue the config flow."""
await _continue_flow( await _find_and_continue_flow(
hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
) )
_LOGGER.debug( _LOGGER.debug(
@ -447,6 +460,27 @@ async def smartapp_install(hass: HomeAssistant, req, resp, app):
async def smartapp_update(hass: HomeAssistant, req, resp, app): async def smartapp_update(hass: HomeAssistant, req, resp, app):
"""Handle a SmartApp update and either update the entry or continue the flow.""" """Handle a SmartApp update and either update the entry or continue the flow."""
unique_id = format_unique_id(app.app_id, req.location_id)
flow = next(
(
flow
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
if flow["context"].get("unique_id") == unique_id
and flow["step_id"] == "authorize"
),
None,
)
if flow is not None:
await _continue_flow(
hass, app.app_id, req.installed_app_id, req.refresh_token, flow
)
_LOGGER.debug(
"Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'",
flow["flow_id"],
req.installed_app_id,
app.app_id,
)
return
entry = next( entry = next(
( (
entry entry
@ -466,7 +500,7 @@ async def smartapp_update(hass: HomeAssistant, req, resp, app):
app.app_id, app.app_id,
) )
await _continue_flow( await _find_and_continue_flow(
hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
) )
_LOGGER.debug( _LOGGER.debug(

View File

@ -7,7 +7,7 @@
}, },
"pat": { "pat": {
"title": "Enter Personal Access Token", "title": "Enter Personal Access Token",
"description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.", "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**",
"data": { "data": {
"access_token": "[%key:common::config_flow::data::access_token%]" "access_token": "[%key:common::config_flow::data::access_token%]"
} }
@ -17,11 +17,20 @@
"description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.", "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
"data": { "location_id": "[%key:common::config_flow::data::location%]" } "data": { "location_id": "[%key:common::config_flow::data::location%]" }
}, },
"authorize": { "title": "Authorize Home Assistant" } "authorize": { "title": "Authorize Home Assistant" },
"reauth_confirm": {
"title": "Reauthorize Home Assistant",
"description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again."
},
"update_confirm": {
"title": "Finish reauthentication",
"description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process."
}
}, },
"abort": { "abort": {
"invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.", "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
"no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant." "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.",
"reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings."
}, },
"error": { "error": {
"token_invalid_format": "The token must be in the UID/GUID format", "token_invalid_format": "The token must be in the UID/GUID format",

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud",
"integration_type": "hub", "integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["switchbot-api"], "loggers": ["switchbot_api"],
"requirements": ["switchbot-api==2.2.1"] "requirements": ["switchbot-api==2.3.1"]
} }

View File

@ -181,7 +181,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
ProtectEventEntityDescription( ProtectEventEntityDescription(
key="nfc", key="nfc",
translation_key="nfc", translation_key="nfc",
device_class=EventDeviceClass.DOORBELL,
icon="mdi:nfc", icon="mdi:nfc",
ufp_required_field="feature_flags.support_nfc", ufp_required_field="feature_flags.support_nfc",
ufp_event_obj="last_nfc_card_scanned_event", ufp_event_obj="last_nfc_card_scanned_event",
@ -191,7 +190,6 @@ EVENT_DESCRIPTIONS: tuple[ProtectEventEntityDescription, ...] = (
ProtectEventEntityDescription( ProtectEventEntityDescription(
key="fingerprint", key="fingerprint",
translation_key="fingerprint", translation_key="fingerprint",
device_class=EventDeviceClass.DOORBELL,
icon="mdi:fingerprint", icon="mdi:fingerprint",
ufp_required_field="feature_flags.has_fingerprint_sensor", ufp_required_field="feature_flags.has_fingerprint_sensor",
ufp_event_obj="last_fingerprint_identified_event", ufp_event_obj="last_fingerprint_identified_event",

View File

@ -90,7 +90,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [
WatergateSensorEntityDescription( WatergateSensorEntityDescription(
value_fn=lambda data: ( value_fn=lambda data: (
dt_util.as_utc( dt_util.as_utc(
dt_util.now() - timedelta(microseconds=data.networking.wifi_uptime) dt_util.now() - timedelta(milliseconds=data.networking.wifi_uptime)
) )
if data.networking if data.networking
else None else None
@ -104,7 +104,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [
WatergateSensorEntityDescription( WatergateSensorEntityDescription(
value_fn=lambda data: ( value_fn=lambda data: (
dt_util.as_utc( dt_util.as_utc(
dt_util.now() - timedelta(microseconds=data.networking.mqtt_uptime) dt_util.now() - timedelta(milliseconds=data.networking.mqtt_uptime)
) )
if data.networking if data.networking
else None else None
@ -158,7 +158,11 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [
), ),
WatergateSensorEntityDescription( WatergateSensorEntityDescription(
value_fn=lambda data: ( value_fn=lambda data: (
PowerSupplyMode(data.state.power_supply.replace("+", "_")) PowerSupplyMode(
data.state.power_supply.replace("+", "_").replace(
"external_battery", "battery_external"
)
)
if data.state if data.state
else None else None
), ),

View File

@ -16,6 +16,7 @@ from aiohttp import ClientError
from aiohttp.hdrs import METH_POST from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response from aiohttp.web import Request, Response
from aiowithings import NotificationCategory, WithingsClient from aiowithings import NotificationCategory, WithingsClient
from aiowithings.exceptions import WithingsError
from aiowithings.util import to_enum from aiowithings.util import to_enum
from yarl import URL from yarl import URL
@ -223,10 +224,13 @@ class WithingsWebhookManager:
"Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID] "Unregister Withings webhook (%s)", self.entry.data[CONF_WEBHOOK_ID]
) )
webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID]) webhook_unregister(self.hass, self.entry.data[CONF_WEBHOOK_ID])
await async_unsubscribe_webhooks(self.withings_data.client)
for coordinator in self.withings_data.coordinators: for coordinator in self.withings_data.coordinators:
coordinator.webhook_subscription_listener(False) coordinator.webhook_subscription_listener(False)
self._webhooks_registered = False self._webhooks_registered = False
try:
await async_unsubscribe_webhooks(self.withings_data.client)
except WithingsError as ex:
LOGGER.warning("Failed to unsubscribe from Withings webhook: %s", ex)
async def register_webhook( async def register_webhook(
self, self,

View File

@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025 MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 1 MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "2" PATCH_VERSION: Final = "3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

@ -67,9 +67,11 @@ class DiscoveryFlowHandler[_R: Awaitable[bool] | bool](config_entries.ConfigFlow
in_progress = self._async_in_progress() in_progress = self._async_in_progress()
if not (has_devices := bool(in_progress)): if not (has_devices := bool(in_progress)):
has_devices = await cast( discovery_result = self._discovery_function(self.hass)
"asyncio.Future[bool]", self._discovery_function(self.hass) if isinstance(discovery_result, bool):
) has_devices = discovery_result
else:
has_devices = await cast("asyncio.Future[bool]", discovery_result)
if not has_devices: if not has_devices:
return self.async_abort(reason="no_devices_found") return self.async_abort(reason="no_devices_found")

View File

@ -1589,6 +1589,9 @@ class Script:
target, referenced, script[CONF_SEQUENCE] target, referenced, script[CONF_SEQUENCE]
) )
elif action == cv.SCRIPT_ACTION_SEQUENCE:
Script._find_referenced_target(target, referenced, step[CONF_SEQUENCE])
@cached_property @cached_property
def referenced_devices(self) -> set[str]: def referenced_devices(self) -> set[str]:
"""Return a set of referenced devices.""" """Return a set of referenced devices."""
@ -1636,6 +1639,9 @@ class Script:
for script in step[CONF_PARALLEL]: for script in step[CONF_PARALLEL]:
Script._find_referenced_devices(referenced, script[CONF_SEQUENCE]) Script._find_referenced_devices(referenced, script[CONF_SEQUENCE])
elif action == cv.SCRIPT_ACTION_SEQUENCE:
Script._find_referenced_devices(referenced, step[CONF_SEQUENCE])
@cached_property @cached_property
def referenced_entities(self) -> set[str]: def referenced_entities(self) -> set[str]:
"""Return a set of referenced entities.""" """Return a set of referenced entities."""
@ -1684,6 +1690,9 @@ class Script:
for script in step[CONF_PARALLEL]: for script in step[CONF_PARALLEL]:
Script._find_referenced_entities(referenced, script[CONF_SEQUENCE]) Script._find_referenced_entities(referenced, script[CONF_SEQUENCE])
elif action == cv.SCRIPT_ACTION_SEQUENCE:
Script._find_referenced_entities(referenced, step[CONF_SEQUENCE])
def run( def run(
self, variables: _VarsType | None = None, context: Context | None = None self, variables: _VarsType | None = None, context: Context | None = None
) -> None: ) -> None:

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2025.1.2" version = "2025.1.3"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -182,7 +182,7 @@ aioairq==0.4.3
aioairzone-cloud==0.6.10 aioairzone-cloud==0.6.10
# homeassistant.components.airzone # homeassistant.components.airzone
aioairzone==0.9.7 aioairzone==0.9.9
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@ -318,7 +318,7 @@ aiooncue==0.3.7
aioopenexchangerates==0.6.8 aioopenexchangerates==0.6.8
# homeassistant.components.nmap_tracker # homeassistant.components.nmap_tracker
aiooui==0.1.7 aiooui==0.1.9
# homeassistant.components.pegel_online # homeassistant.components.pegel_online
aiopegelonline==0.1.1 aiopegelonline==0.1.1
@ -344,7 +344,7 @@ aiopyarr==23.4.0
aioqsw==0.4.1 aioqsw==0.4.1
# homeassistant.components.rainforest_raven # homeassistant.components.rainforest_raven
aioraven==0.7.0 aioraven==0.7.1
# homeassistant.components.recollect_waste # homeassistant.components.recollect_waste
aiorecollect==2023.09.0 aiorecollect==2023.09.0
@ -738,7 +738,7 @@ debugpy==1.8.11
# decora==0.6 # decora==0.6
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==10.1.0 deebot-client==11.0.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -749,7 +749,7 @@ defusedxml==0.7.1
deluge-client==1.10.2 deluge-client==1.10.2
# homeassistant.components.lametric # homeassistant.components.lametric
demetriek==1.1.1 demetriek==1.2.0
# homeassistant.components.denonavr # homeassistant.components.denonavr
denonavr==1.0.1 denonavr==1.0.1
@ -824,7 +824,7 @@ elgato==5.1.2
eliqonline==1.2.2 eliqonline==1.2.2
# homeassistant.components.elkm1 # homeassistant.components.elkm1
elkm1-lib==2.2.10 elkm1-lib==2.2.11
# homeassistant.components.elmax # homeassistant.components.elmax
elmax-api==0.0.6.4rc0 elmax-api==0.0.6.4rc0
@ -940,7 +940,7 @@ forecast-solar==4.0.0
fortiosapi==1.0.5 fortiosapi==1.0.5
# homeassistant.components.freebox # homeassistant.components.freebox
freebox-api==1.2.1 freebox-api==1.2.2
# homeassistant.components.free_mobile # homeassistant.components.free_mobile
freesms==0.2.0 freesms==0.2.0
@ -1260,7 +1260,7 @@ kiwiki-client==0.1.1
knocki==0.4.2 knocki==0.4.2
# homeassistant.components.knx # homeassistant.components.knx
knx-frontend==2024.12.26.233449 knx-frontend==2025.1.18.164225
# homeassistant.components.konnected # homeassistant.components.konnected
konnected==1.2.0 konnected==1.2.0
@ -1467,7 +1467,7 @@ nextcord==2.6.0
nextdns==4.0.0 nextdns==4.0.0
# homeassistant.components.niko_home_control # homeassistant.components.niko_home_control
nhc==0.3.2 nhc==0.3.4
# homeassistant.components.nibe_heatpump # homeassistant.components.nibe_heatpump
nibe==2.14.0 nibe==2.14.0
@ -1537,7 +1537,7 @@ omnilogic==0.4.5
ondilo==0.5.0 ondilo==0.5.0
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==3.1.13 onvif-zeep-async==3.2.3
# homeassistant.components.opengarage # homeassistant.components.opengarage
open-garage==0.2.0 open-garage==0.2.0
@ -1794,7 +1794,7 @@ pyatmo==8.1.0
pyatv==0.16.0 pyatv==0.16.0
# homeassistant.components.aussie_broadband # homeassistant.components.aussie_broadband
pyaussiebb==0.1.4 pyaussiebb==0.1.5
# homeassistant.components.balboa # homeassistant.components.balboa
pybalboa==1.0.2 pybalboa==1.0.2
@ -1965,7 +1965,7 @@ pyhaversion==22.8.0
pyheos==0.7.2 pyheos==0.7.2
# homeassistant.components.hive # homeassistant.components.hive
pyhiveapi==0.5.16 pyhive-integration==1.0.1
# homeassistant.components.homematic # homeassistant.components.homematic
pyhomematic==0.1.77 pyhomematic==0.1.77
@ -2782,7 +2782,7 @@ surepy==0.9.0
swisshydrodata==0.1.0 swisshydrodata==0.1.0
# homeassistant.components.switchbot_cloud # homeassistant.components.switchbot_cloud
switchbot-api==2.2.1 switchbot-api==2.3.1
# homeassistant.components.synology_srm # homeassistant.components.synology_srm
synology-srm==0.2.0 synology-srm==0.2.0
@ -3082,7 +3082,7 @@ youless-api==2.1.2
youtubeaio==1.1.5 youtubeaio==1.1.5
# homeassistant.components.media_extractor # homeassistant.components.media_extractor
yt-dlp[default]==2024.12.23 yt-dlp[default]==2025.01.15
# homeassistant.components.zabbix # homeassistant.components.zabbix
zabbix-utils==2.0.2 zabbix-utils==2.0.2

View File

@ -170,7 +170,7 @@ aioairq==0.4.3
aioairzone-cloud==0.6.10 aioairzone-cloud==0.6.10
# homeassistant.components.airzone # homeassistant.components.airzone
aioairzone==0.9.7 aioairzone==0.9.9
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@ -300,7 +300,7 @@ aiooncue==0.3.7
aioopenexchangerates==0.6.8 aioopenexchangerates==0.6.8
# homeassistant.components.nmap_tracker # homeassistant.components.nmap_tracker
aiooui==0.1.7 aiooui==0.1.9
# homeassistant.components.pegel_online # homeassistant.components.pegel_online
aiopegelonline==0.1.1 aiopegelonline==0.1.1
@ -326,7 +326,7 @@ aiopyarr==23.4.0
aioqsw==0.4.1 aioqsw==0.4.1
# homeassistant.components.rainforest_raven # homeassistant.components.rainforest_raven
aioraven==0.7.0 aioraven==0.7.1
# homeassistant.components.recollect_waste # homeassistant.components.recollect_waste
aiorecollect==2023.09.0 aiorecollect==2023.09.0
@ -628,7 +628,7 @@ dbus-fast==2.24.3
debugpy==1.8.11 debugpy==1.8.11
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==10.1.0 deebot-client==11.0.0
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -639,7 +639,7 @@ defusedxml==0.7.1
deluge-client==1.10.2 deluge-client==1.10.2
# homeassistant.components.lametric # homeassistant.components.lametric
demetriek==1.1.1 demetriek==1.2.0
# homeassistant.components.denonavr # homeassistant.components.denonavr
denonavr==1.0.1 denonavr==1.0.1
@ -699,7 +699,7 @@ elevenlabs==1.9.0
elgato==5.1.2 elgato==5.1.2
# homeassistant.components.elkm1 # homeassistant.components.elkm1
elkm1-lib==2.2.10 elkm1-lib==2.2.11
# homeassistant.components.elmax # homeassistant.components.elmax
elmax-api==0.0.6.4rc0 elmax-api==0.0.6.4rc0
@ -796,7 +796,7 @@ foobot_async==1.0.0
forecast-solar==4.0.0 forecast-solar==4.0.0
# homeassistant.components.freebox # homeassistant.components.freebox
freebox-api==1.2.1 freebox-api==1.2.2
# homeassistant.components.fritz # homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor # homeassistant.components.fritzbox_callmonitor
@ -1062,7 +1062,7 @@ kegtron-ble==0.4.0
knocki==0.4.2 knocki==0.4.2
# homeassistant.components.knx # homeassistant.components.knx
knx-frontend==2024.12.26.233449 knx-frontend==2025.1.18.164225
# homeassistant.components.konnected # homeassistant.components.konnected
konnected==1.2.0 konnected==1.2.0
@ -1230,7 +1230,7 @@ nextcord==2.6.0
nextdns==4.0.0 nextdns==4.0.0
# homeassistant.components.niko_home_control # homeassistant.components.niko_home_control
nhc==0.3.2 nhc==0.3.4
# homeassistant.components.nibe_heatpump # homeassistant.components.nibe_heatpump
nibe==2.14.0 nibe==2.14.0
@ -1285,7 +1285,7 @@ omnilogic==0.4.5
ondilo==0.5.0 ondilo==0.5.0
# homeassistant.components.onvif # homeassistant.components.onvif
onvif-zeep-async==3.1.13 onvif-zeep-async==3.2.3
# homeassistant.components.opengarage # homeassistant.components.opengarage
open-garage==0.2.0 open-garage==0.2.0
@ -1474,7 +1474,7 @@ pyatmo==8.1.0
pyatv==0.16.0 pyatv==0.16.0
# homeassistant.components.aussie_broadband # homeassistant.components.aussie_broadband
pyaussiebb==0.1.4 pyaussiebb==0.1.5
# homeassistant.components.balboa # homeassistant.components.balboa
pybalboa==1.0.2 pybalboa==1.0.2
@ -1594,7 +1594,7 @@ pyhaversion==22.8.0
pyheos==0.7.2 pyheos==0.7.2
# homeassistant.components.hive # homeassistant.components.hive
pyhiveapi==0.5.16 pyhive-integration==1.0.1
# homeassistant.components.homematic # homeassistant.components.homematic
pyhomematic==0.1.77 pyhomematic==0.1.77
@ -2237,7 +2237,7 @@ sunweg==3.0.2
surepy==0.9.0 surepy==0.9.0
# homeassistant.components.switchbot_cloud # homeassistant.components.switchbot_cloud
switchbot-api==2.2.1 switchbot-api==2.3.1
# homeassistant.components.system_bridge # homeassistant.components.system_bridge
systembridgeconnector==4.1.5 systembridgeconnector==4.1.5
@ -2477,7 +2477,7 @@ youless-api==2.1.2
youtubeaio==1.1.5 youtubeaio==1.1.5
# homeassistant.components.media_extractor # homeassistant.components.media_extractor
yt-dlp[default]==2024.12.23 yt-dlp[default]==2025.01.15
# homeassistant.components.zamg # homeassistant.components.zamg
zamg==0.3.6 zamg==0.3.6

View File

@ -140,6 +140,7 @@
'heatStages': 1, 'heatStages': 1,
'heatangle': 0, 'heatangle': 0,
'humidity': 40, 'humidity': 40,
'master_zoneID': None,
'maxTemp': 30, 'maxTemp': 30,
'minTemp': 15, 'minTemp': 15,
'mode': 3, 'mode': 3,

View File

@ -28,6 +28,7 @@ from aioairzone.const import (
API_HEAT_STAGES, API_HEAT_STAGES,
API_HUMIDITY, API_HUMIDITY,
API_MAC, API_MAC,
API_MASTER_ZONE_ID,
API_MAX_TEMP, API_MAX_TEMP,
API_MIN_TEMP, API_MIN_TEMP,
API_MODE, API_MODE,
@ -214,6 +215,7 @@ HVAC_MOCK = {
API_FLOOR_DEMAND: 0, API_FLOOR_DEMAND: 0,
API_HEAT_ANGLE: 0, API_HEAT_ANGLE: 0,
API_COLD_ANGLE: 0, API_COLD_ANGLE: 0,
API_MASTER_ZONE_ID: None,
}, },
] ]
}, },

View File

@ -474,6 +474,108 @@
}), }),
]) ])
# --- # ---
# name: test_stt_language_used_instead_of_conversation_language
list([
dict({
'data': dict({
'language': 'en',
'pipeline': <ANY>,
}),
'type': <PipelineEventType.RUN_START: 'run-start'>,
}),
dict({
'data': dict({
'conversation_id': None,
'device_id': None,
'engine': 'conversation.home_assistant',
'intent_input': 'test input',
'language': 'en-US',
'prefer_local_intents': False,
}),
'type': <PipelineEventType.INTENT_START: 'intent-start'>,
}),
dict({
'data': dict({
'intent_output': dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
}),
}),
}),
'processed_locally': True,
}),
'type': <PipelineEventType.INTENT_END: 'intent-end'>,
}),
dict({
'data': None,
'type': <PipelineEventType.RUN_END: 'run-end'>,
}),
])
# ---
# name: test_tts_language_used_instead_of_conversation_language
list([
dict({
'data': dict({
'language': 'en',
'pipeline': <ANY>,
}),
'type': <PipelineEventType.RUN_START: 'run-start'>,
}),
dict({
'data': dict({
'conversation_id': None,
'device_id': None,
'engine': 'conversation.home_assistant',
'intent_input': 'test input',
'language': 'en-us',
'prefer_local_intents': False,
}),
'type': <PipelineEventType.INTENT_START: 'intent-start'>,
}),
dict({
'data': dict({
'intent_output': dict({
'conversation_id': None,
'response': dict({
'card': dict({
}),
'data': dict({
'failed': list([
]),
'success': list([
]),
'targets': list([
]),
}),
'language': 'en',
'response_type': 'action_done',
'speech': dict({
}),
}),
}),
'processed_locally': True,
}),
'type': <PipelineEventType.INTENT_END: 'intent-end'>,
}),
dict({
'data': None,
'type': <PipelineEventType.RUN_END: 'run-end'>,
}),
])
# ---
# name: test_wake_word_detection_aborted # name: test_wake_word_detection_aborted
list([ list([
dict({ dict({

View File

@ -1102,13 +1102,13 @@ async def test_prefer_local_intents(
) )
async def test_pipeline_language_used_instead_of_conversation_language( async def test_stt_language_used_instead_of_conversation_language(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
init_components, init_components,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
) -> None: ) -> None:
"""Test that the pipeline language is used when the conversation language is '*' (all languages).""" """Test that the STT language is used first when the conversation language is '*' (all languages)."""
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
events: list[assist_pipeline.PipelineEvent] = [] events: list[assist_pipeline.PipelineEvent] = []
@ -1165,7 +1165,155 @@ async def test_pipeline_language_used_instead_of_conversation_language(
assert intent_start is not None assert intent_start is not None
# Pipeline language (en) should be used instead of '*' # STT language (en-US) should be used instead of '*'
assert intent_start.data.get("language") == pipeline.stt_language
# Check input to async_converse
mock_async_converse.assert_called_once()
assert (
mock_async_converse.call_args_list[0].kwargs.get("language")
== pipeline.stt_language
)
async def test_tts_language_used_instead_of_conversation_language(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test that the TTS language is used after STT when the conversation language is '*' (all languages)."""
client = await hass_ws_client(hass)
events: list[assist_pipeline.PipelineEvent] = []
await client.send_json_auto_id(
{
"type": "assist_pipeline/pipeline/create",
"conversation_engine": "homeassistant",
"conversation_language": MATCH_ALL,
"language": "en",
"name": "test_name",
"stt_engine": None,
"stt_language": None,
"tts_engine": None,
"tts_language": "en-us",
"tts_voice": "Arnold Schwarzenegger",
"wake_word_entity": None,
"wake_word_id": None,
}
)
msg = await client.receive_json()
assert msg["success"]
pipeline_id = msg["result"]["id"]
pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id)
pipeline_input = assist_pipeline.pipeline.PipelineInput(
intent_input="test input",
run=assist_pipeline.pipeline.PipelineRun(
hass,
context=Context(),
pipeline=pipeline,
start_stage=assist_pipeline.PipelineStage.INTENT,
end_stage=assist_pipeline.PipelineStage.INTENT,
event_callback=events.append,
),
)
await pipeline_input.validate()
with patch(
"homeassistant.components.assist_pipeline.pipeline.conversation.async_converse",
return_value=conversation.ConversationResult(
intent.IntentResponse(pipeline.language)
),
) as mock_async_converse:
await pipeline_input.execute()
# Check intent start event
assert process_events(events) == snapshot
intent_start: assist_pipeline.PipelineEvent | None = None
for event in events:
if event.type == assist_pipeline.PipelineEventType.INTENT_START:
intent_start = event
break
assert intent_start is not None
# STT language (en-US) should be used instead of '*'
assert intent_start.data.get("language") == pipeline.tts_language
# Check input to async_converse
mock_async_converse.assert_called_once()
assert (
mock_async_converse.call_args_list[0].kwargs.get("language")
== pipeline.tts_language
)
async def test_pipeline_language_used_instead_of_conversation_language(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
init_components,
snapshot: SnapshotAssertion,
) -> None:
"""Test that the pipeline language is used last when the conversation language is '*' (all languages)."""
client = await hass_ws_client(hass)
events: list[assist_pipeline.PipelineEvent] = []
await client.send_json_auto_id(
{
"type": "assist_pipeline/pipeline/create",
"conversation_engine": "homeassistant",
"conversation_language": MATCH_ALL,
"language": "en",
"name": "test_name",
"stt_engine": None,
"stt_language": None,
"tts_engine": None,
"tts_language": None,
"tts_voice": None,
"wake_word_entity": None,
"wake_word_id": None,
}
)
msg = await client.receive_json()
assert msg["success"]
pipeline_id = msg["result"]["id"]
pipeline = assist_pipeline.async_get_pipeline(hass, pipeline_id)
pipeline_input = assist_pipeline.pipeline.PipelineInput(
intent_input="test input",
run=assist_pipeline.pipeline.PipelineRun(
hass,
context=Context(),
pipeline=pipeline,
start_stage=assist_pipeline.PipelineStage.INTENT,
end_stage=assist_pipeline.PipelineStage.INTENT,
event_callback=events.append,
),
)
await pipeline_input.validate()
with patch(
"homeassistant.components.assist_pipeline.pipeline.conversation.async_converse",
return_value=conversation.ConversationResult(
intent.IntentResponse(pipeline.language)
),
) as mock_async_converse:
await pipeline_input.execute()
# Check intent start event
assert process_events(events) == snapshot
intent_start: assist_pipeline.PipelineEvent | None = None
for event in events:
if event.type == assist_pipeline.PipelineEventType.INTENT_START:
intent_start = event
break
assert intent_start is not None
# STT language (en-US) should be used instead of '*'
assert intent_start.data.get("language") == pipeline.language assert intent_start.data.get("language") == pipeline.language
# Check input to async_converse # Check input to async_converse

View File

@ -673,7 +673,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions(
"instance_id": ANY, "instance_id": ANY,
"with_automatic_settings": False, "with_automatic_settings": False,
}, },
folders=None, folders={"ssl"},
homeassistant_exclude_database=False, homeassistant_exclude_database=False,
homeassistant=True, homeassistant=True,
location=[None], location=[None],
@ -704,7 +704,7 @@ DEFAULT_BACKUP_OPTIONS = supervisor_backups.PartialBackupOptions(
), ),
( (
{"include_folders": ["media", "share"]}, {"include_folders": ["media", "share"]},
replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share"}), replace(DEFAULT_BACKUP_OPTIONS, folders={"media", "share", "ssl"}),
), ),
( (
{ {

View File

@ -1,6 +1,7 @@
"""Test different accessory types: Lights.""" """Test different accessory types: Lights."""
from datetime import timedelta from datetime import timedelta
import sys
from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE from pyhap.const import HAP_REPR_AID, HAP_REPR_CHARS, HAP_REPR_IID, HAP_REPR_VALUE
import pytest import pytest
@ -540,6 +541,422 @@ async def test_light_color_temperature_and_rgb_color(
assert acc.char_saturation.value == 100 assert acc.char_saturation.value == 100
async def test_light_invalid_hs_color(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test light that starts out with an invalid hs color."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_HS_COLOR: 260,
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 153
assert acc.char_hue.value == 0
assert acc.char_saturation.value == 75
assert hasattr(acc, "char_color_temp")
hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 4464})
await hass.async_block_till_done()
acc.run()
await hass.async_block_till_done()
assert acc.char_color_temp.value == 224
assert acc.char_hue.value == 27
assert acc.char_saturation.value == 27
hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: 2840})
await hass.async_block_till_done()
acc.run()
await hass.async_block_till_done()
assert acc.char_color_temp.value == 352
assert acc.char_hue.value == 28
assert acc.char_saturation.value == 61
char_on_iid = acc.char_on.to_HAP()[HAP_REPR_IID]
char_brightness_iid = acc.char_brightness.to_HAP()[HAP_REPR_IID]
char_hue_iid = acc.char_hue.to_HAP()[HAP_REPR_IID]
char_saturation_iid = acc.char_saturation.to_HAP()[HAP_REPR_IID]
char_color_temp_iid = acc.char_color_temp.to_HAP()[HAP_REPR_IID]
# Set from HomeKit
call_turn_on = async_mock_service(hass, LIGHT_DOMAIN, "turn_on")
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{HAP_REPR_AID: acc.aid, HAP_REPR_IID: char_on_iid, HAP_REPR_VALUE: 1},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_brightness_iid,
HAP_REPR_VALUE: 20,
},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_color_temp_iid,
HAP_REPR_VALUE: 250,
},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_hue_iid,
HAP_REPR_VALUE: 50,
},
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_saturation_iid,
HAP_REPR_VALUE: 50,
},
]
},
"mock_addr",
)
await _wait_for_light_coalesce(hass)
assert call_turn_on[0]
assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
assert call_turn_on[0].data[ATTR_COLOR_TEMP_KELVIN] == 4000
assert len(events) == 1
assert (
events[-1].data[ATTR_VALUE]
== f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250"
)
# Only set Hue
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_hue_iid,
HAP_REPR_VALUE: 30,
}
]
},
"mock_addr",
)
await _wait_for_light_coalesce(hass)
assert call_turn_on[1]
assert call_turn_on[1].data[ATTR_HS_COLOR] == (30, 50)
assert events[-1].data[ATTR_VALUE] == "set color at (30, 50)"
# Only set Saturation
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_saturation_iid,
HAP_REPR_VALUE: 20,
}
]
},
"mock_addr",
)
await _wait_for_light_coalesce(hass)
assert call_turn_on[2]
assert call_turn_on[2].data[ATTR_HS_COLOR] == (30, 20)
assert events[-1].data[ATTR_VALUE] == "set color at (30, 20)"
# Generate a conflict by setting hue and then color temp
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_hue_iid,
HAP_REPR_VALUE: 80,
}
]
},
"mock_addr",
)
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_color_temp_iid,
HAP_REPR_VALUE: 320,
}
]
},
"mock_addr",
)
await _wait_for_light_coalesce(hass)
assert call_turn_on[3]
assert call_turn_on[3].data[ATTR_COLOR_TEMP_KELVIN] == 3125
assert events[-1].data[ATTR_VALUE] == "color temperature at 320"
# Generate a conflict by setting color temp then saturation
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_color_temp_iid,
HAP_REPR_VALUE: 404,
}
]
},
"mock_addr",
)
hk_driver.set_characteristics(
{
HAP_REPR_CHARS: [
{
HAP_REPR_AID: acc.aid,
HAP_REPR_IID: char_saturation_iid,
HAP_REPR_VALUE: 35,
}
]
},
"mock_addr",
)
await _wait_for_light_coalesce(hass)
assert call_turn_on[4]
assert call_turn_on[4].data[ATTR_HS_COLOR] == (80, 35)
assert events[-1].data[ATTR_VALUE] == "set color at (80, 35)"
# Set from HASS
hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (100, 100)})
await hass.async_block_till_done()
acc.run()
await hass.async_block_till_done()
assert acc.char_color_temp.value == 404
assert acc.char_hue.value == 100
assert acc.char_saturation.value == 100
async def test_light_invalid_values(
hass: HomeAssistant, hk_driver, events: list[Event]
) -> None:
"""Test light with a variety of invalid values."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_HS_COLOR: (-1, -1),
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 153
assert acc.char_hue.value == 0
assert acc.char_saturation.value == 0
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: -1,
},
)
await hass.async_block_till_done()
acc.run()
assert acc.char_color_temp.value == 153
assert acc.char_hue.value == 16
assert acc.char_saturation.value == 100
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: sys.maxsize,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 153
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: 2000,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 500
assert acc.char_hue.value == 31
assert acc.char_saturation.value == 95
async def test_light_out_of_range_color_temp(hass: HomeAssistant, hk_driver) -> None:
"""Test light with an out of range color temp."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_COLOR_TEMP_KELVIN: 2000,
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_HS_COLOR: (-1, -1),
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 333
assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333
assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250
assert acc.char_hue.value == 31
assert acc.char_saturation.value == 95
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: -1,
},
)
await hass.async_block_till_done()
acc.run()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 16
assert acc.char_saturation.value == 100
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: sys.maxsize,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: 2000,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
async def test_reversed_color_temp_min_max(hass: HomeAssistant, hk_driver) -> None:
"""Test light with a reversed color temp min max."""
entity_id = "light.demo"
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "hs",
ATTR_COLOR_TEMP_KELVIN: 2000,
ATTR_MAX_COLOR_TEMP_KELVIN: 3000,
ATTR_MIN_COLOR_TEMP_KELVIN: 4000,
ATTR_HS_COLOR: (-1, -1),
},
)
await hass.async_block_till_done()
acc = Light(hass, hk_driver, "Light", entity_id, 1, None)
hk_driver.add_accessory(acc)
assert acc.char_color_temp.value == 333
assert acc.char_color_temp.properties[PROP_MAX_VALUE] == 333
assert acc.char_color_temp.properties[PROP_MIN_VALUE] == 250
assert acc.char_hue.value == 31
assert acc.char_saturation.value == 95
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: -1,
},
)
await hass.async_block_till_done()
acc.run()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 16
assert acc.char_saturation.value == 100
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_MAX_COLOR_TEMP_KELVIN: 4000,
ATTR_MIN_COLOR_TEMP_KELVIN: 3000,
ATTR_COLOR_TEMP_KELVIN: sys.maxsize,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
hass.states.async_set(
entity_id,
STATE_ON,
{
ATTR_SUPPORTED_COLOR_MODES: ["color_temp", "hs"],
ATTR_COLOR_MODE: "color_temp",
ATTR_COLOR_TEMP_KELVIN: 2000,
},
)
await hass.async_block_till_done()
assert acc.char_color_temp.value == 250
assert acc.char_hue.value == 220
assert acc.char_saturation.value == 41
@pytest.mark.parametrize( @pytest.mark.parametrize(
"supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]]
) )

View File

@ -26,6 +26,7 @@ from homeassistant.components.climate import (
ATTR_TARGET_TEMP_STEP, ATTR_TARGET_TEMP_STEP,
DEFAULT_MAX_TEMP, DEFAULT_MAX_TEMP,
DEFAULT_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY,
DEFAULT_MIN_TEMP,
DOMAIN as DOMAIN_CLIMATE, DOMAIN as DOMAIN_CLIMATE,
FAN_AUTO, FAN_AUTO,
FAN_HIGH, FAN_HIGH,
@ -2009,8 +2010,8 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO],
ATTR_MAX_TEMP: 50, ATTR_MAX_TEMP: 100,
ATTR_MIN_TEMP: 100, ATTR_MIN_TEMP: 50,
} }
hass.states.async_set( hass.states.async_set(
entity_id, entity_id,
@ -2024,14 +2025,14 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
acc.run() acc.run()
await hass.async_block_till_done() await hass.async_block_till_done()
assert acc.char_cooling_thresh_temp.value == 100 assert acc.char_cooling_thresh_temp.value == 50
assert acc.char_heating_thresh_temp.value == 100 assert acc.char_heating_thresh_temp.value == 50
assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100 assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == 100
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 100 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 50
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100 assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == 100
assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 50
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_target_heat_cool.value == 3 assert acc.char_target_heat_cool.value == 3
@ -2048,7 +2049,7 @@ async def test_thermostat_with_temp_clamps(hass: HomeAssistant, hk_driver) -> No
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert acc.char_heating_thresh_temp.value == 100.0 assert acc.char_heating_thresh_temp.value == 50.0
assert acc.char_cooling_thresh_temp.value == 100.0 assert acc.char_cooling_thresh_temp.value == 100.0
assert acc.char_current_heat_cool.value == 1 assert acc.char_current_heat_cool.value == 1
assert acc.char_target_heat_cool.value == 3 assert acc.char_target_heat_cool.value == 3
@ -2633,3 +2634,44 @@ async def test_thermostat_handles_unknown_state(hass: HomeAssistant, hk_driver)
assert call_set_hvac_mode assert call_set_hvac_mode
assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id assert call_set_hvac_mode[1].data[ATTR_ENTITY_ID] == entity_id
assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT assert call_set_hvac_mode[1].data[ATTR_HVAC_MODE] == HVACMode.HEAT
async def test_thermostat_reversed_min_max(hass: HomeAssistant, hk_driver) -> None:
"""Test reversed min/max temperatures."""
entity_id = "climate.test"
base_attrs = {
ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
ATTR_HVAC_MODES: [
HVACMode.HEAT,
HVACMode.HEAT_COOL,
HVACMode.FAN_ONLY,
HVACMode.COOL,
HVACMode.OFF,
HVACMode.AUTO,
],
ATTR_MAX_TEMP: DEFAULT_MAX_TEMP,
ATTR_MIN_TEMP: DEFAULT_MIN_TEMP,
}
# support_auto = True
hass.states.async_set(
entity_id,
HVACMode.OFF,
base_attrs,
)
await hass.async_block_till_done()
acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None)
hk_driver.add_accessory(acc)
acc.run()
await hass.async_block_till_done()
assert acc.char_cooling_thresh_temp.value == 23.0
assert acc.char_heating_thresh_temp.value == 19.0
assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] == 7.0
assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.1
assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 7.0
assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1

View File

@ -0,0 +1,68 @@
{
"audio": {
"available": true,
"volume": 53,
"volume_limit": {
"max": 100,
"min": 0
},
"volume_range": {
"max": 100,
"min": 0
}
},
"bluetooth": {
"active": false,
"address": "40:F4:C9:AA:AA:AA",
"available": true,
"discoverable": true,
"mac": "40:F4:C9:AA:AA:AA",
"name": "LM8367",
"pairable": false
},
"display": {
"brightness": 75,
"brightness_limit": {
"max": 76,
"min": 2
},
"brightness_mode": "manual",
"brightness_range": {
"max": 100,
"min": 0
},
"height": 8,
"on": true,
"screensaver": {
"enabled": true,
"modes": {
"time_based": {
"enabled": false
},
"when_dark": {
"enabled": true
}
},
"widget": "1_com.lametric.clock"
},
"type": "mixed",
"width": 37
},
"id": "67790",
"mode": "manual",
"model": "sa8",
"name": "TIME",
"os_version": "3.1.3",
"serial_number": "SA840700836700W00BAA",
"wifi": {
"active": true,
"mac": "40:F4:C9:AA:AA:AA",
"available": true,
"encryption": "WPA",
"ssid": "My wifi",
"ip": "10.0.0.99",
"mode": "dhcp",
"netmask": "255.255.255.0",
"rssi": 78
}
}

View File

@ -24,7 +24,15 @@
'device_id': '**REDACTED**', 'device_id': '**REDACTED**',
'display': dict({ 'display': dict({
'brightness': 100, 'brightness': 100,
'brightness_limit': dict({
'range_max': 100,
'range_min': 2,
}),
'brightness_mode': 'auto', 'brightness_mode': 'auto',
'brightness_range': dict({
'range_max': 100,
'range_min': 0,
}),
'display_type': 'mixed', 'display_type': 'mixed',
'height': 8, 'height': 8,
'on': None, 'on': None,

View File

@ -42,7 +42,7 @@ async def test_brightness(
assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) is None
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Frenck's LaMetric Brightness"
assert state.attributes.get(ATTR_MAX) == 100 assert state.attributes.get(ATTR_MAX) == 100
assert state.attributes.get(ATTR_MIN) == 0 assert state.attributes.get(ATTR_MIN) == 2
assert state.attributes.get(ATTR_STEP) == 1 assert state.attributes.get(ATTR_STEP) == 1
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "100" assert state.state == "100"
@ -183,3 +183,16 @@ async def test_number_connection_error(
state = hass.states.get("number.frenck_s_lametric_volume") state = hass.states.get("number.frenck_s_lametric_volume")
assert state assert state
assert state.state == STATE_UNAVAILABLE assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("device_fixture", ["computer_powered"])
async def test_computer_powered_devices(
hass: HomeAssistant,
mock_lametric: MagicMock,
) -> None:
"""Test Brightness is properly limited for computer powered devices."""
state = hass.states.get("number.time_brightness")
assert state
assert state.state == "75"
assert state.attributes[ATTR_MIN] == 2
assert state.attributes[ATTR_MAX] == 76

View File

@ -29,6 +29,7 @@ from homeassistant.const import (
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, State from homeassistant.core import HomeAssistant, State
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .test_common import ( from .test_common import (
help_custom_config, help_custom_config,
@ -157,6 +158,101 @@ async def test_run_number_setup(
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == unit_of_measurement
@pytest.mark.parametrize(
"hass_config",
[
{
mqtt.DOMAIN: {
number.DOMAIN: {
"state_topic": "test/state_number",
"command_topic": "test/cmd_number",
"name": "Test Number",
"min": 15,
"max": 28,
"device_class": "temperature",
"unit_of_measurement": UnitOfTemperature.CELSIUS.value,
}
}
}
],
)
async def test_native_value_validation(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test state validation and native value conversion."""
mqtt_mock = await mqtt_mock_entry()
async_fire_mqtt_message(hass, "test/state_number", "23.5")
state = hass.states.get("number.test_number")
assert state is not None
assert state.attributes.get(ATTR_MIN) == 15
assert state.attributes.get(ATTR_MAX) == 28
assert (
state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
== UnitOfTemperature.CELSIUS.value
)
assert state.state == "23.5"
# Test out of range validation
async_fire_mqtt_message(hass, "test/state_number", "29.5")
state = hass.states.get("number.test_number")
assert state is not None
assert state.attributes.get(ATTR_MIN) == 15
assert state.attributes.get(ATTR_MAX) == 28
assert (
state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
== UnitOfTemperature.CELSIUS.value
)
assert state.state == "23.5"
assert (
"Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text
)
caplog.clear()
# Check if validation still works when changing unit system
hass.config.units = US_CUSTOMARY_SYSTEM
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "test/state_number", "24.5")
state = hass.states.get("number.test_number")
assert state is not None
assert state.attributes.get(ATTR_MIN) == 59.0
assert state.attributes.get(ATTR_MAX) == 82.4
assert (
state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
== UnitOfTemperature.FAHRENHEIT.value
)
assert state.state == "76.1"
# Test out of range validation again
async_fire_mqtt_message(hass, "test/state_number", "29.5")
state = hass.states.get("number.test_number")
assert state is not None
assert state.attributes.get(ATTR_MIN) == 59.0
assert state.attributes.get(ATTR_MAX) == 82.4
assert (
state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
== UnitOfTemperature.FAHRENHEIT.value
)
assert state.state == "76.1"
assert (
"Invalid value for number.test_number: 29.5 (range 15.0 - 28.0)" in caplog.text
)
caplog.clear()
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{ATTR_ENTITY_ID: "number.test_number", ATTR_VALUE: 68},
blocking=True,
)
mqtt_mock.async_publish.assert_called_once_with("test/cmd_number", "20", 0, False)
mqtt_mock.async_publish.reset_mock()
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",
[ [

View File

@ -42,11 +42,11 @@ async def test_entities(
@pytest.mark.parametrize( @pytest.mark.parametrize(
("light_id", "data", "set_brightness"), ("light_id", "data", "set_brightness"),
[ [
(0, {ATTR_ENTITY_ID: "light.light"}, 100.0), (0, {ATTR_ENTITY_ID: "light.light"}, 100),
( (
1, 1,
{ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50}, {ATTR_ENTITY_ID: "light.dimmable_light", ATTR_BRIGHTNESS: 50},
19.607843137254903, 20,
), ),
], ],
) )

View File

@ -726,7 +726,6 @@ async def test_options_add_and_configure_device(
result["flow_id"], result["flow_id"],
user_input={ user_input={
"data_bits": 4, "data_bits": 4,
"off_delay": "abcdef",
"command_on": "xyz", "command_on": "xyz",
"command_off": "xyz", "command_off": "xyz",
}, },
@ -735,7 +734,6 @@ async def test_options_add_and_configure_device(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "set_device_options" assert result["step_id"] == "set_device_options"
assert result["errors"] assert result["errors"]
assert result["errors"]["off_delay"] == "invalid_input_off_delay"
assert result["errors"]["command_on"] == "invalid_input_2262_on" assert result["errors"]["command_on"] == "invalid_input_2262_on"
assert result["errors"]["command_off"] == "invalid_input_2262_off" assert result["errors"]["command_off"] == "invalid_input_2262_off"
@ -745,7 +743,7 @@ async def test_options_add_and_configure_device(
"data_bits": 4, "data_bits": 4,
"command_on": "0xE", "command_on": "0xE",
"command_off": "0x7", "command_off": "0x7",
"off_delay": "9", "off_delay": 9,
}, },
) )

View File

@ -14,6 +14,7 @@ from homeassistant.components.smartthings.const import (
CONF_APP_ID, CONF_APP_ID,
CONF_INSTALLED_APP_ID, CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID, CONF_LOCATION_ID,
CONF_REFRESH_TOKEN,
DOMAIN, DOMAIN,
) )
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
@ -757,3 +758,56 @@ async def test_no_available_locations_aborts(
) )
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_available_locations" assert result["reason"] == "no_available_locations"
async def test_reauth(
hass: HomeAssistant, app, app_oauth_client, location, smartthings_mock
) -> None:
"""Test reauth flow."""
token = str(uuid4())
installed_app_id = str(uuid4())
refresh_token = str(uuid4())
smartthings_mock.apps.return_value = []
smartthings_mock.create_app.return_value = (app, app_oauth_client)
smartthings_mock.locations.return_value = [location]
request = Mock()
request.installed_app_id = installed_app_id
request.auth_token = token
request.location_id = location.location_id
request.refresh_token = refresh_token
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_APP_ID: app.app_id,
CONF_CLIENT_ID: app_oauth_client.client_id,
CONF_CLIENT_SECRET: app_oauth_client.client_secret,
CONF_LOCATION_ID: location.location_id,
CONF_INSTALLED_APP_ID: installed_app_id,
CONF_ACCESS_TOKEN: token,
CONF_REFRESH_TOKEN: "abc",
},
unique_id=smartapp.format_unique_id(app.app_id, location.location_id),
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.EXTERNAL_STEP
assert result["step_id"] == "authorize"
assert result["url"] == format_install_url(app.app_id, location.location_id)
await smartapp.smartapp_update(hass, request, None, app)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "update_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_REFRESH_TOKEN] == refresh_token

View File

@ -23,6 +23,7 @@ from homeassistant.components.smartthings.const import (
PLATFORMS, PLATFORMS,
SIGNAL_SMARTTHINGS_UPDATE, SIGNAL_SMARTTHINGS_UPDATE,
) )
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.core_config import async_process_ha_core_config from homeassistant.core_config import async_process_ha_core_config
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
@ -68,17 +69,10 @@ async def test_unrecoverable_api_errors_create_new_flow(
) )
# Assert setup returns false # Assert setup returns false
result = await smartthings.async_setup_entry(hass, config_entry) result = await hass.config_entries.async_setup(config_entry.entry_id)
assert not result assert not result
# Assert entry was removed and new flow created assert config_entry.state == ConfigEntryState.SETUP_ERROR
await hass.async_block_till_done()
assert not hass.config_entries.async_entries(DOMAIN)
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["handler"] == "smartthings"
assert flows[0]["context"] == {"source": config_entries.SOURCE_IMPORT}
hass.config_entries.flow.async_abort(flows[0]["flow_id"])
async def test_recoverable_api_errors_raise_not_ready( async def test_recoverable_api_errors_raise_not_ready(

View File

@ -43,7 +43,7 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '2021-01-09T11:59:59+00:00', 'state': '2021-01-09T11:59:58+00:00',
}) })
# --- # ---
# name: test_sensor[sensor.sonic_power_supply_mode-entry] # name: test_sensor[sensor.sonic_power_supply_mode-entry]
@ -501,6 +501,6 @@
'last_changed': <ANY>, 'last_changed': <ANY>,
'last_reported': <ANY>, 'last_reported': <ANY>,
'last_updated': <ANY>, 'last_updated': <ANY>,
'state': '2021-01-09T11:59:59+00:00', 'state': '2021-01-09T11:59:57+00:00',
}) })
# --- # ---

View File

@ -140,11 +140,11 @@ async def test_power_supply_webhook(
power_supply_change_data = { power_supply_change_data = {
"type": "power-supply-changed", "type": "power-supply-changed",
"data": {"supply": "external"}, "data": {"supply": "external_battery"},
} }
client = await hass_client_no_auth() client = await hass_client_no_auth()
await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data) await client.post(f"/api/webhook/{MOCK_WEBHOOK_ID}", json=power_supply_change_data)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "external" assert hass.states.get(entity_id).state == "battery_external"

View File

@ -10,6 +10,7 @@ from aiohttp.hdrs import METH_HEAD
from aiowithings import ( from aiowithings import (
NotificationCategory, NotificationCategory,
WithingsAuthenticationFailedError, WithingsAuthenticationFailedError,
WithingsConnectionError,
WithingsUnauthorizedError, WithingsUnauthorizedError,
) )
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
@ -532,6 +533,59 @@ async def test_cloud_disconnect_retry(
assert mock_async_active_subscription.call_count == 4 assert mock_async_active_subscription.call_count == 4
async def test_internet_timeout_then_restore(
hass: HomeAssistant,
withings: AsyncMock,
webhook_config_entry: MockConfigEntry,
hass_client_no_auth: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test we can recover from internet disconnects."""
await mock_cloud(hass)
await hass.async_block_till_done()
with (
patch("homeassistant.components.cloud.async_is_logged_in", return_value=True),
patch.object(cloud, "async_is_connected", return_value=True),
patch.object(cloud, "async_active_subscription", return_value=True),
patch(
"homeassistant.components.cloud.async_create_cloudhook",
return_value="https://hooks.nabu.casa/ABCD",
),
patch(
"homeassistant.components.withings.async_get_config_entry_implementation",
),
patch(
"homeassistant.components.cloud.async_delete_cloudhook",
),
patch(
"homeassistant.components.withings.webhook_generate_url",
),
):
await setup_integration(hass, webhook_config_entry)
await prepare_webhook_setup(hass, freezer)
assert cloud.async_active_subscription(hass) is True
assert cloud.async_is_connected(hass) is True
assert withings.revoke_notification_configurations.call_count == 3
assert withings.subscribe_notification.call_count == 6
await hass.async_block_till_done()
withings.list_notification_configurations.side_effect = WithingsConnectionError
async_mock_cloud_connection_status(hass, False)
await hass.async_block_till_done()
assert withings.revoke_notification_configurations.call_count == 3
withings.list_notification_configurations.side_effect = None
async_mock_cloud_connection_status(hass, True)
await hass.async_block_till_done()
assert withings.subscribe_notification.call_count == 12
@pytest.mark.parametrize( @pytest.mark.parametrize(
("body", "expected_code"), ("body", "expected_code"),
[ [

View File

@ -1,6 +1,8 @@
"""Tests for the Config Entry Flow helper.""" """Tests for the Config Entry Flow helper."""
from collections.abc import Generator import asyncio
from collections.abc import Callable, Generator
from contextlib import contextmanager
from unittest.mock import Mock, PropertyMock, patch from unittest.mock import Mock, PropertyMock, patch
import pytest import pytest
@ -13,22 +15,44 @@ from homeassistant.helpers import config_entry_flow
from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform
@contextmanager
def _make_discovery_flow_conf(
has_discovered_devices: Callable[[], asyncio.Future[bool] | bool],
) -> Generator[None]:
with patch.dict(config_entries.HANDLERS):
config_entry_flow.register_discovery_flow(
"test", "Test", has_discovered_devices
)
yield
@pytest.fixture @pytest.fixture
def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]: def async_discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]:
"""Register a handler.""" """Register a handler with an async discovery function."""
handler_conf = {"discovered": False} handler_conf = {"discovered": False}
async def has_discovered_devices(hass: HomeAssistant) -> bool: async def has_discovered_devices(hass: HomeAssistant) -> bool:
"""Mock if we have discovered devices.""" """Mock if we have discovered devices."""
return handler_conf["discovered"] return handler_conf["discovered"]
with patch.dict(config_entries.HANDLERS): with _make_discovery_flow_conf(has_discovered_devices):
config_entry_flow.register_discovery_flow(
"test", "Test", has_discovered_devices
)
yield handler_conf yield handler_conf
@pytest.fixture
def discovery_flow_conf(hass: HomeAssistant) -> Generator[dict[str, bool]]:
"""Register a handler with a async friendly callback function."""
handler_conf = {"discovered": False}
def has_discovered_devices(hass: HomeAssistant) -> bool:
"""Mock if we have discovered devices."""
return handler_conf["discovered"]
with _make_discovery_flow_conf(has_discovered_devices):
yield handler_conf
handler_conf = {"discovered": False}
@pytest.fixture @pytest.fixture
def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]: def webhook_flow_conf(hass: HomeAssistant) -> Generator[None]:
"""Register a handler.""" """Register a handler."""
@ -95,6 +119,33 @@ async def test_user_has_confirmation(
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
async def test_user_has_confirmation_async_discovery_flow(
hass: HomeAssistant, async_discovery_flow_conf: dict[str, bool]
) -> None:
"""Test user requires confirmation to setup with an async has_discovered_devices."""
async_discovery_flow_conf["discovered"] = True
mock_platform(hass, "test.config_flow", None)
result = await hass.config_entries.flow.async_init(
"test", context={"source": config_entries.SOURCE_USER}, data={}
)
assert result["type"] == data_entry_flow.FlowResultType.FORM
assert result["step_id"] == "confirm"
progress = hass.config_entries.flow.async_progress()
assert len(progress) == 1
assert progress[0]["flow_id"] == result["flow_id"]
assert progress[0]["context"] == {
"confirm_only": True,
"source": config_entries.SOURCE_USER,
"unique_id": "test",
}
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY
@pytest.mark.parametrize( @pytest.mark.parametrize(
"source", "source",
[ [

View File

@ -4118,6 +4118,14 @@ async def test_referenced_labels(hass: HomeAssistant) -> None:
} }
], ],
}, },
{
"sequence": [
{
"action": "test.script",
"data": {"label_id": "label_sequence"},
}
],
},
] ]
), ),
"Test Name", "Test Name",
@ -4135,6 +4143,7 @@ async def test_referenced_labels(hass: HomeAssistant) -> None:
"label_if_then", "label_if_then",
"label_if_else", "label_if_else",
"label_parallel", "label_parallel",
"label_sequence",
} }
# Test we cache results. # Test we cache results.
assert script_obj.referenced_labels is script_obj.referenced_labels assert script_obj.referenced_labels is script_obj.referenced_labels
@ -4220,6 +4229,14 @@ async def test_referenced_floors(hass: HomeAssistant) -> None:
} }
], ],
}, },
{
"sequence": [
{
"action": "test.script",
"data": {"floor_id": "floor_sequence"},
}
],
},
] ]
), ),
"Test Name", "Test Name",
@ -4236,6 +4253,7 @@ async def test_referenced_floors(hass: HomeAssistant) -> None:
"floor_if_then", "floor_if_then",
"floor_if_else", "floor_if_else",
"floor_parallel", "floor_parallel",
"floor_sequence",
} }
# Test we cache results. # Test we cache results.
assert script_obj.referenced_floors is script_obj.referenced_floors assert script_obj.referenced_floors is script_obj.referenced_floors
@ -4321,6 +4339,14 @@ async def test_referenced_areas(hass: HomeAssistant) -> None:
} }
], ],
}, },
{
"sequence": [
{
"action": "test.script",
"data": {"area_id": "area_sequence"},
}
],
},
] ]
), ),
"Test Name", "Test Name",
@ -4337,6 +4363,7 @@ async def test_referenced_areas(hass: HomeAssistant) -> None:
"area_if_then", "area_if_then",
"area_if_else", "area_if_else",
"area_parallel", "area_parallel",
"area_sequence",
# 'area_service_template', # no area extraction from template # 'area_service_template', # no area extraction from template
} }
# Test we cache results. # Test we cache results.
@ -4437,6 +4464,14 @@ async def test_referenced_entities(hass: HomeAssistant) -> None:
} }
], ],
}, },
{
"sequence": [
{
"action": "test.script",
"data": {"entity_id": "light.sequence"},
}
],
},
] ]
), ),
"Test Name", "Test Name",
@ -4456,6 +4491,7 @@ async def test_referenced_entities(hass: HomeAssistant) -> None:
"light.if_then", "light.if_then",
"light.if_else", "light.if_else",
"light.parallel", "light.parallel",
"light.sequence",
# "light.service_template", # no entity extraction from template # "light.service_template", # no entity extraction from template
"scene.hello", "scene.hello",
"sensor.condition", "sensor.condition",
@ -4554,6 +4590,14 @@ async def test_referenced_devices(hass: HomeAssistant) -> None:
} }
], ],
}, },
{
"sequence": [
{
"action": "test.script",
"target": {"device_id": "sequence-device"},
}
],
},
] ]
), ),
"Test Name", "Test Name",
@ -4575,6 +4619,7 @@ async def test_referenced_devices(hass: HomeAssistant) -> None:
"if-then", "if-then",
"if-else", "if-else",
"parallel-device", "parallel-device",
"sequence-device",
} }
# Test we cache results. # Test we cache results.
assert script_obj.referenced_devices is script_obj.referenced_devices assert script_obj.referenced_devices is script_obj.referenced_devices