Compare commits

...

41 Commits

Author SHA1 Message Date
Claude f119b6dd2e Implement conditional area toggle behavior
When toggling an area, instead of forwarding individual toggle commands
to each entity, check if any entity is currently on. If any is on, turn
all entities off; otherwise turn all entities on.

This provides a more intuitive toggle behavior for areas, similar to how
light switches work in rooms with multiple lights.

https://claude.ai/code/session_014GHmTAHzUNCVQz6jbtfRSm
2026-01-31 15:22:47 +00:00
Shay Levy eafeba792d Fix Shelly CoIoT repair issue (#161973)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-31 16:33:31 +02:00
Norbert Rittel c9318b6fbf Clarify action description for input_button helper (#161963) 2026-01-31 15:16:36 +01:00
epenet 99be382abf Remove outdated device registry cleanup in generic_hygrostat (#161859) 2026-01-31 15:15:19 +01:00
epenet 7cfcfca210 Remove outdated device registry cleanup in generic_thermostat (#161861) 2026-01-31 15:14:57 +01:00
epenet f29daccb19 Remove outdated device registry cleanup in history_stats (#161862) 2026-01-31 15:14:42 +01:00
epenet be869fce6c Remove outdated device registry cleanup in mold_indicator (#161864) 2026-01-31 15:14:26 +01:00
epenet 7bb0414a39 Remove outdated device registry cleanup in statistics (#161865) 2026-01-31 15:14:09 +01:00
epenet 3f8807d063 Remove outdated device registry cleanup in threshold (#161866) 2026-01-31 15:13:54 +01:00
mettolen 67642e6246 Add reauthentication flow to Liebherr integration (#161902)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-31 15:12:52 +01:00
mvn23 0d215597f3 Fix OpenTherm Gateway button availability (#161933) 2026-01-31 15:06:21 +01:00
mvn23 f41bd2b582 Bump pyotgw to 2.2.3 (#161928) 2026-01-31 15:03:56 +01:00
Norbert Rittel 5c9ec1911b Clarify action descriptions for input_boolean (#161924) 2026-01-31 15:03:08 +01:00
J. Diego Rodríguez Royo 1a0b7fe984 Restore the Home Connect program option entities (#156401)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-01-31 12:32:18 +01:00
Erwin Douna 26ee25d7bb Pattern fix for Proxmox config flow (#161946) 2026-01-31 11:41:41 +01:00
Norbert Rittel aabf52d3cf Rename "service" to "action", use common state for "High" (#161940) 2026-01-31 11:40:55 +01:00
Erwin Douna 99fcb46a7e Add parallel updates to Portainer (#161947) 2026-01-31 11:40:25 +01:00
Raphael Hehl 6580c5e5bf Bump uiprotect to version 10.1.0 (#161967)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-01-31 11:39:20 +01:00
tronikos 63e7d4dc08 Bump opower to 0.17.0 (#161962) 2026-01-31 11:38:43 +01:00
Sid cc6900d846 Bump eheimdigital to 1.6.0 (#161961) 2026-01-31 11:38:14 +01:00
Brett Adams ca2ad22884 Rename drive inverter unavailable state in Teslemetry (#161960)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 11:36:12 +01:00
Armin Ghofrani 40944f0f2d Enable prompt caching for Anthropic conversation integration (#158957)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:32:47 +03:00
uptimeZERO_ 91a3e488b1 Bump media source upload limit from 10mb to 20mb (#161436) 2026-01-30 13:07:37 +01:00
Magnus Øverli 9a1f517e6e Convert flexit_bacnet fireplace mode to climate preset- Rename 'Boost… (#155760)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-30 12:59:10 +01:00
Simone Chemelli c82c614bb9 Handle hostname resolution for Shelly repair issue (#161914) 2026-01-30 12:26:48 +01:00
Norbert Rittel 20914dce67 Improve action descriptions of camera (#161876) 2026-01-30 12:08:49 +01:00
Paul Bottein 5fc407d2f3 Update frontend to 20260128.3 (#161918) 2026-01-30 11:51:53 +01:00
Marc Mueller c7444d38a1 Remove pydantic v1 mypy plugin (#161901) 2026-01-30 11:19:06 +01:00
puddly 81f6136bda Bump ZHA to 0.0.88 (#161904) 2026-01-30 11:18:38 +01:00
Steve Easley 862d0ea49e Bump JVC Projector dependency to 2.0.1 (#161898) 2026-01-30 11:17:14 +01:00
hanwg f2fdfed241 Update translations for Telegram bot (#161903) 2026-01-30 11:13:46 +01:00
David Recordon 15640049cb Fix Control4 HVAC state-to-action mapping (#161916) 2026-01-30 10:59:39 +01:00
dependabot[bot] 5c163434f8 Bump actions/cache from 5.0.2 to 5.0.3 (#161906)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 10:47:02 +01:00
Sebastiaan Speck e54c2ea55e Ensure Renault buttons are supported by the vehicle (#161893)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-01-30 09:58:50 +01:00
Kevin Stillhammer 1ec42693ab Bump fressnapftracker to 0.2.2 (#161913) 2026-01-30 09:32:13 +01:00
epenet 672864ae4f Remove outdated device registry cleanup in trend (#161867) 2026-01-30 08:07:53 +01:00
Artur Pragacz e54d7e42cb Add subscription pattern for conversation intents (#158456) 2026-01-30 07:19:57 +01:00
Jan Bouwhuis 5d63fce015 Re-add Claude code to devcontainer via native install script (#161807) 2026-01-29 23:35:59 -05:00
Paul Bottein 190fe10eed Allow lovelace path for dashboard in yaml and fix yaml dashboard migration (#161816) 2026-01-29 17:19:37 -05:00
Bram Kragten ef410c1e2a Update frontend to 20260128.2 (#161881) 2026-01-29 23:02:59 +01:00
Artur Pragacz 5a712398e7 Fix validation of actions config in intent_script (#158266) 2026-01-29 22:12:46 +01:00
114 changed files with 1645 additions and 1257 deletions
+3 -3
View File
@@ -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
+3
View File
@@ -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
+11 -5
View File
@@ -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",
+4 -4
View File
@@ -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%]"
}
},
+9 -5
View File
@@ -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
+18 -12
View File
@@ -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.
+11 -1
View File
@@ -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%]"
+43 -22
View File
@@ -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,
+9 -1
View File
@@ -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
+30 -2
View File
@@ -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):
+1 -2
View File
@@ -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},
+14 -4
View File
@@ -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
+10 -15
View File
@@ -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(
+2 -53
View File
@@ -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")
+90 -3
View File
@@ -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": {
+1 -11
View File
@@ -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,
+1 -11
View File
@@ -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",
+1 -1
View File
@@ -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*",
+14 -4
View File
@@ -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)
+1 -1
View File
@@ -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
Generated
+1 -1
View File
@@ -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
+8 -8
View File
@@ -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
+8 -8
View File
@@ -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
-1
View File
@@ -36,7 +36,6 @@ GENERAL_SETTINGS: Final[dict[str, str]] = {
"plugins": ", ".join( # noqa: FLY002
[
"pydantic.mypy",
"pydantic.v1.mypy",
]
),
"show_error_codes": "true",
+20 -19
View File
@@ -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 (
+1 -1
View File
@@ -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'>,
+99 -81
View File
@@ -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
+3 -3
View File
@@ -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,
+45 -1
View File
@@ -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)
+174 -1
View File
@@ -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."""
+116
View File
@@ -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
+42 -2
View File
@@ -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,
+45 -1
View File
@@ -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