mirror of
https://github.com/home-assistant/core.git
synced 2025-12-01 13:38:03 +00:00
Compare commits
14 Commits
copilot/fi
...
2025.12.0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8d5a8bc58 | ||
|
|
3f1f8da6f5 | ||
|
|
55613f56b6 | ||
|
|
3ee2a78663 | ||
|
|
814a0c4cc9 | ||
|
|
71b674d8f1 | ||
|
|
c952fc5e31 | ||
|
|
8c3d40a348 | ||
|
|
2451dfb63d | ||
|
|
8e5921eab6 | ||
|
|
bc730da9b1 | ||
|
|
28b7ebea6e | ||
|
|
cfa447c7a9 | ||
|
|
f64c870e42 |
@@ -98,6 +98,12 @@
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"started_cooling": {
|
||||
"trigger": "mdi:snowflake"
|
||||
},
|
||||
"started_drying": {
|
||||
"trigger": "mdi:water-percent"
|
||||
},
|
||||
"started_heating": {
|
||||
"trigger": "mdi:fire"
|
||||
},
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
60
homeassistant/components/tuya/raw_data_models.py
Normal file
60
homeassistant/components/tuya/raw_data_models.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
2
requirements.txt
generated
@@ -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
6
requirements_all.txt
generated
@@ -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
|
||||
|
||||
6
requirements_test_all.txt
generated
6
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user