mirror of
https://github.com/home-assistant/core.git
synced 2026-04-13 05:06:12 +02:00
Compare commits
25 Commits
drop-ignor
...
trigger_ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
028f1aece5 | ||
|
|
a141fb195a | ||
|
|
459f247620 | ||
|
|
f4f202a8a1 | ||
|
|
c30ccf3750 | ||
|
|
0b8390cf21 | ||
|
|
1a048a7845 | ||
|
|
08097c67eb | ||
|
|
550e53d192 | ||
|
|
09ee76c265 | ||
|
|
f7b2f5e8f1 | ||
|
|
a1414717ad | ||
|
|
2f0889ac02 | ||
|
|
323b3a4d96 | ||
|
|
8aa0e9f6c3 | ||
|
|
906475249c | ||
|
|
354b5860bb | ||
|
|
74957969f7 | ||
|
|
b52ce22ee7 | ||
|
|
920ffdb9b5 | ||
|
|
4a454dff02 | ||
|
|
481eb66bc5 | ||
|
|
b76627a442 | ||
|
|
1aa214fb61 | ||
|
|
6e30de3a1c |
4
.github/workflows/builder.yml
vendored
4
.github/workflows/builder.yml
vendored
@@ -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]
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -38,6 +38,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
Platform.VALVE,
|
||||
]
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
12
homeassistant/components/gardena_bluetooth/icons.json
Normal file
12
homeassistant/components/gardena_bluetooth/icons.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"text": {
|
||||
"contour_name": {
|
||||
"default": "mdi:vector-polygon"
|
||||
},
|
||||
"position_name": {
|
||||
"default": "mdi:map-marker-radius"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,14 @@
|
||||
"state": {
|
||||
"name": "[%key:common::state::open%]"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"contour_name": {
|
||||
"name": "Contour {number}"
|
||||
},
|
||||
"position_name": {
|
||||
"name": "Position {number}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
homeassistant/components/gardena_bluetooth/text.py
Normal file
88
homeassistant/components/gardena_bluetooth/text.py
Normal 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)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
detected:
|
||||
fields: *trigger_common_fields
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."""
|
||||
|
||||
127
homeassistant/components/music_assistant/number.py
Normal file
127
homeassistant/components/music_assistant/number.py
Normal 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
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyportainer==1.0.37"]
|
||||
"requirements": ["pyportainer==1.0.38"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"light_sensor": {
|
||||
"default": "mdi:brightness-auto"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"climate": {
|
||||
"state_attributes": {
|
||||
|
||||
@@ -102,6 +102,9 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"light_sensor": {
|
||||
"name": "Light sensor"
|
||||
},
|
||||
"next_image": {
|
||||
"name": "Next image"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["holidays"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["holidays==0.93"]
|
||||
"requirements": ["holidays==0.94"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["zcc-helper==3.7"]
|
||||
"requirements": ["zcc-helper==3.8"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -523,6 +523,7 @@ class UnitOfEnergyDistance(StrEnum):
|
||||
class UnitOfElectricCurrent(StrEnum):
|
||||
"""Electric current units."""
|
||||
|
||||
MICROAMPERE = "μA"
|
||||
MILLIAMPERE = "mA"
|
||||
AMPERE = "A"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
2
requirements.txt
generated
@@ -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
10
requirements_all.txt
generated
@@ -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
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'temperature': 1.0,
|
||||
'thinking': dict({
|
||||
'type': 'disabled',
|
||||
'budget_tokens': 1024,
|
||||
'type': 'enabled',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
591
tests/components/gardena_bluetooth/snapshots/test_text.ambr
Normal file
591
tests/components/gardena_bluetooth/snapshots/test_text.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
87
tests/components/gardena_bluetooth/test_text.py
Normal file
87
tests/components/gardena_bluetooth/test_text.py
Normal 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"
|
||||
@@ -1847,6 +1847,7 @@
|
||||
'°',
|
||||
'°C',
|
||||
'°F',
|
||||
'μA',
|
||||
'μS/cm',
|
||||
'μV',
|
||||
'μg',
|
||||
@@ -2191,6 +2192,7 @@
|
||||
'°',
|
||||
'°C',
|
||||
'°F',
|
||||
'μA',
|
||||
'μS/cm',
|
||||
'μV',
|
||||
'μg',
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
119
tests/components/music_assistant/snapshots/test_number.ambr
Normal file
119
tests/components/music_assistant/snapshots/test_number.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
153
tests/components/music_assistant/test_number.py
Normal file
153
tests/components/music_assistant/test_number.py
Normal 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"
|
||||
)
|
||||
@@ -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": {}},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
""",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user