mirror of
https://github.com/home-assistant/core.git
synced 2026-05-26 04:01:46 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f119b6dd2e | |||
| eafeba792d | |||
| c9318b6fbf | |||
| 99be382abf | |||
| 7cfcfca210 | |||
| f29daccb19 | |||
| be869fce6c | |||
| 7bb0414a39 | |||
| 3f8807d063 | |||
| 67642e6246 | |||
| 0d215597f3 | |||
| f41bd2b582 | |||
| 5c9ec1911b | |||
| 1a0b7fe984 | |||
| 26ee25d7bb | |||
| aabf52d3cf | |||
| 99fcb46a7e | |||
| 6580c5e5bf | |||
| 63e7d4dc08 | |||
| cc6900d846 | |||
| ca2ad22884 | |||
| 40944f0f2d | |||
| 91a3e488b1 | |||
| 9a1f517e6e | |||
| c82c614bb9 | |||
| 20914dce67 | |||
| 5fc407d2f3 | |||
| c7444d38a1 | |||
| 81f6136bda | |||
| 862d0ea49e | |||
| f2fdfed241 | |||
| 15640049cb | |||
| 5c163434f8 | |||
| e54c2ea55e | |||
| 1ec42693ab | |||
| 672864ae4f | |||
| e54d7e42cb | |||
| 5d63fce015 | |||
| 190fe10eed | |||
| ef410c1e2a | |||
| 5a712398e7 |
@@ -310,7 +310,7 @@ jobs:
|
||||
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
|
||||
- name: Restore base Python virtual environment
|
||||
id: cache-venv
|
||||
uses: &actions-cache actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: &actions-cache actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: venv
|
||||
key: &key-python-venv >-
|
||||
@@ -374,7 +374,7 @@ jobs:
|
||||
fi
|
||||
- name: Save apt cache
|
||||
if: steps.cache-apt-check.outputs.cache-hit != 'true'
|
||||
uses: &actions-cache-save actions/cache/save@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: &actions-cache-save actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
key: *key-apt-cache
|
||||
@@ -425,7 +425,7 @@ jobs:
|
||||
steps:
|
||||
- &cache-restore-apt
|
||||
name: Restore apt cache
|
||||
uses: &actions-cache-restore actions/cache/restore@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
|
||||
uses: &actions-cache-restore actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: *path-apt-cache
|
||||
fail-on-cache-miss: true
|
||||
|
||||
@@ -52,6 +52,9 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
|
||||
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
|
||||
# Claude Code native install
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
WORKDIR /workspaces
|
||||
|
||||
# Set the default shell to bash instead of sh
|
||||
|
||||
@@ -600,6 +600,16 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
|
||||
# System prompt with caching enabled
|
||||
system_prompt: list[TextBlockParam] = [
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=system.content,
|
||||
cache_control={"type": "ephemeral"},
|
||||
)
|
||||
]
|
||||
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
@@ -608,7 +618,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
system=system.content,
|
||||
system=system_prompt,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
@@ -695,10 +705,6 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
type="auto",
|
||||
)
|
||||
|
||||
if isinstance(model_args["system"], str):
|
||||
model_args["system"] = [
|
||||
TextBlockParam(type="text", text=model_args["system"])
|
||||
]
|
||||
model_args["system"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
|
||||
@@ -50,11 +50,11 @@
|
||||
"selector": {},
|
||||
"services": {
|
||||
"disable_motion_detection": {
|
||||
"description": "Disables the motion detection.",
|
||||
"description": "Disables the motion detection of a camera.",
|
||||
"name": "Disable motion detection"
|
||||
},
|
||||
"enable_motion_detection": {
|
||||
"description": "Enables the motion detection.",
|
||||
"description": "Enables the motion detection of a camera.",
|
||||
"name": "Enable motion detection"
|
||||
},
|
||||
"play_stream": {
|
||||
@@ -100,11 +100,11 @@
|
||||
"name": "Take snapshot"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off the camera.",
|
||||
"description": "Turns off a camera.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the camera.",
|
||||
"description": "Turns on a camera.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -58,12 +58,13 @@ C4_TO_HA_HVAC_MODE = {
|
||||
|
||||
HA_TO_C4_HVAC_MODE = {v: k for k, v in C4_TO_HA_HVAC_MODE.items()}
|
||||
|
||||
# Map Control4 HVAC state to Home Assistant HVAC action
|
||||
# Map the five known Control4 HVAC states to Home Assistant HVAC actions
|
||||
C4_TO_HA_HVAC_ACTION = {
|
||||
"heating": HVACAction.HEATING,
|
||||
"cooling": HVACAction.COOLING,
|
||||
"idle": HVACAction.IDLE,
|
||||
"off": HVACAction.OFF,
|
||||
"heat": HVACAction.HEATING,
|
||||
"cool": HVACAction.COOLING,
|
||||
"dry": HVACAction.DRYING,
|
||||
"fan": HVACAction.FAN,
|
||||
}
|
||||
|
||||
|
||||
@@ -236,7 +237,10 @@ class Control4Climate(Control4Entity, ClimateEntity):
|
||||
if c4_state is None:
|
||||
return None
|
||||
# Convert state to lowercase for mapping
|
||||
return C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
action = C4_TO_HA_HVAC_ACTION.get(str(c4_state).lower())
|
||||
if action is None:
|
||||
_LOGGER.debug("Unknown HVAC state received from Control4: %s", c4_state)
|
||||
return action
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
|
||||
@@ -335,20 +335,18 @@ def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str,
|
||||
"""Return config intents."""
|
||||
intents = config.get(DOMAIN, {}).get("intents", {})
|
||||
return {
|
||||
"intents": {
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in intents.items()
|
||||
intent_name: {
|
||||
"data": [
|
||||
{
|
||||
"sentences": sentences,
|
||||
"metadata": {
|
||||
METADATA_CUSTOM_SENTENCE: True,
|
||||
METADATA_CUSTOM_FILE: hass_config_path,
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
for intent_name, sentences in intents.items()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import dataclasses
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -18,7 +19,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT
|
||||
from .const import DATA_COMPONENT, HOME_ASSISTANT_AGENT, IntentSource
|
||||
from .entity import ConversationEntity
|
||||
from .models import (
|
||||
AbstractConversationAgent,
|
||||
@@ -34,9 +35,11 @@ from .trace import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
TRIGGER_INTENT_NAME_PREFIX = "HassSentenceTrigger"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
from .trigger import TriggerDetails
|
||||
from .trigger import TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@@ -139,6 +142,10 @@ async def async_converse(
|
||||
return result
|
||||
|
||||
|
||||
type IntentSourceConfig = dict[str, dict[str, Any]]
|
||||
type IntentsCallback = Callable[[dict[IntentSource, IntentSourceConfig]], None]
|
||||
|
||||
|
||||
class AgentManager:
|
||||
"""Class to manage conversation agents."""
|
||||
|
||||
@@ -147,8 +154,13 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.config_intents: dict[str, Any] = {}
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
self._intents: dict[IntentSource, IntentSourceConfig] = {
|
||||
IntentSource.CONFIG: {"intents": {}},
|
||||
IntentSource.TRIGGER: {"intents": {}},
|
||||
}
|
||||
self._intents_subscribers: list[IntentsCallback] = []
|
||||
self._trigger_callbacks: dict[int, TRIGGER_CALLBACK_TYPE] = {}
|
||||
self._trigger_callback_counter: int = 0
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -200,27 +212,75 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_config_intents(self.config_intents)
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
@callback
|
||||
def subscribe_intents(self, subscriber: IntentsCallback) -> CALLBACK_TYPE:
|
||||
"""Subscribe to intents updates.
|
||||
|
||||
The subscriber callback is called immediately with all intent sources
|
||||
and whenever intents are updated (only with the changed source).
|
||||
"""
|
||||
subscriber(self._intents)
|
||||
self._intents_subscribers.append(subscriber)
|
||||
|
||||
@callback
|
||||
def unsubscribe() -> None:
|
||||
"""Unsubscribe from intents updates."""
|
||||
self._intents_subscribers.remove(subscriber)
|
||||
|
||||
return unsubscribe
|
||||
|
||||
def _notify_intents_subscribers(self, source: IntentSource) -> None:
|
||||
"""Notify all intents subscribers of a change to a specific source."""
|
||||
update = {source: self._intents[source]}
|
||||
for subscriber in self._intents_subscribers:
|
||||
subscriber(update)
|
||||
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self.config_intents = intents
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_config_intents(intents)
|
||||
self._intents[IntentSource.CONFIG]["intents"] = intents
|
||||
self._notify_intents_subscribers(IntentSource.CONFIG)
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
def register_trigger(
|
||||
self, sentences: list[str], trigger_callback: TRIGGER_CALLBACK_TYPE
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
trigger_id = self._trigger_callback_counter
|
||||
self._trigger_callback_counter += 1
|
||||
trigger_intent_name = f"{TRIGGER_INTENT_NAME_PREFIX}{trigger_id}"
|
||||
|
||||
trigger_intents = self._intents[IntentSource.TRIGGER]
|
||||
trigger_intents["intents"][trigger_intent_name] = {
|
||||
"data": [{"sentences": sentences}]
|
||||
}
|
||||
self._trigger_callbacks[trigger_id] = trigger_callback
|
||||
self._notify_intents_subscribers(IntentSource.TRIGGER)
|
||||
|
||||
@callback
|
||||
def unregister_trigger() -> None:
|
||||
"""Unregister the trigger."""
|
||||
self.triggers_details.remove(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
del trigger_intents["intents"][trigger_intent_name]
|
||||
del self._trigger_callbacks[trigger_id]
|
||||
self._notify_intents_subscribers(IntentSource.TRIGGER)
|
||||
|
||||
return unregister_trigger
|
||||
|
||||
@property
|
||||
def trigger_sentences(self) -> list[str]:
|
||||
"""Get all trigger sentences."""
|
||||
sentences: list[str] = []
|
||||
trigger_intents = self._intents[IntentSource.TRIGGER]
|
||||
for trigger_intent in trigger_intents.get("intents", {}).values():
|
||||
for data in trigger_intent.get("data", []):
|
||||
sentences.extend(data.get("sentences", []))
|
||||
return sentences
|
||||
|
||||
def get_trigger_callback(
|
||||
self, trigger_intent_name: str
|
||||
) -> TRIGGER_CALLBACK_TYPE | None:
|
||||
"""Get the callback for a trigger from its intent name."""
|
||||
if not trigger_intent_name.startswith(TRIGGER_INTENT_NAME_PREFIX):
|
||||
return None
|
||||
trigger_id = int(trigger_intent_name[len(TRIGGER_INTENT_NAME_PREFIX) :])
|
||||
return self._trigger_callbacks.get(trigger_id)
|
||||
|
||||
@@ -36,6 +36,13 @@ METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
|
||||
|
||||
class IntentSource(StrEnum):
|
||||
"""Source of intents."""
|
||||
|
||||
CONFIG = "config"
|
||||
TRIGGER = "trigger"
|
||||
|
||||
|
||||
class ChatLogEventType(StrEnum):
|
||||
"""Chat log event type."""
|
||||
|
||||
|
||||
@@ -76,18 +76,18 @@ from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util import language as language_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent_manager import get_agent_manager
|
||||
from .agent_manager import IntentSourceConfig, get_agent_manager
|
||||
from .chat_log import AssistantContent, ChatLog, ToolResultContent
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
METADATA_CUSTOM_FILE,
|
||||
METADATA_CUSTOM_SENTENCE,
|
||||
ConversationEntityFeature,
|
||||
IntentSource,
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +126,7 @@ class SentenceTriggerResult:
|
||||
|
||||
sentence: str
|
||||
sentence_template: str | None
|
||||
matched_triggers: dict[int, RecognizeResult]
|
||||
matched_triggers: dict[str, RecognizeResult]
|
||||
|
||||
|
||||
class IntentMatchingStage(Enum):
|
||||
@@ -236,15 +236,19 @@ class DefaultAgent(ConversationEntity):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# Intents from common conversation config
|
||||
self._config_intents: dict[str, Any] = {}
|
||||
self._config_intents_config: IntentSourceConfig = {}
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
# Intents from conversation triggers
|
||||
self._trigger_intents: Intents | None = None
|
||||
self._trigger_intents_config: IntentSourceConfig = {}
|
||||
|
||||
# Subscription to intents updates
|
||||
self._unsub_intents: Callable[[], None] | None = None
|
||||
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
@@ -261,6 +265,33 @@ class DefaultAgent(ConversationEntity):
|
||||
self.fuzzy_matching = True
|
||||
self._fuzzy_config: FuzzyConfig | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to intents updates when added to hass."""
|
||||
self._unsub_intents = get_agent_manager(self.hass).subscribe_intents(
|
||||
self._update_intents
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from intents updates when removed from hass."""
|
||||
if self._unsub_intents is not None:
|
||||
self._unsub_intents()
|
||||
self._unsub_intents = None
|
||||
|
||||
@callback
|
||||
def _update_intents(
|
||||
self, intents_update: dict[IntentSource, IntentSourceConfig]
|
||||
) -> None:
|
||||
"""Handle intents update from agent_manager subscription."""
|
||||
if IntentSource.CONFIG in intents_update:
|
||||
self._config_intents_config = intents_update[IntentSource.CONFIG]
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
if IntentSource.TRIGGER in intents_update:
|
||||
self._trigger_intents_config = intents_update[IntentSource.TRIGGER]
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
@property
|
||||
def supported_languages(self) -> list[str]:
|
||||
"""Return a list of supported languages."""
|
||||
@@ -1059,14 +1090,6 @@ class DefaultAgent(ConversationEntity):
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
@callback
|
||||
def update_config_intents(self, intents: dict[str, Any]) -> None:
|
||||
"""Update config intents."""
|
||||
self._config_intents = intents
|
||||
|
||||
# Intents have changed, so we must clear the cache
|
||||
self._intent_cache.clear()
|
||||
|
||||
async def async_prepare(self, language: str | None = None) -> None:
|
||||
"""Load intents for a language."""
|
||||
if language is None:
|
||||
@@ -1193,7 +1216,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
merge_dict(
|
||||
intents_dict,
|
||||
self._config_intents,
|
||||
self._config_intents_config,
|
||||
)
|
||||
|
||||
if not intents_dict:
|
||||
@@ -1461,27 +1484,12 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return response_template.async_render(response_args)
|
||||
|
||||
@callback
|
||||
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
|
||||
"""Update triggers."""
|
||||
self._triggers_details = triggers_details
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
def _rebuild_trigger_intents(self) -> None:
|
||||
"""Rebuild the HassIL intents object from the current trigger sentences."""
|
||||
"""Rebuild the HassIL intents object from the trigger intents dict."""
|
||||
intents_dict = {
|
||||
"language": self.hass.config.language,
|
||||
"intents": {
|
||||
# Use trigger data index as a virtual intent name for HassIL.
|
||||
# This works because the intents are rebuilt on every
|
||||
# register/unregister.
|
||||
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
|
||||
for trigger_id, trigger_details in enumerate(self._triggers_details)
|
||||
},
|
||||
**self._trigger_intents_config,
|
||||
}
|
||||
|
||||
trigger_intents = Intents.from_dict(intents_dict)
|
||||
|
||||
# Assume slot list references are wildcards
|
||||
@@ -1496,7 +1504,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
self._trigger_intents = trigger_intents
|
||||
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", self._trigger_intents_config)
|
||||
|
||||
async def async_recognize_sentence_trigger(
|
||||
self, user_input: ConversationInput
|
||||
@@ -1506,7 +1514,7 @@ class DefaultAgent(ConversationEntity):
|
||||
Calls the registered callbacks if there's a match and returns a sentence
|
||||
trigger result.
|
||||
"""
|
||||
if not self._triggers_details:
|
||||
if not self._trigger_intents_config.get("intents"):
|
||||
# No triggers registered
|
||||
return None
|
||||
|
||||
@@ -1516,18 +1524,18 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
assert self._trigger_intents is not None
|
||||
|
||||
matched_triggers: dict[int, RecognizeResult] = {}
|
||||
matched_triggers: dict[str, RecognizeResult] = {}
|
||||
matched_template: str | None = None
|
||||
for result in recognize_all(user_input.text, self._trigger_intents):
|
||||
if result.intent_sentence is not None:
|
||||
matched_template = result.intent_sentence.text
|
||||
|
||||
trigger_id = int(result.intent.name)
|
||||
if trigger_id in matched_triggers:
|
||||
trigger_intent_name = result.intent.name
|
||||
if trigger_intent_name in matched_triggers:
|
||||
# Already matched a sentence from this trigger
|
||||
break
|
||||
|
||||
matched_triggers[trigger_id] = result
|
||||
matched_triggers[trigger_intent_name] = result
|
||||
|
||||
if not matched_triggers:
|
||||
# Sentence did not match any trigger sentences
|
||||
@@ -1551,10 +1559,14 @@ class DefaultAgent(ConversationEntity):
|
||||
chat_log: ChatLog,
|
||||
) -> str:
|
||||
"""Run sentence trigger callbacks and return response text."""
|
||||
manager = get_agent_manager(self.hass)
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
trigger_callback(user_input, trigger_result)
|
||||
for trigger_intent_name, trigger_result in result.matched_triggers.items()
|
||||
if (trigger_callback := manager.get_trigger_callback(trigger_intent_name))
|
||||
is not None
|
||||
]
|
||||
|
||||
tool_input = llm.ToolInput(
|
||||
|
||||
@@ -165,11 +165,7 @@ async def websocket_list_sentences(
|
||||
"""List custom registered sentences."""
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
sentences = []
|
||||
for trigger_details in manager.triggers_details:
|
||||
sentences.extend(trigger_details.sentences)
|
||||
|
||||
connection.send_result(msg["id"], {"trigger_sentences": sentences})
|
||||
connection.send_result(msg["id"], {"trigger_sentences": manager.trigger_sentences})
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
@@ -31,14 +30,6 @@ TRIGGER_CALLBACK_TYPE = Callable[
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerDetails:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
for sentence in value:
|
||||
@@ -149,5 +140,5 @@ async def async_attach_trigger(
|
||||
return None
|
||||
|
||||
return get_agent_manager(hass).register_trigger(
|
||||
TriggerDetails(sentences=sentences, callback=call_action)
|
||||
sentences=sentences, trigger_callback=call_action
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.5.0"],
|
||||
"requirements": ["eheimdigital==1.6.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -4,6 +4,8 @@ import asyncio.exceptions
|
||||
from typing import Any
|
||||
|
||||
from flexit_bacnet import (
|
||||
OPERATION_MODE_FIREPLACE,
|
||||
OPERATION_MODE_OFF,
|
||||
VENTILATION_MODE_AWAY,
|
||||
VENTILATION_MODE_HOME,
|
||||
VENTILATION_MODE_STOP,
|
||||
@@ -12,7 +14,6 @@ from flexit_bacnet.bacnet import DecodingError
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_AWAY,
|
||||
PRESET_BOOST,
|
||||
PRESET_HOME,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
@@ -28,8 +29,10 @@ from .const import (
|
||||
DOMAIN,
|
||||
MAX_TEMP,
|
||||
MIN_TEMP,
|
||||
OPERATION_TO_PRESET_MODE_MAP,
|
||||
PRESET_FIREPLACE,
|
||||
PRESET_HIGH,
|
||||
PRESET_TO_VENTILATION_MODE_MAP,
|
||||
VENTILATION_TO_PRESET_MODE_MAP,
|
||||
)
|
||||
from .coordinator import FlexitConfigEntry, FlexitCoordinator
|
||||
from .entity import FlexitEntity
|
||||
@@ -51,6 +54,7 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
"""Flexit air handling unit."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_translation_key = "flexit_bacnet"
|
||||
|
||||
_attr_hvac_modes = [
|
||||
HVACMode.OFF,
|
||||
@@ -60,7 +64,8 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
_attr_preset_modes = [
|
||||
PRESET_AWAY,
|
||||
PRESET_HOME,
|
||||
PRESET_BOOST,
|
||||
PRESET_HIGH,
|
||||
PRESET_FIREPLACE,
|
||||
]
|
||||
|
||||
_attr_supported_features = (
|
||||
@@ -127,20 +132,29 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
|
||||
Requires ClimateEntityFeature.PRESET_MODE.
|
||||
"""
|
||||
return VENTILATION_TO_PRESET_MODE_MAP[self.device.ventilation_mode]
|
||||
return OPERATION_TO_PRESET_MODE_MAP[self.device.operation_mode]
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set new preset mode."""
|
||||
ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode]
|
||||
|
||||
try:
|
||||
await self.device.set_ventilation_mode(ventilation_mode)
|
||||
if preset_mode == PRESET_FIREPLACE:
|
||||
# Use trigger method for fireplace mode
|
||||
await self.device.trigger_fireplace_mode()
|
||||
else:
|
||||
# If currently in fireplace mode, toggle it off first
|
||||
# trigger_fireplace_mode() acts as a toggle
|
||||
if self.device.operation_mode == OPERATION_MODE_FIREPLACE:
|
||||
await self.device.trigger_fireplace_mode()
|
||||
|
||||
# Set the desired ventilation mode
|
||||
ventilation_mode = PRESET_TO_VENTILATION_MODE_MAP[preset_mode]
|
||||
await self.device.set_ventilation_mode(ventilation_mode)
|
||||
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_preset_mode",
|
||||
translation_placeholders={
|
||||
"preset": str(ventilation_mode),
|
||||
"preset": preset_mode,
|
||||
},
|
||||
) from exc
|
||||
finally:
|
||||
@@ -149,7 +163,7 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
"""Return hvac operation ie. heat, cool mode."""
|
||||
if self.device.ventilation_mode == VENTILATION_MODE_STOP:
|
||||
if self.device.operation_mode == OPERATION_MODE_OFF:
|
||||
return HVACMode.OFF
|
||||
|
||||
return HVACMode.FAN_ONLY
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
"""Constants for the Flexit Nordic (BACnet) integration."""
|
||||
|
||||
from flexit_bacnet import (
|
||||
OPERATION_MODE_AWAY,
|
||||
OPERATION_MODE_FIREPLACE,
|
||||
OPERATION_MODE_HIGH,
|
||||
OPERATION_MODE_HOME,
|
||||
OPERATION_MODE_OFF,
|
||||
VENTILATION_MODE_AWAY,
|
||||
VENTILATION_MODE_HIGH,
|
||||
VENTILATION_MODE_HOME,
|
||||
VENTILATION_MODE_STOP,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
PRESET_AWAY,
|
||||
PRESET_BOOST,
|
||||
PRESET_HOME,
|
||||
PRESET_NONE,
|
||||
)
|
||||
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME, PRESET_NONE
|
||||
|
||||
DOMAIN = "flexit_bacnet"
|
||||
|
||||
MAX_TEMP = 30
|
||||
MIN_TEMP = 10
|
||||
|
||||
VENTILATION_TO_PRESET_MODE_MAP = {
|
||||
VENTILATION_MODE_STOP: PRESET_NONE,
|
||||
VENTILATION_MODE_AWAY: PRESET_AWAY,
|
||||
VENTILATION_MODE_HOME: PRESET_HOME,
|
||||
VENTILATION_MODE_HIGH: PRESET_BOOST,
|
||||
PRESET_HIGH = "high"
|
||||
PRESET_FIREPLACE = "fireplace"
|
||||
|
||||
# Map operation mode (what device reports) to Home Assistant preset
|
||||
OPERATION_TO_PRESET_MODE_MAP = {
|
||||
OPERATION_MODE_OFF: PRESET_NONE,
|
||||
OPERATION_MODE_AWAY: PRESET_AWAY,
|
||||
OPERATION_MODE_HOME: PRESET_HOME,
|
||||
OPERATION_MODE_HIGH: PRESET_HIGH,
|
||||
OPERATION_MODE_FIREPLACE: PRESET_FIREPLACE,
|
||||
}
|
||||
|
||||
# Map preset to ventilation mode (for setting standard modes)
|
||||
PRESET_TO_VENTILATION_MODE_MAP = {
|
||||
PRESET_NONE: VENTILATION_MODE_STOP,
|
||||
PRESET_AWAY: VENTILATION_MODE_AWAY,
|
||||
PRESET_HOME: VENTILATION_MODE_HOME,
|
||||
PRESET_BOOST: VENTILATION_MODE_HIGH,
|
||||
PRESET_HIGH: VENTILATION_MODE_HIGH,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
{
|
||||
"entity": {
|
||||
"climate": {
|
||||
"flexit_bacnet": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"fireplace": "mdi:fireplace",
|
||||
"high": "mdi:fan-speed-3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"away_extract_fan_setpoint": {
|
||||
"default": "mdi:fan-minus"
|
||||
|
||||
@@ -26,6 +26,18 @@
|
||||
"name": "Air filter polluted"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
"flexit_bacnet": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"fireplace": "Fireplace",
|
||||
"high": "[%key:common::state::high%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"away_extract_fan_setpoint": {
|
||||
"name": "Away extract fan setpoint"
|
||||
@@ -139,5 +151,11 @@
|
||||
"switch_turn": {
|
||||
"message": "Failed to turn the switch {state}."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_fireplace_switch": {
|
||||
"description": "The fireplace mode switch entity `{entity_id}` is deprecated and will be removed in a future version.\n\nFireplace mode has been moved to a climate preset on the climate entity to better match the device interface.\n\nPlease update your automations to use the `climate.set_preset_mode` action with preset mode `fireplace` instead of using the switch entity.\n\nAfter updating your automations, you can safely disable this switch entity.",
|
||||
"title": "Fireplace mode switch is deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@ from homeassistant.components.switch import (
|
||||
SwitchEntity,
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FlexitConfigEntry, FlexitCoordinator
|
||||
@@ -39,13 +42,6 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
|
||||
turn_on_fn=lambda data: data.enable_electric_heater(),
|
||||
turn_off_fn=lambda data: data.disable_electric_heater(),
|
||||
),
|
||||
FlexitSwitchEntityDescription(
|
||||
key="fireplace_mode",
|
||||
translation_key="fireplace_mode",
|
||||
is_on_fn=lambda data: data.fireplace_ventilation_status,
|
||||
turn_on_fn=lambda data: data.trigger_fireplace_mode(),
|
||||
turn_off_fn=lambda data: data.trigger_fireplace_mode(),
|
||||
),
|
||||
FlexitSwitchEntityDescription(
|
||||
key="cooker_hood_mode",
|
||||
translation_key="cooker_hood_mode",
|
||||
@@ -53,6 +49,13 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
|
||||
turn_on_fn=lambda data: data.activate_cooker_hood(),
|
||||
turn_off_fn=lambda data: data.deactivate_cooker_hood(),
|
||||
),
|
||||
FlexitSwitchEntityDescription(
|
||||
key="fireplace_mode",
|
||||
translation_key="fireplace_mode",
|
||||
is_on_fn=lambda data: data.fireplace_ventilation_status,
|
||||
turn_on_fn=lambda data: data.trigger_fireplace_mode(),
|
||||
turn_off_fn=lambda data: data.trigger_fireplace_mode(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -64,9 +67,42 @@ async def async_setup_entry(
|
||||
"""Set up Flexit (bacnet) switch from a config entry."""
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
FlexitSwitch(coordinator, description) for description in SWITCHES
|
||||
)
|
||||
entities: list[FlexitSwitch] = []
|
||||
for description in SWITCHES:
|
||||
if description.key == "fireplace_mode":
|
||||
# Check if deprecated fireplace switch is enabled and create repair issue
|
||||
entity_reg = er.async_get(hass)
|
||||
fireplace_switch_unique_id = (
|
||||
f"{coordinator.device.serial_number}-fireplace_mode"
|
||||
)
|
||||
# Look up the fireplace switch entity by unique_id
|
||||
fireplace_switch_entity_id = entity_reg.async_get_entity_id(
|
||||
Platform.SWITCH, DOMAIN, fireplace_switch_unique_id
|
||||
)
|
||||
if not fireplace_switch_entity_id:
|
||||
continue
|
||||
entity_registry_entry = entity_reg.async_get(fireplace_switch_entity_id)
|
||||
|
||||
if entity_registry_entry:
|
||||
if entity_registry_entry.disabled:
|
||||
entity_reg.async_remove(fireplace_switch_entity_id)
|
||||
else:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_switch_{fireplace_switch_unique_id}",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_fireplace_switch",
|
||||
translation_placeholders={
|
||||
"entity_id": fireplace_switch_entity_id,
|
||||
},
|
||||
)
|
||||
entities.append(FlexitSwitch(coordinator, description))
|
||||
else:
|
||||
entities.append(FlexitSwitch(coordinator, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.1"]
|
||||
"requirements": ["fressnapftracker==0.2.2"]
|
||||
}
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"preview_features": {
|
||||
"winter_mode": {}
|
||||
},
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.1"]
|
||||
"requirements": ["home-assistant-frontend==20260128.3"]
|
||||
}
|
||||
|
||||
@@ -13,10 +13,7 @@ from homeassistant.helpers import (
|
||||
discovery,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
@@ -96,13 +93,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_HUMIDIFIER],
|
||||
)
|
||||
|
||||
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -5,10 +5,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
@@ -23,13 +20,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_HEATER],
|
||||
)
|
||||
|
||||
def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -8,10 +8,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
@@ -53,13 +50,6 @@ async def async_setup_entry(
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_ENTITY_ID],
|
||||
)
|
||||
|
||||
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -169,6 +169,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect binary sensor."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -73,6 +73,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect button entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -7,18 +7,44 @@ from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
|
||||
|
||||
def should_add_option_entity(
|
||||
description: EntityDescription,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
platform: Platform,
|
||||
) -> bool:
|
||||
"""Check if the option entity should be added for the appliance.
|
||||
|
||||
This function returns `True` if the option is available in the appliance options
|
||||
or if the entity was added in previous loads of this integration.
|
||||
"""
|
||||
description_key = description.key
|
||||
return description_key in appliance.options or (
|
||||
entity_registry.async_get_entity_id(
|
||||
platform, DOMAIN, f"{appliance.info.ha_id}-{description_key}"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _create_option_entities(
|
||||
entity_registry: er.EntityRegistry,
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
@@ -26,7 +52,9 @@ def _create_option_entities(
|
||||
"""Create the required option entities for the appliances."""
|
||||
option_entities_to_add = [
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
known_entity_unique_ids.update(
|
||||
@@ -39,13 +67,14 @@ def _create_option_entities(
|
||||
|
||||
|
||||
def _handle_paired_or_connected_appliance(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None,
|
||||
@@ -60,6 +89,7 @@ def _handle_paired_or_connected_appliance(
|
||||
already or it is the first time we see them when the appliance is connected.
|
||||
"""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
entity_registry = er.async_get(hass)
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = [
|
||||
entity
|
||||
@@ -69,7 +99,9 @@ def _handle_paired_or_connected_appliance(
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
for event_key in (
|
||||
@@ -80,6 +112,7 @@ def _handle_paired_or_connected_appliance(
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entity_registry,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
@@ -120,13 +153,14 @@ def _handle_depaired_appliance(
|
||||
|
||||
|
||||
def setup_home_connect_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None = None,
|
||||
@@ -141,6 +175,7 @@ def setup_home_connect_entry(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(
|
||||
_handle_paired_or_connected_appliance,
|
||||
hass,
|
||||
entry,
|
||||
known_entity_unique_ids,
|
||||
get_entities_for_appliance,
|
||||
|
||||
@@ -96,6 +96,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect light."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -11,12 +11,13 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.const import PERCENTAGE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import DOMAIN, UNIT_MAP
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
@@ -136,12 +137,15 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
|
||||
for description in NUMBER_OPTIONS
|
||||
if description.key in appliance.options
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.NUMBER
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -152,6 +156,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect number."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import (
|
||||
AVAILABLE_MAPS_ENUM,
|
||||
BEAN_AMOUNT_OPTIONS,
|
||||
@@ -358,12 +360,13 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
|
||||
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
|
||||
if desc.key in appliance.options
|
||||
if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT)
|
||||
]
|
||||
|
||||
|
||||
@@ -374,6 +377,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect select entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -540,6 +540,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect sensor."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -7,12 +7,14 @@ from aiohomeconnect.model import OptionKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
@@ -190,12 +192,15 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
|
||||
for description in SWITCH_OPTIONS
|
||||
if description.key in appliance.options
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.SWITCH
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -206,6 +211,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect switch."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.const import (
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import (
|
||||
Event,
|
||||
@@ -127,6 +128,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
)
|
||||
return
|
||||
|
||||
# Determine the actual service to call
|
||||
actual_service = service.service
|
||||
|
||||
# For toggle, implement conditional behavior: if any entity is on,
|
||||
# turn all off; otherwise turn all on
|
||||
if service.service == SERVICE_TOGGLE:
|
||||
any_on = any(
|
||||
(state := hass.states.get(entity_id)) is not None
|
||||
and state.state == STATE_ON
|
||||
for entity_id in all_referenced
|
||||
)
|
||||
actual_service = SERVICE_TURN_OFF if any_on else SERVICE_TURN_ON
|
||||
|
||||
# Group entity_ids by domain. groupby requires sorted data.
|
||||
by_domain = it.groupby(
|
||||
sorted(all_referenced), lambda item: split_entity_id(item)[0]
|
||||
@@ -145,7 +159,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
)
|
||||
continue
|
||||
|
||||
if not hass.services.has_service(domain, service.service):
|
||||
if not hass.services.has_service(domain, actual_service):
|
||||
unsupported_entities.update(set(ent_ids) & referenced.referenced)
|
||||
continue
|
||||
|
||||
@@ -158,7 +172,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
tasks.append(
|
||||
hass.services.async_call(
|
||||
domain,
|
||||
service.service,
|
||||
actual_service,
|
||||
data,
|
||||
blocking=True,
|
||||
context=service.context,
|
||||
|
||||
@@ -23,15 +23,15 @@
|
||||
"name": "[%key:common::action::reload%]"
|
||||
},
|
||||
"toggle": {
|
||||
"description": "Toggles the helper on/off.",
|
||||
"description": "Toggles an input boolean on/off.",
|
||||
"name": "[%key:common::action::toggle%]"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off the helper.",
|
||||
"description": "Turns off an input boolean.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the helper.",
|
||||
"description": "Turns on an input boolean.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"services": {
|
||||
"press": {
|
||||
"description": "Mimics the physical button press on the device.",
|
||||
"description": "Mimics a physical button press on a device.",
|
||||
"name": "Press"
|
||||
},
|
||||
"reload": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import voluptuous as vol
|
||||
from homeassistant.components.script import CONF_MODE
|
||||
from homeassistant.const import CONF_DESCRIPTION, CONF_TYPE, SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
intent,
|
||||
@@ -18,6 +19,7 @@ from homeassistant.helpers import (
|
||||
template,
|
||||
)
|
||||
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||
from homeassistant.helpers.script import async_validate_actions_config
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -85,19 +87,29 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
|
||||
|
||||
new_intents = new_config[DOMAIN]
|
||||
|
||||
async_load_intents(hass, new_intents)
|
||||
await async_load_intents(hass, new_intents)
|
||||
|
||||
|
||||
def async_load_intents(hass: HomeAssistant, intents: dict[str, ConfigType]) -> None:
|
||||
async def async_load_intents(
|
||||
hass: HomeAssistant, intents: dict[str, ConfigType]
|
||||
) -> None:
|
||||
"""Load YAML intents into the intent system."""
|
||||
hass.data[DOMAIN] = intents
|
||||
|
||||
for intent_type, conf in intents.items():
|
||||
if CONF_ACTION in conf:
|
||||
try:
|
||||
actions = await async_validate_actions_config(hass, conf[CONF_ACTION])
|
||||
except (vol.Invalid, HomeAssistantError) as exc:
|
||||
_LOGGER.error(
|
||||
"Failed to validate actions for intent %s: %s", intent_type, exc
|
||||
)
|
||||
continue # Skip this intent
|
||||
|
||||
script_mode: str = conf.get(CONF_MODE, script.DEFAULT_SCRIPT_MODE)
|
||||
conf[CONF_ACTION] = script.Script(
|
||||
hass,
|
||||
conf[CONF_ACTION],
|
||||
actions,
|
||||
f"Intent Script {intent_type}",
|
||||
DOMAIN,
|
||||
script_mode=script_mode,
|
||||
@@ -109,7 +121,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the intent script component."""
|
||||
intents = config[DOMAIN]
|
||||
|
||||
async_load_intents(hass, intents)
|
||||
await async_load_intents(hass, intents)
|
||||
|
||||
async def _handle_reload(service_call: ServiceCall) -> None:
|
||||
return await async_reload(hass, service_call)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jvcprojector"],
|
||||
"requirements": ["pyjvcprojector==2.0.0"]
|
||||
"requirements": ["pyjvcprojector==2.0.1"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from pyliebherrhomeapi.exceptions import (
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
@@ -32,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError as err:
|
||||
raise ConfigEntryError("Invalid API key") from err
|
||||
raise ConfigEntryAuthFailed("Invalid API key") from err
|
||||
except LiebherrConnectionError as err:
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -30,6 +31,25 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for liebherr."""
|
||||
|
||||
async def _validate_api_key(self, api_key: str) -> tuple[list, dict[str, str]]:
|
||||
"""Validate the API key and return devices and errors."""
|
||||
errors: dict[str, str] = {}
|
||||
devices: list = []
|
||||
client = LiebherrClient(
|
||||
api_key=api_key,
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except LiebherrConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
return devices, errors
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -40,21 +60,8 @@ class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]})
|
||||
|
||||
try:
|
||||
# Create a client and test the connection
|
||||
client = LiebherrClient(
|
||||
api_key=user_input[CONF_API_KEY],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except LiebherrConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
devices, errors = await self._validate_api_key(user_input[CONF_API_KEY])
|
||||
if not errors:
|
||||
if not devices:
|
||||
return self.async_abort(reason="no_devices")
|
||||
|
||||
@@ -66,3 +73,31 @@ class LiebherrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm re-authentication."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
api_key = user_input[CONF_API_KEY].strip()
|
||||
|
||||
_, errors = await self._validate_api_key(api_key)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_KEY: api_key},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,11 @@ from pyliebherrhomeapi import (
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -64,7 +68,7 @@ class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
try:
|
||||
return await self.client.get_device_state(self.device_id)
|
||||
except LiebherrAuthenticationError as err:
|
||||
raise ConfigEntryError("API key is no longer valid") from err
|
||||
raise ConfigEntryAuthFailed("API key is no longer valid") from err
|
||||
except LiebherrTimeoutError as err:
|
||||
raise UpdateFailed(
|
||||
f"Timeout communicating with device {self.device_id}"
|
||||
|
||||
@@ -36,7 +36,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
@@ -60,7 +60,9 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: The only configuration option is the API key, which is handled by the reauthentication flow.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No repair issues to implement at this time.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_devices": "No devices found for this API key"
|
||||
"no_devices": "No devices found for this API key",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -11,6 +12,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::liebherr::config::step::user::data_description::api_key%]"
|
||||
},
|
||||
"description": "Your API key is no longer valid. Please enter a new API key to continue."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
|
||||
@@ -33,6 +33,7 @@ from .const import ( # noqa: F401
|
||||
CONF_ALLOW_SINGLE_WORD,
|
||||
CONF_ICON,
|
||||
CONF_REQUIRE_ADMIN,
|
||||
CONF_RESOURCE_MODE,
|
||||
CONF_SHOW_IN_SIDEBAR,
|
||||
CONF_TITLE,
|
||||
CONF_URL_PATH,
|
||||
@@ -61,7 +62,7 @@ def _validate_url_slug(value: Any) -> str:
|
||||
"""Validate value is a valid url slug."""
|
||||
if value is None:
|
||||
raise vol.Invalid("Slug should not be None")
|
||||
if "-" not in value:
|
||||
if value != "lovelace" and "-" not in value:
|
||||
raise vol.Invalid("Url path needs to contain a hyphen (-)")
|
||||
str_value = str(value)
|
||||
slg = slugify(str_value, separator="-")
|
||||
@@ -84,9 +85,13 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(DOMAIN, default={}): vol.Schema(
|
||||
{
|
||||
# Deprecated - Remove in 2026.8
|
||||
vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All(
|
||||
vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])
|
||||
),
|
||||
vol.Optional(CONF_RESOURCE_MODE): vol.All(
|
||||
vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])
|
||||
),
|
||||
vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys(
|
||||
YAML_DASHBOARD_SCHEMA,
|
||||
slug_validator=_validate_url_slug,
|
||||
@@ -103,7 +108,7 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
class LovelaceData:
|
||||
"""Dataclass to store information in hass.data."""
|
||||
|
||||
mode: str
|
||||
resource_mode: str # The mode used for resources (yaml or storage)
|
||||
dashboards: dict[str | None, dashboard.LovelaceConfig]
|
||||
resources: resources.ResourceYAMLCollection | resources.ResourceStorageCollection
|
||||
yaml_dashboards: dict[str | None, ConfigType]
|
||||
@@ -114,18 +119,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
mode = config[DOMAIN][CONF_MODE]
|
||||
yaml_resources = config[DOMAIN].get(CONF_RESOURCES)
|
||||
|
||||
# Deprecated - Remove in 2026.8
|
||||
# For YAML mode, register the default panel in yaml mode (temporary until user migrates)
|
||||
if mode == MODE_YAML:
|
||||
frontend.async_register_built_in_panel(
|
||||
hass,
|
||||
DOMAIN,
|
||||
config={"mode": mode},
|
||||
sidebar_title="overview",
|
||||
sidebar_icon="mdi:view-dashboard",
|
||||
sidebar_default_visible=False,
|
||||
)
|
||||
_async_create_yaml_mode_repair(hass)
|
||||
# resource_mode controls how resources are loaded (yaml vs storage)
|
||||
# Deprecated - Remove mode fallback in 2026.8
|
||||
resource_mode = config[DOMAIN].get(CONF_RESOURCE_MODE, mode)
|
||||
|
||||
async def reload_resources_service_handler(service_call: ServiceCall) -> None:
|
||||
"""Reload yaml resources."""
|
||||
@@ -149,12 +145,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
hass.data[LOVELACE_DATA].resources = resource_collection
|
||||
|
||||
default_config: dashboard.LovelaceConfig
|
||||
resource_collection: (
|
||||
resources.ResourceYAMLCollection | resources.ResourceStorageCollection
|
||||
)
|
||||
if mode == MODE_YAML:
|
||||
default_config = dashboard.LovelaceYAML(hass, None, None)
|
||||
default_config = dashboard.LovelaceStorage(hass, None)
|
||||
|
||||
# Load resources based on resource_mode
|
||||
if resource_mode == MODE_YAML:
|
||||
resource_collection = await create_yaml_resource_col(hass, yaml_resources)
|
||||
|
||||
async_register_admin_service(
|
||||
@@ -177,8 +174,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
else:
|
||||
default_config = dashboard.LovelaceStorage(hass, None)
|
||||
|
||||
if yaml_resources is not None:
|
||||
_LOGGER.warning(
|
||||
"Lovelace is running in storage mode. Define resources via user"
|
||||
@@ -195,18 +190,44 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
RESOURCE_UPDATE_FIELDS,
|
||||
).async_setup(hass)
|
||||
|
||||
websocket_api.async_register_command(hass, websocket.websocket_lovelace_info)
|
||||
websocket_api.async_register_command(hass, websocket.websocket_lovelace_config)
|
||||
websocket_api.async_register_command(hass, websocket.websocket_lovelace_save_config)
|
||||
websocket_api.async_register_command(
|
||||
hass, websocket.websocket_lovelace_delete_config
|
||||
)
|
||||
|
||||
yaml_dashboards = config[DOMAIN].get(CONF_DASHBOARDS, {})
|
||||
|
||||
# Deprecated - Remove in 2026.8
|
||||
# For YAML mode, add the default "lovelace" dashboard if not already defined
|
||||
# This migrates the legacy yaml mode to a proper yaml dashboard entry
|
||||
if mode == MODE_YAML and DOMAIN not in yaml_dashboards:
|
||||
translations = await async_get_translations(
|
||||
hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
|
||||
)
|
||||
title = translations.get(
|
||||
"component.onboarding.dashboard.overview.title", "Overview"
|
||||
)
|
||||
yaml_dashboards = {
|
||||
DOMAIN: {
|
||||
CONF_TITLE: title,
|
||||
CONF_ICON: DEFAULT_ICON,
|
||||
CONF_SHOW_IN_SIDEBAR: True,
|
||||
CONF_REQUIRE_ADMIN: False,
|
||||
CONF_MODE: MODE_YAML,
|
||||
CONF_FILENAME: LOVELACE_CONFIG_FILE,
|
||||
},
|
||||
**yaml_dashboards,
|
||||
}
|
||||
_async_create_yaml_mode_repair(hass)
|
||||
|
||||
hass.data[LOVELACE_DATA] = LovelaceData(
|
||||
mode=mode,
|
||||
resource_mode=resource_mode,
|
||||
# We store a dictionary mapping url_path: config. None is the default.
|
||||
dashboards={None: default_config},
|
||||
resources=resource_collection,
|
||||
yaml_dashboards=config[DOMAIN].get(CONF_DASHBOARDS, {}),
|
||||
yaml_dashboards=yaml_dashboards,
|
||||
)
|
||||
|
||||
if hass.config.recovery_mode:
|
||||
@@ -450,7 +471,7 @@ async def _async_migrate_default_config(
|
||||
# Deprecated - Remove in 2026.8
|
||||
@callback
|
||||
def _async_create_yaml_mode_repair(hass: HomeAssistant) -> None:
|
||||
"""Create repair issue for YAML mode migration."""
|
||||
"""Create repair issue for YAML mode deprecation."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
|
||||
@@ -158,7 +158,15 @@ async def _get_dashboard_info(
|
||||
"""Load a dashboard and return info on views."""
|
||||
if url_path == DEFAULT_DASHBOARD:
|
||||
url_path = None
|
||||
dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path)
|
||||
|
||||
# When url_path is None, prefer "lovelace" dashboard if it exists (for YAML mode)
|
||||
# Otherwise fall back to dashboards[None] (storage mode default)
|
||||
if url_path is None:
|
||||
dashboard = hass.data[LOVELACE_DATA].dashboards.get(DOMAIN) or hass.data[
|
||||
LOVELACE_DATA
|
||||
].dashboards.get(None)
|
||||
else:
|
||||
dashboard = hass.data[LOVELACE_DATA].dashboards.get(url_path)
|
||||
|
||||
if dashboard is None:
|
||||
raise ValueError("Invalid dashboard specified")
|
||||
|
||||
@@ -57,6 +57,7 @@ RESOURCE_UPDATE_FIELDS: VolDictType = {
|
||||
SERVICE_RELOAD_RESOURCES = "reload_resources"
|
||||
RESOURCE_RELOAD_SERVICE_SCHEMA = vol.Schema({})
|
||||
|
||||
CONF_RESOURCE_MODE = "resource_mode"
|
||||
CONF_TITLE = "title"
|
||||
CONF_REQUIRE_ADMIN = "require_admin"
|
||||
CONF_SHOW_IN_SIDEBAR = "show_in_sidebar"
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"yaml_mode_deprecated": {
|
||||
"description": "Starting with Home Assistant 2026.8, the default Lovelace dashboard will no longer support YAML mode. To migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Rename `{config_file}` to a new filename (e.g., `my-dashboard.yaml`)\n3. Add a dashboard entry in your `configuration.yaml`:\n\n```yaml\nlovelace:\n dashboards:\n lovelace:\n mode: yaml\n filename: my-dashboard.yaml\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode migration required"
|
||||
"description": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode deprecated"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -42,9 +42,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
else:
|
||||
health_info[key] = dashboard[key]
|
||||
|
||||
if hass.data[LOVELACE_DATA].mode == MODE_YAML:
|
||||
health_info[CONF_MODE] = MODE_YAML
|
||||
elif MODE_STORAGE in modes:
|
||||
if MODE_STORAGE in modes:
|
||||
health_info[CONF_MODE] = MODE_STORAGE
|
||||
elif MODE_YAML in modes:
|
||||
health_info[CONF_MODE] = MODE_YAML
|
||||
|
||||
@@ -14,7 +14,13 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.json import json_fragment
|
||||
|
||||
from .const import CONF_URL_PATH, LOVELACE_DATA, ConfigNotFound
|
||||
from .const import (
|
||||
CONF_RESOURCE_MODE,
|
||||
CONF_URL_PATH,
|
||||
DOMAIN,
|
||||
LOVELACE_DATA,
|
||||
ConfigNotFound,
|
||||
)
|
||||
from .dashboard import LovelaceConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -38,7 +44,15 @@ def _handle_errors[_R](
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
url_path = msg.get(CONF_URL_PATH)
|
||||
config = hass.data[LOVELACE_DATA].dashboards.get(url_path)
|
||||
|
||||
# When url_path is None, prefer "lovelace" dashboard if it exists (for YAML mode)
|
||||
# Otherwise fall back to dashboards[None] (storage mode default)
|
||||
if url_path is None:
|
||||
config = hass.data[LOVELACE_DATA].dashboards.get(DOMAIN) or hass.data[
|
||||
LOVELACE_DATA
|
||||
].dashboards.get(None)
|
||||
else:
|
||||
config = hass.data[LOVELACE_DATA].dashboards.get(url_path)
|
||||
|
||||
if config is None:
|
||||
connection.send_error(
|
||||
@@ -100,6 +114,20 @@ async def websocket_lovelace_resources_impl(
|
||||
connection.send_result(msg["id"], resources.async_items())
|
||||
|
||||
|
||||
@websocket_api.websocket_command({"type": "lovelace/info"})
|
||||
@websocket_api.async_response
|
||||
async def websocket_lovelace_info(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Send Lovelace UI info over WebSocket connection."""
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
{CONF_RESOURCE_MODE: hass.data[LOVELACE_DATA].resource_mode},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "lovelace/config",
|
||||
|
||||
@@ -24,7 +24,7 @@ from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA
|
||||
from .error import Unresolvable
|
||||
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
|
||||
|
||||
MAX_UPLOAD_SIZE = 1024 * 1024 * 10
|
||||
MAX_UPLOAD_SIZE = 1024 * 1024 * 20
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
@@ -29,11 +26,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Mold indicator from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass, entry.entry_id, entry.options[CONF_INDOOR_HUMIDITY]
|
||||
)
|
||||
|
||||
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -31,7 +31,6 @@ class OpenThermEntity(Entity):
|
||||
"""Represent an OpenTherm entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
entity_description: OpenThermEntityDescription
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +60,8 @@ class OpenThermEntity(Entity):
|
||||
class OpenThermStatusEntity(OpenThermEntity):
|
||||
"""Represent an OpenTherm entity that receives status updates."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to updates from the component."""
|
||||
self.async_on_remove(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyotgw"],
|
||||
"requirements": ["pyotgw==2.2.2"]
|
||||
"requirements": ["pyotgw==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.5"]
|
||||
"requirements": ["opower==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ from .entity import (
|
||||
PortainerEndpointEntity,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerContainerBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
@@ -6,7 +6,6 @@ from abc import abstractmethod
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyportainer import Portainer
|
||||
@@ -35,7 +34,7 @@ from .coordinator import (
|
||||
)
|
||||
from .entity import PortainerContainerEntity, PortainerEndpointEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
|
||||
@@ -28,6 +28,8 @@ from .entity import (
|
||||
PortainerEndpointEntity,
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class PortainerContainerSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -37,6 +37,9 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription):
|
||||
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def perform_action(
|
||||
action: str, portainer: Portainer, endpoint_id: int, container_id: str
|
||||
) -> None:
|
||||
|
||||
@@ -123,8 +123,7 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "ssl_error"
|
||||
except ProxmoxNoNodesFound:
|
||||
errors["base"] = "no_nodes_found"
|
||||
|
||||
if not errors:
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_HOST],
|
||||
data={**user_input, CONF_NODES: proxmox_nodes},
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import RenaultConfigEntry
|
||||
from .entity import RenaultEntity
|
||||
from .renault_vehicle import RenaultVehicleProxy
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
# but renault servers are unreliable and it's safer to queue action calls
|
||||
@@ -23,7 +24,7 @@ class RenaultButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Class describing Renault button entities."""
|
||||
|
||||
async_press: Callable[[RenaultButtonEntity], Coroutine[Any, Any, Any]]
|
||||
requires_electricity: bool = False
|
||||
is_supported: Callable[[RenaultVehicleProxy], bool]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -36,7 +37,7 @@ async def async_setup_entry(
|
||||
RenaultButtonEntity(vehicle, description)
|
||||
for vehicle in config_entry.runtime_data.vehicles.values()
|
||||
for description in BUTTON_TYPES
|
||||
if not description.requires_electricity or vehicle.details.uses_electricity()
|
||||
if description.is_supported(vehicle)
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -55,18 +56,27 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = (
|
||||
RenaultButtonEntityDescription(
|
||||
async_press=lambda x: x.vehicle.set_ac_start(21, None),
|
||||
key="start_air_conditioner",
|
||||
is_supported=lambda vehicle: (
|
||||
vehicle.details.supports_endpoint("actions/hvac-start")
|
||||
),
|
||||
translation_key="start_air_conditioner",
|
||||
),
|
||||
RenaultButtonEntityDescription(
|
||||
async_press=lambda x: x.vehicle.set_charge_start(),
|
||||
key="start_charge",
|
||||
requires_electricity=True,
|
||||
is_supported=lambda vehicle: (
|
||||
vehicle.details.supports_endpoint("actions/charge-start")
|
||||
and vehicle.details.uses_electricity()
|
||||
),
|
||||
translation_key="start_charge",
|
||||
),
|
||||
RenaultButtonEntityDescription(
|
||||
async_press=lambda x: x.vehicle.set_charge_stop(),
|
||||
key="stop_charge",
|
||||
requires_electricity=True,
|
||||
is_supported=lambda vehicle: (
|
||||
vehicle.details.supports_endpoint("actions/charge-stop")
|
||||
and vehicle.details.uses_electricity()
|
||||
),
|
||||
translation_key="stop_charge",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,6 @@ from .coordinator import (
|
||||
)
|
||||
from .repairs import (
|
||||
async_manage_ble_scanner_firmware_unsupported_issue,
|
||||
async_manage_coiot_unconfigured_issue,
|
||||
async_manage_deprecated_firmware_issue,
|
||||
async_manage_open_wifi_ap_issue,
|
||||
async_manage_outbound_websocket_incorrectly_enabled_issue,
|
||||
@@ -233,7 +232,6 @@ async def _async_setup_block_entry(
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, runtime_data.platforms
|
||||
)
|
||||
async_manage_coiot_unconfigured_issue(hass, entry)
|
||||
remove_empty_sub_devices(hass, entry)
|
||||
elif (
|
||||
sleep_period is None
|
||||
|
||||
@@ -47,6 +47,7 @@ from .const import (
|
||||
ATTR_DEVICE,
|
||||
ATTR_GENERATION,
|
||||
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
CONF_BLE_SCANNER_MODE,
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
@@ -72,6 +73,7 @@ from .const import (
|
||||
)
|
||||
from .utils import (
|
||||
async_create_issue_unsupported_firmware,
|
||||
async_manage_coiot_issues_task,
|
||||
get_block_device_sleep_period,
|
||||
get_device_entry_gen,
|
||||
get_host,
|
||||
@@ -442,26 +444,19 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]):
|
||||
DOMAIN,
|
||||
PUSH_UPDATE_ISSUE_ID.format(unique=self.mac),
|
||||
)
|
||||
ir.async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID.format(unique=self.mac),
|
||||
)
|
||||
self._push_update_failures = 0
|
||||
elif update_type is BlockUpdateType.COAP_REPLY:
|
||||
self._push_update_failures += 1
|
||||
if self._push_update_failures == MAX_PUSH_UPDATE_FAILURES:
|
||||
LOGGER.debug(
|
||||
"Creating issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=self.mac)
|
||||
)
|
||||
ir.async_create_issue(
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
PUSH_UPDATE_ISSUE_ID.format(unique=self.mac),
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1",
|
||||
translation_key="push_update_failure",
|
||||
translation_placeholders={
|
||||
"device_name": self.config_entry.title,
|
||||
"ip_address": self.device.ip_address,
|
||||
},
|
||||
async_manage_coiot_issues_task(self.hass, self.config_entry),
|
||||
"coiot_issues",
|
||||
)
|
||||
if self._push_update_failures:
|
||||
LOGGER.debug(
|
||||
|
||||
@@ -5,12 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioshelly.block_device import BlockDevice
|
||||
from aioshelly.const import (
|
||||
MODEL_OUT_PLUG_S_G3,
|
||||
MODEL_PLUG,
|
||||
MODEL_PLUG_S_G3,
|
||||
RPC_GENERATIONS,
|
||||
)
|
||||
from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3, RPC_GENERATIONS
|
||||
from aioshelly.exceptions import DeviceConnectionError, RpcCallError
|
||||
from aioshelly.rpc_device import RpcDevice
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -24,7 +19,6 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from .const import (
|
||||
BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID,
|
||||
BLE_SCANNER_MIN_FIRMWARE,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
CONF_BLE_SCANNER_MODE,
|
||||
DEPRECATED_FIRMWARE_ISSUE_ID,
|
||||
DEPRECATED_FIRMWARES,
|
||||
@@ -162,51 +156,6 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_manage_coiot_unconfigured_issue(
|
||||
hass: HomeAssistant,
|
||||
entry: ShellyConfigEntry,
|
||||
) -> None:
|
||||
"""Manage the CoIoT unconfigured issue."""
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=entry.unique_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.runtime_data.block is not None
|
||||
|
||||
device = entry.runtime_data.block.device
|
||||
|
||||
if device.model == MODEL_PLUG:
|
||||
# Shelly Plug Gen 1 does not have CoIoT settings
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
return
|
||||
|
||||
coiot_config = device.settings["coiot"]
|
||||
coiot_enabled = coiot_config.get("enabled")
|
||||
|
||||
# Check if CoIoT is disabled or peer address is not correctly set
|
||||
if not coiot_enabled or (
|
||||
(peer_config := coiot_config.get("peer"))
|
||||
and peer_config != get_coiot_address(hass)
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="coiot_unconfigured",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_manage_open_wifi_ap_issue(
|
||||
hass: HomeAssistant,
|
||||
@@ -275,7 +224,7 @@ class CoiotConfigureFlow(ShellyBlockRepairsFlow):
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
coiot_addr = get_coiot_address(self.hass)
|
||||
coiot_addr = await get_coiot_address(self.hass)
|
||||
coiot_port = get_coiot_port(self.hass)
|
||||
if coiot_addr is None or coiot_port is None:
|
||||
return self.async_abort(reason="cannot_configure")
|
||||
|
||||
@@ -22,6 +22,7 @@ from aioshelly.const import (
|
||||
MODEL_EM3,
|
||||
MODEL_I3,
|
||||
MODEL_NAMES,
|
||||
MODEL_PLUG,
|
||||
RPC_GENERATIONS,
|
||||
)
|
||||
from aioshelly.rpc_device import RpcDevice, WsServer
|
||||
@@ -29,6 +30,7 @@ from yarl import URL
|
||||
|
||||
from homeassistant.components import network
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.network import async_get_source_ip
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -54,6 +56,7 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import (
|
||||
API_WS_URL,
|
||||
BASIC_INPUTS_EVENTS_TYPES,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
COMPONENT_ID_PATTERN,
|
||||
CONF_COAP_PORT,
|
||||
CONF_GEN,
|
||||
@@ -66,6 +69,7 @@ from .const import (
|
||||
GEN2_RELEASE_URL,
|
||||
LOGGER,
|
||||
MAX_SCRIPT_SIZE,
|
||||
PUSH_UPDATE_ISSUE_ID,
|
||||
ROLE_GENERIC,
|
||||
RPC_INPUTS_EVENTS_TYPES,
|
||||
SHAIR_MAX_WORK_HOURS,
|
||||
@@ -732,12 +736,12 @@ def _get_homeassistant_url(hass: HomeAssistant) -> URL | None:
|
||||
return URL(raw_url)
|
||||
|
||||
|
||||
def get_coiot_address(hass: HomeAssistant) -> str | None:
|
||||
async def get_coiot_address(hass: HomeAssistant) -> str | None:
|
||||
"""Return the CoIoT ip address."""
|
||||
url = _get_homeassistant_url(hass)
|
||||
if url is None:
|
||||
if url is None or url.host is None:
|
||||
return None
|
||||
return str(url.host)
|
||||
return await async_get_source_ip(hass, url.host)
|
||||
|
||||
|
||||
def get_rpc_ws_url(hass: HomeAssistant) -> str | None:
|
||||
@@ -1003,3 +1007,86 @@ def is_rpc_ble_scanner_supported(entry: ConfigEntry) -> bool:
|
||||
entry.runtime_data.rpc_supports_scripts
|
||||
and not entry.runtime_data.rpc_zigbee_firmware
|
||||
)
|
||||
|
||||
|
||||
async def check_coiot_config(device: BlockDevice, hass: HomeAssistant) -> bool:
|
||||
"""Check if CoIoT is correctly configured."""
|
||||
if device.model == MODEL_PLUG:
|
||||
# Shelly Plug Gen 1 does not have CoIoT settings
|
||||
return True
|
||||
|
||||
coiot_config = device.settings["coiot"]
|
||||
|
||||
# Check if CoIoT is disabled
|
||||
if not coiot_config.get("enabled"):
|
||||
return False
|
||||
|
||||
coiot_address = await get_coiot_address(hass)
|
||||
if coiot_address is None:
|
||||
LOGGER.debug(
|
||||
"Skipping CoIoT peer check for device %s as no local address is available",
|
||||
device.name,
|
||||
)
|
||||
return True
|
||||
|
||||
coiot_peer = f"{coiot_address}:{get_coiot_port(hass)}"
|
||||
# Check if CoIoT address is not correctly set
|
||||
if (peer_config := coiot_config.get("peer")) and peer_config != coiot_peer:
|
||||
LOGGER.debug(
|
||||
"CoIoT is unconfigured for device %s, peer_config: %s, coiot_peer: %s",
|
||||
device.name,
|
||||
peer_config,
|
||||
coiot_peer,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_manage_coiot_issues_task(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""CoIoT configuration or push updates issues task."""
|
||||
config_issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=entry.unique_id)
|
||||
push_updates_issue_id = PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.runtime_data.block is not None
|
||||
|
||||
device = entry.runtime_data.block.device
|
||||
|
||||
if await check_coiot_config(device, hass):
|
||||
# CoIoT is correctly configured, create push updates issue
|
||||
ir.async_delete_issue(hass, DOMAIN, config_issue_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
push_updates_issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1",
|
||||
translation_key="push_update_failure",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# CoIoT is not correctly configured, create config issue
|
||||
ir.async_delete_issue(hass, DOMAIN, push_updates_issue_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
config_issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="coiot_unconfigured",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
|
||||
@@ -5,10 +5,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
@@ -23,13 +20,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Statistics from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_ENTITY_ID],
|
||||
)
|
||||
|
||||
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"bot_logout_failed": "Failed to logout Telegram bot. Please try again later.",
|
||||
"bot_logout_failed": "Failed to log out Telegram bot. Please try again later.",
|
||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||
"invalid_proxy_url": "{proxy_url_error}",
|
||||
"invalid_trusted_networks": "Invalid trusted network: {error_message}",
|
||||
@@ -231,11 +231,9 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"api_endpoint": "API endpoint",
|
||||
"parse_mode": "Parse mode"
|
||||
},
|
||||
"data_description": {
|
||||
"api_endpoint": "Telegram bot API server endpoint.\nThe bot will be **locked out for 10 minutes** if you switch back to the default.\nDefault: `{default_api_endpoint}`.",
|
||||
"parse_mode": "Default parse mode for messages if not explicit in message data."
|
||||
},
|
||||
"title": "Configure Telegram bot"
|
||||
|
||||
@@ -395,8 +395,8 @@
|
||||
"abort": "mdi:stop-circle",
|
||||
"enabled": "mdi:check-circle",
|
||||
"fault": "mdi:alert-circle",
|
||||
"standby": "mdi:power-sleep",
|
||||
"unavailable": "mdi:car-off"
|
||||
"not_available": "mdi:car-off",
|
||||
"standby": "mdi:power-sleep"
|
||||
}
|
||||
},
|
||||
"di_state_r": {
|
||||
@@ -405,8 +405,8 @@
|
||||
"abort": "mdi:stop-circle",
|
||||
"enabled": "mdi:check-circle",
|
||||
"fault": "mdi:alert-circle",
|
||||
"standby": "mdi:power-sleep",
|
||||
"unavailable": "mdi:car-off"
|
||||
"not_available": "mdi:car-off",
|
||||
"standby": "mdi:power-sleep"
|
||||
}
|
||||
},
|
||||
"di_state_rel": {
|
||||
@@ -415,8 +415,8 @@
|
||||
"abort": "mdi:stop-circle",
|
||||
"enabled": "mdi:check-circle",
|
||||
"fault": "mdi:alert-circle",
|
||||
"standby": "mdi:power-sleep",
|
||||
"unavailable": "mdi:car-off"
|
||||
"not_available": "mdi:car-off",
|
||||
"standby": "mdi:power-sleep"
|
||||
}
|
||||
},
|
||||
"di_state_rer": {
|
||||
@@ -425,8 +425,8 @@
|
||||
"abort": "mdi:stop-circle",
|
||||
"enabled": "mdi:check-circle",
|
||||
"fault": "mdi:alert-circle",
|
||||
"standby": "mdi:power-sleep",
|
||||
"unavailable": "mdi:car-off"
|
||||
"not_available": "mdi:car-off",
|
||||
"standby": "mdi:power-sleep"
|
||||
}
|
||||
},
|
||||
"di_stator_temp_f": {
|
||||
|
||||
@@ -75,11 +75,7 @@ rules:
|
||||
new TeslemetryVehicleData/TeslemetryEnergyData to runtime_data, then trigger
|
||||
entity creation via coordinator listeners in each platform.
|
||||
entity-category: done
|
||||
entity-device-class:
|
||||
status: todo
|
||||
comment: |
|
||||
DRIVE_INVERTER_STATES has "unavailable" as a state value which conflicts with HA's
|
||||
unavailable state - shows duplicate in state trigger UI.
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations:
|
||||
|
||||
@@ -72,7 +72,7 @@ CHARGE_STATES = {
|
||||
}
|
||||
|
||||
DRIVE_INVERTER_STATES = {
|
||||
"Unavailable": "unavailable",
|
||||
"Unavailable": "not_available",
|
||||
"Standby": "standby",
|
||||
"Fault": "fault",
|
||||
"Abort": "abort",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"end_time": "End time",
|
||||
"end_time_description": "The time this schedule ends, e.g. 01:05 for 1:05 AM.",
|
||||
"location_description": "The approximate location the vehicle must be at to use this schedule. Defaults to Home Assistant's configured location.",
|
||||
"not_available": "Not available",
|
||||
"one_time": "One-time",
|
||||
"one_time_description": "If this is a one-time schedule.",
|
||||
"precondition_time": "Precondition time",
|
||||
@@ -18,7 +19,6 @@
|
||||
"schedule_name_description": "The name of the schedule.",
|
||||
"start_time": "Start time",
|
||||
"start_time_description": "The time this schedule begins, e.g. 01:05 for 1:05 AM.",
|
||||
"unavailable": "Unavailable",
|
||||
"vehicle": "Vehicle",
|
||||
"vehicle_to_remove_schedule": "Vehicle to remove schedule from.",
|
||||
"vehicle_to_schedule": "Vehicle to schedule.",
|
||||
@@ -636,8 +636,8 @@
|
||||
"abort": "[%key:component::teslemetry::common::abort%]",
|
||||
"enabled": "[%key:common::state::enabled%]",
|
||||
"fault": "[%key:common::state::fault%]",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"unavailable": "[%key:component::teslemetry::common::unavailable%]"
|
||||
"not_available": "[%key:component::teslemetry::common::not_available%]",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"di_state_r": {
|
||||
@@ -646,8 +646,8 @@
|
||||
"abort": "[%key:component::teslemetry::common::abort%]",
|
||||
"enabled": "[%key:common::state::enabled%]",
|
||||
"fault": "[%key:common::state::fault%]",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"unavailable": "[%key:component::teslemetry::common::unavailable%]"
|
||||
"not_available": "[%key:component::teslemetry::common::not_available%]",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"di_state_rel": {
|
||||
@@ -656,8 +656,8 @@
|
||||
"abort": "[%key:component::teslemetry::common::abort%]",
|
||||
"enabled": "[%key:common::state::enabled%]",
|
||||
"fault": "[%key:common::state::fault%]",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"unavailable": "[%key:component::teslemetry::common::unavailable%]"
|
||||
"not_available": "[%key:component::teslemetry::common::not_available%]",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"di_state_rer": {
|
||||
@@ -666,8 +666,8 @@
|
||||
"abort": "[%key:component::teslemetry::common::abort%]",
|
||||
"enabled": "[%key:common::state::enabled%]",
|
||||
"fault": "[%key:common::state::fault%]",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"unavailable": "[%key:component::teslemetry::common::unavailable%]"
|
||||
"not_available": "[%key:component::teslemetry::common::not_available%]",
|
||||
"standby": "[%key:common::state::standby%]"
|
||||
}
|
||||
},
|
||||
"di_stator_temp_f": {
|
||||
|
||||
@@ -5,10 +5,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
@@ -20,13 +17,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Min/Max from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_ENTITY_ID],
|
||||
)
|
||||
|
||||
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -7,10 +7,7 @@ import logging
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.device import async_entity_id_to_device_id
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
@@ -24,13 +21,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Trend from a config entry."""
|
||||
|
||||
# This can be removed in HA Core 2026.2
|
||||
async_remove_stale_devices_links_keep_entity_device(
|
||||
hass,
|
||||
entry.entry_id,
|
||||
entry.options[CONF_ENTITY_ID],
|
||||
)
|
||||
|
||||
def set_source_entity_id_or_uuid(source_entity_id: str) -> None:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==10.0.1", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==10.1.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.87", "serialx==0.6.2"],
|
||||
"requirements": ["zha==0.0.88", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -53,6 +53,10 @@ import zigpy.backups
|
||||
from zigpy.config import CONF_DEVICE
|
||||
from zigpy.config.validators import cv_boolean
|
||||
from zigpy.types.named import EUI64, KeyData
|
||||
from zigpy.typing import (
|
||||
UNDEFINED as ZIGPY_UNDEFINED,
|
||||
UndefinedType as ZigpyUndefinedType,
|
||||
)
|
||||
from zigpy.zcl.clusters.security import IasAce
|
||||
import zigpy.zdo.types as zdo_types
|
||||
|
||||
@@ -850,7 +854,7 @@ async def websocket_read_zigbee_cluster_attributes(
|
||||
cluster_id: int = msg[ATTR_CLUSTER_ID]
|
||||
cluster_type: str = msg[ATTR_CLUSTER_TYPE]
|
||||
attribute: int = msg[ATTR_ATTRIBUTE]
|
||||
manufacturer: int | None = msg.get(ATTR_MANUFACTURER)
|
||||
manufacturer: int | ZigpyUndefinedType = msg.get(ATTR_MANUFACTURER, ZIGPY_UNDEFINED)
|
||||
zha_device = zha_gateway.get_device(ieee)
|
||||
success = {}
|
||||
failure = {}
|
||||
@@ -1326,7 +1330,9 @@ def async_load_api(hass: HomeAssistant) -> None:
|
||||
cluster_type: str = service.data[ATTR_CLUSTER_TYPE]
|
||||
attribute: int | str = service.data[ATTR_ATTRIBUTE]
|
||||
value: int | bool | str = service.data[ATTR_VALUE]
|
||||
manufacturer: int | None = service.data.get(ATTR_MANUFACTURER)
|
||||
manufacturer: int | ZigpyUndefinedType = service.data.get(
|
||||
ATTR_MANUFACTURER, ZIGPY_UNDEFINED
|
||||
)
|
||||
zha_device = zha_gateway.get_device(ieee)
|
||||
response = None
|
||||
if zha_device is not None:
|
||||
@@ -1380,7 +1386,9 @@ def async_load_api(hass: HomeAssistant) -> None:
|
||||
command_type: str = service.data[ATTR_COMMAND_TYPE]
|
||||
args: list | None = service.data.get(ATTR_ARGS)
|
||||
params: dict | None = service.data.get(ATTR_PARAMS)
|
||||
manufacturer: int | None = service.data.get(ATTR_MANUFACTURER)
|
||||
manufacturer: int | ZigpyUndefinedType = service.data.get(
|
||||
ATTR_MANUFACTURER, ZIGPY_UNDEFINED
|
||||
)
|
||||
zha_device = zha_gateway.get_device(ieee)
|
||||
if zha_device is not None:
|
||||
if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None:
|
||||
@@ -1435,7 +1443,9 @@ def async_load_api(hass: HomeAssistant) -> None:
|
||||
cluster_id: int = service.data[ATTR_CLUSTER_ID]
|
||||
command: int = service.data[ATTR_COMMAND]
|
||||
args: list = service.data[ATTR_ARGS]
|
||||
manufacturer: int | None = service.data.get(ATTR_MANUFACTURER)
|
||||
manufacturer: int | ZigpyUndefinedType = service.data.get(
|
||||
ATTR_MANUFACTURER, ZIGPY_UNDEFINED
|
||||
)
|
||||
group = zha_gateway.get_group(group_id)
|
||||
if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None:
|
||||
_LOGGER.error("Missing manufacturer attribute for cluster: %d", cluster_id)
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260128.1
|
||||
home-assistant-frontend==20260128.3
|
||||
home-assistant-intents==2026.1.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[mypy]
|
||||
python_version = 3.14
|
||||
platform = linux
|
||||
plugins = pydantic.mypy, pydantic.v1.mypy
|
||||
plugins = pydantic.mypy
|
||||
show_error_codes = true
|
||||
follow_imports = normal
|
||||
local_partial_types = true
|
||||
|
||||
Generated
+8
-8
@@ -854,7 +854,7 @@ ecoaliface==0.4.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.5.0
|
||||
eheimdigital==1.6.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -1011,7 +1011,7 @@ freebox-api==1.3.0
|
||||
freesms==0.2.0
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.1
|
||||
fressnapftracker==0.2.2
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1219,7 +1219,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.1
|
||||
home-assistant-frontend==20260128.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
@@ -1691,7 +1691,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.5
|
||||
opower==0.17.0
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2143,7 +2143,7 @@ pyitachip2ir==0.0.7
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.0
|
||||
pyjvcprojector==2.0.1
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -2299,7 +2299,7 @@ pyoppleio-legacy==1.0.8
|
||||
pyosoenergyapi==1.2.4
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==2.2.2
|
||||
pyotgw==2.2.3
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -3097,7 +3097,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.6
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.0.1
|
||||
uiprotect==10.1.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -3299,7 +3299,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.87
|
||||
zha==0.0.88
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
Generated
+8
-8
@@ -754,7 +754,7 @@ easyenergy==2.2.0
|
||||
egauge-async==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.5.0
|
||||
eheimdigital==1.6.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.1
|
||||
@@ -890,7 +890,7 @@ forecast-solar==4.2.0
|
||||
freebox-api==1.3.0
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.1
|
||||
fressnapftracker==0.2.2
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1077,7 +1077,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.1
|
||||
home-assistant-frontend==20260128.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
@@ -1465,7 +1465,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.5
|
||||
opower==0.17.0
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1817,7 +1817,7 @@ pyisy==3.4.1
|
||||
pyituran==0.1.5
|
||||
|
||||
# homeassistant.components.jvc_projector
|
||||
pyjvcprojector==2.0.0
|
||||
pyjvcprojector==2.0.1
|
||||
|
||||
# homeassistant.components.kaleidescape
|
||||
pykaleidescape==1.0.2
|
||||
@@ -1949,7 +1949,7 @@ pyopnsense==0.4.0
|
||||
pyosoenergyapi==1.2.4
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==2.2.2
|
||||
pyotgw==2.2.3
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -2594,7 +2594,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.6
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.0.1
|
||||
uiprotect==10.1.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -2766,7 +2766,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.87
|
||||
zha==0.0.88
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.68.0
|
||||
|
||||
@@ -36,7 +36,6 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
|
||||
"plugins": ", ".join( # noqa: FLY002
|
||||
[
|
||||
"pydantic.mypy",
|
||||
"pydantic.v1.mypy",
|
||||
]
|
||||
),
|
||||
"show_error_codes": "true",
|
||||
|
||||
@@ -190,22 +190,23 @@ async def test_generate_data_with_attachments(
|
||||
assert user_message_with_attachments is not None
|
||||
assert isinstance(user_message_with_attachments["content"], list)
|
||||
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
|
||||
assert user_message_with_attachments["content"] == [
|
||||
{"type": "text", "text": "Test prompt"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "image/jpeg",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "application/pdf",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
text_block, image_block, document_block = user_message_with_attachments["content"]
|
||||
|
||||
# Text block
|
||||
assert text_block["type"] == "text"
|
||||
assert text_block["text"] == "Test prompt"
|
||||
|
||||
# Image attachment
|
||||
assert image_block["type"] == "image"
|
||||
assert image_block["source"] == {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "image/jpeg",
|
||||
"type": "base64",
|
||||
}
|
||||
|
||||
# Document attachment (ignore extra metadata like cache_control)
|
||||
assert document_block["type"] == "document"
|
||||
assert document_block["source"]["data"] == "ZmFrZV9pbWFnZV9kYXRh"
|
||||
assert document_block["source"]["media_type"] == "application/pdf"
|
||||
assert document_block["source"]["type"] == "base64"
|
||||
|
||||
@@ -153,10 +153,13 @@ async def test_template_variables(
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "Okay, let me take care of that for you."
|
||||
)
|
||||
assert (
|
||||
"The user name is Test User." in mock_create_stream.call_args.kwargs["system"]
|
||||
)
|
||||
assert "The user id is 12345." in mock_create_stream.call_args.kwargs["system"]
|
||||
|
||||
system = mock_create_stream.call_args.kwargs["system"]
|
||||
assert isinstance(system, list)
|
||||
system_text = " ".join(block["text"] for block in system if "text" in block)
|
||||
|
||||
assert "The user name is Test User." in system_text
|
||||
assert "The user id is 12345." in system_text
|
||||
|
||||
|
||||
async def test_conversation_agent(
|
||||
@@ -169,6 +172,38 @@ async def test_conversation_agent(
|
||||
assert agent.supported_languages == "*"
|
||||
|
||||
|
||||
async def test_system_prompt_uses_text_block_with_cache_control(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Ensure system prompt is sent as TextBlockParam with cache_control."""
|
||||
context = Context()
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
create_content_block(0, ["ok"]),
|
||||
]
|
||||
|
||||
with patch("anthropic.resources.models.AsyncModels.list", new_callable=AsyncMock):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
None,
|
||||
context,
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
system = mock_create_stream.call_args.kwargs["system"]
|
||||
assert isinstance(system, list)
|
||||
assert len(system) == 1
|
||||
block = system[0]
|
||||
assert block["type"] == "text"
|
||||
assert "Home Assistant" in block["text"]
|
||||
assert block["cache_control"] == {"type": "ephemeral"}
|
||||
|
||||
|
||||
@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
|
||||
@pytest.mark.parametrize(
|
||||
("tool_call_json_parts", "expected_call_tool_args"),
|
||||
@@ -229,10 +264,10 @@ async def test_function_call(
|
||||
agent_id=agent_id,
|
||||
)
|
||||
|
||||
assert (
|
||||
"You are a voice assistant for Home Assistant."
|
||||
in mock_create_stream.mock_calls[1][2]["system"]
|
||||
)
|
||||
system = mock_create_stream.mock_calls[1][2]["system"]
|
||||
assert isinstance(system, list)
|
||||
system_text = " ".join(block["text"] for block in system if "text" in block)
|
||||
assert "You are a voice assistant for Home Assistant." in system_text
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
|
||||
assert (
|
||||
|
||||
@@ -121,7 +121,7 @@ def mock_climate_variables() -> dict:
|
||||
"""Mock climate variable data for default thermostat state."""
|
||||
return {
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_STATE": "Off",
|
||||
"HVAC_MODE": "Heat",
|
||||
"TEMPERATURE_F": 72.5,
|
||||
"HUMIDITY": 45,
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
'current_humidity': 45,
|
||||
'current_temperature': 72,
|
||||
'friendly_name': 'Test Controller Residential Thermostat V2',
|
||||
'hvac_action': <HVACAction.IDLE: 'idle'>,
|
||||
'hvac_action': <HVACAction.OFF: 'off'>,
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Control4 Climate."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -28,6 +29,27 @@ from tests.common import MockConfigEntry, snapshot_platform
|
||||
ENTITY_ID = "climate.test_controller_residential_thermostat_v2"
|
||||
|
||||
|
||||
def _make_climate_data(
|
||||
hvac_state: str = "off",
|
||||
hvac_mode: str = "Heat",
|
||||
temperature: float = 72.0,
|
||||
humidity: int = 50,
|
||||
cool_setpoint: float = 75.0,
|
||||
heat_setpoint: float = 68.0,
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""Build mock climate variable data for item ID 123."""
|
||||
return {
|
||||
123: {
|
||||
"HVAC_STATE": hvac_state,
|
||||
"HVAC_MODE": hvac_mode,
|
||||
"TEMPERATURE_F": temperature,
|
||||
"HUMIDITY": humidity,
|
||||
"COOL_SETPOINT_F": cool_setpoint,
|
||||
"HEAT_SETPOINT_F": heat_setpoint,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Platforms which should be loaded during the test."""
|
||||
@@ -60,6 +82,53 @@ async def test_climate_entities(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_climate_variables", "expected_action"),
|
||||
[
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Off", hvac_mode="Off"),
|
||||
HVACAction.OFF,
|
||||
id="off",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Heat"),
|
||||
HVACAction.HEATING,
|
||||
id="heat",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Cool", hvac_mode="Cool"),
|
||||
HVACAction.COOLING,
|
||||
id="cool",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Dry"),
|
||||
HVACAction.DRYING,
|
||||
id="dry",
|
||||
),
|
||||
pytest.param(
|
||||
_make_climate_data(hvac_state="Fan"),
|
||||
HVACAction.FAN,
|
||||
id="fan",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_c4_account",
|
||||
"mock_c4_director",
|
||||
"mock_climate_update_variables",
|
||||
"init_integration",
|
||||
)
|
||||
async def test_hvac_action_mapping(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
expected_action: HVACAction,
|
||||
) -> None:
|
||||
"""Test all 5 official Control4 HVAC states map to correct HA actions."""
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.attributes["hvac_action"] == expected_action
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"mock_climate_variables",
|
||||
@@ -71,16 +140,7 @@ async def test_climate_entities(
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "off",
|
||||
"HVAC_MODE": "Off",
|
||||
"TEMPERATURE_F": 72.0,
|
||||
"HUMIDITY": 50,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
},
|
||||
_make_climate_data(hvac_state="Off", hvac_mode="Off"),
|
||||
HVACMode.OFF,
|
||||
HVACAction.OFF,
|
||||
None,
|
||||
@@ -89,16 +149,13 @@ async def test_climate_entities(
|
||||
id="off",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "cooling",
|
||||
"HVAC_MODE": "Cool",
|
||||
"TEMPERATURE_F": 74.0,
|
||||
"HUMIDITY": 55,
|
||||
"COOL_SETPOINT_F": 72.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
},
|
||||
_make_climate_data(
|
||||
hvac_state="Cool",
|
||||
hvac_mode="Cool",
|
||||
temperature=74.0,
|
||||
humidity=55,
|
||||
cool_setpoint=72.0,
|
||||
),
|
||||
HVACMode.COOL,
|
||||
HVACAction.COOLING,
|
||||
72.0,
|
||||
@@ -107,16 +164,12 @@ async def test_climate_entities(
|
||||
id="cool",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "heating",
|
||||
"HVAC_MODE": "Auto",
|
||||
"TEMPERATURE_F": 65.0,
|
||||
"HUMIDITY": 40,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
},
|
||||
_make_climate_data(
|
||||
hvac_state="Heat",
|
||||
hvac_mode="Auto",
|
||||
temperature=65.0,
|
||||
humidity=40,
|
||||
),
|
||||
HVACMode.HEAT_COOL,
|
||||
HVACAction.HEATING,
|
||||
None,
|
||||
@@ -143,6 +196,7 @@ async def test_climate_states(
|
||||
) -> None:
|
||||
"""Test climate entity in different states."""
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == expected_hvac_mode
|
||||
assert state.attributes["hvac_action"] == expected_hvac_action
|
||||
|
||||
@@ -186,30 +240,21 @@ async def test_set_hvac_mode(
|
||||
("mock_climate_variables", "method_name"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_MODE": "Heat",
|
||||
"TEMPERATURE_F": 72.5,
|
||||
"HUMIDITY": 45,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
},
|
||||
_make_climate_data(
|
||||
hvac_state="Off",
|
||||
temperature=72.5,
|
||||
humidity=45,
|
||||
),
|
||||
"setHeatSetpointF",
|
||||
id="heat",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_MODE": "Cool",
|
||||
"TEMPERATURE_F": 74.0,
|
||||
"HUMIDITY": 50,
|
||||
"COOL_SETPOINT_F": 72.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
},
|
||||
_make_climate_data(
|
||||
hvac_state="Cool",
|
||||
hvac_mode="Cool",
|
||||
temperature=74.0,
|
||||
cool_setpoint=72.0,
|
||||
),
|
||||
"setCoolSetpointF",
|
||||
id="cool",
|
||||
),
|
||||
@@ -240,16 +285,7 @@ async def test_set_temperature(
|
||||
@pytest.mark.parametrize(
|
||||
"mock_climate_variables",
|
||||
[
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_MODE": "Auto",
|
||||
"TEMPERATURE_F": 70.0,
|
||||
"HUMIDITY": 50,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
}
|
||||
_make_climate_data(hvac_state="Off", hvac_mode="Auto"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
@@ -300,7 +336,7 @@ async def test_climate_not_created_when_no_initial_data(
|
||||
[
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_STATE": "Off",
|
||||
"HVAC_MODE": "Heat",
|
||||
# Missing TEMPERATURE_F and HUMIDITY
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
@@ -331,16 +367,7 @@ async def test_climate_missing_variables(
|
||||
@pytest.mark.parametrize(
|
||||
"mock_climate_variables",
|
||||
[
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "idle",
|
||||
"HVAC_MODE": "UnknownMode",
|
||||
"TEMPERATURE_F": 72.0,
|
||||
"HUMIDITY": 50,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
}
|
||||
_make_climate_data(hvac_state="off", hvac_mode="UnknownMode"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
@@ -362,16 +389,7 @@ async def test_climate_unknown_hvac_mode(
|
||||
@pytest.mark.parametrize(
|
||||
"mock_climate_variables",
|
||||
[
|
||||
{
|
||||
123: {
|
||||
"HVAC_STATE": "unknown_state",
|
||||
"HVAC_MODE": "Heat",
|
||||
"TEMPERATURE_F": 72.0,
|
||||
"HUMIDITY": 50,
|
||||
"COOL_SETPOINT_F": 75.0,
|
||||
"HEAT_SETPOINT_F": 68.0,
|
||||
}
|
||||
}
|
||||
_make_climate_data(hvac_state="unknown_state"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.conversation.chat_log import (
|
||||
)
|
||||
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
|
||||
from homeassistant.components.conversation.models import ConversationInput
|
||||
from homeassistant.components.conversation.trigger import TriggerDetails
|
||||
from homeassistant.components.cover import SERVICE_OPEN_COVER
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_get_assistant_settings,
|
||||
@@ -429,7 +428,7 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
|
||||
manager = get_agent_manager(hass)
|
||||
|
||||
callback = AsyncMock(return_value=trigger_response)
|
||||
unregister = manager.register_trigger(TriggerDetails(trigger_sentences, callback))
|
||||
unregister = manager.register_trigger(trigger_sentences, callback)
|
||||
|
||||
result = await conversation.async_converse(hass, "Not the trigger", None, Context())
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
@@ -485,7 +484,7 @@ async def test_trigger_sentence_response_translation(
|
||||
return_value=translations.get(language),
|
||||
):
|
||||
unregister = manager.register_trigger(
|
||||
TriggerDetails(["test sentence"], AsyncMock(return_value=None))
|
||||
["test sentence"], AsyncMock(return_value=None)
|
||||
)
|
||||
result = await conversation.async_converse(
|
||||
hass, "test sentence", None, Context()
|
||||
@@ -3496,7 +3495,7 @@ async def test_trigger_tool_call_in_chat_log(hass: HomeAssistant) -> None:
|
||||
|
||||
manager = get_agent_manager(hass)
|
||||
callback = AsyncMock(return_value=trigger_response)
|
||||
manager.register_trigger(TriggerDetails([trigger_sentence], callback))
|
||||
manager.register_trigger([trigger_sentence], callback)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass, trigger_sentence, None, Context(), None
|
||||
|
||||
@@ -156,7 +156,7 @@ async def test_reload(hass: HomeAssistant) -> None:
|
||||
# Confirm intents are loaded
|
||||
assert agent._lang_intents.get(language)
|
||||
# Confirm config intents are empty
|
||||
assert not agent._config_intents["intents"]
|
||||
assert not agent._config_intents_config["intents"]
|
||||
|
||||
# Try to clear for a different language
|
||||
await hass.services.async_call(
|
||||
@@ -166,7 +166,7 @@ async def test_reload(hass: HomeAssistant) -> None:
|
||||
# Confirm intents are still loaded
|
||||
assert agent._lang_intents.get(language)
|
||||
# Confirm config intents are still empty
|
||||
assert not agent._config_intents["intents"]
|
||||
assert not agent._config_intents_config["intents"]
|
||||
|
||||
# Reload from a changed configuration file
|
||||
hass_config_new = {
|
||||
@@ -187,7 +187,7 @@ async def test_reload(hass: HomeAssistant) -> None:
|
||||
# Confirm intent cache is cleared
|
||||
assert not agent._lang_intents.get(language)
|
||||
# Confirm new config intents are loaded
|
||||
assert agent._config_intents["intents"]
|
||||
assert agent._config_intents_config["intents"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_components")
|
||||
|
||||
@@ -49,6 +49,7 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]:
|
||||
flexit_bacnet.air_temp_setpoint_away = 18.0
|
||||
flexit_bacnet.air_temp_setpoint_home = 22.0
|
||||
flexit_bacnet.ventilation_mode = 4
|
||||
flexit_bacnet.operation_mode = 4 # HIGH mode
|
||||
flexit_bacnet.air_filter_operating_time = 8000
|
||||
flexit_bacnet.outside_air_temperature = -8.6
|
||||
flexit_bacnet.supply_air_temperature = 19.1
|
||||
@@ -68,6 +69,8 @@ def mock_flexit_bacnet() -> Generator[AsyncMock]:
|
||||
flexit_bacnet.air_filter_exchange_interval = 8784
|
||||
flexit_bacnet.electric_heater = True
|
||||
flexit_bacnet.fireplace_mode_runtime = 10
|
||||
flexit_bacnet.fireplace_ventilation_status = False
|
||||
flexit_bacnet.cooker_hood_status = False
|
||||
|
||||
# Mock fan setpoints
|
||||
flexit_bacnet.fan_setpoint_extract_air_fire = 56
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
'preset_modes': list([
|
||||
'away',
|
||||
'home',
|
||||
'boost',
|
||||
'high',
|
||||
'fireplace',
|
||||
]),
|
||||
'target_temp_step': 0.5,
|
||||
}),
|
||||
@@ -43,7 +44,7 @@
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'translation_key': None,
|
||||
'translation_key': 'flexit_bacnet',
|
||||
'unique_id': '0000-0001',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
@@ -60,11 +61,12 @@
|
||||
]),
|
||||
'max_temp': 30,
|
||||
'min_temp': 10,
|
||||
'preset_mode': 'boost',
|
||||
'preset_mode': 'high',
|
||||
'preset_modes': list([
|
||||
'away',
|
||||
'home',
|
||||
'boost',
|
||||
'high',
|
||||
'fireplace',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'target_temp_step': 0.5,
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.device_name_electric_heater-entry]
|
||||
@@ -99,56 +99,6 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.device_name_fireplace_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': None,
|
||||
'entity_id': 'switch.device_name_fireplace_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Fireplace mode',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SwitchDeviceClass.SWITCH: 'switch'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Fireplace mode',
|
||||
'platform': 'flexit_bacnet',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'fireplace_mode',
|
||||
'unique_id': '0000-0001-fireplace_mode',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[switch.device_name_fireplace_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'switch',
|
||||
'friendly_name': 'Device Name Fireplace mode',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.device_name_fireplace_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches_implementation[switch.device_name_electric_heater-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
|
||||
@@ -59,6 +59,7 @@ async def test_set_hvac_preset_mode(
|
||||
|
||||
# Set preset mode to away
|
||||
mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_AWAY
|
||||
mock_flexit_bacnet.operation_mode = 2 # OPERATION_MODE_AWAY
|
||||
await hass.services.async_call(
|
||||
Platform.CLIMATE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
@@ -78,6 +79,7 @@ async def test_set_hvac_preset_mode(
|
||||
|
||||
# Set preset mode to home
|
||||
mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_HOME
|
||||
mock_flexit_bacnet.operation_mode = 3 # OPERATION_MODE_HOME
|
||||
await hass.services.async_call(
|
||||
Platform.CLIMATE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
@@ -121,6 +123,7 @@ async def test_set_hvac_mode(
|
||||
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
|
||||
|
||||
mock_flexit_bacnet.ventilation_mode = VENTILATION_MODE_STOP
|
||||
mock_flexit_bacnet.operation_mode = 1 # OPERATION_MODE_OFF
|
||||
await hass.services.async_call(
|
||||
Platform.CLIMATE,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
|
||||
@@ -23,6 +23,7 @@ from tests.common import MockConfigEntry, snapshot_platform
|
||||
ENTITY_ID = "switch.device_name_electric_heater"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_switches(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
|
||||
@@ -14,8 +14,6 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||
|
||||
from .test_humidifier import ENT_SENSOR
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@@ -142,93 +140,6 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s
|
||||
return events
|
||||
|
||||
|
||||
async def test_device_cleaning(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test cleaning of devices linked to the helper config entry."""
|
||||
|
||||
# Source entity device config entry
|
||||
source_config_entry = MockConfigEntry()
|
||||
source_config_entry.add_to_hass(hass)
|
||||
|
||||
# Device entry of the source entity
|
||||
source_device1_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=source_config_entry.entry_id,
|
||||
identifiers={("switch", "identifier_test1")},
|
||||
connections={("mac", "30:31:32:33:34:01")},
|
||||
)
|
||||
|
||||
# Source entity registry
|
||||
source_entity = entity_registry.async_get_or_create(
|
||||
"switch",
|
||||
"test",
|
||||
"source",
|
||||
config_entry=source_config_entry,
|
||||
device_id=source_device1_entry.id,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert entity_registry.async_get("switch.test_source") is not None
|
||||
|
||||
# Configure the configuration entry for helper
|
||||
helper_config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"device_class": "humidifier",
|
||||
"dry_tolerance": 2.0,
|
||||
"humidifier": "switch.test_source",
|
||||
"name": "Test",
|
||||
"target_sensor": ENT_SENSOR,
|
||||
"wet_tolerance": 4.0,
|
||||
},
|
||||
title="Test",
|
||||
)
|
||||
helper_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the helper entity
|
||||
helper_entity = entity_registry.async_get("humidifier.test")
|
||||
assert helper_entity is not None
|
||||
assert helper_entity.device_id == source_entity.device_id
|
||||
|
||||
# Device entry incorrectly linked to config entry
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=helper_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test2")},
|
||||
connections={("mac", "30:31:32:33:34:02")},
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=helper_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test3")},
|
||||
connections={("mac", "30:31:32:33:34:03")},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Before reloading the config entry, 3 devices are expected to be linked
|
||||
devices_before_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
helper_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_before_reload) == 2
|
||||
|
||||
# Config entry reload
|
||||
await hass.config_entries.async_reload(helper_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the helper entity
|
||||
helper_entity = entity_registry.async_get("humidifier.test")
|
||||
assert helper_entity is not None
|
||||
assert helper_entity.device_id == source_entity.device_id
|
||||
|
||||
# After reloading the config entry, only one linked device is expected
|
||||
devices_after_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
helper_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_after_reload) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"sensor_config_entry",
|
||||
"sensor_device",
|
||||
|
||||
@@ -140,93 +140,6 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s
|
||||
return events
|
||||
|
||||
|
||||
async def test_device_cleaning(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test cleaning of devices linked to the helper config entry."""
|
||||
|
||||
# Source entity device config entry
|
||||
source_config_entry = MockConfigEntry()
|
||||
source_config_entry.add_to_hass(hass)
|
||||
|
||||
# Device entry of the source entity
|
||||
source_device1_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=source_config_entry.entry_id,
|
||||
identifiers={("switch", "identifier_test1")},
|
||||
connections={("mac", "30:31:32:33:34:01")},
|
||||
)
|
||||
|
||||
# Source entity registry
|
||||
source_entity = entity_registry.async_get_or_create(
|
||||
"switch",
|
||||
"test",
|
||||
"source",
|
||||
config_entry=source_config_entry,
|
||||
device_id=source_device1_entry.id,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert entity_registry.async_get("switch.test_source") is not None
|
||||
|
||||
# Configure the configuration entry for helper
|
||||
helper_config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
"name": "Test",
|
||||
"heater": "switch.test_source",
|
||||
"target_sensor": "sensor.temperature",
|
||||
"ac_mode": False,
|
||||
"cold_tolerance": 0.3,
|
||||
"hot_tolerance": 0.3,
|
||||
},
|
||||
title="Test",
|
||||
)
|
||||
helper_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(helper_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the helper entity
|
||||
helper_entity = entity_registry.async_get("climate.test")
|
||||
assert helper_entity is not None
|
||||
assert helper_entity.device_id == source_entity.device_id
|
||||
|
||||
# Device entry incorrectly linked to config entry
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=helper_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test2")},
|
||||
connections={("mac", "30:31:32:33:34:02")},
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=helper_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test3")},
|
||||
connections={("mac", "30:31:32:33:34:03")},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Before reloading the config entry, 3 devices are expected to be linked
|
||||
devices_before_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
helper_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_before_reload) == 2
|
||||
|
||||
# Config entry reload
|
||||
await hass.config_entries.async_reload(helper_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the helper entity
|
||||
helper_entity = entity_registry.async_get("climate.test")
|
||||
assert helper_entity is not None
|
||||
assert helper_entity.device_id == source_entity.device_id
|
||||
|
||||
# After reloading the config entry, only one linked device is expected
|
||||
devices_after_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
helper_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_after_reload) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"sensor_config_entry",
|
||||
"sensor_device",
|
||||
|
||||
@@ -112,94 +112,6 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry)
|
||||
assert loaded_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
async def test_device_cleaning(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the cleaning of devices linked to the helper History stats."""
|
||||
|
||||
# Source entity device config entry
|
||||
source_config_entry = MockConfigEntry()
|
||||
source_config_entry.add_to_hass(hass)
|
||||
|
||||
# Device entry of the source entity
|
||||
source_device1_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=source_config_entry.entry_id,
|
||||
identifiers={("binary_sensor", "identifier_test1")},
|
||||
connections={("mac", "30:31:32:33:34:01")},
|
||||
)
|
||||
|
||||
# Source entity registry
|
||||
source_entity = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"source",
|
||||
config_entry=source_config_entry,
|
||||
device_id=source_device1_entry.id,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert entity_registry.async_get("binary_sensor.test_source") is not None
|
||||
|
||||
# Configure the configuration entry for History stats
|
||||
history_stats_config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "binary_sensor.test_source",
|
||||
CONF_STATE: ["on"],
|
||||
CONF_TYPE: "count",
|
||||
CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}",
|
||||
CONF_END: "{{ utcnow() }}",
|
||||
},
|
||||
title="History stats",
|
||||
)
|
||||
history_stats_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the History stats sensor
|
||||
history_stats_entity = entity_registry.async_get("sensor.history_stats")
|
||||
assert history_stats_entity is not None
|
||||
assert history_stats_entity.device_id == source_entity.device_id
|
||||
|
||||
# Device entry incorrectly linked to History stats config entry
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=history_stats_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test2")},
|
||||
connections={("mac", "30:31:32:33:34:02")},
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=history_stats_config_entry.entry_id,
|
||||
identifiers={("sensor", "identifier_test3")},
|
||||
connections={("mac", "30:31:32:33:34:03")},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Before reloading the config entry, two devices are expected to be linked
|
||||
devices_before_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
history_stats_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_before_reload) == 2
|
||||
|
||||
# Config entry reload
|
||||
await hass.config_entries.async_reload(history_stats_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Confirm the link between the source entity device and the History stats sensor
|
||||
history_stats_entity = entity_registry.async_get("sensor.history_stats")
|
||||
assert history_stats_entity is not None
|
||||
assert history_stats_entity.device_id == source_entity.device_id
|
||||
|
||||
# After reloading the config entry, only one linked device is expected
|
||||
devices_after_reload = device_registry.devices.get_devices_for_config_entry_id(
|
||||
history_stats_config_entry.entry_id
|
||||
)
|
||||
assert len(devices_after_reload) == 0
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("recorder_mock")
|
||||
async def test_async_handle_source_entity_changes_source_entity_removed(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -44,7 +44,12 @@ from homeassistant.components.number import (
|
||||
SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -757,3 +762,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "number.oven_setpoint_temperature"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.NUMBER,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE}",
|
||||
suggested_object_id="oven_setpoint_temperature",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -44,6 +44,7 @@ from homeassistant.components.select import (
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
SERVICE_SELECT_OPTION,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
@@ -1105,3 +1106,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "select.washer_temperature"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.SELECT,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE}",
|
||||
suggested_object_id="washer_temperature",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -38,6 +38,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
@@ -878,3 +879,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "switch.dishwasher_half_load"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.SWITCH,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.DISHCARE_DISHWASHER_HALF_LOAD}",
|
||||
suggested_object_id="dishwasher_half_load",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -20,6 +20,7 @@ from homeassistant.components.homeassistant import (
|
||||
SERVICE_SET_LOCATION,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_ENTITY_ID,
|
||||
ENTITY_MATCH_ALL,
|
||||
ENTITY_MATCH_NONE,
|
||||
@@ -34,15 +35,25 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
from homeassistant.helpers import entity, entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockEntityPlatform,
|
||||
MockUser,
|
||||
RegistryEntryWithDefaults,
|
||||
async_capture_events,
|
||||
async_mock_service,
|
||||
mock_area_registry,
|
||||
mock_device_registry,
|
||||
mock_registry,
|
||||
patch_yaml_files,
|
||||
)
|
||||
|
||||
@@ -85,6 +96,168 @@ async def test_toggle(hass: HomeAssistant) -> None:
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_toggle_area_any_on_turns_all_off(hass: HomeAssistant) -> None:
|
||||
"""Test toggle with area target turns all off when any entity is on."""
|
||||
await async_setup_component(hass, ha.DOMAIN, {})
|
||||
|
||||
# Set up area
|
||||
area = ar.AreaEntry(
|
||||
id="test-area",
|
||||
name="Test area",
|
||||
aliases={},
|
||||
floor_id=None,
|
||||
icon=None,
|
||||
picture=None,
|
||||
temperature_entity_id=None,
|
||||
humidity_entity_id=None,
|
||||
)
|
||||
mock_area_registry(hass, {area.id: area})
|
||||
|
||||
# Set up device in area
|
||||
device = dr.DeviceEntry(id="device-1", area_id="test-area")
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
# Set up entities in the area
|
||||
entity1 = RegistryEntryWithDefaults(
|
||||
entity_id="light.one",
|
||||
unique_id="light-1",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
entity2 = RegistryEntryWithDefaults(
|
||||
entity_id="light.two",
|
||||
unique_id="light-2",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
mock_registry(hass, {entity1.entity_id: entity1, entity2.entity_id: entity2})
|
||||
|
||||
# One entity ON, one OFF
|
||||
hass.states.async_set("light.one", STATE_ON)
|
||||
hass.states.async_set("light.two", STATE_OFF)
|
||||
|
||||
# Mock turn_off service (toggle should become turn_off when any is on)
|
||||
turn_off_calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
|
||||
turn_on_calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
|
||||
|
||||
await hass.services.async_call(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, {ATTR_AREA_ID: "test-area"}, blocking=True
|
||||
)
|
||||
|
||||
# Should call turn_off, not turn_on or toggle
|
||||
assert len(turn_off_calls) == 1
|
||||
assert len(turn_on_calls) == 0
|
||||
assert set(turn_off_calls[0].data[ATTR_ENTITY_ID]) == {"light.one", "light.two"}
|
||||
|
||||
|
||||
async def test_toggle_area_all_off_turns_all_on(hass: HomeAssistant) -> None:
|
||||
"""Test toggle with area target turns all on when all entities are off."""
|
||||
await async_setup_component(hass, ha.DOMAIN, {})
|
||||
|
||||
# Set up area
|
||||
area = ar.AreaEntry(
|
||||
id="test-area",
|
||||
name="Test area",
|
||||
aliases={},
|
||||
floor_id=None,
|
||||
icon=None,
|
||||
picture=None,
|
||||
temperature_entity_id=None,
|
||||
humidity_entity_id=None,
|
||||
)
|
||||
mock_area_registry(hass, {area.id: area})
|
||||
|
||||
# Set up device in area
|
||||
device = dr.DeviceEntry(id="device-1", area_id="test-area")
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
# Set up entities in the area
|
||||
entity1 = RegistryEntryWithDefaults(
|
||||
entity_id="light.one",
|
||||
unique_id="light-1",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
entity2 = RegistryEntryWithDefaults(
|
||||
entity_id="light.two",
|
||||
unique_id="light-2",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
mock_registry(hass, {entity1.entity_id: entity1, entity2.entity_id: entity2})
|
||||
|
||||
# Both entities OFF
|
||||
hass.states.async_set("light.one", STATE_OFF)
|
||||
hass.states.async_set("light.two", STATE_OFF)
|
||||
|
||||
# Mock services
|
||||
turn_off_calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
|
||||
turn_on_calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
|
||||
|
||||
await hass.services.async_call(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, {ATTR_AREA_ID: "test-area"}, blocking=True
|
||||
)
|
||||
|
||||
# Should call turn_on, not turn_off or toggle
|
||||
assert len(turn_on_calls) == 1
|
||||
assert len(turn_off_calls) == 0
|
||||
assert set(turn_on_calls[0].data[ATTR_ENTITY_ID]) == {"light.one", "light.two"}
|
||||
|
||||
|
||||
async def test_toggle_area_all_on_turns_all_off(hass: HomeAssistant) -> None:
|
||||
"""Test toggle with area target turns all off when all entities are on."""
|
||||
await async_setup_component(hass, ha.DOMAIN, {})
|
||||
|
||||
# Set up area
|
||||
area = ar.AreaEntry(
|
||||
id="test-area",
|
||||
name="Test area",
|
||||
aliases={},
|
||||
floor_id=None,
|
||||
icon=None,
|
||||
picture=None,
|
||||
temperature_entity_id=None,
|
||||
humidity_entity_id=None,
|
||||
)
|
||||
mock_area_registry(hass, {area.id: area})
|
||||
|
||||
# Set up device in area
|
||||
device = dr.DeviceEntry(id="device-1", area_id="test-area")
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
# Set up entities in the area
|
||||
entity1 = RegistryEntryWithDefaults(
|
||||
entity_id="light.one",
|
||||
unique_id="light-1",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
entity2 = RegistryEntryWithDefaults(
|
||||
entity_id="light.two",
|
||||
unique_id="light-2",
|
||||
platform="test",
|
||||
device_id=device.id,
|
||||
)
|
||||
mock_registry(hass, {entity1.entity_id: entity1, entity2.entity_id: entity2})
|
||||
|
||||
# Both entities ON
|
||||
hass.states.async_set("light.one", STATE_ON)
|
||||
hass.states.async_set("light.two", STATE_ON)
|
||||
|
||||
# Mock services
|
||||
turn_off_calls = async_mock_service(hass, "light", SERVICE_TURN_OFF)
|
||||
turn_on_calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
|
||||
|
||||
await hass.services.async_call(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, {ATTR_AREA_ID: "test-area"}, blocking=True
|
||||
)
|
||||
|
||||
# Should call turn_off, not turn_on or toggle
|
||||
assert len(turn_off_calls) == 1
|
||||
assert len(turn_on_calls) == 0
|
||||
assert set(turn_off_calls[0].data[ATTR_ENTITY_ID]) == {"light.one", "light.two"}
|
||||
|
||||
|
||||
@patch("homeassistant.config.os.path.isfile", Mock(return_value=True))
|
||||
async def test_reload_core_conf(hass: HomeAssistant) -> None:
|
||||
"""Test reload core conf service."""
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config as hass_config
|
||||
from homeassistant.components.intent_script import DOMAIN
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME, SERVICE_RELOAD
|
||||
@@ -294,6 +296,120 @@ async def test_intent_script_targets(
|
||||
calls.clear()
|
||||
|
||||
|
||||
async def test_intent_script_action_validation(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test action validation in intent scripts.
|
||||
|
||||
This tests that async_validate_actions_config is called during setup,
|
||||
which resolves entity registry IDs to entity IDs in conditions.
|
||||
Without async_validate_actions_config, the entity registry ID would not
|
||||
be resolved and the condition would fail.
|
||||
"""
|
||||
calls = async_mock_service(hass, "test", "service")
|
||||
|
||||
entry = entity_registry.async_get_or_create(
|
||||
"binary_sensor", "test", "1234", suggested_object_id="test_sensor"
|
||||
)
|
||||
assert entry.entity_id == "binary_sensor.test_sensor"
|
||||
|
||||
# Use a non-existent entity registry ID to trigger validation error
|
||||
non_existent_registry_id = "abcd1234abcd1234abcd1234abcd1234"
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"intent_script",
|
||||
{
|
||||
"intent_script": {
|
||||
"ChooseWithRegistryIdIntent": {
|
||||
"action": [
|
||||
{
|
||||
"choose": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
# Use entity registry ID instead of entity_id
|
||||
# This requires async_validate_actions_config
|
||||
# to resolve to the actual entity_id
|
||||
"entity_id": entry.id,
|
||||
"state": "on",
|
||||
}
|
||||
],
|
||||
"sequence": [
|
||||
{
|
||||
"action": "test.service",
|
||||
"data": {"result": "sensor_on"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"default": [
|
||||
{
|
||||
"action": "test.service",
|
||||
"data": {"result": "sensor_off"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"speech": {"text": "Done"},
|
||||
},
|
||||
# This intent has an invalid entity registry ID and should fail validation
|
||||
"InvalidIntent": {
|
||||
"action": [
|
||||
{
|
||||
"choose": [
|
||||
{
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "state",
|
||||
"entity_id": non_existent_registry_id,
|
||||
"state": "on",
|
||||
}
|
||||
],
|
||||
"sequence": [
|
||||
{"action": "test.service"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"speech": {"text": "Invalid"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Verify that the invalid intent logged an error
|
||||
assert "Failed to validate actions for intent InvalidIntent" in caplog.text
|
||||
|
||||
# The invalid intent should not be registered
|
||||
with pytest.raises(intent.UnknownIntent):
|
||||
await intent.async_handle(hass, "test", "InvalidIntent")
|
||||
|
||||
# Test when condition is true (sensor is "on")
|
||||
hass.states.async_set("binary_sensor.test_sensor", "on")
|
||||
|
||||
response = await intent.async_handle(hass, "test", "ChooseWithRegistryIdIntent")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["result"] == "sensor_on"
|
||||
assert response.speech["plain"]["speech"] == "Done"
|
||||
|
||||
calls.clear()
|
||||
|
||||
# Test when condition is false (sensor is "off")
|
||||
hass.states.async_set("binary_sensor.test_sensor", "off")
|
||||
|
||||
response = await intent.async_handle(hass, "test", "ChooseWithRegistryIdIntent")
|
||||
|
||||
assert len(calls) == 1
|
||||
assert calls[0].data["result"] == "sensor_off"
|
||||
assert response.speech["plain"]["speech"] == "Done"
|
||||
|
||||
|
||||
async def test_reload(hass: HomeAssistant) -> None:
|
||||
"""Verify we can reload intent config."""
|
||||
|
||||
|
||||
@@ -168,3 +168,67 @@ async def test_zeroconf_already_configured(
|
||||
)
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == "already_configured"
|
||||
|
||||
|
||||
async def test_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reauth flow."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "reauth_confirm"
|
||||
|
||||
new_api_key = "new-api-key"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_KEY: new_api_key}
|
||||
)
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_API_KEY] == new_api_key
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_error"),
|
||||
[
|
||||
(LiebherrAuthenticationError("Invalid"), "invalid_auth"),
|
||||
(LiebherrConnectionError("Failed"), "cannot_connect"),
|
||||
(Exception("Unexpected"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_reauth_flow_errors_with_recovery(
|
||||
hass: HomeAssistant,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
expected_error: str,
|
||||
) -> None:
|
||||
"""Test reauth flow error handling with successful recovery."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_config_entry.start_reauth_flow(hass)
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "reauth_confirm"
|
||||
|
||||
# Trigger error
|
||||
mock_liebherr_client.get_devices.side_effect = side_effect
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_KEY: "new-api-key"}
|
||||
)
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("errors") == {"base": expected_error}
|
||||
|
||||
# Recover and complete successfully
|
||||
mock_liebherr_client.get_devices.side_effect = None
|
||||
new_api_key = "new-api-key-recovered"
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_API_KEY: new_api_key}
|
||||
)
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == "reauth_successful"
|
||||
assert mock_config_entry.data[CONF_API_KEY] == new_api_key
|
||||
|
||||
@@ -20,6 +20,8 @@ from pyliebherrhomeapi.exceptions import (
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.liebherr.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -135,9 +137,8 @@ async def test_multi_zone_with_none_position(
|
||||
[
|
||||
LiebherrConnectionError("Connection failed"),
|
||||
LiebherrTimeoutError("Timeout"),
|
||||
LiebherrAuthenticationError("API key revoked"),
|
||||
],
|
||||
ids=["connection_error", "timeout_error", "auth_error"],
|
||||
ids=["connection_error", "timeout_error"],
|
||||
)
|
||||
async def test_sensor_update_failure(
|
||||
hass: HomeAssistant,
|
||||
@@ -179,6 +180,45 @@ async def test_sensor_update_failure(
|
||||
assert state.state == "5"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_sensor_update_auth_failure_triggers_reauth(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test authentication error triggers reauth flow."""
|
||||
entity_id = "sensor.test_fridge_top_zone"
|
||||
|
||||
# Initial state should be available with value
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "5"
|
||||
|
||||
# Simulate auth error
|
||||
mock_liebherr_client.get_device_state.side_effect = LiebherrAuthenticationError(
|
||||
"API key revoked"
|
||||
)
|
||||
|
||||
# Advance time to trigger coordinator refresh (60 second interval)
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Sensor should now be unavailable
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
# Config entry should be in reauth state
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
flows = hass.config_entries.flow.async_progress()
|
||||
assert any(
|
||||
flow["handler"] == DOMAIN and flow["context"]["source"] == "reauth"
|
||||
for flow in flows
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_sensor_unavailable_when_control_missing(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -368,7 +368,7 @@ async def test_lovelace_from_yaml_creates_repair_issue(
|
||||
"""Test YAML mode creates a repair issue."""
|
||||
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}})
|
||||
|
||||
# Panel should still be registered for backwards compatibility
|
||||
# Panel should be registered as a YAML dashboard
|
||||
assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "yaml"}
|
||||
|
||||
# Repair issue should be created
|
||||
@@ -803,3 +803,47 @@ async def test_lovelace_no_migration_no_default_panel_set(
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"]["value"] is None
|
||||
|
||||
|
||||
async def test_lovelace_info_default(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test lovelace/info returns default resource_mode."""
|
||||
assert await async_setup_component(hass, "lovelace", {})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json({"id": 5, "type": "lovelace/info"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"resource_mode": "storage"}
|
||||
|
||||
|
||||
async def test_lovelace_info_yaml_resource_mode(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test lovelace/info returns yaml resource_mode."""
|
||||
assert await async_setup_component(
|
||||
hass, "lovelace", {"lovelace": {"resource_mode": "yaml"}}
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json({"id": 5, "type": "lovelace/info"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"resource_mode": "yaml"}
|
||||
|
||||
|
||||
async def test_lovelace_info_yaml_mode_fallback(
|
||||
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test lovelace/info returns yaml resource_mode when mode is yaml."""
|
||||
assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "yaml"}})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json({"id": 5, "type": "lovelace/info"})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"resource_mode": "yaml"}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user