Compare commits

..

25 Commits

Author SHA1 Message Date
Erik
028f1aece5 Adjust comment 2026-04-09 07:48:29 +02:00
Erik
a141fb195a Change for to optional 2026-04-09 07:46:26 +02:00
Erik
459f247620 Add duration to state based entity triggers 2026-04-09 07:36:06 +02:00
Maciej Bieniek
f4f202a8a1 Fix Tractive switch availability (#167599) 2026-04-08 07:44:45 +02:00
Erwin Douna
c30ccf3750 Bump pyportainer 1.0.38 (#167627) 2026-04-08 05:36:10 +02:00
Raphael Hehl
0b8390cf21 Bump py-unifi-access to 1.1.5 (#167633)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-04-08 00:47:58 +02:00
Joakim Plate
1a048a7845 Move logging for loading/unloading config entry to integration logger (#167415) 2026-04-07 23:43:11 +02:00
Erik Montnemery
08097c67eb Bump securetar to 2026.4.1 (#167617) 2026-04-07 20:19:51 +01:00
Oliver Verity
550e53d192 Add support for storing OpenAI conversation responses (#165723) 2026-04-07 20:19:25 +01:00
Fabian Munkes
09ee76c265 Add initial support for PlayerOptions: Number entities to Music Assistant (#162669)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2026-04-07 20:51:26 +02:00
Norbert Rittel
f7b2f5e8f1 Improve Remote action naming consistency (#167382) 2026-04-07 18:48:05 +02:00
G Johansson
a1414717ad Bump holidays to 0.94 (#167604) 2026-04-07 18:41:28 +02:00
Erik Montnemery
2f0889ac02 Fix securetar size calculation when encrypting backup (#167602) 2026-04-07 18:40:06 +02:00
Joakim Plate
323b3a4d96 Add contour and position names to gardena (#167512) 2026-04-07 18:29:05 +02:00
Erwin Douna
8aa0e9f6c3 Refactor to active_containers (#167529) 2026-04-07 18:27:43 +02:00
Erik Montnemery
906475249c Bump securetar to 2026.4.0 (#167600) 2026-04-07 16:00:06 +02:00
Oliver Verity
354b5860bb Add read-only MCP Assist context snapshot resource (#167396) 2026-04-07 06:57:55 -07:00
Ludovic BOUÉ
74957969f7 Add select entities for Roborock q10 s5+ (#166142)
Co-authored-by: Ludovic BOUÉ <132135057+lboue@users.noreply.github.com>
2026-04-07 06:55:43 -07:00
Leo Periou
b52ce22ee7 fix EWS deviceType problem (#167597) 2026-04-07 15:49:21 +02:00
Jan Čermák
920ffdb9b5 Remove homeassistant/actions/helpers/info from builder workflow (#167573) 2026-04-07 14:52:05 +02:00
Artur Pragacz
4a454dff02 Set up condition and trigger helpers in check config script (#167589)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-07 14:50:37 +02:00
Stefan S
481eb66bc5 Add unit 'µA' for the units of electric current (#166786)
Co-authored-by: Ariel Ebersberger <ariel@ebersberger.io>
2026-04-07 14:48:21 +02:00
Retha Runolfsson
b76627a442 Add light sensor button to switchbot air purifier (#167134)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:13:35 +02:00
Denis Shulyaka
1aa214fb61 Enable minimal thinking budget by default for Anthropic integration (#167593) 2026-04-07 13:36:49 +02:00
markhannon
6e30de3a1c Bump zcc-helper to 3.8 (#167555) 2026-04-07 13:31:51 +02:00
75 changed files with 2835 additions and 160 deletions

View File

@@ -47,10 +47,6 @@ jobs:
with:
python-version-file: ".python-version"
- name: Get information
id: info
uses: home-assistant/actions/helpers/info@5752577ea7cc5aefb064b0b21432f18fe4d6ba90 # zizmor: ignore[unpinned-uses]
- name: Get version
id: version
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses]

View File

@@ -709,7 +709,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint homeassistant
pylint --ignore-missing-annotations=y homeassistant
- name: Run pylint (partially)
if: needs.info.outputs.test_full_suite == 'false'
shell: bash
@@ -718,7 +718,7 @@ jobs:
run: |
. venv/bin/activate
python --version
pylint $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint --ignore-missing-annotations=y $(printf "homeassistant/components/%s " ${INTEGRATIONS_GLOB})
pylint-tests:
name: Check pylint on tests

View File

@@ -36,13 +36,15 @@ class PromptCaching(StrEnum):
AUTOMATIC = "automatic"
MIN_THINKING_BUDGET = 1024
DEFAULT = {
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_CODE_EXECUTION: False,
CONF_MAX_TOKENS: 3000,
CONF_PROMPT_CACHING: PromptCaching.PROMPT.value,
CONF_TEMPERATURE: 1.0,
CONF_THINKING_BUDGET: 0,
CONF_THINKING_BUDGET: MIN_THINKING_BUDGET,
CONF_THINKING_EFFORT: "low",
CONF_TOOL_SEARCH: False,
CONF_WEB_SEARCH: False,
@@ -50,8 +52,6 @@ DEFAULT = {
CONF_WEB_SEARCH_MAX_USES: 5,
}
MIN_THINKING_BUDGET = 1024
NON_THINKING_MODELS = [
"claude-3-haiku",
]

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.7", "securetar==2026.2.0"],
"requirements": ["cronsim==2.7", "securetar==2026.4.1"],
"single_config_entry": true
}

View File

@@ -22,6 +22,7 @@ from securetar import (
SecureTarFile,
SecureTarReadError,
SecureTarRootKeyContext,
get_archive_max_ciphertext_size,
)
from homeassistant.core import HomeAssistant
@@ -383,9 +384,12 @@ def _encrypt_backup(
if prefix not in expected_archives:
LOGGER.debug("Unknown inner tar file %s will not be encrypted", obj.name)
continue
output_archive.import_tar(
input_tar.extractfile(obj), obj, derived_key_id=inner_tar_idx
)
if (fileobj := input_tar.extractfile(obj)) is None:
LOGGER.debug(
"Non regular inner tar file %s will not be encrypted", obj.name
)
continue
output_archive.import_tar(fileobj, obj, derived_key_id=inner_tar_idx)
inner_tar_idx += 1
@@ -419,7 +423,7 @@ class _CipherBackupStreamer:
hass: HomeAssistant,
backup: AgentBackup,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
password: str | None,
password: str,
) -> None:
"""Initialize."""
self._workers: list[_CipherWorkerStatus] = []
@@ -431,7 +435,9 @@ class _CipherBackupStreamer:
def size(self) -> int:
"""Return the maximum size of the decrypted or encrypted backup."""
return self._backup.size + self._num_tar_files() * tarfile.RECORDSIZE
return get_archive_max_ciphertext_size(
self._backup.size, SECURETAR_CREATE_VERSION, self._num_tar_files()
)
def _num_tar_files(self) -> int:
"""Return the number of inner tar files."""

View File

@@ -38,6 +38,7 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.TEXT,
Platform.VALVE,
]
LOGGER = logging.getLogger(__name__)

View File

@@ -0,0 +1,12 @@
{
"entity": {
"text": {
"contour_name": {
"default": "mdi:vector-polygon"
},
"position_name": {
"default": "mdi:map-marker-radius"
}
}
}
}

View File

@@ -154,6 +154,14 @@
"state": {
"name": "[%key:common::state::open%]"
}
},
"text": {
"contour_name": {
"name": "Contour {number}"
},
"position_name": {
"name": "Position {number}"
}
}
}
}

View File

@@ -0,0 +1,88 @@
"""Support for text entities."""
from __future__ import annotations
from dataclasses import dataclass
from gardena_bluetooth.const import AquaContourContours, AquaContourPosition
from gardena_bluetooth.parse import CharacteristicNullString
from homeassistant.components.text import TextEntity, TextEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry
from .entity import GardenaBluetoothDescriptorEntity
@dataclass(frozen=True, kw_only=True)
class GardenaBluetoothTextEntityDescription(TextEntityDescription):
"""Description of entity."""
char: CharacteristicNullString
@property
def context(self) -> set[str]:
"""Context needed for update coordinator."""
return {self.char.uuid}
DESCRIPTIONS = (
*(
GardenaBluetoothTextEntityDescription(
key=f"position_{i}_name",
translation_key="position_name",
translation_placeholders={"number": str(i)},
has_entity_name=True,
char=getattr(AquaContourPosition, f"position_name_{i}"),
native_max=20,
entity_category=EntityCategory.CONFIG,
)
for i in range(1, 6)
),
*(
GardenaBluetoothTextEntityDescription(
key=f"contour_{i}_name",
translation_key="contour_name",
translation_placeholders={"number": str(i)},
has_entity_name=True,
char=getattr(AquaContourContours, f"contour_name_{i}"),
native_max=20,
entity_category=EntityCategory.CONFIG,
)
for i in range(1, 6)
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up text based on a config entry."""
coordinator = entry.runtime_data
entities = [
GardenaBluetoothTextEntity(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.char.unique_id in coordinator.characteristics
]
async_add_entities(entities)
class GardenaBluetoothTextEntity(GardenaBluetoothDescriptorEntity, TextEntity):
"""Representation of a text entity."""
entity_description: GardenaBluetoothTextEntityDescription
@property
def native_value(self) -> str | None:
"""Return the value reported by the text."""
char = self.entity_description.char
return self.coordinator.get_cached(char)
async def async_set_value(self, value: str) -> None:
"""Change the text."""
char = self.entity_description.char
await self.coordinator.write(char, value)

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.93", "babel==2.15.0"]
"requirements": ["holidays==0.94", "babel==2.15.0"]
}

View File

@@ -10,10 +10,12 @@ See https://modelcontextprotocol.io/docs/concepts/architecture#implementation-ex
from collections.abc import Callable, Sequence
import json
import logging
from typing import Any
from typing import Any, cast
from mcp import types
from mcp.server import Server
from mcp.server.lowlevel.helper_types import ReadResourceContents
from pydantic import AnyUrl
import voluptuous as vol
from voluptuous_openapi import convert
@@ -25,6 +27,16 @@ from .const import STATELESS_LLM_API
_LOGGER = logging.getLogger(__name__)
SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot"
SNAPSHOT_RESOURCE_URL = AnyUrl(SNAPSHOT_RESOURCE_URI)
SNAPSHOT_RESOURCE_MIME_TYPE = "text/plain"
LIVE_CONTEXT_TOOL_NAME = "GetLiveContext"
def _has_live_context_tool(llm_api: llm.APIInstance) -> bool:
"""Return if the selected API exposes the live context tool."""
return any(tool.name == LIVE_CONTEXT_TOOL_NAME for tool in llm_api.tools)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
@@ -90,6 +102,47 @@ async def create_server(
],
)
@server.list_resources() # type: ignore[no-untyped-call,untyped-decorator]
async def handle_list_resources() -> list[types.Resource]:
llm_api = await get_api_instance()
if not _has_live_context_tool(llm_api):
return []
return [
types.Resource(
uri=SNAPSHOT_RESOURCE_URL,
name="assist_context_snapshot",
title="Assist context snapshot",
description=(
"A snapshot of the current Assist context, matching the"
" existing GetLiveContext tool output."
),
mimeType=SNAPSHOT_RESOURCE_MIME_TYPE,
)
]
@server.read_resource() # type: ignore[no-untyped-call,untyped-decorator]
async def handle_read_resource(uri: AnyUrl) -> Sequence[ReadResourceContents]:
if str(uri) != SNAPSHOT_RESOURCE_URI:
raise ValueError(f"Unknown resource: {uri}")
llm_api = await get_api_instance()
if not _has_live_context_tool(llm_api):
raise ValueError(f"Unknown resource: {uri}")
tool_response = await llm_api.async_call_tool(
llm.ToolInput(tool_name=LIVE_CONTEXT_TOOL_NAME, tool_args={})
)
if not tool_response.get("success"):
raise HomeAssistantError(cast(str, tool_response["error"]))
return [
ReadResourceContents(
content=cast(str, tool_response["result"]),
mime_type=SNAPSHOT_RESOURCE_MIME_TYPE,
)
]
@server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
async def list_tools() -> list[types.Tool]:
"""List available time tools."""

View File

@@ -1,7 +1,8 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
},
"conditions": {
"is_detected": {
@@ -45,6 +46,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::trigger_for_name%]"
}
},
"name": "Motion cleared"
@@ -54,6 +58,9 @@
"fields": {
"behavior": {
"name": "[%key:component::motion::common::trigger_behavior_name%]"
},
"for": {
"name": "[%key:component::motion::common::trigger_for_name%]"
}
},
"name": "Motion detected"

View File

@@ -9,6 +9,11 @@
- first
- last
- any
for:
required: true
default: 00:00:00
selector:
duration:
detected:
fields: *trigger_common_fields

View File

@@ -49,7 +49,11 @@ if TYPE_CHECKING:
from homeassistant.helpers.typing import ConfigType
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
PLATFORMS = [
Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.NUMBER,
]
CONNECT_TIMEOUT = 10
LISTEN_READY_TIMEOUT = 30

View File

@@ -80,3 +80,5 @@ ATTR_FANART_IMAGE = "fanart_image"
ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
LOGGER = logging.getLogger(__package__)
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options."

View File

@@ -6,8 +6,9 @@ from typing import TYPE_CHECKING
from music_assistant_models.enums import EventType
from music_assistant_models.event import MassEvent
from music_assistant_models.player import Player
from music_assistant_models.player import Player, PlayerOption
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -84,3 +85,45 @@ class MusicAssistantEntity(Entity):
async def async_on_update(self) -> None:
"""Handle player updates."""
class MusicAssistantPlayerOptionEntity(MusicAssistantEntity):
"""Base entity for Music Assistant Player Options."""
_attr_entity_category = EntityCategory.CONFIG
def __init__(
self, mass: MusicAssistantClient, player_id: str, player_option: PlayerOption
) -> None:
"""Initialize MusicAssistantPlayerOptionEntity."""
super().__init__(mass, player_id)
self.mass_option_key = player_option.key
self.mass_type = player_option.type
self.on_player_option_update(player_option)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
# need callbacks of parent to catch availability
await super().async_added_to_hass()
# main callback for player options
self.async_on_remove(
self.mass.subscribe(
self.__on_mass_player_options_update,
EventType.PLAYER_OPTIONS_UPDATED,
self.player_id,
)
)
def __on_mass_player_options_update(self, event: MassEvent) -> None:
"""Call when we receive an event from MusicAssistant."""
for option in self.player.options:
if option.key == self.mass_option_key:
self.on_player_option_update(option)
self.async_write_ha_state()
break
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Callback for player option updates."""

View File

@@ -0,0 +1,127 @@
"""Music Assistant Number platform."""
from __future__ import annotations
from typing import Final
from music_assistant_client.client import MusicAssistantClient
from music_assistant_models.player import PlayerOption, PlayerOptionType
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MusicAssistantConfigEntry
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
from .entity import MusicAssistantPlayerOptionEntity
from .helpers import catch_musicassistant_error
PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER: Final[list[str]] = [
"bass",
"dialogue_level",
"dialogue_lift",
"dts_dialogue_control",
"equalizer_high",
"equalizer_low",
"equalizer_mid",
"subwoofer_volume",
"treble",
]
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant Number Entities (Player Options) from Config Entry."""
mass = entry.runtime_data.mass
def add_player(player_id: str) -> None:
"""Handle add player."""
player = mass.players.get(player_id)
if player is None:
return
entities: list[MusicAssistantPlayerConfigNumber] = []
for player_option in player.options:
if (
not player_option.read_only
and player_option.type
in (
PlayerOptionType.INTEGER,
PlayerOptionType.FLOAT,
)
and not player_option.options # these we map to select
):
# the MA translation key must have the format player_options.<translation key>
# we ignore entities with unknown translation keys.
if (
player_option.translation_key is None
or not player_option.translation_key.startswith(
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
)
):
continue
translation_key = player_option.translation_key[
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
]
if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER:
continue
entities.append(
MusicAssistantPlayerConfigNumber(
mass,
player_id,
player_option=player_option,
entity_description=NumberEntityDescription(
key=player_option.key,
translation_key=translation_key,
),
)
)
async_add_entities(entities)
# register callback to add players when they are discovered
entry.runtime_data.platform_handlers.setdefault(Platform.NUMBER, add_player)
class MusicAssistantPlayerConfigNumber(MusicAssistantPlayerOptionEntity, NumberEntity):
"""Representation of a Number entity to control player provider dependent settings."""
def __init__(
self,
mass: MusicAssistantClient,
player_id: str,
player_option: PlayerOption,
entity_description: NumberEntityDescription,
) -> None:
"""Initialize MusicAssistantPlayerConfigNumber."""
super().__init__(mass, player_id, player_option)
self.entity_description = entity_description
@catch_musicassistant_error
async def async_set_native_value(self, value: float) -> None:
"""Set a new value."""
_value = round(value) if self.mass_type == PlayerOptionType.INTEGER else value
await self.mass.players.set_option(
self.player_id,
self.mass_option_key,
_value,
)
def on_player_option_update(self, player_option: PlayerOption) -> None:
"""Update on player option update."""
if player_option.min_value is not None:
self._attr_native_min_value = player_option.min_value
if player_option.max_value is not None:
self._attr_native_max_value = player_option.max_value
if player_option.step is not None:
self._attr_native_step = player_option.step
self._attr_native_value = (
player_option.value
if isinstance(player_option.value, (int, float))
else None
)

View File

@@ -53,6 +53,35 @@
"favorite_now_playing": {
"name": "Favorite current song"
}
},
"number": {
"bass": {
"name": "Bass"
},
"dialogue_level": {
"name": "Dialogue level"
},
"dialogue_lift": {
"name": "Dialogue lift"
},
"dts_dialogue_control": {
"name": "DTS dialogue control"
},
"equalizer_high": {
"name": "Equalizer high"
},
"equalizer_low": {
"name": "Equalizer low"
},
"equalizer_mid": {
"name": "Equalizer mid"
},
"subwoofer_volume": {
"name": "Subwoofer volume"
},
"treble": {
"name": "Treble"
}
}
},
"issues": {

View File

@@ -104,12 +104,8 @@ async def async_setup_entry(
def _create_entity(device: dict) -> MyNeoSelect:
"""Create a select entity for a device."""
if device["model"] == "EWS":
# According to the MyNeomitis API, EWS "relais" devices expose a "relayMode"
# field in their state, while "pilote" devices do not. We therefore use the
# presence of "relayMode" as an explicit heuristic to distinguish relais
# from pilote devices. If the upstream API changes this behavior, this
# detection logic must be revisited.
if "relayMode" in device.get("state", {}):
state = device.get("state") or {}
if state.get("deviceType") == 0:
description = SELECT_TYPES["relais"]
else:
description = SELECT_TYPES["pilote"]

View File

@@ -168,7 +168,7 @@ class NumberDeviceClass(StrEnum):
CURRENT = "current"
"""Current.
Unit of measurement: `A`, `mA`
Unit of measurement: `A`, `mA`, `μA`
"""
DATA_RATE = "data_rate"

View File

@@ -46,6 +46,7 @@ from .const import (
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_REASONING_EFFORT,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
DEFAULT_AI_TASK_NAME,
@@ -58,6 +59,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -208,7 +210,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
),
"user": call.context.user_id,
"store": False,
"store": conversation_subentry.data.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
}
if model.startswith("o"):

View File

@@ -55,6 +55,7 @@ from .const import (
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -82,6 +83,7 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
@@ -357,6 +359,10 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
CONF_TEMPERATURE,
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_STORE_RESPONSES,
default=RECOMMENDED_STORE_RESPONSES,
): bool,
}
if user_input is not None:
@@ -641,7 +647,9 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
"strict": False,
}
},
store=False,
store=self.options.get(
CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES
),
)
location_data = location_schema(json.loads(response.output_text) or {})

View File

@@ -24,6 +24,7 @@ CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
CONF_REASONING_SUMMARY = "reasoning_summary"
CONF_RECOMMENDED = "recommended"
CONF_STORE_RESPONSES = "store_responses"
CONF_SERVICE_TIER = "service_tier"
CONF_TEMPERATURE = "temperature"
CONF_TOP_P = "top_p"
@@ -42,6 +43,7 @@ RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_STORE_RESPONSES = False
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_SERVICE_TIER = "auto"
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"

View File

@@ -75,6 +75,7 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_REASONING_SUMMARY,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_VERBOSITY,
@@ -94,6 +95,7 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_SERVICE_TIER,
RECOMMENDED_STORE_RESPONSES,
RECOMMENDED_STT_MODEL,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
@@ -508,7 +510,7 @@ class OpenAIBaseLLMEntity(Entity):
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
user=chat_log.conversation_id,
service_tier=options.get(CONF_SERVICE_TIER, RECOMMENDED_SERVICE_TIER),
store=False,
store=options.get(CONF_STORE_RESPONSES, RECOMMENDED_STORE_RESPONSES),
stream=True,
)
@@ -611,8 +613,10 @@ class OpenAIBaseLLMEntity(Entity):
if image_model != "gpt-image-1-mini":
image_tool["input_fidelity"] = "high"
tools.append(image_tool)
# Keep image state on OpenAI so follow-up prompts can continue by
# conversation ID without resending the generated image data.
model_args["store"] = True
model_args["tool_choice"] = ToolChoiceTypesParam(type="image_generation")
model_args["store"] = True # Avoid sending image data back and forth
if tools:
model_args["tools"] = tools

View File

@@ -51,9 +51,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]",
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::store_responses%]",
"temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]",
"top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]"
},
"data_description": {
"store_responses": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data_description::store_responses%]"
},
"title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]"
},
"init": {
@@ -109,9 +113,13 @@
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "Maximum tokens to return in response",
"store_responses": "Store requests and responses in OpenAI",
"temperature": "Temperature",
"top_p": "Top P"
},
"data_description": {
"store_responses": "If enabled, requests and responses are stored by OpenAI and visible in your OpenAI dashboard logs"
},
"title": "Advanced settings"
},
"init": {

View File

@@ -214,19 +214,19 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
else None,
)
# Separately fetch stats for running containers
running_containers = [
# Separately fetch stats for active containers
active_containers = [
container
for container in containers
if container.state
in (DockerContainerState.RUNNING, DockerContainerState.PAUSED)
]
if running_containers:
if active_containers:
container_stats = dict(
zip(
(
self._get_container_name(container.names[0])
for container in running_containers
for container in active_containers
),
await asyncio.gather(
*(
@@ -234,7 +234,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
endpoint_id=endpoint.id,
container_id=container.id,
)
for container in running_containers
for container in active_containers
)
),
strict=False,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["pyportainer==1.0.37"]
"requirements": ["pyportainer==1.0.38"]
}

View File

@@ -41,7 +41,7 @@
},
"services": {
"delete_command": {
"description": "Deletes a command or a list of commands from the database.",
"description": "Deletes a command or a list of commands from a remote's database.",
"fields": {
"command": {
"description": "The single command or the list of commands to be deleted.",
@@ -52,10 +52,10 @@
"name": "Device"
}
},
"name": "Delete command"
"name": "Delete remote command"
},
"learn_command": {
"description": "Learns a command or a list of commands from a device.",
"description": "Teaches a remote a command or list of commands from a device.",
"fields": {
"alternative": {
"description": "If code must be stored as an alternative. This is useful for discrete codes. Discrete codes are used for toggles that only perform one function. For example, a code to only turn a device on. If it is on already, sending the code won't change the state.",
@@ -78,7 +78,7 @@
"name": "Timeout"
}
},
"name": "Learn command"
"name": "Learn remote command"
},
"send_command": {
"description": "Sends a command or a list of commands to a device.",
@@ -104,15 +104,15 @@
"name": "Repeats"
}
},
"name": "Send command"
"name": "Send remote command"
},
"toggle": {
"description": "Sends the toggle command.",
"name": "[%key:common::action::toggle%]"
"name": "Toggle via remote"
},
"turn_off": {
"description": "Sends the turn off command.",
"name": "[%key:common::action::turn_off%]"
"name": "Turn off via remote"
},
"turn_on": {
"description": "Sends the turn on command.",
@@ -122,7 +122,7 @@
"name": "Activity"
}
},
"name": "[%key:common::action::turn_on%]"
"name": "Turn on via remote"
}
},
"title": "Remote",

View File

@@ -20,6 +20,7 @@ from roborock.data import (
ZeoSpin,
ZeoTemperature,
)
from roborock.data.b01_q10.b01_q10_code_mappings import YXCleanType
from roborock.devices.traits.b01 import Q7PropertiesApi
from roborock.devices.traits.v1 import PropertiesApi
from roborock.devices.traits.v1.home import HomeTrait
@@ -37,6 +38,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MAP_SLEEP
from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockB01Q10UpdateCoordinator,
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
@@ -44,6 +46,7 @@ from .coordinator import (
from .entity import (
RoborockCoordinatedEntityA01,
RoborockCoordinatedEntityB01Q7,
RoborockCoordinatedEntityB01Q10,
RoborockCoordinatedEntityV1,
)
@@ -266,6 +269,10 @@ async def async_setup_entry(
for description in A01_SELECT_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(
RoborockQ10CleanModeSelectEntity(coordinator)
for coordinator in config_entry.runtime_data.b01_q10
)
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity):
@@ -466,3 +473,59 @@ class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity):
self.entity_description.key,
)
return str(current_value)
class RoborockQ10CleanModeSelectEntity(RoborockCoordinatedEntityB01Q10, SelectEntity):
"""Select entity for Q10 cleaning mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "cleaning_mode"
coordinator: RoborockB01Q10UpdateCoordinator
def __init__(
self,
coordinator: RoborockB01Q10UpdateCoordinator,
) -> None:
"""Create a select entity for Q10 cleaning mode."""
super().__init__(
f"cleaning_mode_{coordinator.duid_slug}",
coordinator,
)
async def async_added_to_hass(self) -> None:
"""Register trait listener for push-based status updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.api.status.add_update_listener(self.async_write_ha_state)
)
@property
def options(self) -> list[str]:
"""Return available cleaning modes."""
return [mode.value for mode in YXCleanType if mode != YXCleanType.UNKNOWN]
@property
def current_option(self) -> str | None:
"""Get the current cleaning mode."""
clean_mode = self.coordinator.api.status.clean_mode
if clean_mode is None or clean_mode == YXCleanType.UNKNOWN:
return None
return clean_mode.value
async def async_select_option(self, option: str) -> None:
"""Set the cleaning mode."""
try:
mode = YXCleanType.from_value(option)
except ValueError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="select_option_failed",
) from err
try:
await self.coordinator.api.vacuum.set_clean_mode(mode)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"command": "cleaning_mode"},
) from err

View File

@@ -180,7 +180,7 @@ class SensorDeviceClass(StrEnum):
CURRENT = "current"
"""Current.
Unit of measurement: `A`, `mA`
Unit of measurement: `A`, `mA`, `μA`
"""
DATA_RATE = "data_rate"

View File

@@ -110,10 +110,26 @@ PLATFORMS_BY_TYPE = {
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.AIR_PURIFIER_JP.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_US.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE_JP.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE_US.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_JP.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
],
SupportedModels.AIR_PURIFIER_US.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
],
SupportedModels.AIR_PURIFIER_TABLE_JP.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
],
SupportedModels.AIR_PURIFIER_TABLE_US.value: [
Platform.FAN,
Platform.SENSOR,
Platform.BUTTON,
],
SupportedModels.EVAPORATIVE_HUMIDIFIER.value: [
Platform.HUMIDIFIER,
Platform.SENSOR,

View File

@@ -24,6 +24,8 @@ async def async_setup_entry(
) -> None:
"""Set up Switchbot button platform."""
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotAirPurifier):
async_add_entities([LightSensorButton(coordinator)])
if isinstance(coordinator.device, switchbot.SwitchbotArtFrame):
async_add_entities(
@@ -37,6 +39,24 @@ async def async_setup_entry(
async_add_entities([SwitchBotMeterProCO2SyncDateTimeButton(coordinator)])
class LightSensorButton(SwitchbotEntity, ButtonEntity):
"""Representation of a Switchbot light sensor button."""
_attr_translation_key = "light_sensor"
_device: switchbot.SwitchbotAirPurifier
def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
"""Initialize the Switchbot light sensor button."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.base_unique_id}_light_sensor"
@exception_handler
async def async_press(self) -> None:
"""Handle the button press."""
_LOGGER.debug("Toggling light sensor mode for %s", self._address)
await self._device.open_light_sensitive_switch()
class SwitchBotArtFrameButtonBase(SwitchbotEntity, ButtonEntity):
"""Base class for Art Frame buttons."""

View File

@@ -1,5 +1,10 @@
{
"entity": {
"button": {
"light_sensor": {
"default": "mdi:brightness-auto"
}
},
"climate": {
"climate": {
"state_attributes": {

View File

@@ -102,6 +102,9 @@
}
},
"button": {
"light_sensor": {
"name": "Light sensor"
},
"next_image": {
"name": "Next image"
},

View File

@@ -246,6 +246,7 @@ class TractiveClient:
):
self._last_hw_time = event["hardware"]["time"]
self._send_hardware_update(event)
self._send_switch_update(event)
if (
"position" in event
and self._last_pos_time != event["position"]["time"]
@@ -302,7 +303,10 @@ class TractiveClient:
for switch, key in SWITCH_KEY_MAP.items():
if switch_data := event.get(key):
payload[switch] = switch_data["active"]
payload[ATTR_POWER_SAVING] = event.get("tracker_state_reason") == "POWER_SAVING"
if hardware := event.get("hardware", {}):
payload[ATTR_POWER_SAVING] = (
hardware.get("power_saving_zone_id") is not None
)
self._dispatch_tracker_event(
TRACKER_SWITCH_STATUS_UPDATED, event["tracker_id"], payload
)

View File

@@ -100,13 +100,11 @@ class TractiveSwitch(TractiveEntity, SwitchEntity):
@callback
def handle_status_update(self, event: dict[str, Any]) -> None:
"""Handle status update."""
if self.entity_description.key not in event:
return
if ATTR_POWER_SAVING in event:
self._attr_available = not event[ATTR_POWER_SAVING]
# We received an event, so the service is online and the switch entities should
# be available.
self._attr_available = not event[ATTR_POWER_SAVING]
self._attr_is_on = event[self.entity_description.key]
if self.entity_description.key in event:
self._attr_is_on = event[self.entity_description.key]
self.async_write_ha_state()

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["unifi_access_api"],
"quality_scale": "silver",
"requirements": ["py-unifi-access==1.1.3"]
"requirements": ["py-unifi-access==1.1.5"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.93"]
"requirements": ["holidays==0.94"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["zcc-helper==3.7"]
"requirements": ["zcc-helper==3.8"]
}

View File

@@ -579,6 +579,13 @@ class ConfigEntry[_DataT = Any]:
self.clear_state_cache()
self.clear_storage_cache()
@property
def logger(self) -> logging.Logger:
"""Return logger for this config entry."""
if self._integration_for_domain:
return self._integration_for_domain.logger
return _LOGGER
@property
def supports_options(self) -> bool:
"""Return if entry supports config options."""
@@ -698,6 +705,9 @@ class ConfigEntry[_DataT = Any]:
integration = await loader.async_get_integration(hass, self.domain)
self._integration_for_domain = integration
# Log setup to the integration logger so it's visible when debug logs are enabled.
logger = self.logger
# Only store setup result as state if it was not forwarded.
if domain_is_integration := self.domain == integration.domain:
if self.state in (
@@ -726,7 +736,7 @@ class ConfigEntry[_DataT = Any]:
try:
component = await integration.async_get_component()
except ImportError as err:
_LOGGER.error(
logger.error(
"Error importing integration %s to set up %s configuration entry: %s",
integration.domain,
self.domain,
@@ -742,7 +752,7 @@ class ConfigEntry[_DataT = Any]:
try:
await integration.async_get_platform("config_flow")
except ImportError as err:
_LOGGER.error(
logger.error(
(
"Error importing platform config_flow from integration %s to"
" set up %s configuration entry: %s"
@@ -777,7 +787,7 @@ class ConfigEntry[_DataT = Any]:
result = await component.async_setup_entry(hass, self)
if not isinstance(result, bool):
_LOGGER.error( # type: ignore[unreachable]
logger.error( # type: ignore[unreachable]
"%s.async_setup_entry did not return boolean", integration.domain
)
result = False
@@ -785,7 +795,7 @@ class ConfigEntry[_DataT = Any]:
error_reason = str(exc) or "Unknown fatal config entry error"
error_reason_translation_key = exc.translation_key
error_reason_translation_placeholders = exc.translation_placeholders
_LOGGER.exception(
logger.exception(
"Error setting up entry %s for %s: %s",
self.title,
self.domain,
@@ -800,13 +810,13 @@ class ConfigEntry[_DataT = Any]:
auth_message = (
f"{auth_base_message}: {message}" if message else auth_base_message
)
_LOGGER.warning(
logger.warning(
"Config entry '%s' for %s integration %s",
self.title,
self.domain,
auth_message,
)
_LOGGER.debug("Full exception", exc_info=True)
logger.debug("Full exception", exc_info=True)
self.async_start_reauth(hass)
except ConfigEntryNotReady as exc:
message = str(exc)
@@ -824,14 +834,14 @@ class ConfigEntry[_DataT = Any]:
)
self._tries += 1
ready_message = f"ready yet: {message}" if message else "ready yet"
_LOGGER.info(
logger.info(
"Config entry '%s' for %s integration not %s; Retrying in %d seconds",
self.title,
self.domain,
ready_message,
wait_time,
)
_LOGGER.debug("Full exception", exc_info=True)
logger.debug("Full exception", exc_info=True)
if hass.state is CoreState.running:
self._async_cancel_retry_setup = async_call_later(
@@ -854,7 +864,7 @@ class ConfigEntry[_DataT = Any]:
except asyncio.CancelledError:
# We want to propagate CancelledError if we are being cancelled.
if (task := asyncio.current_task()) and task.cancelling() > 0:
_LOGGER.exception(
logger.exception(
"Setup of config entry '%s' for %s integration cancelled",
self.title,
self.domain,
@@ -869,13 +879,13 @@ class ConfigEntry[_DataT = Any]:
raise
# This was not a "real" cancellation, log it and treat as a normal error.
_LOGGER.exception(
logger.exception(
"Error setting up entry %s for %s", self.title, integration.domain
)
# pylint: disable-next=broad-except
except SystemExit, Exception:
_LOGGER.exception(
logger.exception(
"Error setting up entry %s for %s", self.title, integration.domain
)
@@ -1029,7 +1039,7 @@ class ConfigEntry[_DataT = Any]:
)
except Exception as exc:
_LOGGER.exception(
self.logger.exception(
"Error unloading entry %s for %s", self.title, integration.domain
)
if domain_is_integration:
@@ -1072,7 +1082,7 @@ class ConfigEntry[_DataT = Any]:
try:
await component.async_remove_entry(hass, self)
except Exception:
_LOGGER.exception(
self.logger.exception(
"Error calling entry remove callback %s for %s",
self.title,
integration.domain,
@@ -1117,7 +1127,7 @@ class ConfigEntry[_DataT = Any]:
Returns True if config entry is up-to-date or has been migrated.
"""
if (handler := HANDLERS.get(self.domain)) is None:
_LOGGER.error(
self.logger.error(
"Flow handler not found for entry %s for %s", self.title, self.domain
)
return False
@@ -1138,7 +1148,7 @@ class ConfigEntry[_DataT = Any]:
if not supports_migrate:
if same_major_version:
return True
_LOGGER.error(
self.logger.error(
"Migration handler not found for entry %s for %s",
self.title,
self.domain,
@@ -1148,14 +1158,14 @@ class ConfigEntry[_DataT = Any]:
try:
result = await component.async_migrate_entry(hass, self)
if not isinstance(result, bool):
_LOGGER.error( # type: ignore[unreachable]
self.logger.error( # type: ignore[unreachable]
"%s.async_migrate_entry did not return boolean", self.domain
)
return False
if result:
hass.config_entries._async_schedule_save() # noqa: SLF001
except Exception:
_LOGGER.exception(
self.logger.exception(
"Error migrating entry %s for %s", self.title, self.domain
)
return False
@@ -1218,7 +1228,7 @@ class ConfigEntry[_DataT = Any]:
)
for task in pending:
_LOGGER.warning(
self.logger.warning(
"Unloading %s (%s) config entry. Task %s did not complete in time",
self.title,
self.domain,
@@ -1247,7 +1257,7 @@ class ConfigEntry[_DataT = Any]:
try:
func()
except Exception:
_LOGGER.exception(
self.logger.exception(
"Error calling on_state_change callback for %s (%s)",
self.title,
self.domain,
@@ -1636,7 +1646,7 @@ class ConfigEntriesFlowManager(
)
}
)
_LOGGER.debug(
entry.logger.debug(
"Updating discovery keys for %s entry %s %s -> %s",
entry.domain,
unique_id,
@@ -1869,7 +1879,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
if entry_id in data:
# This is likely a bug in a test that is adding the same entry twice.
# In the future, once we have fixed the tests, this will raise HomeAssistantError.
_LOGGER.error("An entry with the id %s already exists", entry_id)
entry.logger.error("An entry with the id %s already exists", entry_id)
self._unindex_entry(entry_id)
data[entry_id] = entry
self._index_entry(entry)
@@ -1892,7 +1902,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
report_issue = async_suggest_report_issue(
self._hass, integration_domain=entry.domain
)
_LOGGER.error(
entry.logger.error(
(
"Config entry '%s' from integration %s has an invalid unique_id"
" '%s' of type %s when a string is expected, please %s"
@@ -2282,7 +2292,7 @@ class ConfigEntries:
try:
await loader.async_get_integration(self.hass, entry.domain)
except loader.IntegrationNotFound:
_LOGGER.info(
entry.logger.info(
"Integration for ignored config entry %s not found. Creating repair issue",
entry,
)
@@ -2514,7 +2524,7 @@ class ConfigEntries:
report_issue = async_suggest_report_issue(
self.hass, integration_domain=entry.domain
)
_LOGGER.error(
entry.logger.error(
(
"Unique id of config entry '%s' from integration %s changed to"
" '%s' which is already in use, please %s"
@@ -4046,7 +4056,7 @@ async def _load_integration(
try:
await integration.async_get_platform("config_flow")
except ImportError as err:
_LOGGER.error(
integration.logger.error(
"Error occurred loading flow for integration %s: %s",
domain,
err,

View File

@@ -523,6 +523,7 @@ class UnitOfEnergyDistance(StrEnum):
class UnitOfElectricCurrent(StrEnum):
"""Electric current units."""
MICROAMPERE = "μA"
MILLIAMPERE = "mA"
AMPERE = "A"

View File

@@ -31,7 +31,7 @@ from homeassistant.requirements import (
async_get_integration_with_requirements,
)
from . import config_validation as cv
from . import condition, config_validation as cv, trigger
from .typing import ConfigType
@@ -93,6 +93,12 @@ async def async_check_ha_config_file( # noqa: C901
result = HomeAssistantConfig()
async_clear_install_history(hass)
# Set up condition and trigger helpers needed for config validation.
if condition.CONDITIONS not in hass.data:
await condition.async_setup(hass)
if trigger.TRIGGERS not in hass.data:
await trigger.async_setup(hass)
def _pack_error(
hass: HomeAssistant,
package: str,

View File

@@ -7,6 +7,7 @@ import asyncio
from collections import defaultdict
from collections.abc import Callable, Coroutine, Iterable, Mapping
from dataclasses import dataclass, field
from datetime import timedelta
import functools
import inspect
import logging
@@ -31,6 +32,7 @@ from homeassistant.const import (
CONF_ENABLED,
CONF_ENTITY_ID,
CONF_EVENT_DATA,
CONF_FOR,
CONF_ID,
CONF_OPTIONS,
CONF_PLATFORM,
@@ -74,6 +76,7 @@ from .automation import (
get_relative_description_key,
move_options_fields_to_top_level,
)
from .event import async_track_same_state
from .integration_platform import async_process_integration_platforms
from .selector import (
NumericThresholdMode,
@@ -340,6 +343,7 @@ ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST = ENTITY_STATE_TRIGGER_SCHEMA.extend(
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
vol.Optional(CONF_FOR): cv.positive_time_period_dict,
},
}
)
@@ -365,6 +369,7 @@ class EntityTriggerBase(Trigger):
if TYPE_CHECKING:
assert config.target is not None
self._options = config.options or {}
self._duration: timedelta | None = self._options.get(CONF_FOR)
self._target = config.target
def entity_filter(self, entities: set[str]) -> set[str]:
@@ -394,15 +399,12 @@ class EntityTriggerBase(Trigger):
if (state := self._hass.states.get(entity_id)) is not None
)
def check_one_match(self, entity_ids: set[str]) -> bool:
"""Check that only one entity state matches."""
return (
sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
)
== 1
def count_matches(self, entity_ids: set[str]) -> int:
"""Count the number of entity states that match."""
return sum(
self.is_valid_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
)
@override
@@ -411,7 +413,8 @@ class EntityTriggerBase(Trigger):
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
behavior = self._options.get(ATTR_BEHAVIOR)
behavior: str = self._options.get(ATTR_BEHAVIOR, BEHAVIOR_ANY)
unsub_track_same: dict[str, Callable[[], None]] = {}
@callback
def state_change_listener(
@@ -423,6 +426,31 @@ class EntityTriggerBase(Trigger):
from_state = event.data["old_state"]
to_state = event.data["new_state"]
def state_still_valid(
_: str, from_state: State | None, to_state: State | None
) -> bool:
"""Check if the state is still valid during the duration wait.
Called by async_track_same_state on each state change to
determine whether to cancel the timer.
For behavior any, checks the individual entity's state.
For behavior first/last, checks the combined state.
"""
if not from_state or not to_state:
return False
if behavior == BEHAVIOR_LAST:
return self.check_all_match(
target_state_change_data.targeted_entity_ids
)
if behavior == BEHAVIOR_FIRST:
return (
self.count_matches(target_state_change_data.targeted_entity_ids)
>= 1
)
# Behavior any: check the individual entity's state
return self.is_valid_state(to_state)
if not from_state or not to_state:
return
@@ -440,25 +468,59 @@ class EntityTriggerBase(Trigger):
):
return
elif behavior == BEHAVIOR_FIRST:
if not self.check_one_match(
target_state_change_data.targeted_entity_ids
if (
self.count_matches(target_state_change_data.targeted_entity_ids)
!= 1
):
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"state of {entity_id}",
event.context,
@callback
def call_action() -> None:
"""Call action with right context."""
# After a `for` delay, keep the original triggering event payload.
# `async_track_same_state` only verifies the state remained valid
# for the configured duration before firing the action.
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"state of {entity_id}",
event.context,
)
if not self._duration:
call_action()
return
subscription_key = entity_id if behavior == BEHAVIOR_ANY else behavior
if subscription_key in unsub_track_same:
unsub_track_same.pop(subscription_key)()
unsub_track_same[subscription_key] = async_track_same_state(
self._hass,
self._duration,
call_action,
state_still_valid,
entity_ids=entity_id
if behavior == BEHAVIOR_ANY
else target_state_change_data.targeted_entity_ids,
)
return async_track_target_selector_state_change_event(
unsub = async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, self.entity_filter
)
@callback
def async_remove() -> None:
"""Remove state listeners async."""
unsub()
for async_remove in unsub_track_same.values():
async_remove()
unsub_track_same.clear()
return async_remove
class EntityTargetStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes to a specific state.

View File

@@ -767,6 +767,7 @@ class Integration:
self.pkg_path = pkg_path
self.file_path = file_path
self.manifest = manifest
self.logger = logging.getLogger(pkg_path)
manifest["is_built_in"] = self.is_built_in
manifest["overwrites_built_in"] = self.overwrites_built_in

View File

@@ -63,7 +63,7 @@ python-slugify==8.0.4
PyTurboJPEG==1.8.0
PyYAML==6.0.3
requests==2.33.1
securetar==2026.2.0
securetar==2026.4.1
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0

View File

@@ -353,6 +353,7 @@ class ElectricCurrentConverter(BaseUnitConverter):
_UNIT_CONVERSION: dict[str | None, float] = {
UnitOfElectricCurrent.AMPERE: 1,
UnitOfElectricCurrent.MILLIAMPERE: 1e3,
UnitOfElectricCurrent.MICROAMPERE: 1e6,
}
VALID_UNITS = set(UnitOfElectricCurrent)

View File

@@ -67,7 +67,7 @@ dependencies = [
"python-slugify==8.0.4",
"PyYAML==6.0.3",
"requests==2.33.1",
"securetar==2026.2.0",
"securetar==2026.4.1",
"SQLAlchemy==2.0.41",
"standard-aifc==3.13.0",
"standard-telnetlib==3.13.0",

2
requirements.txt generated
View File

@@ -47,7 +47,7 @@ python-slugify==8.0.4
PyTurboJPEG==1.8.0
PyYAML==6.0.3
requests==2.33.1
securetar==2026.2.0
securetar==2026.4.1
SQLAlchemy==2.0.41
standard-aifc==3.13.0
standard-telnetlib==3.13.0

10
requirements_all.txt generated
View File

@@ -1229,7 +1229,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.93
holidays==0.94
# homeassistant.components.frontend
home-assistant-frontend==20260325.6
@@ -1895,7 +1895,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.3
py-unifi-access==1.1.5
# homeassistant.components.atome
pyAtome==0.1.1
@@ -2397,7 +2397,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.37
pyportainer==1.0.38
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2895,7 +2895,7 @@ screenlogicpy==0.10.2
scsgate==0.1.0
# homeassistant.components.backup
securetar==2026.2.0
securetar==2026.4.1
# homeassistant.components.sendgrid
sendgrid==6.8.2
@@ -3380,7 +3380,7 @@ zabbix-utils==2.0.3
zamg==0.3.6
# homeassistant.components.zimi
zcc-helper==3.7
zcc-helper==3.8
# homeassistant.components.zeroconf
zeroconf==0.148.0

View File

@@ -1093,7 +1093,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.93
holidays==0.94
# homeassistant.components.frontend
home-assistant-frontend==20260325.6
@@ -1647,7 +1647,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.1.3
py-unifi-access==1.1.5
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -2053,7 +2053,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.37
pyportainer==1.0.38
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2455,7 +2455,7 @@ satel-integra==1.1.0
screenlogicpy==0.10.2
# homeassistant.components.backup
securetar==2026.2.0
securetar==2026.4.1
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
@@ -2862,7 +2862,7 @@ yt-dlp[default]==2026.03.17
zamg==0.3.6
# homeassistant.components.zimi
zcc-helper==3.7
zcc-helper==3.8
# homeassistant.components.zeroconf
zeroconf==0.148.0

View File

@@ -47,9 +47,9 @@
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
'budget_tokens': 1024,
'type': 'enabled',
}),
})
# ---

View File

@@ -195,7 +195,7 @@
}),
])
# ---
# name: test_disabled_thinking
# name: test_disabled_thinking[subentry_data0]
list([
dict({
'content': '''
@@ -224,7 +224,72 @@
}),
])
# ---
# name: test_disabled_thinking.1
# name: test_disabled_thinking[subentry_data0].1
dict({
'container': None,
'max_tokens': 3000,
'messages': list([
dict({
'content': 'hello',
'role': 'user',
}),
dict({
'content': 'Hello, how can I help you today?',
'role': 'assistant',
}),
]),
'model': 'claude-haiku-4-5',
'stream': True,
'system': list([
dict({
'cache_control': dict({
'type': 'ephemeral',
}),
'text': '''
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
''',
'type': 'text',
}),
]),
'temperature': 1.0,
'thinking': dict({
'type': 'disabled',
}),
})
# ---
# name: test_disabled_thinking[subentry_data1]
list([
dict({
'content': '''
You are a voice assistant for Home Assistant.
Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
Only if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.
''',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'system',
}),
dict({
'attachments': None,
'content': 'hello',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'role': 'user',
}),
dict({
'agent_id': 'conversation.claude_conversation',
'content': 'Hello, how can I help you today?',
'created': HAFakeDatetime(2024, 5, 24, 12, 0, tzinfo=datetime.timezone.utc),
'native': None,
'role': 'assistant',
'thinking_content': None,
'tool_calls': None,
}),
])
# ---
# name: test_disabled_thinking[subentry_data1].1
dict({
'container': None,
'max_tokens': 3000,

View File

@@ -10,7 +10,10 @@ from syrupy.assertion import SnapshotAssertion
import voluptuous as vol
from homeassistant.components import ai_task, media_source
from homeassistant.components.anthropic.const import CONF_CHAT_MODEL
from homeassistant.components.anthropic.const import (
CONF_CHAT_MODEL,
CONF_THINKING_BUDGET,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
@@ -127,6 +130,7 @@ async def test_generate_structured_data_legacy(
subentry,
data={
CONF_CHAT_MODEL: "claude-sonnet-4-0",
CONF_THINKING_BUDGET: 0,
},
)
@@ -183,7 +187,11 @@ async def test_generate_structured_data_legacy_tools(
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={"chat_model": "claude-sonnet-4-0", "web_search": True},
data={
"chat_model": "claude-sonnet-4-0",
"web_search": True,
"thinking_budget": 0,
},
)
result = await ai_task.async_generate_data(

View File

@@ -330,7 +330,7 @@ async def test_subentry_web_search_user_location(
"recommended": False,
"region": "California",
"temperature": 1.0,
"thinking_budget": 0,
"thinking_budget": 1024,
"timezone": "America/Los_Angeles",
"tool_search": False,
"user_location": True,
@@ -487,7 +487,7 @@ async def test_model_list_error(
CONF_TEMPERATURE: 1.0,
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
CONF_THINKING_BUDGET: 0,
CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET],
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_MAX_USES: 10,
CONF_WEB_SEARCH_USER_LOCATION: False,
@@ -614,7 +614,7 @@ async def test_model_list_error(
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL],
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
CONF_THINKING_BUDGET: 0,
CONF_THINKING_BUDGET: DEFAULT[CONF_THINKING_BUDGET],
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: False,
@@ -788,7 +788,7 @@ async def test_creating_ai_task_subentry_advanced(
result["flow_id"],
{
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_MAX_TOKENS: 200,
CONF_MAX_TOKENS: 1200,
CONF_TEMPERATURE: 0.5,
},
)
@@ -809,13 +809,13 @@ async def test_creating_ai_task_subentry_advanced(
assert result4.get("data") == {
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_MAX_TOKENS: 200,
CONF_MAX_TOKENS: 1200,
CONF_TEMPERATURE: 0.5,
CONF_TOOL_SEARCH: False,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_THINKING_BUDGET: 0,
CONF_THINKING_BUDGET: 1024,
CONF_CODE_EXECUTION: False,
CONF_PROMPT_CACHING: "prompt",
}

View File

@@ -706,6 +706,21 @@ async def test_extended_thinking(
assert call_args == snapshot
@pytest.mark.parametrize(
"subentry_data",
[
{
CONF_LLM_HASS_API: "assist",
CONF_CHAT_MODEL: "claude-haiku-4-5",
CONF_THINKING_BUDGET: 0,
},
{
CONF_LLM_HASS_API: "assist",
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_THINKING_EFFORT: "none",
},
],
)
@freeze_time("2024-05-24 12:00:00")
async def test_disabled_thinking(
hass: HomeAssistant,
@@ -713,16 +728,13 @@ async def test_disabled_thinking(
mock_init_component,
mock_create_stream: AsyncMock,
snapshot: SnapshotAssertion,
subentry_data: dict[str, Any],
) -> None:
"""Test conversation with thinking effort disabled."""
hass.config_entries.async_update_subentry(
mock_config_entry,
next(iter(mock_config_entry.subentries.values())),
data={
CONF_LLM_HASS_API: "assist",
CONF_CHAT_MODEL: "claude-opus-4-6",
CONF_THINKING_EFFORT: "none",
},
data=subentry_data,
)
mock_create_stream.return_value = [

View File

@@ -282,14 +282,14 @@ def test_validate_password_no_homeassistant(caplog: pytest.LogCaptureFixture) ->
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
AddonInfo(name="Core 2", slug="core2", version="1.0.0"),
],
40960, # 4 x 10240 byte of padding
51200, # 5 x 10240 byte of padding
"test_backups/c0cb53bd.tar.decrypted",
),
(
[
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
],
30720, # 3 x 10240 byte of padding
40960, # 4 x 10240 byte of padding
"test_backups/c0cb53bd.tar.decrypted_skip_core2",
),
],
@@ -460,14 +460,14 @@ async def test_decrypted_backup_streamer_wrong_password(hass: HomeAssistant) ->
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
AddonInfo(name="Core 2", slug="core2", version="1.0.0"),
],
40960, # 4 x 10240 byte of padding
51200, # 5 x 10240 byte of padding
"test_backups/c0cb53bd.tar.encrypted_v3",
),
(
[
AddonInfo(name="Core 1", slug="core1", version="1.0.0"),
],
30720, # 3 x 10240 byte of padding
40960, # 4 x 10240 byte of padding
"test_backups/c0cb53bd.tar.encrypted_v3_skip_core2",
),
],
@@ -674,8 +674,8 @@ async def test_encrypted_backup_streamer_random_nonce(hass: HomeAssistant) -> No
# Expect the output length to match the stored encrypted backup file, with
# additional padding.
encrypted_backup_data = encrypted_backup_path.read_bytes()
# 4 x 10240 byte of padding
assert len(encrypted_output1) == len(encrypted_backup_data) + 40960
# 5 x 10240 byte of padding
assert len(encrypted_output1) == len(encrypted_backup_data) + 51200
assert encrypted_output1[: len(encrypted_backup_data)] != encrypted_backup_data

View File

@@ -0,0 +1,591 @@
# serializer version: 1
# name: test_setup[aqua_contour][text.mock_title_contour_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_contour_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Contour 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Contour 1',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'contour_name',
'unique_id': '00000000-0000-0000-0000-000000000003-contour_1_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Contour 1',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_contour_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Contour 1',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_contour_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Contour 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Contour 2',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'contour_name',
'unique_id': '00000000-0000-0000-0000-000000000003-contour_2_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Contour 2',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_contour_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Contour 2',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_3-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_contour_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Contour 3',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Contour 3',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'contour_name',
'unique_id': '00000000-0000-0000-0000-000000000003-contour_3_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Contour 3',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_contour_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Contour 3',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_4-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_contour_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Contour 4',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Contour 4',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'contour_name',
'unique_id': '00000000-0000-0000-0000-000000000003-contour_4_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Contour 4',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_contour_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Contour 4',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_5-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_contour_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Contour 5',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Contour 5',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'contour_name',
'unique_id': '00000000-0000-0000-0000-000000000003-contour_5_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_contour_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Contour 5',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_contour_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Contour 5',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_position_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Position 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Position 1',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'position_name',
'unique_id': '00000000-0000-0000-0000-000000000003-position_1_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Position 1',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_position_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Position 1',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_position_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Position 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Position 2',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'position_name',
'unique_id': '00000000-0000-0000-0000-000000000003-position_2_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Position 2',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_position_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Position 2',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_3-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_position_3',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Position 3',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Position 3',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'position_name',
'unique_id': '00000000-0000-0000-0000-000000000003-position_3_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Position 3',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_position_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Position 3',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_4-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_position_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Position 4',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Position 4',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'position_name',
'unique_id': '00000000-0000-0000-0000-000000000003-position_4_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Position 4',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_position_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Position 4',
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_5-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'text',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'text.mock_title_position_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Position 5',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Position 5',
'platform': 'gardena_bluetooth',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'position_name',
'unique_id': '00000000-0000-0000-0000-000000000003-position_5_name',
'unit_of_measurement': None,
})
# ---
# name: test_setup[aqua_contour][text.mock_title_position_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock Title Position 5',
'max': 20,
'min': 0,
'mode': <TextMode.TEXT: 'text'>,
'pattern': None,
}),
'context': <ANY>,
'entity_id': 'text.mock_title_position_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Position 5',
})
# ---

View File

@@ -0,0 +1,87 @@
"""Test Gardena Bluetooth text entities."""
from unittest.mock import Mock
from gardena_bluetooth.const import AquaContourContours, AquaContourPosition
from habluetooth import BluetoothServiceInfo
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.text import (
ATTR_VALUE,
DOMAIN as TEXT_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import AQUA_CONTOUR_SERVICE_INFO, setup_entry
from tests.common import snapshot_platform
@pytest.mark.parametrize(
("service_info", "raw"),
[
pytest.param(
AQUA_CONTOUR_SERVICE_INFO,
{
AquaContourPosition.position_name_1.uuid: b"Position 1\x00",
AquaContourPosition.position_name_2.uuid: b"Position 2\x00",
AquaContourPosition.position_name_3.uuid: b"Position 3\x00",
AquaContourPosition.position_name_4.uuid: b"Position 4\x00",
AquaContourPosition.position_name_5.uuid: b"Position 5\x00",
AquaContourContours.contour_name_1.uuid: b"Contour 1\x00",
AquaContourContours.contour_name_2.uuid: b"Contour 2\x00",
AquaContourContours.contour_name_3.uuid: b"Contour 3\x00",
AquaContourContours.contour_name_4.uuid: b"Contour 4\x00",
AquaContourContours.contour_name_5.uuid: b"Contour 5\x00",
},
id="aqua_contour",
),
],
)
async def test_setup(
hass: HomeAssistant,
mock_read_char_raw: dict[str, bytes],
service_info: BluetoothServiceInfo,
raw: dict[str, bytes],
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test text entities."""
mock_read_char_raw.update(raw)
entry = await setup_entry(
hass, platforms=[Platform.TEXT], service_info=service_info
)
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)
async def test_text_set_value(
hass: HomeAssistant,
mock_read_char_raw: dict[str, bytes],
mock_client: Mock,
) -> None:
"""Test setting text value."""
mock_read_char_raw[AquaContourPosition.position_name_1.uuid] = b"Position 1\x00"
await setup_entry(
hass, platforms=[Platform.TEXT], service_info=AQUA_CONTOUR_SERVICE_INFO
)
await hass.services.async_call(
TEXT_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: "text.mock_title_position_1",
ATTR_VALUE: "New Position Name",
},
blocking=True,
)
assert len(mock_client.write_char.mock_calls) == 1
args = mock_client.write_char.mock_calls[0].args
assert args[0] == AquaContourPosition.position_name_1
assert args[1] == "New Position Name"

View File

@@ -1847,6 +1847,7 @@
'°',
'°C',
'°F',
'μA',
'μS/cm',
'μV',
'μg',
@@ -2191,6 +2192,7 @@
'°',
'°C',
'°F',
'μA',
'μS/cm',
'μV',
'μg',

View File

@@ -117,7 +117,7 @@ async def test_setting_level(hass: HomeAssistant) -> None:
)
await hass.async_block_till_done()
assert len(mocks) == 4
assert len(mocks) == 5
assert len(mocks[""].orig_setLevel.mock_calls) == 1
assert mocks[""].orig_setLevel.mock_calls[0][1][0] == LOGSEVERITY["WARNING"]
@@ -134,6 +134,8 @@ async def test_setting_level(hass: HomeAssistant) -> None:
== LOGSEVERITY["WARNING"]
)
assert len(mocks["homeassistant.components.logger"].orig_setLevel.mock_calls) == 0
# Test set default level
with patch("logging.getLogger", mocks.__getitem__):
await hass.services.async_call(
@@ -150,7 +152,7 @@ async def test_setting_level(hass: HomeAssistant) -> None:
{"test.child": "info", "new_logger": "notset"},
blocking=True,
)
assert len(mocks) == 5
assert len(mocks) == 6
assert len(mocks["test.child"].orig_setLevel.mock_calls) == 2
assert mocks["test.child"].orig_setLevel.mock_calls[1][1][0] == LOGSEVERITY["INFO"]

View File

@@ -44,6 +44,8 @@ from tests.typing import ClientSessionGenerator
_LOGGER = logging.getLogger(__name__)
TEST_ENTITY = "light.kitchen"
SNAPSHOT_RESOURCE_URI = "homeassistant://assist/context-snapshot"
TEST_LLM_API_ID = "test-api"
INITIALIZE_MESSAGE = {
"jsonrpc": "2.0",
"id": "request-id-1",
@@ -66,6 +68,21 @@ EXPECTED_PROMPT_SUFFIX = """
"""
class MockLLMAPI(llm.API):
"""Test LLM API that does not expose any tools."""
async def async_get_api_instance(
self, llm_context: llm.LLMContext
) -> llm.APIInstance:
"""Return a test API instance."""
return llm.APIInstance(
api=self,
api_prompt="Test prompt",
llm_context=llm_context,
tools=[],
)
@pytest.fixture
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the config entry."""
@@ -481,6 +498,104 @@ async def test_get_unknown_prompt(
await session.get_prompt(name="Unknown")
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API])
async def test_mcp_resources_list(
hass: HomeAssistant,
setup_integration: None,
mcp_url: str,
mcp_client: Any,
hass_supervisor_access_token: str,
) -> None:
"""Test the resource list endpoint."""
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
result = await session.list_resources()
assert len(result.resources) == 1
resource = result.resources[0]
assert str(resource.uri) == SNAPSHOT_RESOURCE_URI
assert resource.name == "assist_context_snapshot"
assert resource.title == "Assist context snapshot"
assert resource.description is not None
assert resource.mimeType == "text/plain"
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API])
async def test_mcp_resource_read(
hass: HomeAssistant,
setup_integration: None,
mcp_url: str,
mcp_client: Any,
hass_supervisor_access_token: str,
) -> None:
"""Test reading an MCP resource."""
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
resources = await session.list_resources()
resource = resources.resources[0]
result = await session.read_resource(resource.uri)
assert len(result.contents) == 1
content = result.contents[0]
assert content.uri == resource.uri
assert content.mimeType == "text/plain"
assert content.text == (
"Live Context: An overview of the areas and the devices in this smart home:\n"
"- names: Kitchen Light\n"
" domain: light\n"
" state: 'off'\n"
" areas: Kitchen\n"
)
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST, STATELESS_LLM_API])
async def test_mcp_resource_read_unknown_resource(
hass: HomeAssistant,
setup_integration: None,
mcp_url: str,
mcp_client: Any,
hass_supervisor_access_token: str,
) -> None:
"""Test reading an unknown MCP resource."""
unknown_uri = mcp.types.Resource(
uri="homeassistant://assist/missing",
name="missing",
).uri
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
with pytest.raises(McpError, match="Unknown resource"):
await session.read_resource(unknown_uri)
@pytest.mark.parametrize("llm_hass_api", [TEST_LLM_API_ID])
async def test_mcp_resources_unavailable_without_live_context_tool(
hass: HomeAssistant,
setup_integration: None,
mcp_url: str,
mcp_client: Any,
hass_supervisor_access_token: str,
) -> None:
"""Test resources are unavailable when the selected API exposes no live context."""
llm.async_register_api(
hass, MockLLMAPI(hass=hass, id=TEST_LLM_API_ID, name="Test API")
)
resource_uri = mcp.types.Resource(
uri=SNAPSHOT_RESOURCE_URI,
name="assist_context_snapshot",
).uri
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
result = await session.list_resources()
assert result.resources == []
with pytest.raises(McpError, match="Unknown resource"):
await session.read_resource(resource_uri)
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST])
async def test_mcp_tool_call_unicode(
hass: HomeAssistant,

View File

@@ -26,6 +26,131 @@
"volume_level": 20,
"volume_muted": false,
"group_members": [],
"options": [
{
"key": "treble",
"name": "Treble",
"type": "integer",
"translation_key": "player_options.treble",
"translation_params": null,
"value": -6,
"read_only": false,
"min_value": -10,
"max_value": 10,
"step": 1,
"options": null
},
{
"key": "bass",
"name": "Bass",
"type": "float",
"translation_key": "player_options.bass",
"translation_params": null,
"value": -6.0,
"read_only": false,
"min_value": -10.0,
"max_value": 10.0,
"step": 1.0,
"options": null
},
{
"key": "treble_ro",
"name": "Treble RO",
"type": "integer",
"translation_key": "player_options.treble",
"translation_params": null,
"value": -6,
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "enhancer",
"name": "Enhancer",
"type": "boolean",
"translation_key": "player_options.enhancer",
"translation_params": null,
"value": false,
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "enhancer_ro",
"name": "Enhancer RO",
"type": "boolean",
"translation_key": "player_options.enhancer",
"translation_params": null,
"value": false,
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "network_name",
"name": "Network Name",
"type": "string",
"translation_key": "player_options.network_name",
"translation_params": null,
"value": "receiver",
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "network_name_ro",
"name": "Network Name RO",
"type": "string",
"translation_key": "player_options.network_name",
"translation_params": null,
"value": "receiver ro",
"read_only": true,
"min_value": null,
"max_value": null,
"step": null,
"options": null
},
{
"key": "link_audio_delay",
"name": "Link Audio Delay",
"type": "string",
"translation_key": "player_options.link_audio_delay",
"translation_params": null,
"value": "lip_sync",
"read_only": false,
"min_value": null,
"max_value": null,
"step": null,
"options": [
{
"key": "audio_sync",
"name": "audio_sync",
"type": "string",
"value": "audio_sync"
},
{
"key": "balanced",
"name": "balanced",
"type": "string",
"value": "balanced"
},
{
"key": "lip_sync",
"name": "lip_sync",
"type": "string",
"value": "lip_sync"
}
]
}
],
"active_source": "00:00:00:00:00:01",
"active_group": null,
"current_media": null,

View File

@@ -0,0 +1,119 @@
# serializer version: 1
# name: test_number_entities[number.test_player_1_bass-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 10.0,
'min': -10.0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.test_player_1_bass',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bass',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bass',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bass',
'unique_id': '00:00:00:00:00:01_bass',
'unit_of_measurement': None,
})
# ---
# name: test_number_entities[number.test_player_1_bass-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Player 1 Bass',
'max': 10.0,
'min': -10.0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1.0,
}),
'context': <ANY>,
'entity_id': 'number.test_player_1_bass',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-6.0',
})
# ---
# name: test_number_entities[number.test_player_1_treble-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
'max': 10,
'min': -10,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.test_player_1_treble',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Treble',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Treble',
'platform': 'music_assistant',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'treble',
'unique_id': '00:00:00:00:00:01_treble',
'unit_of_measurement': None,
})
# ---
# name: test_number_entities[number.test_player_1_treble-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test Player 1 Treble',
'max': 10,
'min': -10,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'context': <ANY>,
'entity_id': 'number.test_player_1_treble',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-6',
})
# ---

View File

@@ -0,0 +1,153 @@
"""Test Music Assistant number entities."""
from unittest.mock import MagicMock, call
from music_assistant_models.enums import EventType
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.music_assistant.const import DOMAIN
from homeassistant.components.music_assistant.number import (
PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER,
)
from homeassistant.components.number import (
ATTR_VALUE,
DOMAIN as NUMBER_DOMAIN,
SERVICE_SET_VALUE,
)
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.translation import LOCALE_EN, async_get_translations
from .common import (
setup_integration_from_fixtures,
snapshot_music_assistant_entities,
trigger_subscription_callback,
)
async def test_number_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
music_assistant_client: MagicMock,
) -> None:
"""Test number entities."""
await setup_integration_from_fixtures(hass, music_assistant_client)
snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.NUMBER)
async def test_number_set_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test number set action."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "treble"
entity_id = "number.test_player_1_treble"
option_value = 3
await setup_integration_from_fixtures(hass, music_assistant_client)
state = hass.states.get(entity_id)
assert state
# test within range
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: option_value,
},
blocking=True,
)
assert music_assistant_client.send_command.call_count == 1
assert music_assistant_client.send_command.call_args == call(
"players/cmd/set_option",
player_id=mass_player_id,
option_key=mass_option_key,
option_value=option_value,
)
# test out of range
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: 20,
},
blocking=True,
)
async def test_external_update(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test external value update."""
mass_player_id = "00:00:00:00:00:01"
mass_option_key = "treble"
entity_id = "number.test_player_1_treble"
await setup_integration_from_fixtures(hass, music_assistant_client)
# get current option and remove it
number_option = next(
option
for option in music_assistant_client.players._players[mass_player_id].options
if option.key == mass_option_key
)
music_assistant_client.players._players[mass_player_id].options.remove(
number_option
)
# set new value different from previous one
previous_value = number_option.value
new_value = 5
number_option.value = new_value
assert previous_value != number_option.value
music_assistant_client.players._players[mass_player_id].options.append(
number_option
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_OPTIONS_UPDATED, mass_player_id
)
state = hass.states.get(entity_id)
assert state
assert int(float(state.state)) == new_value
async def test_ignored(
hass: HomeAssistant,
music_assistant_client: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that non-compatible player options are ignored."""
config_entry = await setup_integration_from_fixtures(hass, music_assistant_client)
registry_entries = er.async_entries_for_config_entry(
entity_registry, config_entry_id=config_entry.entry_id
)
# we only have two non read-only player options, bass and treble
assert sum(1 for entry in registry_entries if entry.domain == NUMBER_DOMAIN) == 2
async def test_name_translation_availability(
hass: HomeAssistant,
) -> None:
"""Verify, that the list of available translation keys is reflected in strings.json."""
# verify, that PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER matches strings.json
translations = await async_get_translations(
hass, language=LOCALE_EN, category="entity", integrations=[DOMAIN]
)
prefix = f"component.{DOMAIN}.entity.{Platform.NUMBER.value}."
for translation_key in PLAYER_OPTIONS_TRANSLATION_KEYS_NUMBER:
assert translations.get(f"{prefix}{translation_key}.name") is not None, (
f"{translation_key} is missing in strings.json for platform number"
)

View File

@@ -14,7 +14,7 @@ RELAIS_DEVICE = {
"_id": "relais1",
"name": "Relais Device",
"model": "EWS",
"state": {"relayMode": 1, "targetMode": 2},
"state": {"deviceType": 0, "targetMode": 2},
"connected": True,
"program": {"data": {}},
}
@@ -23,7 +23,7 @@ PILOTE_DEVICE = {
"_id": "pilote1",
"name": "Pilote Device",
"model": "EWS",
"state": {"targetMode": 1},
"state": {"deviceType": 1, "targetMode": 1},
"connected": True,
"program": {"data": {}},
}

View File

@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.components import ai_task, media_source
from homeassistant.components.openai_conversation import DOMAIN
from homeassistant.components.openai_conversation.const import CONF_STORE_RESPONSES
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector
@@ -20,11 +21,13 @@ from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.parametrize("expected_store", [False, True])
async def test_generate_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
expected_store: bool,
) -> None:
"""Test AI Task data generation."""
entity_id = "ai_task.openai_ai_task"
@@ -38,6 +41,12 @@ async def test_generate_data(
if entry.subentry_type == "ai_task_data"
)
)
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={**ai_task_entry.data, CONF_STORE_RESPONSES: expected_store},
)
await hass.async_block_till_done()
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
@@ -55,6 +64,8 @@ async def test_generate_data(
)
assert result.data == "The test data"
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
@pytest.mark.usefixtures("mock_init_component")
@@ -212,6 +223,7 @@ async def test_generate_data_with_attachments(
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.freeze_time("2025-06-14 22:59:00")
@pytest.mark.parametrize("configured_store", [False, True])
@pytest.mark.parametrize(
"image_model", ["gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini"]
)
@@ -222,6 +234,7 @@ async def test_generate_image(
entity_registry: er.EntityRegistry,
issue_registry: ir.IssueRegistry,
image_model: str,
configured_store: bool,
) -> None:
"""Test AI Task image generation."""
entity_id = "ai_task.openai_ai_task"
@@ -238,7 +251,11 @@ async def test_generate_image(
hass.config_entries.async_update_subentry(
mock_config_entry,
ai_task_entry,
data={"image_model": image_model},
data={
**ai_task_entry.data,
"image_model": image_model,
CONF_STORE_RESPONSES: configured_store,
},
)
await hass.async_block_till_done()
assert entity_entry is not None
@@ -277,6 +294,8 @@ async def test_generate_image(
assert result["model"] == image_model
mock_upload_media.assert_called_once()
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is True
image_data = mock_upload_media.call_args[0][1]
assert image_data.file.getvalue() == b"A"
assert image_data.content_type == "image/png"

View File

@@ -21,6 +21,7 @@ from homeassistant.components.openai_conversation.const import (
CONF_REASONING_SUMMARY,
CONF_RECOMMENDED,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_TEMPERATURE,
CONF_TOP_P,
CONF_TTS_SPEED,
@@ -539,6 +540,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "o1-pro",
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_MAX_TOKENS: 10000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "high",
CONF_CODE_INTERPRETER: True,
},
@@ -575,6 +577,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_STORE_RESPONSES: False,
CONF_SERVICE_TIER: "auto",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
@@ -592,6 +595,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-4o",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: True,
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
CONF_WEB_SEARCH_USER_LOCATION: True,
@@ -613,6 +617,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-4o",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: True,
},
{
CONF_WEB_SEARCH: True,
@@ -630,6 +635,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-4o",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: True,
CONF_SERVICE_TIER: "default",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "low",
@@ -686,6 +692,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-5",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "minimal",
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
CONF_CODE_INTERPRETER: False,
@@ -799,6 +806,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "o3-mini",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_EFFORT: "low",
CONF_CODE_INTERPRETER: True,
},
@@ -844,6 +852,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-4o",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_SERVICE_TIER: "priority",
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "high",
@@ -896,6 +905,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_CHAT_MODEL: "gpt-5-pro",
CONF_TOP_P: 0.9,
CONF_MAX_TOKENS: 1000,
CONF_STORE_RESPONSES: False,
CONF_REASONING_SUMMARY: RECOMMENDED_REASONING_SUMMARY,
CONF_VERBOSITY: "medium",
CONF_WEB_SEARCH: True,
@@ -915,7 +925,11 @@ async def test_subentry_switching(
expected_options,
) -> None:
"""Test the subentry form."""
subentry = next(iter(mock_config_entry.subentries.values()))
subentry = next(
sub
for sub in mock_config_entry.subentries.values()
if sub.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry, subentry, data=current_options
)
@@ -952,11 +966,19 @@ async def test_subentry_switching(
assert subentry.data == expected_options
@pytest.mark.parametrize("store_responses", [False, True])
async def test_subentry_web_search_user_location(
hass: HomeAssistant, mock_config_entry, mock_init_component
hass: HomeAssistant,
mock_config_entry,
mock_init_component,
store_responses: bool,
) -> None:
"""Test fetching user location."""
subentry = next(iter(mock_config_entry.subentries.values()))
subentry = next(
sub
for sub in mock_config_entry.subentries.values()
if sub.subentry_type == "conversation"
)
subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow(
hass, subentry.subentry_id
)
@@ -982,6 +1004,7 @@ async def test_subentry_web_search_user_location(
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_STORE_RESPONSES: store_responses,
},
)
await hass.async_block_till_done()
@@ -1036,6 +1059,7 @@ async def test_subentry_web_search_user_location(
mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following"
" coordinates located: (37.7749, -122.4194)?"
)
assert mock_create.call_args.kwargs["store"] is store_responses
assert subentry_flow["type"] is FlowResultType.ABORT
assert subentry_flow["reason"] == "reconfigure_successful"
assert subentry.data == {
@@ -1046,6 +1070,7 @@ async def test_subentry_web_search_user_location(
CONF_TOP_P: RECOMMENDED_TOP_P,
CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS,
CONF_SERVICE_TIER: "auto",
CONF_STORE_RESPONSES: store_responses,
CONF_WEB_SEARCH: True,
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
CONF_WEB_SEARCH_USER_LOCATION: True,
@@ -1149,6 +1174,7 @@ async def test_creating_ai_task_subentry_advanced(
{
CONF_CHAT_MODEL: "gpt-4o",
CONF_MAX_TOKENS: 200,
CONF_STORE_RESPONSES: True,
CONF_TEMPERATURE: 0.5,
CONF_TOP_P: 0.9,
},
@@ -1172,6 +1198,7 @@ async def test_creating_ai_task_subentry_advanced(
CONF_CHAT_MODEL: "gpt-4o",
CONF_IMAGE_MODEL: "gpt-image-1.5",
CONF_MAX_TOKENS: 200,
CONF_STORE_RESPONSES: True,
CONF_TEMPERATURE: 0.5,
CONF_TOP_P: 0.9,
CONF_CODE_INTERPRETER: False,

View File

@@ -20,6 +20,7 @@ from homeassistant.components.homeassistant.exposed_entities import async_expose
from homeassistant.components.openai_conversation.const import (
CONF_CODE_INTERPRETER,
CONF_SERVICE_TIER,
CONF_STORE_RESPONSES,
CONF_WEB_SEARCH,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_CONTEXT_SIZE,
@@ -472,6 +473,46 @@ async def test_assist_api_tools_conversion(
assert tools
@pytest.mark.parametrize(
"expected_store",
[
False,
True,
],
)
async def test_store_responses_forwarded_for_conversation_agent(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
expected_store: bool,
) -> None:
"""Test store_responses is forwarded for the conversation agent."""
subentry = next(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
subentry,
data={**subentry.data, CONF_STORE_RESPONSES: expected_store},
)
await hass.config_entries.async_reload(mock_config_entry.entry_id)
mock_create_stream.return_value = [
create_message_item(id="msg_A", text="Hello!", output_index=0)
]
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert mock_create_stream.call_args is not None
assert mock_create_stream.call_args.kwargs["store"] is expected_store
@pytest.mark.parametrize("inline_citations", [True, False])
async def test_web_search(
hass: HomeAssistant,

View File

@@ -19,6 +19,7 @@ from syrupy.filters import props
from homeassistant.components.openai_conversation import CONF_CHAT_MODEL
from homeassistant.components.openai_conversation.const import (
CONF_STORE_RESPONSES,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
@@ -308,6 +309,7 @@ async def test_init_auth_error(
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
@pytest.mark.parametrize("store_responses", [False, True])
@pytest.mark.parametrize(
("service_data", "expected_args", "number_of_files"),
[
@@ -404,18 +406,31 @@ async def test_generate_content_service(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
store_responses: bool,
service_data,
expected_args,
number_of_files,
) -> None:
"""Test generate content service."""
conversation_subentry = next(
sub
for sub in mock_config_entry.subentries.values()
if sub.subentry_type == "conversation"
)
hass.config_entries.async_update_subentry(
mock_config_entry,
conversation_subentry,
data={**conversation_subentry.data, CONF_STORE_RESPONSES: store_responses},
)
await hass.async_block_till_done()
service_data["config_entry"] = mock_config_entry.entry_id
expected_args["model"] = "gpt-4o-mini"
expected_args["max_output_tokens"] = 3000
expected_args["top_p"] = 1.0
expected_args["temperature"] = 1.0
expected_args["user"] = None
expected_args["store"] = False
expected_args["store"] = store_responses
expected_args["input"][0]["type"] = "message"
expected_args["input"][0]["role"] = "user"

View File

@@ -10,6 +10,7 @@ from roborock.data import (
WaterLevelMapping,
ZeoProgram,
)
from roborock.data.b01_q10.b01_q10_code_mappings import B01_Q10_DP, YXCleanType
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockZeoProtocol
@@ -383,3 +384,98 @@ async def test_update_failure_zeo_invalid_option() -> None:
await entity.async_select_option("invalid_option")
coordinator.api.set_value.assert_not_called()
async def test_q10_cleaning_mode_select_current_option(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test Q10 cleaning mode select entity current option."""
entity_id = "select.roborock_q10_s5_cleaning_mode"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
options = state.attributes.get("options")
assert options is not None
assert set(options) == {"vac_and_mop", "vacuum", "mop"}
assert fake_q10_vacuum.b01_q10_properties
fake_q10_vacuum.b01_q10_properties.status.update_from_dps(
{B01_Q10_DP.CLEAN_MODE: YXCleanType.VAC_AND_MOP.code}
)
await hass.async_block_till_done()
updated_state = hass.states.get(entity_id)
assert updated_state is not None
assert updated_state.state == "vac_and_mop"
async def test_q10_cleaning_mode_select_update_success(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test setting Q10 cleaning mode select option."""
entity_id = "select.roborock_q10_s5_cleaning_mode"
assert hass.states.get(entity_id) is not None
# Test setting value
await hass.services.async_call(
"select",
SERVICE_SELECT_OPTION,
service_data={"option": "vac_and_mop"},
blocking=True,
target={"entity_id": entity_id},
)
assert fake_q10_vacuum.b01_q10_properties
assert fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.call_count == 1
fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_called_once_with(
YXCleanType.VAC_AND_MOP
)
async def test_q10_cleaning_mode_select_update_failure(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test failure when setting Q10 cleaning mode."""
assert fake_q10_vacuum.b01_q10_properties
fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.side_effect = (
RoborockException
)
entity_id = "select.roborock_q10_s5_cleaning_mode"
assert hass.states.get(entity_id) is not None
with pytest.raises(HomeAssistantError, match="cleaning_mode"):
await hass.services.async_call(
"select",
SERVICE_SELECT_OPTION,
service_data={"option": "vac_and_mop"},
blocking=True,
target={"entity_id": entity_id},
)
async def test_q10_cleaning_mode_select_invalid_option(
hass: HomeAssistant,
setup_entry: MockConfigEntry,
fake_q10_vacuum: FakeDevice,
) -> None:
"""Test that an invalid option raises ServiceValidationError and does not call set_clean_mode."""
entity_id = "select.roborock_q10_s5_cleaning_mode"
assert hass.states.get(entity_id) is not None
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
"select",
SERVICE_SELECT_OPTION,
service_data={"option": "invalid_option"},
blocking=True,
target={"entity_id": entity_id},
)
assert fake_q10_vacuum.b01_q10_properties
fake_q10_vacuum.b01_q10_properties.vacuum.set_clean_mode.assert_not_called()

View File

@@ -6,12 +6,21 @@ from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.bluetooth import BluetoothServiceInfoBleak
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from . import ART_FRAME_INFO, DOMAIN, WOMETERTHPC_SERVICE_INFO
from . import (
AIR_PURIFIER_JP_SERVICE_INFO,
AIR_PURIFIER_TABLE_JP_SERVICE_INFO,
AIR_PURIFIER_TABLE_US_SERVICE_INFO,
AIR_PURIFIER_US_SERVICE_INFO,
ART_FRAME_INFO,
DOMAIN,
WOMETERTHPC_SERVICE_INFO,
)
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@@ -167,3 +176,45 @@ async def test_meter_pro_co2_sync_datetime_button_with_timezone(
utc_offset_hours=expected_utc_offset_hours,
utc_offset_minutes=expected_utc_offset_minutes,
)
@pytest.mark.parametrize(
("service_info", "sensor_type"),
[
(AIR_PURIFIER_JP_SERVICE_INFO, "air_purifier_jp"),
(AIR_PURIFIER_TABLE_JP_SERVICE_INFO, "air_purifier_table_jp"),
(AIR_PURIFIER_US_SERVICE_INFO, "air_purifier_us"),
(AIR_PURIFIER_TABLE_US_SERVICE_INFO, "air_purifier_table_us"),
],
)
async def test_air_purifier_buttons(
hass: HomeAssistant,
mock_entry_encrypted_factory: Callable[[str], MockConfigEntry],
service_info: BluetoothServiceInfoBleak,
sensor_type: str,
) -> None:
"""Test pressing the air purifier buttons."""
inject_bluetooth_service_info(hass, service_info)
entry = mock_entry_encrypted_factory(sensor_type)
entry.add_to_hass(hass)
entity_id = "button.test_name_light_sensor"
mock_instance = AsyncMock(return_value=True)
with patch.multiple(
"homeassistant.components.switchbot.button.switchbot.SwitchbotAirPurifier",
update=AsyncMock(return_value=None),
get_basic_info=AsyncMock(return_value=None),
open_light_sensitive_switch=mock_instance,
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_instance.assert_awaited_once()

View File

@@ -248,10 +248,7 @@ async def test_switch_unavailable(
event = {
"tracker_id": "device_id_123",
"buzzer_control": {"active": True},
"led_control": {"active": False},
"live_tracking": {"active": True},
"tracker_state_reason": "POWER_SAVING",
"hardware": {"power_saving_zone_id": "zone_id_123"},
}
mock_tractive_client.send_switch_event(mock_config_entry, event)
await hass.async_block_till_done()
@@ -260,7 +257,9 @@ async def test_switch_unavailable(
assert state
assert state.state == STATE_UNAVAILABLE
mock_tractive_client.send_switch_event(mock_config_entry)
event["hardware"]["power_saving_zone_id"] = None
mock_tractive_client.send_switch_event(mock_config_entry, event)
await hass.async_block_till_done()
state = hass.states.get(entity_id)

View File

@@ -15,6 +15,8 @@ from homeassistant.helpers.check_config import (
HomeAssistantConfig,
async_check_ha_config_file,
)
from homeassistant.helpers.condition import CONDITIONS
from homeassistant.helpers.trigger import TRIGGERS
from homeassistant.requirements import RequirementsNotFound
from tests.common import (
@@ -493,6 +495,11 @@ async def test_missing_included_file(hass: HomeAssistant) -> None:
async def test_automation_config_platform(hass: HomeAssistant) -> None:
"""Test automation async config."""
# Remove keys pre-populated by the test fixture to simulate
# the check_config script which doesn't run bootstrap.
del hass.data[TRIGGERS]
del hass.data[CONDITIONS]
files = {
YAML_CONFIG_FILE: BASE_CONFIG
+ """
@@ -514,6 +521,9 @@ blueprint:
trigger:
platform: event
event_type: !input trigger_event
condition:
condition: template
value_template: "{{ true }}"
action:
service: !input service_to_call
""",

View File

@@ -2,10 +2,13 @@
from collections.abc import Mapping
from contextlib import AbstractContextManager, nullcontext as does_not_raise
import datetime
import io
import logging
from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from pytest_unordered import unordered
import voluptuous as vol
@@ -19,9 +22,12 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
CONF_ENTITY_ID,
CONF_FOR,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
@@ -41,6 +47,10 @@ from homeassistant.helpers.automation import (
move_top_level_schema_fields_to_options,
)
from homeassistant.helpers.trigger import (
ATTR_BEHAVIOR,
BEHAVIOR_ANY,
BEHAVIOR_FIRST,
BEHAVIOR_LAST,
DATA_PLUGGABLE_ACTIONS,
TRIGGERS,
EntityNumericalStateChangedTriggerWithUnitBase,
@@ -65,7 +75,13 @@ from homeassistant.setup import async_setup_component
from homeassistant.util.unit_conversion import TemperatureConverter
from homeassistant.util.yaml.loader import parse_yaml
from tests.common import MockModule, MockPlatform, mock_integration, mock_platform
from tests.common import (
MockModule,
MockPlatform,
async_fire_time_changed,
mock_integration,
mock_platform,
)
from tests.typing import WebSocketGenerator
@@ -3080,3 +3096,528 @@ async def test_make_entity_origin_state_trigger(
# To-state still matches from_state — not valid
assert not trig.is_valid_state(from_state)
class _OnOffTrigger(EntityTriggerBase):
"""Test trigger that fires when state becomes 'on'."""
_domain_specs = {"test": DomainSpec()}
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Valid if transitioning from a non-'on' state."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
return from_state.state != STATE_ON
def is_valid_state(self, state: State) -> bool:
"""Valid if the state is 'on'."""
return state.state == STATE_ON
async def _arm_on_off_trigger(
hass: HomeAssistant,
entity_ids: list[str],
behavior: str,
calls: list[dict[str, Any]],
duration: dict[str, int] | None = None,
) -> CALLBACK_TYPE:
"""Set up _OnOffTrigger via async_initialize_triggers."""
async def async_get_triggers(
hass: HomeAssistant,
) -> dict[str, type[Trigger]]:
return {"on_off": _OnOffTrigger}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
options: dict[str, Any] = {ATTR_BEHAVIOR: behavior}
if duration is not None:
options[CONF_FOR] = duration
trigger_config = {
CONF_PLATFORM: "test.on_off",
CONF_TARGET: {CONF_ENTITY_ID: entity_ids},
CONF_OPTIONS: options,
}
log = logging.getLogger(__name__)
@callback
def action(run_variables: dict[str, Any], context: Context | None = None) -> None:
calls.append(run_variables["trigger"])
validated_config = await async_validate_trigger_config(hass, [trigger_config])
return await async_initialize_triggers(
hass,
validated_config,
action,
domain="test",
name="test_on_off",
log_cb=log.log,
)
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
async def test_entity_trigger_no_duration(hass: HomeAssistant, behavior: str) -> None:
"""Test EntityTriggerBase fires immediately without duration."""
entity_id = "test.entity_1"
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(hass, [entity_id], behavior, calls)
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == entity_id
# Transition back and trigger again
calls.clear()
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
async def test_entity_trigger_with_duration(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, behavior: str
) -> None:
"""Test EntityTriggerBase waits for duration before firing."""
entity_id = "test.entity_1"
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_id], behavior, calls, duration={"seconds": 5}
)
# Turn on — should NOT fire immediately
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance time past duration — should fire
freezer.tick(datetime.timedelta(seconds=6))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == entity_id
unsub()
@pytest.mark.parametrize("behavior", [BEHAVIOR_ANY, BEHAVIOR_FIRST, BEHAVIOR_LAST])
async def test_entity_trigger_duration_cancelled_on_state_change(
hass: HomeAssistant, freezer: FrozenDateTimeFactory, behavior: str
) -> None:
"""Test that the duration timer is cancelled if state changes back."""
entity_id = "test.entity_1"
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_id], behavior, calls, duration={"seconds": 5}
)
# Turn on, then back off before duration expires
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
# Advance past the original duration — should NOT fire
freezer.tick(datetime.timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
unsub()
async def test_entity_trigger_duration_any_independent(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior any tracks per-entity durations independently."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_ANY, calls, duration={"seconds": 5}
)
# Turn A on
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 0
# Turn B on 2 seconds later
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 0
# After 5s from A's turn-on, A should fire
freezer.tick(datetime.timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == entity_a
# After 5s from B's turn-on (2 more seconds), B should fire
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1]["entity_id"] == entity_b
unsub()
async def test_entity_trigger_duration_any_entity_off_cancels_only_that_entity(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior any: turning off one entity doesn't cancel the other's timer."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_ANY, calls, duration={"seconds": 5}
)
# Turn both on
hass.states.async_set(entity_a, STATE_ON)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# Turn A off after 2 seconds — cancels A's timer but not B's
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_a, STATE_OFF)
await hass.async_block_till_done()
# After 5s total, B should fire but A should not
freezer.tick(datetime.timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
assert calls[0]["entity_id"] == entity_b
unsub()
async def test_entity_trigger_duration_last_requires_all(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior last: trigger fires only when ALL entities are on for duration."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
)
# Turn only A on — should not start timer (not all match)
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
freezer.tick(datetime.timedelta(seconds=6))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# Turn B on — now all match, timer starts
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
freezer.tick(datetime.timedelta(seconds=6))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
async def test_entity_trigger_duration_last_cancelled_when_one_turns_off(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior last: timer is cancelled when one entity turns off."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
)
# Turn both on
hass.states.async_set(entity_a, STATE_ON)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# Turn A off after 2 seconds
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_a, STATE_OFF)
await hass.async_block_till_done()
# Advance past original duration — should NOT fire
freezer.tick(datetime.timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
unsub()
async def test_entity_trigger_duration_last_timer_reset(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior last: timer resets when combined state goes off and back on."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_LAST, calls, duration={"seconds": 5}
)
# Turn both on — combined state "all on", timer starts
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# After 2 seconds, B turns off — combined state breaks, timer cancelled
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
# B turns back on — combined state restored, timer restarts
freezer.tick(datetime.timedelta(seconds=1))
async_fire_time_changed(hass)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# 4 seconds after restart (not enough) — should NOT fire
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# 1 more second (5 total from restart) — should fire
freezer.tick(datetime.timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
async def test_entity_trigger_duration_first_fires_when_any_on(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior first: trigger fires when first entity turns on for duration."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
)
# Turn A on — combined state goes to "at least one on", timer starts
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 0
# Advance past duration — should fire
freezer.tick(datetime.timedelta(seconds=6))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
async def test_entity_trigger_duration_first_not_cancelled_by_second(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior first: second entity turning on doesn't restart timer."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
)
# Turn A on
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
# Turn B on 3 seconds later — combined state was already "any on",
# so this should NOT restart the timer
freezer.tick(datetime.timedelta(seconds=3))
async_fire_time_changed(hass)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
assert len(calls) == 0
# 2 more seconds (5 total from A) — should fire
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
async def test_entity_trigger_duration_first_not_cancelled_by_partial_off(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior first: one entity off doesn't cancel if another is still on."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
)
# Turn both on
hass.states.async_set(entity_a, STATE_ON)
await hass.async_block_till_done()
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# Turn A off after 2 seconds — combined state still "any on" (B is on)
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_a, STATE_OFF)
await hass.async_block_till_done()
# Advance past duration — should still fire (combined state never went to "none on")
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()
async def test_entity_trigger_duration_first_cancelled_when_all_off(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior first: timer cancelled when ALL entities turn off."""
entity_a = "test.entity_a"
entity_b = "test.entity_b"
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_a, entity_b], BEHAVIOR_FIRST, calls, duration={"seconds": 5}
)
# Turn both on
hass.states.async_set(entity_a, STATE_ON)
hass.states.async_set(entity_b, STATE_ON)
await hass.async_block_till_done()
# Turn both off after 2 seconds — combined state goes to "none on"
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
hass.states.async_set(entity_a, STATE_OFF)
hass.states.async_set(entity_b, STATE_OFF)
await hass.async_block_till_done()
# Advance past original duration — should NOT fire
freezer.tick(datetime.timedelta(seconds=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
unsub()
async def test_entity_trigger_duration_any_retrigger_resets_timer(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test behavior any: turning an entity off and on resets its timer."""
entity_id = "test.entity_1"
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
calls: list[dict[str, Any]] = []
unsub = await _arm_on_off_trigger(
hass, [entity_id], BEHAVIOR_ANY, calls, duration={"seconds": 5}
)
# Turn on
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
# After 3 seconds, turn off and on again — resets the timer
freezer.tick(datetime.timedelta(seconds=3))
async_fire_time_changed(hass)
hass.states.async_set(entity_id, STATE_OFF)
await hass.async_block_till_done()
hass.states.async_set(entity_id, STATE_ON)
await hass.async_block_till_done()
# 3 more seconds (6 from start, but only 3 from retrigger) — should NOT fire
freezer.tick(datetime.timedelta(seconds=3))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 0
# 2 more seconds (5 from retrigger) — should fire
freezer.tick(datetime.timedelta(seconds=2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert len(calls) == 1
unsub()

View File

@@ -628,7 +628,11 @@ _CONVERTED_VALUE: dict[
],
ElectricCurrentConverter: [
(5, UnitOfElectricCurrent.AMPERE, 5000, UnitOfElectricCurrent.MILLIAMPERE),
(5, UnitOfElectricCurrent.MILLIAMPERE, 0.005, UnitOfElectricCurrent.AMPERE),
(5, UnitOfElectricCurrent.AMPERE, 5e6, UnitOfElectricCurrent.MICROAMPERE),
(5, UnitOfElectricCurrent.MILLIAMPERE, 5e-3, UnitOfElectricCurrent.AMPERE),
(5, UnitOfElectricCurrent.MILLIAMPERE, 5e3, UnitOfElectricCurrent.MICROAMPERE),
(5, UnitOfElectricCurrent.MICROAMPERE, 5e-6, UnitOfElectricCurrent.AMPERE),
(5, UnitOfElectricCurrent.MICROAMPERE, 5e-3, UnitOfElectricCurrent.MILLIAMPERE),
],
ElectricPotentialConverter: [
(5, UnitOfElectricPotential.VOLT, 5000, UnitOfElectricPotential.MILLIVOLT),