Compare commits

...

14 Commits

Author SHA1 Message Date
Franck Nijhof
f8d5a8bc58 Bump version to 2025.12.0b1 2025-11-27 11:49:46 +00:00
epenet
3f1f8da6f5 Bump renault-api to 0.5.1 (#157411) 2025-11-27 11:48:09 +00:00
Jan Čermák
55613f56b6 Fix state classes of Ecowitt rain sensors (#157409) 2025-11-27 11:48:08 +00:00
victorigualada
3ee2a78663 Bump hass-nabucasa from 1.6.1 to 1.6.2 (#157405) 2025-11-27 11:48:06 +00:00
victorigualada
814a0c4cc9 Return early when setting cloud ai_task and conversation and not logged in to cloud (#157402) 2025-11-27 11:48:04 +00:00
starkillerOG
71b674d8f1 Bump reolink-aio to 0.16.6 (#157399) 2025-11-27 11:48:03 +00:00
Erik Montnemery
c952fc5e31 Minor polish of cover trigger tests (#157397) 2025-11-27 11:48:02 +00:00
Allen Porter
8c3d40a348 Remove old roborock map storage (#157379) 2025-11-27 11:48:01 +00:00
Paulus Schoutsen
2451dfb63d Default conversation agent to store tool calls in chat log (#157377) 2025-11-27 11:48:00 +00:00
Sarah Seidman
8e5921eab6 Normalize input for Droplet pairing code (#157361) 2025-11-27 11:47:59 +00:00
Jaap Pieroen
bc730da9b1 Bugfix: Essent remove average gas price today (#157317) 2025-11-27 11:47:57 +00:00
abelyliu
28b7ebea6e Fix parsing of Tuya electricity RAW values (#157039) 2025-11-27 11:47:56 +00:00
Erik Montnemery
cfa447c7a9 Add climate started_cooling and started_drying triggers (#156945) 2025-11-27 11:47:55 +00:00
Franck Nijhof
f64c870e42 Bump version to 2025.12.0b0 2025-11-26 17:13:42 +00:00
33 changed files with 560 additions and 165 deletions

View File

@@ -98,6 +98,12 @@
}
},
"triggers": {
"started_cooling": {
"trigger": "mdi:snowflake"
},
"started_drying": {
"trigger": "mdi:water-percent"
},
"started_heating": {
"trigger": "mdi:fire"
},

View File

@@ -298,6 +298,28 @@
},
"title": "Climate",
"triggers": {
"started_cooling": {
"description": "Triggers when a climate started cooling.",
"description_configured": "[%key:component::climate::triggers::started_cooling::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started cooling"
},
"started_drying": {
"description": "Triggers when a climate started drying.",
"description_configured": "[%key:component::climate::triggers::started_drying::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started drying"
},
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",

View File

@@ -11,6 +11,12 @@ from homeassistant.helpers.trigger import (
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
TRIGGERS: dict[str, type[Trigger]] = {
"started_cooling": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
),
"started_drying": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"turned_off": make_entity_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_conditional_entity_state_trigger(
DOMAIN,

View File

@@ -14,6 +14,8 @@
- last
- any
started_cooling: *trigger_common
started_drying: *trigger_common
started_heating: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -6,6 +6,7 @@ import io
from json import JSONDecodeError
import logging
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import (
LLMAuthenticationError,
LLMError,
@@ -93,10 +94,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Home Assistant Cloud AI Task entity."""
cloud = hass.data[DATA_CLOUD]
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
try:
await cloud.llm.async_ensure_token()
except LLMError:
except (LLMError, NabuCasaBaseError):
return
async_add_entities([CloudLLMTaskEntity(cloud, config_entry)])

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Literal
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import LLMError
from homeassistant.components import conversation
@@ -23,10 +24,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Assistant Cloud conversation entity."""
cloud = hass.data[DATA_CLOUD]
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
try:
await cloud.llm.async_ensure_token()
except LLMError:
except (LLMError, NabuCasaBaseError):
return
async_add_entities([CloudConversationEntity(cloud, config_entry)])

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.6.1"],
"requirements": ["hass-nabucasa==1.6.2"],
"single_config_entry": true
}

View File

@@ -66,6 +66,7 @@ from homeassistant.helpers import (
entity_registry as er,
floor_registry as fr,
intent,
llm,
start as ha_start,
template,
translation,
@@ -76,7 +77,7 @@ from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
from .chat_log import AssistantContent, ChatLog, ToolResultContent
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
@@ -430,6 +431,8 @@ class DefaultAgent(ConversationEntity):
) -> ConversationResult:
"""Handle a message."""
response: intent.IntentResponse | None = None
tool_input: llm.ToolInput | None = None
tool_result: dict[str, Any] = {}
# Check if a trigger matched
if trigger_result := await self.async_recognize_sentence_trigger(user_input):
@@ -438,6 +441,16 @@ class DefaultAgent(ConversationEntity):
trigger_result, user_input
)
# Create tool result
tool_input = llm.ToolInput(
tool_name="trigger_sentence",
tool_args={},
external=True,
)
tool_result = {
"response": response_text,
}
# Convert to conversation result
response = intent.IntentResponse(
language=user_input.language or self.hass.config.language
@@ -447,10 +460,44 @@ class DefaultAgent(ConversationEntity):
if response is None:
# Match intents
intent_result = await self.async_recognize_intent(user_input)
response = await self._async_process_intent_result(
intent_result, user_input
)
if response.response_type != intent.IntentResponseType.ERROR:
assert intent_result is not None
assert intent_result.intent is not None
# Create external tool call for the intent
tool_input = llm.ToolInput(
tool_name=intent_result.intent.name,
tool_args={
entity.name: entity.value or entity.text
for entity in intent_result.entities_list
},
external=True,
)
# Create tool result from intent response
tool_result = llm.IntentResponseDict(response)
# Add tool call and result to chat log if we have one
if tool_input is not None:
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id=user_input.agent_id,
content=None,
tool_calls=[tool_input],
)
)
chat_log.async_add_assistant_content_without_tools(
ToolResultContent(
agent_id=user_input.agent_id,
tool_call_id=tool_input.id,
tool_name=tool_input.tool_name,
tool_result=tool_result,
)
)
speech: str = response.speech.get("plain", {}).get("speech", "")
chat_log.async_add_assistant_content_without_tools(
AssistantContent(

View File

@@ -15,6 +15,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
def normalize_pairing_code(code: str) -> str:
"""Normalize pairing code by removing spaces and capitalizing."""
return code.replace(" ", "").upper()
class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle Droplet config flow."""
@@ -52,14 +57,13 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
# Test if we can connect before returning
session = async_get_clientsession(self.hass)
if await self._droplet_discovery.try_connect(
session, user_input[CONF_CODE]
):
code = normalize_pairing_code(user_input[CONF_CODE])
if await self._droplet_discovery.try_connect(session, code):
device_data = {
CONF_IP_ADDRESS: self._droplet_discovery.host,
CONF_PORT: self._droplet_discovery.port,
CONF_DEVICE_ID: device_id,
CONF_CODE: user_input[CONF_CODE],
CONF_CODE: code,
}
return self.async_create_entry(
@@ -90,14 +94,15 @@ class DropletConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, ""
)
session = async_get_clientsession(self.hass)
if await self._droplet_discovery.try_connect(
session, user_input[CONF_CODE]
) and (device_id := await self._droplet_discovery.get_device_id()):
code = normalize_pairing_code(user_input[CONF_CODE])
if await self._droplet_discovery.try_connect(session, code) and (
device_id := await self._droplet_discovery.get_device_id()
):
device_data = {
CONF_IP_ADDRESS: self._droplet_discovery.host,
CONF_PORT: self._droplet_discovery.port,
CONF_DEVICE_ID: device_id,
CONF_CODE: user_input[CONF_CODE],
CONF_CODE: code,
}
await self.async_set_unique_id(device_id, raise_on_progress=False)
self._abort_if_unique_id_configured(

View File

@@ -285,16 +285,14 @@ async def async_setup_entry(
name=sensor.name,
)
# Hourly rain doesn't reset to fixed hours, it must be measurement state classes
# Only total rain needs state class for long-term statistics
if sensor.key in (
"hrain_piezomm",
"hrain_piezo",
"hourlyrainmm",
"hourlyrainin",
"totalrainin",
"totalrainmm",
):
description = dataclasses.replace(
description,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
)
async_add_entities([EcowittSensorEntity(sensor, description)])

View File

@@ -102,6 +102,7 @@ SENSORS: tuple[EssentSensorEntityDescription, ...] = (
key="average_today",
translation_key="average_today",
value_fn=lambda energy_data: energy_data.avg_price,
energy_types=(EnergyType.ELECTRICITY,),
),
EssentSensorEntityDescription(
key="lowest_price_today",

View File

@@ -44,9 +44,6 @@
"electricity_next_price": {
"name": "Next electricity price"
},
"gas_average_today": {
"name": "Average gas price today"
},
"gas_current_price": {
"name": "Current gas price"
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["renault_api"],
"quality_scale": "silver",
"requirements": ["renault-api==0.5.0"]
"requirements": ["renault-api==0.5.1"]
}

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.5"]
"requirements": ["reolink-aio==0.16.6"]
}

View File

@@ -33,7 +33,7 @@ from .coordinator import (
RoborockWashingMachineUpdateCoordinator,
RoborockWetDryVacUpdateCoordinator,
)
from .roborock_storage import CacheStore, async_remove_map_storage
from .roborock_storage import CacheStore, async_cleanup_map_storage
SCAN_INTERVAL = timedelta(seconds=30)
@@ -42,6 +42,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
"""Set up roborock from a config entry."""
await async_cleanup_map_storage(hass, entry.entry_id)
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
user_params = UserParams(
@@ -245,6 +246,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None:
"""Handle removal of an entry."""
await async_remove_map_storage(hass, entry.entry_id)
store = CacheStore(hass, entry.entry_id)
await store.async_remove()

View File

@@ -25,8 +25,8 @@ def _storage_path_prefix(hass: HomeAssistant, entry_id: str) -> Path:
return Path(hass.config.path(STORAGE_PATH)) / entry_id
async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
"""Remove all map storage associated with a config entry.
async def async_cleanup_map_storage(hass: HomeAssistant, entry_id: str) -> None:
"""Remove map storage in the old format, if any.
This removes all on-disk map files for the given config entry. This is the
old format that was replaced by the `CacheStore` implementation.
@@ -34,13 +34,13 @@ async def async_remove_map_storage(hass: HomeAssistant, entry_id: str) -> None:
def remove(path_prefix: Path) -> None:
try:
if path_prefix.exists():
if path_prefix.exists() and path_prefix.is_dir():
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
shutil.rmtree(path_prefix, ignore_errors=True)
except OSError as err:
_LOGGER.error("Unable to remove map files in %s: %s", path_prefix, err)
path_prefix = _storage_path_prefix(hass, entry_id)
_LOGGER.debug("Removing maps from disk store: %s", path_prefix)
await hass.async_add_executor_job(remove, path_prefix)

View File

@@ -0,0 +1,60 @@
"""Parsers for RAW (base64-encoded bytes) values."""
from dataclasses import dataclass
import struct
from typing import Self
@dataclass(kw_only=True)
class ElectricityData:
"""Electricity RAW value."""
current: float
power: float
voltage: float
@classmethod
def from_bytes(cls, raw: bytes) -> Self | None:
"""Parse bytes and return an ElectricityValue object."""
# Format:
# - legacy: 8 bytes
# - v01: [ver=0x01][len=0x0F][data(15 bytes)]
# - v02: [ver=0x02][len=0x0F][data(15 bytes)][sign_bitmap(1 byte)]
# Data layout (big-endian):
# - voltage: 2B, unit 0.1 V
# - current: 3B, unit 0.001 A (i.e., mA)
# - active power: 3B, unit 0.001 kW (i.e., W)
# - reactive power: 3B, unit 0.001 kVar
# - apparent power: 3B, unit 0.001 kVA
# - power factor: 1B, unit 0.01
# Sign bitmap (v02 only, 1 bit means negative):
# - bit0 current
# - bit1 active power
# - bit2 reactive
# - bit3 power factor
is_v1 = len(raw) == 17 and raw[0:2] == b"\x01\x0f"
is_v2 = len(raw) == 18 and raw[0:2] == b"\x02\x0f"
if is_v1 or is_v2:
data = raw[2:17]
voltage = struct.unpack(">H", data[0:2])[0] / 10.0
current = struct.unpack(">L", b"\x00" + data[2:5])[0]
power = struct.unpack(">L", b"\x00" + data[5:8])[0]
if is_v2:
sign_bitmap = raw[17]
if sign_bitmap & 0x01:
current = -current
if sign_bitmap & 0x02:
power = -power
return cls(current=current, power=power, voltage=voltage)
if len(raw) >= 8:
voltage = struct.unpack(">H", raw[0:2])[0] / 10.0
current = struct.unpack(">L", b"\x00" + raw[2:5])[0]
power = struct.unpack(">L", b"\x00" + raw[5:8])[0]
return cls(current=current, power=power, voltage=voltage)
return None

View File

@@ -3,7 +3,6 @@
from __future__ import annotations
from dataclasses import dataclass
import struct
from tuya_sharing import CustomerDevice, Manager
@@ -49,6 +48,7 @@ from .models import (
DPCodeWrapper,
EnumTypeData,
)
from .raw_data_models import ElectricityData
class _WindDirectionWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
@@ -120,42 +120,52 @@ class _JsonElectricityVoltageWrapper(DPCodeJsonWrapper):
return raw_value.get("voltage")
class _RawElectricityCurrentWrapper(DPCodeBase64Wrapper):
class _RawElectricityDataWrapper(DPCodeBase64Wrapper):
"""Custom DPCode Wrapper for extracting ElectricityData from base64."""
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from T."""
raise NotImplementedError
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None or (
value := ElectricityData.from_bytes(raw_value)
) is None:
return None
return self._convert(value)
class _RawElectricityCurrentWrapper(_RawElectricityDataWrapper):
"""Custom DPCode Wrapper for extracting electricity current from base64."""
native_unit = UnitOfElectricCurrent.MILLIAMPERE
suggested_unit = UnitOfElectricCurrent.AMPERE
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[2:5])[0]
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.current
class _RawElectricityPowerWrapper(DPCodeBase64Wrapper):
class _RawElectricityPowerWrapper(_RawElectricityDataWrapper):
"""Custom DPCode Wrapper for extracting electricity power from base64."""
native_unit = UnitOfPower.WATT
suggested_unit = UnitOfPower.KILO_WATT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">L", b"\x00" + raw_value[5:8])[0]
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.power
class _RawElectricityVoltageWrapper(DPCodeBase64Wrapper):
class _RawElectricityVoltageWrapper(_RawElectricityDataWrapper):
"""Custom DPCode Wrapper for extracting electricity voltage from base64."""
native_unit = UnitOfElectricPotential.VOLT
def read_device_status(self, device: CustomerDevice) -> float | None:
"""Read the device value for the dpcode."""
if (raw_value := super().read_bytes(device)) is None:
return None
return struct.unpack(">H", raw_value[0:2])[0] / 10.0
def _convert(self, value: ElectricityData) -> float:
"""Extract specific value from ElectricityData."""
return value.voltage
CURRENT_WRAPPER = (_RawElectricityCurrentWrapper, _JsonElectricityCurrentWrapper)

View File

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

View File

@@ -36,7 +36,7 @@ fnv-hash-fast==1.6.0
go2rtc-client==0.3.0
ha-ffmpeg==3.2.2
habluetooth==5.7.0
hass-nabucasa==1.6.1
hass-nabucasa==1.6.2
hassil==3.4.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251126.0

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.12.0.dev0"
version = "2025.12.0b1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -48,7 +48,7 @@ dependencies = [
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==1.6.1",
"hass-nabucasa==1.6.2",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",

2
requirements.txt generated
View File

@@ -22,7 +22,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
fnv-hash-fast==1.6.0
hass-nabucasa==1.6.1
hass-nabucasa==1.6.2
httpx==0.28.1
home-assistant-bluetooth==1.13.1
ifaddr==0.2.0

6
requirements_all.txt generated
View File

@@ -1157,7 +1157,7 @@ habluetooth==5.7.0
hanna-cloud==0.0.6
# homeassistant.components.cloud
hass-nabucasa==1.6.1
hass-nabucasa==1.6.2
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -2711,13 +2711,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.0
renault-api==0.5.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.16.5
reolink-aio==0.16.6
# homeassistant.components.idteck_prox
rfk101py==0.0.1

View File

@@ -1027,7 +1027,7 @@ habluetooth==5.7.0
hanna-cloud==0.0.6
# homeassistant.components.cloud
hass-nabucasa==1.6.1
hass-nabucasa==1.6.2
# homeassistant.components.assist_satellite
# homeassistant.components.conversation
@@ -2265,13 +2265,13 @@ refoss-ha==1.2.5
regenmaschine==2024.03.0
# homeassistant.components.renault
renault-api==0.5.0
renault-api==0.5.1
# homeassistant.components.renson
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.16.5
reolink-aio==0.16.6
# homeassistant.components.rflink
rflink==0.0.67

View File

@@ -142,11 +142,21 @@ async def test_climate_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_cooling",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_drying",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
],
)
async def test_climate_state_attribute_trigger_behavior_any(
@@ -261,11 +271,21 @@ async def test_climate_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_cooling",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_drying",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
],
)
async def test_climate_state_attribute_trigger_behavior_first(
@@ -378,11 +398,21 @@ async def test_climate_state_trigger_behavior_last(
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_cooling",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.COOLING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_drying",
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.DRYING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
target_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.AUTO, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
),
],
)
async def test_climate_state_attribute_trigger_behavior_last(

View File

@@ -21,7 +21,9 @@ from homeassistant.components import ai_task, conversation
from homeassistant.components.cloud.ai_task import (
CloudLLMTaskEntity,
async_prepare_image_generation_attachments,
async_setup_entry,
)
from homeassistant.components.cloud.const import DATA_CLOUD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
@@ -46,6 +48,21 @@ def mock_cloud_ai_task_entity(hass: HomeAssistant) -> CloudLLMTaskEntity:
return entity
async def test_setup_entry_skips_when_not_logged_in(
hass: HomeAssistant,
) -> None:
"""Test setup_entry exits early when not logged in."""
cloud = MagicMock()
cloud.is_logged_in = False
entry = MockConfigEntry(domain="cloud")
entry.add_to_hass(hass)
hass.data[DATA_CLOUD] = cloud
async_add_entities = AsyncMock()
await async_setup_entry(hass, entry, async_add_entities)
async_add_entities.assert_not_called()
@pytest.fixture(name="mock_handle_chat_log")
def mock_handle_chat_log_fixture() -> AsyncMock:
"""Patch the chat log handler."""

View File

@@ -34,6 +34,21 @@ def cloud_conversation_entity(hass: HomeAssistant) -> CloudConversationEntity:
return entity
async def test_setup_entry_skips_when_not_logged_in(
hass: HomeAssistant,
) -> None:
"""Test setup_entry exits early when not logged in."""
cloud = MagicMock()
cloud.is_logged_in = False
entry = MockConfigEntry(domain="cloud")
entry.add_to_hass(hass)
hass.data[DATA_CLOUD] = cloud
async_add_entities = AsyncMock()
await async_setup_entry(hass, entry, async_add_entities)
async_add_entities.assert_not_called()
def test_entity_availability(
cloud_conversation_entity: CloudConversationEntity,
) -> None:

View File

@@ -17,6 +17,11 @@ from homeassistant.components.conversation import (
default_agent,
get_agent_manager,
)
from homeassistant.components.conversation.chat_log import (
AssistantContent,
ToolResultContent,
async_get_chat_log,
)
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
from homeassistant.components.conversation.models import ConversationInput
from homeassistant.components.conversation.trigger import TriggerDetails
@@ -52,6 +57,7 @@ from homeassistant.core import (
)
from homeassistant.helpers import (
area_registry as ar,
chat_session,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@@ -3424,3 +3430,149 @@ async def test_fuzzy_matching(
if slot_name != "preferred_area_id" # context area
}
assert actual_slots == slots
@pytest.mark.usefixtures("init_components")
async def test_intent_tool_call_in_chat_log(hass: HomeAssistant) -> None:
"""Test that intent tool calls are stored in the chat log."""
hass.states.async_set(
"light.test_light", "off", attributes={ATTR_FRIENDLY_NAME: "Test Light"}
)
async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(
hass, "turn on test light", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
with (
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
async_get_chat_log(hass, session) as chat_log,
):
pass
# Find the tool call in the chat log
tool_call_content: AssistantContent | None = None
tool_result_content: ToolResultContent | None = None
assistant_content: AssistantContent | None = None
for content in chat_log.content:
if content.role == "assistant" and content.tool_calls:
tool_call_content = content
if content.role == "tool_result":
tool_result_content = content
if content.role == "assistant" and not content.tool_calls:
assistant_content = content
# Verify tool call was stored
assert tool_call_content is not None and tool_call_content.tool_calls is not None
assert len(tool_call_content.tool_calls) == 1
assert tool_call_content.tool_calls[0].tool_name == "HassTurnOn"
assert tool_call_content.tool_calls[0].external is True
assert tool_call_content.tool_calls[0].tool_args.get("name") == "Test Light"
# Verify tool result was stored
assert tool_result_content is not None
assert tool_result_content.tool_name == "HassTurnOn"
assert tool_result_content.tool_result["response_type"] == "action_done"
# Verify final assistant content with speech
assert assistant_content is not None
assert assistant_content.content is not None
@pytest.mark.usefixtures("init_components")
async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None:
"""Test that trigger tool calls are stored in the chat log."""
trigger_sentence = "test automation trigger"
trigger_response = "Trigger activated!"
manager = get_agent_manager(hass)
callback = AsyncMock(return_value=trigger_response)
manager.register_trigger(TriggerDetails([trigger_sentence], callback))
result = await conversation.async_converse(
hass, trigger_sentence, None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
with (
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
async_get_chat_log(hass, session) as chat_log,
):
pass
# Find the tool call in the chat log
tool_call_content: AssistantContent | None = None
tool_result_content: ToolResultContent | None = None
for content in chat_log.content:
if content.role == "assistant" and content.tool_calls:
tool_call_content = content
if content.role == "tool_result":
tool_result_content = content
# Verify tool call was stored
assert tool_call_content is not None and tool_call_content.tool_calls is not None
assert len(tool_call_content.tool_calls) == 1
assert tool_call_content.tool_calls[0].tool_name == "trigger_sentence"
assert tool_call_content.tool_calls[0].external is True
assert tool_call_content.tool_calls[0].tool_args == {}
# Verify tool result was stored
assert tool_result_content is not None
assert tool_result_content.tool_name == "trigger_sentence"
assert tool_result_content.tool_result["response"] == trigger_response
@pytest.mark.usefixtures("init_components")
async def test_no_tool_call_on_no_intent_match(hass: HomeAssistant) -> None:
"""Test that no tool call is stored when no intent is matched."""
result = await conversation.async_converse(
hass, "this is a random sentence that should not match", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
with (
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
async_get_chat_log(hass, session) as chat_log,
):
pass
# Verify no tool call was stored
for content in chat_log.content:
if content.role == "assistant":
assert content.tool_calls is None or len(content.tool_calls) == 0
break
else:
pytest.fail("No assistant content found in chat log")
@pytest.mark.usefixtures("init_components")
async def test_intent_tool_call_with_error_response(hass: HomeAssistant) -> None:
"""Test that intent tool calls store error information correctly."""
# Request to turn on a non-existent device
result = await conversation.async_converse(
hass, "turn on the non existent device", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert result.response.error_code == intent.IntentResponseErrorCode.NO_VALID_TARGETS
with (
chat_session.async_get_chat_session(hass, result.conversation_id) as session,
async_get_chat_log(hass, session) as chat_log,
):
pass
# Verify no tool call was stored for unmatched entities
tool_call_found = False
for content in chat_log.content:
if content.role == "assistant" and content.tool_calls:
tool_call_found = True
# No tool call should be stored since the entity could not be matched
assert not tool_call_found

View File

@@ -41,34 +41,6 @@ async def target_covers(hass: HomeAssistant) -> list[str]:
return await target_entities(hass, "cover")
@pytest.mark.parametrize(
"trigger_key",
[
"cover.awning_opened",
"cover.blind_opened",
"cover.curtain_opened",
"cover.door_opened",
"cover.garage_opened",
"cover.gate_opened",
"cover.shade_opened",
"cover.shutter_opened",
"cover.window_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the cover triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
def parametrize_opened_trigger_states(
trigger: str, device_class: str
) -> list[tuple[str, dict, str, list[StateDescription]]]:
@@ -120,6 +92,33 @@ def parametrize_opened_trigger_states(
]
@pytest.mark.parametrize(
"trigger_key",
[
"cover.awning_opened",
"cover.blind_opened",
"cover.curtain_opened",
"cover.door_opened",
"cover.garage_opened",
"cover.gate_opened",
"cover.shade_opened",
"cover.shutter_opened",
"cover.window_opened",
],
)
async def test_cover_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the cover triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),

View File

@@ -23,8 +23,22 @@ from .conftest import MOCK_CODE, MOCK_DEVICE_ID, MOCK_HOST, MOCK_PORT
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("pre_normalized_code", "normalized_code"),
[
(
"abc 123",
"ABC123",
),
(" 123456 ", "123456"),
("123ABC", "123ABC"),
],
ids=["alphanumeric_lower_space", "numeric_space", "alphanumeric_no_space"],
)
async def test_user_setup(
hass: HomeAssistant,
pre_normalized_code: str,
normalized_code: str,
mock_droplet_discovery: AsyncMock,
mock_droplet_connection: AsyncMock,
mock_droplet: AsyncMock,
@@ -39,12 +53,12 @@ async def test_user_setup(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: "192.168.1.2"},
user_input={CONF_CODE: pre_normalized_code, CONF_IP_ADDRESS: "192.168.1.2"},
)
assert result is not None
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("data") == {
CONF_CODE: MOCK_CODE,
CONF_CODE: normalized_code,
CONF_DEVICE_ID: MOCK_DEVICE_ID,
CONF_IP_ADDRESS: MOCK_HOST,
CONF_PORT: MOCK_PORT,
@@ -133,8 +147,22 @@ async def test_user_setup_already_configured(
assert result.get("reason") == "already_configured"
@pytest.mark.parametrize(
("pre_normalized_code", "normalized_code"),
[
(
"abc 123",
"ABC123",
),
(" 123456 ", "123456"),
("123ABC", "123ABC"),
],
ids=["alphanumeric_lower_space", "numeric_space", "alphanumeric_no_space"],
)
async def test_zeroconf_setup(
hass: HomeAssistant,
pre_normalized_code: str,
normalized_code: str,
mock_droplet_discovery: AsyncMock,
mock_droplet: AsyncMock,
mock_droplet_connection: AsyncMock,
@@ -159,7 +187,7 @@ async def test_zeroconf_setup(
assert result.get("step_id") == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_CODE: MOCK_CODE}
result["flow_id"], user_input={CONF_CODE: pre_normalized_code}
)
assert result is not None
assert result.get("type") is FlowResultType.CREATE_ENTRY
@@ -167,7 +195,7 @@ async def test_zeroconf_setup(
CONF_DEVICE_ID: MOCK_DEVICE_ID,
CONF_IP_ADDRESS: MOCK_HOST,
CONF_PORT: MOCK_PORT,
CONF_CODE: MOCK_CODE,
CONF_CODE: normalized_code,
}
assert result.get("context") is not None
assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID

View File

@@ -55,62 +55,6 @@
'state': '0.29591',
})
# ---
# name: test_entities[sensor.essent_average_gas_price_today-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.essent_average_gas_price_today',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 3,
}),
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Average gas price today',
'platform': 'essent',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'gas_average_today',
'unique_id': 'gas-average_today',
'unit_of_measurement': '€/m³',
})
# ---
# name: test_entities[sensor.essent_average_gas_price_today-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by Essent',
'friendly_name': 'Essent Average gas price today',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '€/m³',
}),
'context': <ANY>,
'entity_id': 'sensor.essent_average_gas_price_today',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1.13959',
})
# ---
# name: test_entities[sensor.essent_current_electricity_market_price-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -52,25 +52,77 @@ async def test_reauth_started(
@pytest.mark.parametrize("platforms", [[Platform.IMAGE]])
async def test_oserror_remove_image(
@pytest.mark.parametrize(
("exists", "is_dir", "rmtree_called"),
[
(True, True, True),
(False, False, False),
(True, False, False),
],
ids=[
"old_storage_removed",
"new_storage_ignored",
"no_existing_storage",
],
)
async def test_remove_old_storage_directory(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
mock_roborock_entry: MockConfigEntry,
storage_path: pathlib.Path,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
exists: bool,
is_dir: bool,
rmtree_called: bool,
) -> None:
"""Test cleanup of old old map storage."""
with (
patch(
"homeassistant.components.roborock.roborock_storage.Path.exists",
return_value=exists,
),
patch(
"homeassistant.components.roborock.roborock_storage.Path.is_dir",
return_value=is_dir,
),
patch(
"homeassistant.components.roborock.roborock_storage.shutil.rmtree",
) as mock_rmtree,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
assert mock_roborock_entry.state is ConfigEntryState.LOADED
assert mock_rmtree.called == rmtree_called
@pytest.mark.parametrize("platforms", [[Platform.IMAGE]])
async def test_oserror_remove_storage_directory(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
storage_path: pathlib.Path,
hass_client: ClientSessionGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that we gracefully handle failing to remove old map storage."""
with (
patch(
"homeassistant.components.roborock.roborock_storage.Path.exists",
return_value=True,
),
patch(
"homeassistant.components.roborock.roborock_storage.Path.is_dir",
return_value=True,
),
patch(
"homeassistant.components.roborock.roborock_storage.shutil.rmtree",
side_effect=OSError,
) as mock_rmtree,
):
await hass.config_entries.async_remove(setup_entry.entry_id)
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
await hass.async_block_till_done()
assert mock_roborock_entry.state is ConfigEntryState.LOADED
assert mock_rmtree.called
assert "Unable to remove map files" in caplog.text

View File

@@ -5630,7 +5630,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '599.296',
'state': '0.072',
})
# ---
# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_power-entry]
@@ -5689,7 +5689,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.432',
'state': '0.008',
})
# ---
# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_phase_a_voltage-entry]
@@ -5745,7 +5745,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '52.7',
'state': '234.1',
})
# ---
# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_supply_frequency-entry]