mirror of
https://github.com/home-assistant/core.git
synced 2026-04-18 15:39:12 +02:00
Compare commits
19 Commits
homewizard
...
scop-tasmo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e7e299876 | ||
|
|
c12b7bfd18 | ||
|
|
1c2f583587 | ||
|
|
58a376e68b | ||
|
|
78b251e7cb | ||
|
|
a2c65b9126 | ||
|
|
5e443681c3 | ||
|
|
13756863f1 | ||
|
|
fd54e45aeb | ||
|
|
52af74c3b6 | ||
|
|
dc111a475e | ||
|
|
14cb42349a | ||
|
|
c42b50418e | ||
|
|
501b4e6efb | ||
|
|
ca2099b165 | ||
|
|
69b55c295d | ||
|
|
13709b1c90 | ||
|
|
2c013777db | ||
|
|
91099ea489 |
@@ -5,14 +5,6 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
|
||||
|
||||
# Review GitHub Pull Request
|
||||
|
||||
## Preparation:
|
||||
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
|
||||
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
|
||||
- Do NOT attempt any workarounds.
|
||||
- Do NOT proceed with the review.
|
||||
- ALERT about the failure and WAIT for instructions.
|
||||
- This is a hard requirement - no exceptions.
|
||||
|
||||
## Follow these steps:
|
||||
1. Use 'gh pr view' to get the PR details and description.
|
||||
2. Use 'gh pr diff' to see all the changes in the PR.
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1232,8 +1232,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/onvif/ @jterrace
|
||||
/homeassistant/components/open_meteo/ @frenck
|
||||
/tests/components/open_meteo/ @frenck
|
||||
/homeassistant/components/open_router/ @joostlek
|
||||
/tests/components/open_router/ @joostlek
|
||||
/homeassistant/components/open_router/ @joostlek @ab3lson
|
||||
/tests/components/open_router/ @joostlek @ab3lson
|
||||
/homeassistant/components/opendisplay/ @g4bri3lDev
|
||||
/tests/components/opendisplay/ @g4bri3lDev
|
||||
/homeassistant/components/openerz/ @misialq
|
||||
|
||||
@@ -71,6 +71,16 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-1",
|
||||
"claude-opus-4-0",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-sonnet-4-0",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -19,6 +19,8 @@ from anthropic.types import (
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
Container,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
@@ -61,15 +63,16 @@ from anthropic.types import (
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchTool20260209Param,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultContentParam,
|
||||
Content as BashCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultContentParam,
|
||||
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
@@ -105,6 +108,7 @@ from .const import (
|
||||
MIN_THINKING_BUDGET,
|
||||
NON_ADAPTIVE_THINKING_MODELS,
|
||||
NON_THINKING_MODELS,
|
||||
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
|
||||
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
|
||||
)
|
||||
|
||||
@@ -224,12 +228,22 @@ def _convert_content(
|
||||
},
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "code_execution":
|
||||
tool_result_block = {
|
||||
"type": "code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
CodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "bash_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
BashCodeExecutionToolResultContentParam, content.tool_result
|
||||
BashCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "text_editor_code_execution":
|
||||
@@ -237,7 +251,7 @@ def _convert_content(
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
TextEditorCodeExecutionToolResultContentParam,
|
||||
TextEditorCodeExecutionToolResultBlockParamContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
@@ -368,6 +382,7 @@ def _convert_content(
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
],
|
||||
@@ -379,6 +394,7 @@ def _convert_content(
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_search",
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
@@ -470,7 +486,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
@@ -532,13 +548,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
type="server_tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input={},
|
||||
input=response.content_block.input or {},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
CodeExecutionToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
),
|
||||
@@ -594,13 +611,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
current_tool_block["input"] |= tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_block["id"],
|
||||
tool_name=current_tool_block["name"],
|
||||
tool_args=tool_args,
|
||||
tool_args=current_tool_block["input"],
|
||||
external=current_tool_block["type"] == "server_tool_use",
|
||||
)
|
||||
]
|
||||
@@ -735,19 +752,34 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
]
|
||||
|
||||
if options.get(CONF_CODE_EXECUTION):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_WEB_SEARCH):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if model.startswith(
|
||||
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
|
||||
) or not options.get(CONF_CODE_EXECUTION):
|
||||
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
|
||||
WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
type="web_search_20250305",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
)
|
||||
else:
|
||||
web_search = WebSearchTool20260209Param(
|
||||
name="web_search",
|
||||
type="web_search_20260209",
|
||||
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = {
|
||||
"type": "approximate",
|
||||
|
||||
@@ -66,7 +66,7 @@ rules:
|
||||
comment: |
|
||||
To write something about what models we support.
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The bitcoin component."""
|
||||
"""The Bitcoin integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The fail2ban component."""
|
||||
"""The Fail2Ban integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Fortinet FortiOS components."""
|
||||
"""Fortinet FortiOS integration."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support to use FortiOS device like FortiGate as device tracker.
|
||||
|
||||
This component is part of the device_tracker platform.
|
||||
This FortiOS integration provides a device_tracker platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,36 +27,15 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
)
|
||||
from homeassistant.helpers.selector import TextSelector
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import (
|
||||
CONF_PRODUCT_NAME,
|
||||
CONF_PRODUCT_TYPE,
|
||||
CONF_SERIAL,
|
||||
CONF_USAGE,
|
||||
DOMAIN,
|
||||
ENERGY_MONITORING_DEVICES,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
USAGE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=["consumption", "generation"],
|
||||
translation_key="usage",
|
||||
mode=SelectSelectorMode.LIST,
|
||||
)
|
||||
)
|
||||
from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER
|
||||
|
||||
|
||||
class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for HomeWizard devices."""
|
||||
"""Handle a config flow for P1 meter."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
@@ -64,8 +43,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
product_name: str | None = None
|
||||
product_type: str | None = None
|
||||
serial: str | None = None
|
||||
token: str | None = None
|
||||
usage: str | None = None
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -87,12 +64,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
f"{device_info.product_type}_{device_info.serial}"
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
if device_info.product_type in ENERGY_MONITORING_DEVICES:
|
||||
self.ip_address = user_input[CONF_IP_ADDRESS]
|
||||
self.product_name = device_info.product_name
|
||||
self.product_type = device_info.product_type
|
||||
self.serial = device_info.serial
|
||||
return await self.async_step_usage()
|
||||
return self.async_create_entry(
|
||||
title=f"{device_info.product_name}",
|
||||
data=user_input,
|
||||
@@ -111,45 +82,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_usage(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Step where we ask how the energy monitor is used."""
|
||||
assert self.ip_address
|
||||
assert self.product_name
|
||||
assert self.product_type
|
||||
assert self.serial
|
||||
|
||||
data: dict[str, Any] = {CONF_IP_ADDRESS: self.ip_address}
|
||||
if self.token:
|
||||
data[CONF_TOKEN] = self.token
|
||||
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title=f"{self.product_name}",
|
||||
data=data | user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="usage",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USAGE,
|
||||
default=user_input.get(CONF_USAGE)
|
||||
if user_input is not None
|
||||
else "consumption",
|
||||
): USAGE_SELECTOR,
|
||||
}
|
||||
),
|
||||
description_placeholders={
|
||||
CONF_PRODUCT_NAME: self.product_name,
|
||||
CONF_PRODUCT_TYPE: self.product_type,
|
||||
CONF_SERIAL: self.serial,
|
||||
CONF_IP_ADDRESS: self.ip_address,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_authorize(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -169,7 +101,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Now we got a token, we can ask for some more info
|
||||
|
||||
device_info = await HomeWizardEnergyV2(self.ip_address, token=token).device()
|
||||
async with HomeWizardEnergyV2(self.ip_address, token=token) as api:
|
||||
device_info = await api.device()
|
||||
|
||||
data = {
|
||||
CONF_IP_ADDRESS: self.ip_address,
|
||||
@@ -180,14 +113,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
f"{device_info.product_type}_{device_info.serial}"
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=data)
|
||||
self.product_name = device_info.product_name
|
||||
self.product_type = device_info.product_type
|
||||
self.serial = device_info.serial
|
||||
|
||||
if device_info.product_type in ENERGY_MONITORING_DEVICES:
|
||||
self.token = token
|
||||
return await self.async_step_usage()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{device_info.product_name}",
|
||||
data=data,
|
||||
@@ -214,8 +139,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured(
|
||||
updates={CONF_IP_ADDRESS: discovery_info.host}
|
||||
)
|
||||
if self.product_type in ENERGY_MONITORING_DEVICES:
|
||||
return await self.async_step_usage()
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homewizard_energy.const import Model
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "homewizard"
|
||||
@@ -24,14 +22,5 @@ LOGGER = logging.getLogger(__package__)
|
||||
CONF_PRODUCT_NAME = "product_name"
|
||||
CONF_PRODUCT_TYPE = "product_type"
|
||||
CONF_SERIAL = "serial"
|
||||
CONF_USAGE = "usage"
|
||||
|
||||
UPDATE_INTERVAL = timedelta(seconds=5)
|
||||
|
||||
ENERGY_MONITORING_DEVICES = (
|
||||
Model.ENERGY_SOCKET,
|
||||
Model.ENERGY_METER_1_PHASE,
|
||||
Model.ENERGY_METER_3_PHASE,
|
||||
Model.ENERGY_METER_EASTRON_SDM230,
|
||||
Model.ENERGY_METER_EASTRON_SDM630,
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
from homeassistant.util.variance import ignore_variance
|
||||
|
||||
from .const import CONF_USAGE, DOMAIN, ENERGY_MONITORING_DEVICES
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
|
||||
from .entity import HomeWizardEntity
|
||||
|
||||
@@ -267,6 +267,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
|
||||
enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
|
||||
value_fn=lambda data: data.measurement.energy_export_t4_kwh or None,
|
||||
),
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="active_power_w",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
has_fn=lambda data: data.measurement.power_w is not None,
|
||||
value_fn=lambda data: data.measurement.power_w,
|
||||
),
|
||||
HomeWizardSensorEntityDescription(
|
||||
key="active_power_l1_w",
|
||||
translation_key="active_power_phase_w",
|
||||
@@ -692,30 +701,22 @@ async def async_setup_entry(
|
||||
entry: HomeWizardConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Cleanup deleted entrity registry item."""
|
||||
"""Initialize sensors."""
|
||||
|
||||
# Initialize default sensors
|
||||
entities: list = [
|
||||
HomeWizardSensorEntity(entry.runtime_data, description)
|
||||
for description in SENSORS
|
||||
if description.has_fn(entry.runtime_data.data)
|
||||
]
|
||||
active_power_sensor_description = HomeWizardSensorEntityDescription(
|
||||
key="active_power_w",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
suggested_display_precision=0,
|
||||
entity_registry_enabled_default=(
|
||||
entry.runtime_data.data.device.product_type != Model.BATTERY
|
||||
and entry.data.get(CONF_USAGE, "consumption") == "consumption"
|
||||
),
|
||||
has_fn=lambda x: True,
|
||||
value_fn=lambda data: data.measurement.power_w,
|
||||
)
|
||||
|
||||
# Add optional production power sensor for supported energy monitoring devices
|
||||
# or plug-in battery
|
||||
if entry.runtime_data.data.device.product_type in (
|
||||
*ENERGY_MONITORING_DEVICES,
|
||||
Model.ENERGY_SOCKET,
|
||||
Model.ENERGY_METER_1_PHASE,
|
||||
Model.ENERGY_METER_3_PHASE,
|
||||
Model.ENERGY_METER_EASTRON_SDM230,
|
||||
Model.ENERGY_METER_EASTRON_SDM630,
|
||||
Model.BATTERY,
|
||||
):
|
||||
active_prodution_power_sensor_description = HomeWizardSensorEntityDescription(
|
||||
@@ -735,26 +736,16 @@ async def async_setup_entry(
|
||||
is not None
|
||||
and total_export > 0
|
||||
)
|
||||
or entry.data.get(CONF_USAGE, "consumption") == "generation"
|
||||
),
|
||||
has_fn=lambda x: True,
|
||||
value_fn=lambda data: (
|
||||
power_w * -1 if (power_w := data.measurement.power_w) else power_w
|
||||
),
|
||||
)
|
||||
entities.extend(
|
||||
(
|
||||
HomeWizardSensorEntity(
|
||||
entry.runtime_data, active_power_sensor_description
|
||||
),
|
||||
HomeWizardSensorEntity(
|
||||
entry.runtime_data, active_prodution_power_sensor_description
|
||||
),
|
||||
)
|
||||
)
|
||||
elif (data := entry.runtime_data.data) and data.measurement.power_w is not None:
|
||||
entities.append(
|
||||
HomeWizardSensorEntity(entry.runtime_data, active_power_sensor_description)
|
||||
HomeWizardSensorEntity(
|
||||
entry.runtime_data, active_prodution_power_sensor_description
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize external devices
|
||||
|
||||
@@ -41,16 +41,6 @@
|
||||
},
|
||||
"description": "Update configuration for {title}."
|
||||
},
|
||||
"usage": {
|
||||
"data": {
|
||||
"usage": "Usage"
|
||||
},
|
||||
"data_description": {
|
||||
"usage": "This will enable either a power consumption or power production sensor the first time this device is set up."
|
||||
},
|
||||
"description": "What are you going to monitor with your {product_name} ({product_type} {serial} at {ip_address})?",
|
||||
"title": "Usage"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"ip_address": "[%key:common::config_flow::data::ip%]"
|
||||
@@ -209,13 +199,5 @@
|
||||
},
|
||||
"title": "Update the authentication method for {title}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"usage": {
|
||||
"options": {
|
||||
"consumption": "Monitoring consumed energy",
|
||||
"generation": "Monitoring generated energy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,23 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HTML5 services."""
|
||||
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up HTML5 from a config entry."""
|
||||
hass.async_create_task(
|
||||
|
||||
@@ -11,5 +11,18 @@ ATTR_VAPID_EMAIL = "vapid_email"
|
||||
REGISTRATIONS_FILE = "html5_push_registrations.conf"
|
||||
|
||||
ATTR_ACTION = "action"
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_BADGE = "badge"
|
||||
ATTR_DATA = "data"
|
||||
ATTR_DIR = "dir"
|
||||
ATTR_ICON = "icon"
|
||||
ATTR_IMAGE = "image"
|
||||
ATTR_LANG = "lang"
|
||||
ATTR_RENOTIFY = "renotify"
|
||||
ATTR_REQUIRE_INTERACTION = "require_interaction"
|
||||
ATTR_SILENT = "silent"
|
||||
ATTR_TAG = "tag"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_TTL = "ttl"
|
||||
ATTR_URGENCY = "urgency"
|
||||
ATTR_VIBRATE = "vibrate"
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"service": "mdi:bell-off"
|
||||
},
|
||||
"send_message": {
|
||||
"service": "mdi:message-arrow-right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
homeassistant/components/html5/issue.py
Normal file
31
homeassistant/components/html5/issue.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Issues for HTML5 integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@callback
|
||||
def deprecated_notify_action_call(
|
||||
hass: HomeAssistant, target: list[str] | None
|
||||
) -> None:
|
||||
"""Deprecated action call."""
|
||||
|
||||
action = (
|
||||
f"notify.html5_{slugify(target[0])}"
|
||||
if target and len(target) == 1
|
||||
else "notify.html5"
|
||||
)
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_notify_action_{action}",
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_notify_action",
|
||||
translation_placeholders={"action": action},
|
||||
)
|
||||
@@ -47,7 +47,11 @@ from homeassistant.util.json import load_json_object
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_VAPID_EMAIL,
|
||||
ATTR_VAPID_PRV_KEY,
|
||||
ATTR_VAPID_PUB_KEY,
|
||||
@@ -56,6 +60,7 @@ from .const import (
|
||||
SERVICE_DISMISS,
|
||||
)
|
||||
from .entity import HTML5Entity, Registration
|
||||
from .issue import deprecated_notify_action_call
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,13 +74,11 @@ ATTR_AUTH = "auth"
|
||||
ATTR_P256DH = "p256dh"
|
||||
ATTR_EXPIRATIONTIME = "expirationTime"
|
||||
|
||||
ATTR_ACTIONS = "actions"
|
||||
ATTR_TYPE = "type"
|
||||
ATTR_URL = "url"
|
||||
ATTR_DISMISS = "dismiss"
|
||||
ATTR_PRIORITY = "priority"
|
||||
DEFAULT_PRIORITY = "normal"
|
||||
ATTR_TTL = "ttl"
|
||||
DEFAULT_TTL = 86400
|
||||
|
||||
DEFAULT_BADGE = "/static/images/notification-badge.png"
|
||||
@@ -465,6 +468,9 @@ class HTML5NotificationService(BaseNotificationService):
|
||||
|
||||
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
|
||||
"""Send a message to a user."""
|
||||
|
||||
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
|
||||
|
||||
tag = str(uuid.uuid4())
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
@@ -605,32 +611,53 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
|
||||
_key = "device"
|
||||
|
||||
async def async_send_message(self, message: str, title: str | None = None) -> None:
|
||||
"""Send a message to a device."""
|
||||
timestamp = int(time.time())
|
||||
tag = str(uuid.uuid4())
|
||||
"""Send a message to a device via notify.send_message action."""
|
||||
await self._webpush(
|
||||
title=title or ATTR_TITLE_DEFAULT,
|
||||
message=message,
|
||||
badge=DEFAULT_BADGE,
|
||||
icon=DEFAULT_ICON,
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"badge": DEFAULT_BADGE,
|
||||
"body": message,
|
||||
"icon": DEFAULT_ICON,
|
||||
ATTR_TAG: tag,
|
||||
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
|
||||
"timestamp": timestamp * 1000,
|
||||
ATTR_DATA: {
|
||||
ATTR_JWT: add_jwt(
|
||||
timestamp,
|
||||
self.target,
|
||||
tag,
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
},
|
||||
}
|
||||
async def send_push_notification(self, **kwargs: Any) -> None:
|
||||
"""Send a message to a device via html5.send_message action."""
|
||||
await self._webpush(**kwargs)
|
||||
self._async_record_notification()
|
||||
|
||||
async def _webpush(
|
||||
self,
|
||||
message: str | None = None,
|
||||
timestamp: datetime | None = None,
|
||||
ttl: timedelta | None = None,
|
||||
urgency: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Shared internal helper to push messages."""
|
||||
payload: dict[str, Any] = kwargs
|
||||
|
||||
if message is not None:
|
||||
payload["body"] = message
|
||||
|
||||
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
|
||||
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
|
||||
payload[ATTR_TIMESTAMP] = ts * 1000
|
||||
|
||||
if ATTR_REQUIRE_INTERACTION in payload:
|
||||
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
|
||||
|
||||
payload.setdefault(ATTR_DATA, {})
|
||||
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
|
||||
ts,
|
||||
self.target,
|
||||
payload[ATTR_TAG],
|
||||
self.registration["subscription"]["keys"]["auth"],
|
||||
)
|
||||
|
||||
endpoint = urlparse(self.registration["subscription"]["endpoint"])
|
||||
vapid_claims = {
|
||||
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
|
||||
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
|
||||
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -639,6 +666,8 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
|
||||
json.dumps(payload),
|
||||
self.config_entry.data[ATTR_VAPID_PRV_KEY],
|
||||
vapid_claims,
|
||||
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
|
||||
headers={"Urgency": urgency} if urgency else None,
|
||||
aiohttp_session=self.session,
|
||||
)
|
||||
cast(ClientResponse, response).raise_for_status()
|
||||
|
||||
82
homeassistant/components/html5/services.py
Normal file
82
homeassistant/components/html5/services.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Service registration for HTML5 integration."""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TITLE,
|
||||
ATTR_TITLE_DEFAULT,
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTION,
|
||||
ATTR_ACTIONS,
|
||||
ATTR_BADGE,
|
||||
ATTR_DIR,
|
||||
ATTR_ICON,
|
||||
ATTR_IMAGE,
|
||||
ATTR_LANG,
|
||||
ATTR_RENOTIFY,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_SILENT,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_URGENCY,
|
||||
ATTR_VIBRATE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
SERVICE_SEND_MESSAGE = "send_message"
|
||||
|
||||
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
|
||||
vol.Optional(ATTR_MESSAGE): cv.string,
|
||||
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
vol.Optional(ATTR_BADGE): cv.string,
|
||||
vol.Optional(ATTR_IMAGE): cv.string,
|
||||
vol.Optional(ATTR_TAG): cv.string,
|
||||
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
|
||||
cv.ensure_list,
|
||||
[vol.All(vol.Coerce(int), vol.Range(min=0))],
|
||||
),
|
||||
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
|
||||
vol.Optional(ATTR_LANG): cv.language,
|
||||
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
|
||||
vol.Optional(ATTR_RENOTIFY): cv.boolean,
|
||||
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
|
||||
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
|
||||
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
|
||||
vol.Optional(ATTR_ACTIONS): vol.All(
|
||||
cv.ensure_list,
|
||||
[
|
||||
{
|
||||
vol.Required(ATTR_ACTION): cv.string,
|
||||
vol.Required(ATTR_TITLE): cv.string,
|
||||
vol.Optional(ATTR_ICON): cv.string,
|
||||
}
|
||||
],
|
||||
),
|
||||
vol.Optional(ATTR_DATA): dict,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for HTML5 integration."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
entity_domain=NOTIFY_DOMAIN,
|
||||
schema=SERVICE_SEND_MESSAGE_SCHEMA,
|
||||
func="send_push_notification",
|
||||
)
|
||||
@@ -8,3 +8,137 @@ dismiss:
|
||||
example: '{ "tag": "tagname" }'
|
||||
selector:
|
||||
object:
|
||||
send_message:
|
||||
target:
|
||||
entity:
|
||||
domain: notify
|
||||
integration: html5
|
||||
fields:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
example: Home Assistant
|
||||
default: Home Assistant
|
||||
message:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiline: true
|
||||
example: Hello World
|
||||
icon:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/icons/favicon-192x192.png
|
||||
badge:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/notification-badge.png
|
||||
image:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: /static/images/image.jpg
|
||||
tag:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
example: message-group-1
|
||||
actions:
|
||||
selector:
|
||||
object:
|
||||
label_field: "action"
|
||||
description_field: "title"
|
||||
multiple: true
|
||||
translation_key: actions
|
||||
fields:
|
||||
action:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
title:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
icon:
|
||||
selector:
|
||||
text:
|
||||
type: url
|
||||
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
|
||||
dir:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- auto
|
||||
- ltr
|
||||
- rtl
|
||||
mode: dropdown
|
||||
translation_key: dir
|
||||
example: auto
|
||||
renotify:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
silent:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
require_interaction:
|
||||
required: false
|
||||
selector:
|
||||
constant:
|
||||
value: true
|
||||
label: ""
|
||||
example: true
|
||||
vibrate:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
type: number
|
||||
suffix: ms
|
||||
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
|
||||
lang:
|
||||
required: false
|
||||
selector:
|
||||
language:
|
||||
example: es-419
|
||||
timestamp:
|
||||
required: false
|
||||
selector:
|
||||
datetime:
|
||||
example: "1970-01-01 00:00:00"
|
||||
ttl:
|
||||
required: false
|
||||
selector:
|
||||
duration:
|
||||
enable_day: true
|
||||
example: "{'days': 28}"
|
||||
urgency:
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- low
|
||||
- normal
|
||||
- high
|
||||
mode: dropdown
|
||||
translation_key: urgency
|
||||
example: normal
|
||||
data:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
example: "{'customKey': 'customValue'}"
|
||||
|
||||
@@ -48,6 +48,44 @@
|
||||
"message": "Sending notification to {target} failed due to a request error"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_notify_action": {
|
||||
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `notify.send_message` or `html5.send_message` actions instead.",
|
||||
"title": "Detected use of deprecated action {action}"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"actions": {
|
||||
"fields": {
|
||||
"action": {
|
||||
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
|
||||
"name": "Action identifier"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL of an image displayed as the icon for this button.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"title": {
|
||||
"description": "The label of the button displayed to the user.",
|
||||
"name": "Title"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dir": {
|
||||
"options": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"ltr": "Left-to-right",
|
||||
"rtl": "Right-to-left"
|
||||
}
|
||||
},
|
||||
"urgency": {
|
||||
"options": {
|
||||
"high": "[%key:common::state::high%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"description": "Dismisses an HTML5 notification.",
|
||||
@@ -62,6 +100,80 @@
|
||||
}
|
||||
},
|
||||
"name": "Dismiss"
|
||||
},
|
||||
"send_message": {
|
||||
"description": "Sends a message via HTML5 Push Notifications",
|
||||
"fields": {
|
||||
"actions": {
|
||||
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
|
||||
"name": "Action buttons"
|
||||
},
|
||||
"badge": {
|
||||
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
|
||||
"name": "Badge"
|
||||
},
|
||||
"data": {
|
||||
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
|
||||
"name": "Extra data"
|
||||
},
|
||||
"dir": {
|
||||
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
|
||||
"name": "Text direction"
|
||||
},
|
||||
"icon": {
|
||||
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
|
||||
"name": "Icon"
|
||||
},
|
||||
"image": {
|
||||
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
|
||||
"name": "Image"
|
||||
},
|
||||
"lang": {
|
||||
"description": "The language of the notification's content.",
|
||||
"name": "Language"
|
||||
},
|
||||
"message": {
|
||||
"description": "The message body of the notification.",
|
||||
"name": "Message"
|
||||
},
|
||||
"renotify": {
|
||||
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
|
||||
"name": "Renotify"
|
||||
},
|
||||
"require_interaction": {
|
||||
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
|
||||
"name": "Require interaction"
|
||||
},
|
||||
"silent": {
|
||||
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
|
||||
"name": "Silent"
|
||||
},
|
||||
"tag": {
|
||||
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
|
||||
"name": "Tag"
|
||||
},
|
||||
"timestamp": {
|
||||
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
|
||||
"name": "Timestamp"
|
||||
},
|
||||
"title": {
|
||||
"description": "Title for your notification message.",
|
||||
"name": "Title"
|
||||
},
|
||||
"ttl": {
|
||||
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
|
||||
"name": "Time to live"
|
||||
},
|
||||
"urgency": {
|
||||
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
|
||||
"name": "Urgency"
|
||||
},
|
||||
"vibrate": {
|
||||
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
|
||||
"name": "Vibration pattern"
|
||||
}
|
||||
},
|
||||
"name": "Send message"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ ABBREVIATIONS = {
|
||||
"bri_stat_t": "brightness_state_topic",
|
||||
"bri_tpl": "brightness_template",
|
||||
"bri_val_tpl": "brightness_value_template",
|
||||
"cln_segmnts_cmd_t": "clean_segments_command_topic",
|
||||
"cln_segmnts_cmd_tpl": "clean_segments_command_template",
|
||||
"clr_temp_cmd_tpl": "color_temp_command_template",
|
||||
"clrm_stat_t": "color_mode_state_topic",
|
||||
"clrm_val_tpl": "color_mode_value_template",
|
||||
|
||||
@@ -10,12 +10,13 @@ import voluptuous as vol
|
||||
from homeassistant.components import vacuum
|
||||
from homeassistant.components.vacuum import (
|
||||
ENTITY_ID_FORMAT,
|
||||
Segment,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_UNIQUE_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -27,13 +28,14 @@ from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import ReceiveMessage
|
||||
from .models import MqttCommandTemplate, ReceiveMessage
|
||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||
from .util import valid_publish_topic
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
FAN_SPEED = "fan_speed"
|
||||
SEGMENTS = "segments"
|
||||
STATE = "state"
|
||||
|
||||
STATE_IDLE = "idle"
|
||||
@@ -52,6 +54,8 @@ POSSIBLE_STATES: dict[str, VacuumActivity] = {
|
||||
STATE_CLEANING: VacuumActivity.CLEANING,
|
||||
}
|
||||
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC = "clean_segments_command_topic"
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE = "clean_segments_command_template"
|
||||
CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
|
||||
CONF_PAYLOAD_TURN_ON = "payload_turn_on"
|
||||
CONF_PAYLOAD_TURN_OFF = "payload_turn_off"
|
||||
@@ -137,8 +141,22 @@ MQTT_VACUUM_ATTRIBUTES_BLOCKED = frozenset(
|
||||
MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/"
|
||||
|
||||
|
||||
PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
def validate_clean_area_config(config: ConfigType) -> ConfigType:
|
||||
"""Validate clean area configuration."""
|
||||
if CONF_CLEAN_SEGMENTS_COMMAND_TOPIC not in config:
|
||||
return config
|
||||
if not config.get(CONF_UNIQUE_ID):
|
||||
raise vol.Invalid(
|
||||
f"Option `{CONF_CLEAN_SEGMENTS_COMMAND_TOPIC}` requires `{CONF_UNIQUE_ID}` to be configured"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
_BASE_SCHEMA = MQTT_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TOPIC): valid_publish_topic,
|
||||
vol.Optional(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
@@ -164,7 +182,10 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend(
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
|
||||
DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
|
||||
PLATFORM_SCHEMA_MODERN = vol.All(_BASE_SCHEMA, validate_clean_area_config)
|
||||
DISCOVERY_SCHEMA = vol.All(
|
||||
_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_clean_area_config
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -191,9 +212,11 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_attributes_extra_blocked = MQTT_VACUUM_ATTRIBUTES_BLOCKED
|
||||
|
||||
_segments: list[Segment]
|
||||
_command_topic: str | None
|
||||
_set_fan_speed_topic: str | None
|
||||
_send_command_topic: str | None
|
||||
_clean_segments_command_topic: str | None = None
|
||||
_payloads: dict[str, str | None]
|
||||
|
||||
def __init__(
|
||||
@@ -229,6 +252,14 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
self._attr_supported_features = _strings_to_services(
|
||||
supported_feature_strings, STRING_TO_SERVICE
|
||||
)
|
||||
self._clean_segments_command_topic = config.get(
|
||||
CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
|
||||
)
|
||||
self._clean_segments_command_template = MqttCommandTemplate(
|
||||
config.get(CONF_CLEAN_SEGMENTS_COMMAND_TEMPLATE),
|
||||
entity=self,
|
||||
).async_render
|
||||
|
||||
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
|
||||
self._command_topic = config.get(CONF_COMMAND_TOPIC)
|
||||
self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
|
||||
@@ -262,6 +293,24 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
POSSIBLE_STATES[cast(str, state)] if payload[STATE] else None
|
||||
)
|
||||
del payload[STATE]
|
||||
if (
|
||||
(segments_payload := payload.pop(SEGMENTS, None))
|
||||
and self._clean_segments_command_topic is not None
|
||||
and isinstance(segments_payload, dict)
|
||||
and (
|
||||
segments := [
|
||||
Segment(id=segment_id, name=str(segment_name))
|
||||
for segment_id, segment_name in segments_payload.items()
|
||||
]
|
||||
)
|
||||
):
|
||||
self._segments = segments
|
||||
self._attr_supported_features |= VacuumEntityFeature.CLEAN_AREA
|
||||
if (last_seen := self.last_seen_segments) is not None and {
|
||||
s.id: s for s in last_seen
|
||||
} != {s.id: s for s in self._segments}:
|
||||
self.async_create_segments_issue()
|
||||
|
||||
self._update_state_attributes(payload)
|
||||
|
||||
@callback
|
||||
@@ -277,6 +326,20 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity):
|
||||
"""(Re)Subscribe to topics."""
|
||||
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
|
||||
|
||||
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
|
||||
"""Perform an area clean."""
|
||||
assert self._clean_segments_command_topic is not None
|
||||
await self.async_publish_with_config(
|
||||
self._clean_segments_command_topic,
|
||||
self._clean_segments_command_template(
|
||||
json_dumps(segment_ids), {"value": segment_ids}
|
||||
),
|
||||
)
|
||||
|
||||
async def async_get_segments(self) -> list[Segment]:
|
||||
"""Return the available segments."""
|
||||
return self._segments
|
||||
|
||||
async def _async_publish_command(self, feature: VacuumEntityFeature) -> None:
|
||||
"""Publish a command."""
|
||||
if self._command_topic is None:
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The ohmconnect component."""
|
||||
"""The OhmConnect integration."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
from .const import LOGGER
|
||||
from .const import CONF_WEB_SEARCH, LOGGER
|
||||
|
||||
PLATFORMS = [Platform.AI_TASK, Platform.CONVERSATION]
|
||||
|
||||
@@ -56,3 +56,32 @@ async def _async_update_listener(
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool:
|
||||
"""Unload OpenRouter."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: OpenRouterConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate config entry."""
|
||||
LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 1 or (entry.version == 1 and entry.minor_version > 2):
|
||||
return False
|
||||
|
||||
if entry.version == 1 and entry.minor_version < 2:
|
||||
for subentry in entry.subentries.values():
|
||||
if CONF_WEB_SEARCH in subentry.data:
|
||||
continue
|
||||
|
||||
updated_data = {**subentry.data, CONF_WEB_SEARCH: False}
|
||||
|
||||
hass.config_entries.async_update_subentry(
|
||||
entry, subentry, data=updated_data
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.info(
|
||||
"Migration to version %s.%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.core import callback
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
BooleanSelector,
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
@@ -34,7 +35,12 @@ from homeassistant.helpers.selector import (
|
||||
TemplateSelector,
|
||||
)
|
||||
|
||||
from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +49,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenRouter."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 2
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
@@ -66,7 +73,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_API_KEY], async_get_clientsession(self.hass)
|
||||
)
|
||||
try:
|
||||
await client.get_key_data()
|
||||
key_data = await client.get_key_data()
|
||||
except OpenRouterError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
@@ -74,7 +81,7 @@ class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="OpenRouter",
|
||||
title=key_data.label,
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_show_form(
|
||||
@@ -106,7 +113,7 @@ class OpenRouterSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
|
||||
class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
"""Handle subentry flow."""
|
||||
"""Handle conversation subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
@@ -208,13 +215,20 @@ class ConversationFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Optional(
|
||||
CONF_WEB_SEARCH,
|
||||
default=self.options.get(
|
||||
CONF_WEB_SEARCH,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS[CONF_WEB_SEARCH],
|
||||
),
|
||||
): BooleanSelector(),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AITaskDataFlowHandler(OpenRouterSubentryFlowHandler):
|
||||
"""Handle subentry flow."""
|
||||
"""Handle AI task subentry flow."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the subentry flow."""
|
||||
|
||||
@@ -9,9 +9,13 @@ DOMAIN = "open_router"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_WEB_SEARCH = "web_search"
|
||||
|
||||
RECOMMENDED_WEB_SEARCH = False
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
CONF_WEB_SEARCH: RECOMMENDED_WEB_SEARCH,
|
||||
}
|
||||
|
||||
@@ -37,9 +37,8 @@ from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
|
||||
from . import OpenRouterConfigEntry
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import CONF_WEB_SEARCH, DOMAIN, LOGGER
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
@@ -52,7 +51,6 @@ def _adjust_schema(schema: dict[str, Any]) -> None:
|
||||
if "required" not in schema:
|
||||
schema["required"] = []
|
||||
|
||||
# Ensure all properties are required
|
||||
for prop, prop_info in schema["properties"].items():
|
||||
_adjust_schema(prop_info)
|
||||
if prop not in schema["required"]:
|
||||
@@ -233,14 +231,20 @@ class OpenRouterEntity(Entity):
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
|
||||
model = self.model
|
||||
if self.subentry.data.get(CONF_WEB_SEARCH):
|
||||
model = f"{model}:online"
|
||||
|
||||
extra_body: dict[str, Any] = {"require_parameters": True}
|
||||
|
||||
model_args = {
|
||||
"model": self.model,
|
||||
"model": model,
|
||||
"user": chat_log.conversation_id,
|
||||
"extra_headers": {
|
||||
"X-Title": "Home Assistant",
|
||||
"HTTP-Referer": "https://www.home-assistant.io/integrations/open_router",
|
||||
},
|
||||
"extra_body": {"require_parameters": True},
|
||||
"extra_body": extra_body,
|
||||
}
|
||||
|
||||
tools: list[ChatCompletionFunctionToolParam] | None = None
|
||||
@@ -296,6 +300,10 @@ class OpenRouterEntity(Entity):
|
||||
LOGGER.error("Error talking to API: %s", err)
|
||||
raise HomeAssistantError("Error talking to API") from err
|
||||
|
||||
if not result.choices:
|
||||
LOGGER.error("API returned empty choices")
|
||||
raise HomeAssistantError("API returned empty response")
|
||||
|
||||
result_message = result.choices[0].message
|
||||
|
||||
model_args["messages"].extend(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"domain": "open_router",
|
||||
"name": "OpenRouter",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@joostlek"],
|
||||
"codeowners": ["@joostlek", "@ab3lson"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/open_router",
|
||||
|
||||
@@ -23,19 +23,18 @@
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "The main integration entry is not loaded. Please ensure the integration is loaded before reconfiguring.",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure AI task",
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"model": "[%key:component::open_router::config_subentries::conversation::step::init::data::model%]"
|
||||
},
|
||||
"data_description": {
|
||||
"model": "The model to use for the AI task"
|
||||
"model": "[%key:common::generic::model%]"
|
||||
},
|
||||
"description": "Configure the AI task"
|
||||
}
|
||||
@@ -45,22 +44,27 @@
|
||||
"abort": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"entry_not_loaded": "[%key:component::open_router::config_subentries::ai_task_data::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure conversation agent",
|
||||
"user": "Add conversation agent"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"model": "Model",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]"
|
||||
"model": "[%key:common::generic::model%]",
|
||||
"prompt": "[%key:common::config_flow::data::prompt%]",
|
||||
"web_search": "Enable web search"
|
||||
},
|
||||
"data_description": {
|
||||
"llm_hass_api": "Select which tools the model can use to interact with your devices and entities.",
|
||||
"model": "The model to use for the conversation agent",
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"web_search": "Allow the model to search the web for answers"
|
||||
},
|
||||
"description": "Configure the conversation agent"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The opple component."""
|
||||
"""The Opple integration."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The panasonic_bluray component."""
|
||||
"""The Panasonic Blu-Ray Player integration."""
|
||||
|
||||
@@ -39,7 +39,7 @@ def setup_platform(
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Panasonic Blu-ray platform."""
|
||||
"""Set up the Panasonic Blu-ray media player platform."""
|
||||
conf = discovery_info or config
|
||||
|
||||
# Register configured device with Home Assistant.
|
||||
@@ -59,7 +59,7 @@ class PanasonicBluRay(MediaPlayerEntity):
|
||||
)
|
||||
|
||||
def __init__(self, ip, name):
|
||||
"""Initialize the Panasonic Blue-ray device."""
|
||||
"""Initialize the Panasonic Blu-ray device."""
|
||||
self._device = PanasonicBD(ip)
|
||||
self._attr_name = name
|
||||
self._attr_state = MediaPlayerState.OFF
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The sky_hub component."""
|
||||
"""The Sky Hub integration."""
|
||||
|
||||
@@ -19,6 +19,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.UPDATE,
|
||||
]
|
||||
|
||||
TASMOTA_EVENT = "tasmota_event"
|
||||
|
||||
38
homeassistant/components/tasmota/coordinator.py
Normal file
38
homeassistant/components/tasmota/coordinator.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Data update coordinators for Tasmota."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiogithubapi import GitHubAPI, GitHubRatelimitException, GitHubReleaseModel
|
||||
from aiogithubapi.client import GitHubConnectionException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
|
||||
class TasmotaLatestReleaseUpdateCoordinator(DataUpdateCoordinator[GitHubReleaseModel]):
|
||||
"""Data update coordinator for Tasmota latest release info."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
self.client = GitHubAPI(session=async_get_clientsession(hass))
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=logging.getLogger(__name__),
|
||||
config_entry=config_entry,
|
||||
name="Tasmota latest release",
|
||||
update_interval=timedelta(days=1),
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> GitHubReleaseModel:
|
||||
"""Get new data."""
|
||||
try:
|
||||
response = await self.client.repos.releases.latest("arendst/Tasmota")
|
||||
if response.data is None:
|
||||
raise UpdateFailed("No data received")
|
||||
except (GitHubConnectionException, GitHubRatelimitException) as ex:
|
||||
raise UpdateFailed(ex) from ex
|
||||
else:
|
||||
return response.data
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hatasmota"],
|
||||
"mqtt": ["tasmota/discovery/#"],
|
||||
"requirements": ["HATasmota==0.10.1"]
|
||||
"requirements": ["HATasmota==0.10.1", "aiogithubapi==26.0.0"]
|
||||
}
|
||||
|
||||
79
homeassistant/components/tasmota/update.py
Normal file
79
homeassistant/components/tasmota/update.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Update entity for Tasmota."""
|
||||
|
||||
import re
|
||||
|
||||
from homeassistant.components.update import (
|
||||
UpdateDeviceClass,
|
||||
UpdateEntity,
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import TasmotaLatestReleaseUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Tasmota update entities."""
|
||||
coordinator = TasmotaLatestReleaseUpdateCoordinator(hass, config_entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = device_registry.devices.get_devices_for_config_entry_id(
|
||||
config_entry.entry_id
|
||||
)
|
||||
async_add_entities(TasmotaUpdateEntity(coordinator, device) for device in devices)
|
||||
|
||||
|
||||
class TasmotaUpdateEntity(UpdateEntity):
|
||||
"""Representation of a Tasmota update entity."""
|
||||
|
||||
_attr_device_class = UpdateDeviceClass.FIRMWARE
|
||||
_attr_name = "Firmware"
|
||||
_attr_title = "Tasmota firmware"
|
||||
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TasmotaLatestReleaseUpdateCoordinator,
|
||||
device_entry: DeviceEntry,
|
||||
) -> None:
|
||||
"""Initialize the Tasmota update entity."""
|
||||
self.coordinator = coordinator
|
||||
self.device_entry = device_entry
|
||||
self._attr_unique_id = f"{device_entry.id}_update"
|
||||
|
||||
@property
|
||||
def installed_version(self) -> str | None:
|
||||
"""Return the installed version."""
|
||||
return self.device_entry.sw_version # type:ignore[union-attr]
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str:
|
||||
"""Return the latest version."""
|
||||
return self.coordinator.data.tag_name.removeprefix("v")
|
||||
|
||||
@property
|
||||
def release_url(self) -> str:
|
||||
"""Return the release URL."""
|
||||
return self.coordinator.data.html_url
|
||||
|
||||
@property
|
||||
def release_summary(self) -> str:
|
||||
"""Return the release summary."""
|
||||
return self.coordinator.data.name
|
||||
|
||||
def release_notes(self) -> str | None:
|
||||
"""Return the release notes."""
|
||||
if not self.coordinator.data.body:
|
||||
return None
|
||||
return re.sub(
|
||||
r"^<picture>.*?</picture>", "", self.coordinator.data.body, flags=re.DOTALL
|
||||
)
|
||||
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
@@ -24,10 +24,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up UniFi Access binary sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessDoorPositionBinarySensor(coordinator, door)
|
||||
for door in coordinator.data.doors.values()
|
||||
)
|
||||
added_doors: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_doors() -> None:
|
||||
new_door_ids = sorted(set(coordinator.data.doors) - added_doors)
|
||||
if not new_door_ids:
|
||||
return
|
||||
async_add_entities(
|
||||
UnifiAccessDoorPositionBinarySensor(
|
||||
coordinator, coordinator.data.doors[door_id]
|
||||
)
|
||||
for door_id in new_door_ids
|
||||
)
|
||||
added_doors.update(new_door_ids)
|
||||
|
||||
_async_add_new_doors()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors))
|
||||
|
||||
|
||||
class UnifiAccessDoorPositionBinarySensor(UnifiAccessEntity, BinarySensorEntity):
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from unifi_access_api import Door, UnifiAccessError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -23,10 +23,21 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up UniFi Access button entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessUnlockButton(coordinator, door)
|
||||
for door in coordinator.data.doors.values()
|
||||
)
|
||||
added_doors: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_doors() -> None:
|
||||
new_door_ids = sorted(set(coordinator.data.doors) - added_doors)
|
||||
if not new_door_ids:
|
||||
return
|
||||
async_add_entities(
|
||||
UnifiAccessUnlockButton(coordinator, coordinator.data.doors[door_id])
|
||||
for door_id in new_door_ids
|
||||
)
|
||||
added_doors.update(new_door_ids)
|
||||
|
||||
_async_add_new_doors()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors))
|
||||
|
||||
|
||||
class UnifiAccessUnlockButton(UnifiAccessEntity, ButtonEntity):
|
||||
|
||||
@@ -37,6 +37,7 @@ from unifi_access_api.models.websocket import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -194,6 +195,9 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
|
||||
|
||||
supports_lock_rules = bool(door_lock_rules) or bool(unconfirmed_lock_rule_doors)
|
||||
|
||||
current_ids = {door.id for door in doors} | {self.config_entry.entry_id}
|
||||
self._remove_stale_devices(current_ids)
|
||||
|
||||
return UnifiAccessData(
|
||||
doors={door.id: door for door in doors},
|
||||
emergency=emergency,
|
||||
@@ -221,6 +225,23 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
|
||||
except ApiNotFoundError:
|
||||
return None
|
||||
|
||||
@callback
|
||||
def _remove_stale_devices(self, current_ids: set[str]) -> None:
|
||||
"""Remove devices for doors that no longer exist on the hub."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, self.config_entry.entry_id
|
||||
):
|
||||
if any(
|
||||
identifier[0] == DOMAIN and identifier[1] in current_ids
|
||||
for identifier in device.identifiers
|
||||
):
|
||||
continue
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
|
||||
def _on_ws_connect(self) -> None:
|
||||
"""Handle WebSocket connection established."""
|
||||
_LOGGER.debug("WebSocket connected to UniFi Access")
|
||||
|
||||
@@ -55,11 +55,24 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up UniFi Access event entities."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessEventEntity(coordinator, door, description)
|
||||
for door in coordinator.data.doors.values()
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
)
|
||||
added_doors: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_doors() -> None:
|
||||
new_door_ids = sorted(set(coordinator.data.doors) - added_doors)
|
||||
if not new_door_ids:
|
||||
return
|
||||
async_add_entities(
|
||||
UnifiAccessEventEntity(
|
||||
coordinator, coordinator.data.doors[door_id], description
|
||||
)
|
||||
for door_id in new_door_ids
|
||||
for description in EVENT_DESCRIPTIONS
|
||||
)
|
||||
added_doors.update(new_door_ids)
|
||||
|
||||
_async_add_new_doors()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors))
|
||||
|
||||
|
||||
class UnifiAccessEventEntity(UnifiAccessEntity, EventEntity):
|
||||
|
||||
@@ -8,7 +8,7 @@ from unifi_access_api import Door
|
||||
|
||||
from homeassistant.components.image import ImageEntity
|
||||
from homeassistant.const import CONF_VERIFY_SSL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry, UnifiAccessCoordinator
|
||||
@@ -24,10 +24,26 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up image entities for UniFi Access doors."""
|
||||
coordinator = entry.runtime_data
|
||||
async_add_entities(
|
||||
UnifiAccessDoorImageEntity(coordinator, hass, entry.data[CONF_VERIFY_SSL], door)
|
||||
for door in coordinator.data.doors.values()
|
||||
)
|
||||
added_doors: set[str] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_doors() -> None:
|
||||
new_door_ids = sorted(set(coordinator.data.doors) - added_doors)
|
||||
if not new_door_ids:
|
||||
return
|
||||
async_add_entities(
|
||||
UnifiAccessDoorImageEntity(
|
||||
coordinator,
|
||||
hass,
|
||||
entry.data[CONF_VERIFY_SSL],
|
||||
coordinator.data.doors[door_id],
|
||||
)
|
||||
for door_id in new_door_ids
|
||||
)
|
||||
added_doors.update(new_door_ids)
|
||||
|
||||
_async_add_new_doors()
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_doors))
|
||||
|
||||
|
||||
class UnifiAccessDoorImageEntity(UnifiAccessEntity, ImageEntity):
|
||||
|
||||
@@ -51,16 +51,20 @@ rules:
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: All entities provide essential data and should be enabled by default.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration raises ConfigEntryAuthFailed and relies on Home Assistant core to surface reauth/repair issues, no custom repairs are defined.
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["victron-ble-ha-parser==0.6.2"]
|
||||
"requirements": ["victron-ble-ha-parser==0.6.3"]
|
||||
}
|
||||
|
||||
@@ -17,18 +17,28 @@ from zwave_js_server.const.command_class.notification import (
|
||||
SmokeAlarmNotificationEvent,
|
||||
)
|
||||
from zwave_js_server.model.driver import Driver
|
||||
from zwave_js_server.model.value import Value as ZwaveValue
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
|
||||
@@ -72,8 +82,7 @@ ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR = 5632
|
||||
ACCESS_CONTROL_DOOR_STATE_OPEN_TILT = 5633
|
||||
|
||||
|
||||
# Numeric State values used by the "Opening state" notification variable.
|
||||
# This is only needed temporarily until the legacy Access Control door state binary sensors are removed.
|
||||
# Numeric State values used by the Opening state notification variable.
|
||||
class OpeningState(IntEnum):
|
||||
"""Opening state values exposed by Access Control notifications."""
|
||||
|
||||
@@ -82,23 +91,23 @@ class OpeningState(IntEnum):
|
||||
TILTED = 2
|
||||
|
||||
|
||||
# parse_opening_state helpers for the DEPRECATED legacy Access Control binary sensors.
|
||||
def _legacy_is_closed(opening_state: OpeningState) -> bool:
|
||||
# parse_opening_state helpers.
|
||||
def _opening_state_is_closed(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents closed."""
|
||||
return opening_state is OpeningState.CLOSED
|
||||
|
||||
|
||||
def _legacy_is_open(opening_state: OpeningState) -> bool:
|
||||
def _opening_state_is_open(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents open."""
|
||||
return opening_state is OpeningState.OPEN
|
||||
|
||||
|
||||
def _legacy_is_open_or_tilted(opening_state: OpeningState) -> bool:
|
||||
def _opening_state_is_open_or_tilted(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents open or tilted."""
|
||||
return opening_state in (OpeningState.OPEN, OpeningState.TILTED)
|
||||
|
||||
|
||||
def _legacy_is_tilted(opening_state: OpeningState) -> bool:
|
||||
def _opening_state_is_tilted(opening_state: OpeningState) -> bool:
|
||||
"""Return if Opening state represents tilted."""
|
||||
return opening_state is OpeningState.TILTED
|
||||
|
||||
@@ -127,12 +136,51 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpeningStateZWaveJSEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describe a legacy Access Control binary sensor that derives state from Opening state."""
|
||||
"""Describe an Access Control binary sensor that derives state from Opening state."""
|
||||
|
||||
state_key: int
|
||||
parse_opening_state: Callable[[OpeningState], bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LegacyDoorStateRepairDescription:
|
||||
"""Describe how a legacy door state entity should be migrated."""
|
||||
|
||||
issue_translation_key: str
|
||||
replacement_state_key: OpeningState
|
||||
|
||||
|
||||
LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS: dict[str, LegacyDoorStateRepairDescription] = {
|
||||
"legacy_access_control_door_state_simple_open": LegacyDoorStateRepairDescription(
|
||||
issue_translation_key="deprecated_legacy_door_open_state",
|
||||
replacement_state_key=OpeningState.OPEN,
|
||||
),
|
||||
"legacy_access_control_door_state_open": LegacyDoorStateRepairDescription(
|
||||
issue_translation_key="deprecated_legacy_door_open_state",
|
||||
replacement_state_key=OpeningState.OPEN,
|
||||
),
|
||||
"legacy_access_control_door_state_open_regular": LegacyDoorStateRepairDescription(
|
||||
issue_translation_key="deprecated_legacy_door_open_state",
|
||||
replacement_state_key=OpeningState.OPEN,
|
||||
),
|
||||
"legacy_access_control_door_state_open_tilt": LegacyDoorStateRepairDescription(
|
||||
issue_translation_key="deprecated_legacy_door_tilt_state",
|
||||
replacement_state_key=OpeningState.TILTED,
|
||||
),
|
||||
"legacy_access_control_door_tilt_state_tilted": LegacyDoorStateRepairDescription(
|
||||
issue_translation_key="deprecated_legacy_door_tilt_state",
|
||||
replacement_state_key=OpeningState.TILTED,
|
||||
),
|
||||
}
|
||||
|
||||
LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS = frozenset(
|
||||
{
|
||||
description.issue_translation_key
|
||||
for description in LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.values()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# Mappings for Notification sensors
|
||||
# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx
|
||||
#
|
||||
@@ -389,6 +437,9 @@ BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescripti
|
||||
}
|
||||
|
||||
|
||||
# This can likely be removed once the legacy notification binary sensor
|
||||
# discovery path is gone and Opening state is handled only by the dedicated
|
||||
# discovery schemas below.
|
||||
@callback
|
||||
def is_valid_notification_binary_sensor(
|
||||
info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo,
|
||||
@@ -396,13 +447,111 @@ def is_valid_notification_binary_sensor(
|
||||
"""Return if the notification CC Value is valid as binary sensor."""
|
||||
if not info.primary_value.metadata.states:
|
||||
return False
|
||||
# Access Control - Opening state is exposed as a single enum sensor instead
|
||||
# of fanning out one binary sensor per state.
|
||||
# Opening state is handled by dedicated discovery schemas
|
||||
if is_opening_state_notification_value(info.primary_value):
|
||||
return False
|
||||
return len(info.primary_value.metadata.states) > 1
|
||||
|
||||
|
||||
@callback
|
||||
def _async_delete_legacy_entity_repairs(hass: HomeAssistant, entity_id: str) -> None:
|
||||
"""Delete all stale legacy door state repair issues for an entity."""
|
||||
for issue_key in LEGACY_DOOR_STATE_REPAIR_ISSUE_KEYS:
|
||||
async_delete_issue(hass, DOMAIN, f"{issue_key}.{entity_id}")
|
||||
|
||||
|
||||
@callback
|
||||
def _async_check_legacy_entity_repair(
|
||||
hass: HomeAssistant,
|
||||
driver: Driver,
|
||||
entity: ZWaveLegacyDoorStateBinarySensor,
|
||||
) -> None:
|
||||
"""Schedule a repair issue check once HA has fully started."""
|
||||
|
||||
@callback
|
||||
def _async_do_check(hass: HomeAssistant) -> None:
|
||||
"""Create or delete a repair issue for a deprecated legacy door state entity."""
|
||||
ent_reg = er.async_get(hass)
|
||||
if entity.unique_id is None:
|
||||
return
|
||||
entity_id = ent_reg.async_get_entity_id(
|
||||
BINARY_SENSOR_DOMAIN, DOMAIN, entity.unique_id
|
||||
)
|
||||
if entity_id is None:
|
||||
return
|
||||
|
||||
repair_description = LEGACY_DOOR_STATE_REPAIR_DESCRIPTIONS.get(
|
||||
entity.entity_description.key
|
||||
)
|
||||
if repair_description is None:
|
||||
_async_delete_legacy_entity_repairs(hass, entity_id)
|
||||
return
|
||||
|
||||
entity_entry = ent_reg.async_get(entity_id)
|
||||
if entity_entry is None or entity_entry.disabled:
|
||||
_async_delete_legacy_entity_repairs(hass, entity_id)
|
||||
return
|
||||
|
||||
entity_automations = automations_with_entity(hass, entity_id)
|
||||
entity_scripts = scripts_with_entity(hass, entity_id)
|
||||
if not entity_automations and not entity_scripts:
|
||||
_async_delete_legacy_entity_repairs(hass, entity_id)
|
||||
return
|
||||
|
||||
opening_state_value = get_opening_state_notification_value(
|
||||
entity.info.node, entity.info.primary_value.endpoint
|
||||
)
|
||||
if opening_state_value is None:
|
||||
_async_delete_legacy_entity_repairs(hass, entity_id)
|
||||
return
|
||||
|
||||
replacement_unique_id = (
|
||||
f"{driver.controller.home_id}.{opening_state_value.value_id}."
|
||||
f"{repair_description.replacement_state_key}"
|
||||
)
|
||||
replacement_entity_id = ent_reg.async_get_entity_id(
|
||||
BINARY_SENSOR_DOMAIN, DOMAIN, replacement_unique_id
|
||||
)
|
||||
if replacement_entity_id is None:
|
||||
_async_delete_legacy_entity_repairs(hass, entity_id)
|
||||
return
|
||||
|
||||
items = []
|
||||
for domain, entity_ids in (
|
||||
("automation", entity_automations),
|
||||
("script", entity_scripts),
|
||||
):
|
||||
for eid in entity_ids:
|
||||
item = ent_reg.async_get(eid)
|
||||
if item:
|
||||
items.append(
|
||||
f"- [{item.name or item.original_name or eid}]"
|
||||
f"(/config/{domain}/edit/{item.unique_id})"
|
||||
)
|
||||
else:
|
||||
items.append(f"- {eid}")
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"{repair_description.issue_translation_key}.{entity_id}",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key=repair_description.issue_translation_key,
|
||||
translation_placeholders={
|
||||
"entity_id": entity_id,
|
||||
"entity_name": (
|
||||
entity_entry.name or entity_entry.original_name or entity_id
|
||||
),
|
||||
"replacement_entity_id": replacement_entity_id,
|
||||
"items": "\n".join(items),
|
||||
},
|
||||
)
|
||||
|
||||
async_at_started(hass, _async_do_check)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ZwaveJSConfigEntry,
|
||||
@@ -442,13 +591,21 @@ async def async_setup_entry(
|
||||
and info.entity_class is ZWaveBooleanBinarySensor
|
||||
):
|
||||
entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info))
|
||||
elif (
|
||||
isinstance(info, NewZwaveDiscoveryInfo)
|
||||
and info.entity_class is ZWaveOpeningStateBinarySensor
|
||||
and isinstance(
|
||||
info.entity_description, OpeningStateZWaveJSEntityDescription
|
||||
)
|
||||
):
|
||||
entities.append(ZWaveOpeningStateBinarySensor(config_entry, driver, info))
|
||||
elif (
|
||||
isinstance(info, NewZwaveDiscoveryInfo)
|
||||
and info.entity_class is ZWaveLegacyDoorStateBinarySensor
|
||||
):
|
||||
entities.append(
|
||||
ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
|
||||
)
|
||||
entity = ZWaveLegacyDoorStateBinarySensor(config_entry, driver, info)
|
||||
entities.append(entity)
|
||||
_async_check_legacy_entity_repair(hass, driver, entity)
|
||||
elif isinstance(info, NewZwaveDiscoveryInfo):
|
||||
pass # other entity classes are not migrated yet
|
||||
elif info.platform_hint == "notification":
|
||||
@@ -632,6 +789,69 @@ class ZWaveLegacyDoorStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
return None
|
||||
|
||||
|
||||
class ZWaveOpeningStateBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a binary sensor derived from Opening state."""
|
||||
|
||||
entity_description: OpeningStateZWaveJSEntityDescription
|
||||
_known_states: set[str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry: ZwaveJSConfigEntry,
|
||||
driver: Driver,
|
||||
info: NewZwaveDiscoveryInfo,
|
||||
) -> None:
|
||||
"""Initialize an Opening state binary sensor entity."""
|
||||
super().__init__(config_entry, driver, info)
|
||||
self._known_states = set(info.primary_value.metadata.states or ())
|
||||
self._attr_unique_id = (
|
||||
f"{self._attr_unique_id}.{self.entity_description.state_key}"
|
||||
)
|
||||
|
||||
@callback
|
||||
def should_rediscover_on_metadata_update(self) -> bool:
|
||||
"""Check if metadata states require adding the Tilt entity."""
|
||||
return (
|
||||
# Open and Tilt entities share the same underlying Opening state value.
|
||||
# Only let the main Open entity trigger rediscovery when Tilt first
|
||||
# appears so we can add the missing sibling without recreating the
|
||||
# main entity and losing its registry customizations.
|
||||
str(OpeningState.TILTED) not in self._known_states
|
||||
and str(OpeningState.TILTED)
|
||||
in set(self.info.primary_value.metadata.states or ())
|
||||
and self.entity_description.state_key == OpeningState.OPEN
|
||||
)
|
||||
|
||||
async def _async_remove_and_rediscover(self, value: ZwaveValue) -> None:
|
||||
"""Trigger re-discovery while preserving the main Opening state entity."""
|
||||
assert self.device_entry is not None
|
||||
controller_events = (
|
||||
self.config_entry.runtime_data.driver_events.controller_events
|
||||
)
|
||||
|
||||
# Unlike the base implementation, keep this entity in place so its
|
||||
# registry entry and user customizations survive metadata rediscovery.
|
||||
controller_events.discovered_value_ids[self.device_entry.id].discard(
|
||||
value.value_id
|
||||
)
|
||||
node_events = controller_events.node_events
|
||||
value_updates_disc_info = node_events.value_updates_disc_info[
|
||||
value.node.node_id
|
||||
]
|
||||
node_events.async_on_value_added(value_updates_disc_info, value)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return if the sensor is on or off."""
|
||||
value = self.info.primary_value.value
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return self.entity_description.parse_opening_state(OpeningState(int(value)))
|
||||
except TypeError, ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a Z-Wave binary_sensor from a property."""
|
||||
|
||||
@@ -730,11 +950,54 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
),
|
||||
entity_class=ZWaveNotificationBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Opening state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={OpeningState.TILTED},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
# Also derive the main binary sensor from the same value ID
|
||||
allow_multi=True,
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="access_control_opening_state_tilted",
|
||||
name="Tilt",
|
||||
state_key=OpeningState.TILTED,
|
||||
parse_opening_state=_opening_state_is_tilted,
|
||||
),
|
||||
entity_class=ZWaveOpeningStateBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.NOTIFICATION},
|
||||
property={"Access Control"},
|
||||
property_key={"Opening state"},
|
||||
type={ValueType.NUMBER},
|
||||
any_available_states_keys={OpeningState.OPEN},
|
||||
any_available_cc_specific={
|
||||
(CC_SPECIFIC_NOTIFICATION_TYPE, NotificationType.ACCESS_CONTROL)
|
||||
},
|
||||
),
|
||||
entity_description=OpeningStateZWaveJSEntityDescription(
|
||||
key="access_control_opening_state_open",
|
||||
state_key=OpeningState.OPEN,
|
||||
parse_opening_state=_opening_state_is_open_or_tilted,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
),
|
||||
entity_class=ZWaveOpeningStateBinarySensor,
|
||||
),
|
||||
# -------------------------------------------------------------------
|
||||
# DEPRECATED legacy Access Control door/window binary sensors.
|
||||
# These schemas exist only for backwards compatibility with users who
|
||||
# already have these entities registered. New integrations should use
|
||||
# the Opening state enum sensor instead. Do not add new schemas here.
|
||||
# the dedicated Opening state binary sensors instead. Do not add new
|
||||
# schemas here.
|
||||
# All schemas below use ZWaveLegacyDoorStateBinarySensor and are
|
||||
# disabled by default (entity_registry_enabled_default=False).
|
||||
# -------------------------------------------------------------------
|
||||
@@ -758,7 +1021,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_simple_open",
|
||||
name="Window/door is open",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
|
||||
parse_opening_state=_legacy_is_open_or_tilted,
|
||||
parse_opening_state=_opening_state_is_open_or_tilted,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
@@ -784,7 +1047,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_simple_closed",
|
||||
name="Window/door is closed",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
|
||||
parse_opening_state=_legacy_is_closed,
|
||||
parse_opening_state=_opening_state_is_closed,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
@@ -809,7 +1072,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_open",
|
||||
name="Window/door is open",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_OPEN,
|
||||
parse_opening_state=_legacy_is_open,
|
||||
parse_opening_state=_opening_state_is_open,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
@@ -835,7 +1098,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_closed",
|
||||
name="Window/door is closed",
|
||||
state_key=AccessControlNotificationEvent.DOOR_STATE_WINDOW_DOOR_IS_CLOSED,
|
||||
parse_opening_state=_legacy_is_closed,
|
||||
parse_opening_state=_opening_state_is_closed,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
@@ -858,7 +1121,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_open_regular",
|
||||
name="Window/door is open in regular position",
|
||||
state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_REGULAR,
|
||||
parse_opening_state=_legacy_is_open,
|
||||
parse_opening_state=_opening_state_is_open,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
@@ -881,7 +1144,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_state_open_tilt",
|
||||
name="Window/door is open in tilt position",
|
||||
state_key=ACCESS_CONTROL_DOOR_STATE_OPEN_TILT,
|
||||
parse_opening_state=_legacy_is_tilted,
|
||||
parse_opening_state=_opening_state_is_tilted,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
@@ -904,7 +1167,7 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
key="legacy_access_control_door_tilt_state_tilted",
|
||||
name="Window/door is tilted",
|
||||
state_key=OpeningState.OPEN,
|
||||
parse_opening_state=_legacy_is_tilted,
|
||||
parse_opening_state=_opening_state_is_tilted,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
entity_class=ZWaveLegacyDoorStateBinarySensor,
|
||||
|
||||
@@ -303,6 +303,14 @@
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_legacy_door_open_state": {
|
||||
"description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on when the door or window is open or tilted.",
|
||||
"title": "Deprecation: {entity_name}"
|
||||
},
|
||||
"deprecated_legacy_door_tilt_state": {
|
||||
"description": "The binary sensor `{entity_id}` is deprecated because it has been replaced with the binary sensor `{replacement_entity_id}`.\n\nThe entity was found in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the binary sensor `{replacement_entity_id}` and disable the binary sensor `{entity_id}` and then restart Home Assistant, to fix this issue.\n\nNote that `{replacement_entity_id}` is on only when the door or window is tilted.",
|
||||
"title": "Deprecation: {entity_name}"
|
||||
},
|
||||
"device_config_file_changed": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
|
||||
3
requirements_all.txt
generated
3
requirements_all.txt
generated
@@ -267,6 +267,7 @@ aioftp==0.21.3
|
||||
aioghost==0.4.0
|
||||
|
||||
# homeassistant.components.github
|
||||
# homeassistant.components.tasmota
|
||||
aiogithubapi==26.0.0
|
||||
|
||||
# homeassistant.components.guardian
|
||||
@@ -3234,7 +3235,7 @@ venstarcolortouch==0.21
|
||||
viaggiatreno_ha==0.2.4
|
||||
|
||||
# homeassistant.components.victron_ble
|
||||
victron-ble-ha-parser==0.6.2
|
||||
victron-ble-ha-parser==0.6.3
|
||||
|
||||
# homeassistant.components.victron_remote_monitoring
|
||||
victron-vrm==0.1.8
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -255,6 +255,7 @@ aioflo==2021.11.0
|
||||
aioghost==0.4.0
|
||||
|
||||
# homeassistant.components.github
|
||||
# homeassistant.components.tasmota
|
||||
aiogithubapi==26.0.0
|
||||
|
||||
# homeassistant.components.guardian
|
||||
@@ -2737,7 +2738,7 @@ velbus-aio==2026.2.0
|
||||
venstarcolortouch==0.21
|
||||
|
||||
# homeassistant.components.victron_ble
|
||||
victron-ble-ha-parser==0.6.2
|
||||
victron-ble-ha-parser==0.6.3
|
||||
|
||||
# homeassistant.components.victron_remote_monitoring
|
||||
victron-vrm==0.1.8
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for the Anthropic integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from anthropic.types import (
|
||||
BashCodeExecutionOutputBlock,
|
||||
BashCodeExecutionResultBlock,
|
||||
@@ -7,6 +9,9 @@ from anthropic.types import (
|
||||
BashCodeExecutionToolResultError,
|
||||
BashCodeExecutionToolResultErrorCode,
|
||||
CitationsDelta,
|
||||
CodeExecutionToolResultBlock,
|
||||
CodeExecutionToolResultBlockContent,
|
||||
DirectCaller,
|
||||
InputJSONDelta,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
@@ -24,7 +29,9 @@ from anthropic.types import (
|
||||
ToolUseBlock,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultError,
|
||||
)
|
||||
from anthropic.types.server_tool_use_block import Caller
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockContent,
|
||||
)
|
||||
@@ -138,45 +145,58 @@ def create_tool_use_block(
|
||||
|
||||
|
||||
def create_server_tool_use_block(
|
||||
index: int, id: str, name: str, args_parts: list[str]
|
||||
index: int,
|
||||
id: str,
|
||||
name: str,
|
||||
input_parts: list[str] | dict[str, Any],
|
||||
caller: Caller | None = None,
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block."""
|
||||
if caller is None:
|
||||
caller = DirectCaller(type="direct")
|
||||
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ServerToolUseBlock(
|
||||
type="server_tool_use", id=id, input={}, name=name
|
||||
type="server_tool_use",
|
||||
id=id,
|
||||
input=input_parts if isinstance(input_parts, dict) else {},
|
||||
name=name,
|
||||
caller=caller,
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=InputJSONDelta(type="input_json_delta", partial_json=args_part),
|
||||
delta=InputJSONDelta(type="input_json_delta", partial_json=input_part),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for args_part in args_parts
|
||||
for input_part in (input_parts if isinstance(input_parts, list) else [])
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_web_search_block(
|
||||
index: int, id: str, query_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block for web search."""
|
||||
return create_server_tool_use_block(index, id, "web_search", query_parts)
|
||||
|
||||
|
||||
def create_web_search_result_block(
|
||||
index: int, id: str, results: list[WebSearchResultBlock]
|
||||
index: int,
|
||||
id: str,
|
||||
results: list[WebSearchResultBlock] | WebSearchToolResultError,
|
||||
caller: Caller | None = None,
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool result block for web search results."""
|
||||
if caller is None:
|
||||
caller = DirectCaller(type="direct")
|
||||
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=WebSearchToolResultBlock(
|
||||
type="web_search_tool_result", tool_use_id=id, content=results
|
||||
type="web_search_tool_result",
|
||||
tool_use_id=id,
|
||||
content=results,
|
||||
caller=caller,
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
@@ -184,11 +204,20 @@ def create_web_search_result_block(
|
||||
]
|
||||
|
||||
|
||||
def create_bash_code_execution_block(
|
||||
index: int, id: str, command_parts: list[str]
|
||||
def create_code_execution_result_block(
|
||||
index: int, id: str, content: CodeExecutionToolResultBlockContent
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block for bash code execution."""
|
||||
return create_server_tool_use_block(index, id, "bash_code_execution", command_parts)
|
||||
"""Create a server tool result block for code execution results."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=CodeExecutionToolResultBlock(
|
||||
type="code_execution_tool_result", tool_use_id=id, content=content
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_bash_code_execution_result_block(
|
||||
@@ -226,15 +255,6 @@ def create_bash_code_execution_result_block(
|
||||
]
|
||||
|
||||
|
||||
def create_text_editor_code_execution_block(
|
||||
index: int, id: str, command_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block for text editor code execution."""
|
||||
return create_server_tool_use_block(
|
||||
index, id, "text_editor_code_execution", command_parts
|
||||
)
|
||||
|
||||
|
||||
def create_text_editor_code_execution_result_block(
|
||||
index: int,
|
||||
id: str,
|
||||
|
||||
@@ -215,7 +215,11 @@ def mock_create_stream() -> Generator[AsyncMock]:
|
||||
isinstance(event, RawContentBlockStartEvent)
|
||||
and isinstance(event.content_block, ServerToolUseBlock)
|
||||
and event.content_block.name
|
||||
in ["bash_code_execution", "text_editor_code_execution"]
|
||||
in [
|
||||
"code_execution",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
):
|
||||
container = Container(
|
||||
id=kwargs.get("container_id", "container_1234567890ABCDEFGHIJKLMN"),
|
||||
|
||||
@@ -1508,3 +1508,286 @@
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_web_search_dynamic_filtering
|
||||
list([
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': 'Who won the Nobel for Chemistry in 2025?',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': None,
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': dict({
|
||||
'citation_details': list([
|
||||
]),
|
||||
'container': None,
|
||||
'redacted_thinking': None,
|
||||
'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
|
||||
}),
|
||||
'role': 'assistant',
|
||||
'thinking_content': 'Let me search for this information.',
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'external': True,
|
||||
'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
|
||||
'tool_args': dict({
|
||||
'code': '''
|
||||
|
||||
import json
|
||||
result = await web_search({"query": "Nobel Prize chemistry 2025 winner"})
|
||||
parsed = json.loads(result)
|
||||
for r in parsed[:3]:
|
||||
print(r.get("title", ""))
|
||||
print(r.get("content", "")[:300])
|
||||
print("---")
|
||||
|
||||
''',
|
||||
}),
|
||||
'tool_name': 'code_execution',
|
||||
}),
|
||||
dict({
|
||||
'external': True,
|
||||
'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
|
||||
'tool_args': dict({
|
||||
'query': 'Nobel Prize chemistry 2025 winner',
|
||||
}),
|
||||
'tool_name': 'web_search',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
|
||||
'tool_name': 'web_search',
|
||||
'tool_result': dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'encrypted_content': 'ABCDEFG',
|
||||
'page_age': None,
|
||||
'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com',
|
||||
'type': 'web_search_result',
|
||||
'url': 'https://www.example.com/prizes/chemistry/2025/press-release/',
|
||||
}),
|
||||
dict({
|
||||
'encrypted_content': 'ABCDEFG',
|
||||
'page_age': None,
|
||||
'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com',
|
||||
'type': 'web_search_result',
|
||||
'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
|
||||
'tool_name': 'code_execution',
|
||||
'tool_result': dict({
|
||||
'content': list([
|
||||
]),
|
||||
'encrypted_stdout': 'EuQJCioIDRgCIiRj',
|
||||
'return_code': 0,
|
||||
'stderr': '',
|
||||
'type': 'encrypted_code_execution_result',
|
||||
}),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metal–organic frameworks."',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': dict({
|
||||
'citation_details': list([
|
||||
]),
|
||||
'container': Container(id='container_1234567890ABCDEFGHIJKLMN', expires_at=HAFakeDatetime(2025, 10, 31, 12, 5, tzinfo=datetime.timezone.utc)),
|
||||
'redacted_thinking': None,
|
||||
'thinking_signature': None,
|
||||
}),
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_web_search_dynamic_filtering.1
|
||||
list([
|
||||
dict({
|
||||
'content': 'Who won the Nobel for Chemistry in 2025?',
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
|
||||
'thinking': 'Let me search for this information.',
|
||||
'type': 'thinking',
|
||||
}),
|
||||
dict({
|
||||
'id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
|
||||
'input': dict({
|
||||
'code': '''
|
||||
|
||||
import json
|
||||
result = await web_search({"query": "Nobel Prize chemistry 2025 winner"})
|
||||
parsed = json.loads(result)
|
||||
for r in parsed[:3]:
|
||||
print(r.get("title", ""))
|
||||
print(r.get("content", "")[:300])
|
||||
print("---")
|
||||
|
||||
''',
|
||||
}),
|
||||
'name': 'code_execution',
|
||||
'type': 'server_tool_use',
|
||||
}),
|
||||
dict({
|
||||
'id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
|
||||
'input': dict({
|
||||
'query': 'Nobel Prize chemistry 2025 winner',
|
||||
}),
|
||||
'name': 'web_search',
|
||||
'type': 'server_tool_use',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'encrypted_content': 'ABCDEFG',
|
||||
'page_age': None,
|
||||
'title': 'Press release: Nobel Prize in Chemistry 2025 - Example.com',
|
||||
'type': 'web_search_result',
|
||||
'url': 'https://www.example.com/prizes/chemistry/2025/press-release/',
|
||||
}),
|
||||
dict({
|
||||
'encrypted_content': 'ABCDEFG',
|
||||
'page_age': None,
|
||||
'title': 'Nobel Prize in Chemistry 2025 - NewsSite.com',
|
||||
'type': 'web_search_result',
|
||||
'url': 'https://www.newssite.com/prizes/chemistry/2025/summary/',
|
||||
}),
|
||||
]),
|
||||
'tool_use_id': 'srvtoolu_016vjte6G4Lj6yzLc2ak1vY4',
|
||||
'type': 'web_search_tool_result',
|
||||
}),
|
||||
dict({
|
||||
'content': dict({
|
||||
'content': list([
|
||||
]),
|
||||
'encrypted_stdout': 'EuQJCioIDRgCIiRj',
|
||||
'return_code': 0,
|
||||
'stderr': '',
|
||||
'type': 'encrypted_code_execution_result',
|
||||
}),
|
||||
'tool_use_id': 'srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT',
|
||||
'type': 'code_execution_tool_result',
|
||||
}),
|
||||
dict({
|
||||
'text': 'The 2025 Nobel Prize in Chemistry was awarded jointly to **Susumu Kitagawa**, **Richard Robson**, and **Omar M. Yaghi** "for the development of metal–organic frameworks."',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'role': 'assistant',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_web_search_error
|
||||
list([
|
||||
dict({
|
||||
'attachments': None,
|
||||
'content': "What's on the news today?",
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': "To get today's news, I'll perform a web search",
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': dict({
|
||||
'citation_details': list([
|
||||
]),
|
||||
'container': None,
|
||||
'redacted_thinking': None,
|
||||
'thinking_signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
|
||||
}),
|
||||
'role': 'assistant',
|
||||
'thinking_content': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
|
||||
'tool_calls': list([
|
||||
dict({
|
||||
'external': True,
|
||||
'id': 'srvtoolu_12345ABC',
|
||||
'tool_args': dict({
|
||||
'query': "today's news",
|
||||
}),
|
||||
'tool_name': 'web_search',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'role': 'tool_result',
|
||||
'tool_call_id': 'srvtoolu_12345ABC',
|
||||
'tool_name': 'web_search',
|
||||
'tool_result': dict({
|
||||
'error_code': 'too_many_requests',
|
||||
'type': 'web_search_tool_result_error',
|
||||
}),
|
||||
}),
|
||||
dict({
|
||||
'agent_id': 'conversation.claude_conversation',
|
||||
'content': 'I am unable to perform the web search at this time.',
|
||||
'created': HAFakeDatetime(2025, 10, 31, 12, 0, tzinfo=datetime.timezone.utc),
|
||||
'native': None,
|
||||
'role': 'assistant',
|
||||
'thinking_content': None,
|
||||
'tool_calls': None,
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_web_search_error.1
|
||||
list([
|
||||
dict({
|
||||
'content': "What's on the news today?",
|
||||
'role': 'user',
|
||||
}),
|
||||
dict({
|
||||
'content': list([
|
||||
dict({
|
||||
'signature': 'ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/NoB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==',
|
||||
'thinking': "The user is asking about today's news, which requires current, real-time information. This is clearly something that requires recent information beyond my knowledge cutoff. I should use the web_search tool to find today's news.",
|
||||
'type': 'thinking',
|
||||
}),
|
||||
dict({
|
||||
'text': "To get today's news, I'll perform a web search",
|
||||
'type': 'text',
|
||||
}),
|
||||
dict({
|
||||
'id': 'srvtoolu_12345ABC',
|
||||
'input': dict({
|
||||
'query': "today's news",
|
||||
}),
|
||||
'name': 'web_search',
|
||||
'type': 'server_tool_use',
|
||||
}),
|
||||
dict({
|
||||
'content': dict({
|
||||
'error_code': 'too_many_requests',
|
||||
'type': 'web_search_tool_result_error',
|
||||
}),
|
||||
'tool_use_id': 'srvtoolu_12345ABC',
|
||||
'type': 'web_search_tool_result',
|
||||
}),
|
||||
dict({
|
||||
'text': 'I am unable to perform the web search at this time.',
|
||||
'type': 'text',
|
||||
}),
|
||||
]),
|
||||
'role': 'assistant',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
|
||||
@@ -8,7 +8,9 @@ from anthropic import AuthenticationError, RateLimitError
|
||||
from anthropic.types import (
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
EncryptedCodeExecutionResultBlock,
|
||||
Message,
|
||||
ServerToolCaller20260120,
|
||||
TextBlock,
|
||||
TextEditorCodeExecutionCreateResultBlock,
|
||||
TextEditorCodeExecutionStrReplaceResultBlock,
|
||||
@@ -16,6 +18,7 @@ from anthropic.types import (
|
||||
TextEditorCodeExecutionViewResultBlock,
|
||||
Usage,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultError,
|
||||
)
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block import (
|
||||
Content as TextEditorCodeExecutionToolResultBlockContent,
|
||||
@@ -51,15 +54,14 @@ from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import ulid as ulid_util
|
||||
|
||||
from . import (
|
||||
create_bash_code_execution_block,
|
||||
create_bash_code_execution_result_block,
|
||||
create_code_execution_result_block,
|
||||
create_content_block,
|
||||
create_redacted_thinking_block,
|
||||
create_text_editor_code_execution_block,
|
||||
create_server_tool_use_block,
|
||||
create_text_editor_code_execution_result_block,
|
||||
create_thinking_block,
|
||||
create_tool_use_block,
|
||||
create_web_search_block,
|
||||
create_web_search_result_block,
|
||||
)
|
||||
|
||||
@@ -864,7 +866,7 @@ async def test_web_search(
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-5",
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-0",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
@@ -909,9 +911,10 @@ async def test_web_search(
|
||||
*create_content_block(
|
||||
1, ["To get today's news, I'll perform a web search"]
|
||||
),
|
||||
*create_web_search_block(
|
||||
*create_server_tool_use_block(
|
||||
2,
|
||||
"srvtoolu_12345ABC",
|
||||
"web_search",
|
||||
["", '{"que', 'ry"', ": \"today's", ' news"}'],
|
||||
),
|
||||
*create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results),
|
||||
@@ -984,6 +987,226 @@ async def test_web_search(
|
||||
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_web_search_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test web search error."""
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-0",
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
CONF_WEB_SEARCH_CITY: "San Francisco",
|
||||
CONF_WEB_SEARCH_REGION: "California",
|
||||
CONF_WEB_SEARCH_COUNTRY: "US",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
|
||||
},
|
||||
)
|
||||
|
||||
web_search_results = WebSearchToolResultError(
|
||||
type="web_search_tool_result_error",
|
||||
error_code="too_many_requests",
|
||||
)
|
||||
mock_create_stream.return_value = [
|
||||
(
|
||||
*create_thinking_block(
|
||||
0,
|
||||
[
|
||||
"The user is",
|
||||
" asking about today's news, which",
|
||||
" requires current, real-time information",
|
||||
". This is clearly something that requires recent",
|
||||
" information beyond my knowledge cutoff.",
|
||||
" I should use the web",
|
||||
"_search tool to fin",
|
||||
"d today's news.",
|
||||
],
|
||||
),
|
||||
*create_content_block(
|
||||
1, ["To get today's news, I'll perform a web search"]
|
||||
),
|
||||
*create_server_tool_use_block(
|
||||
2,
|
||||
"srvtoolu_12345ABC",
|
||||
"web_search",
|
||||
["", '{"que', 'ry"', ": \"today's", ' news"}'],
|
||||
),
|
||||
*create_web_search_result_block(3, "srvtoolu_12345ABC", web_search_results),
|
||||
*create_content_block(
|
||||
4,
|
||||
["I am unable to perform the web search at this time."],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"What's on the news today?",
|
||||
None,
|
||||
Context(),
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
|
||||
result.conversation_id
|
||||
)
|
||||
# Don't test the prompt because it's not deterministic
|
||||
assert chat_log.content[1:] == snapshot
|
||||
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_web_search_dynamic_filtering(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test web search with dynamic filtering of the results."""
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_CHAT_MODEL: "claude-opus-4-6",
|
||||
CONF_CODE_EXECUTION: True,
|
||||
CONF_WEB_SEARCH: True,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: True,
|
||||
CONF_WEB_SEARCH_CITY: "San Francisco",
|
||||
CONF_WEB_SEARCH_REGION: "California",
|
||||
CONF_WEB_SEARCH_COUNTRY: "US",
|
||||
CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles",
|
||||
},
|
||||
)
|
||||
|
||||
web_search_results = [
|
||||
WebSearchResultBlock(
|
||||
type="web_search_result",
|
||||
title="Press release: Nobel Prize in Chemistry 2025 - Example.com",
|
||||
url="https://www.example.com/prizes/chemistry/2025/press-release/",
|
||||
page_age=None,
|
||||
encrypted_content="ABCDEFG",
|
||||
),
|
||||
WebSearchResultBlock(
|
||||
type="web_search_result",
|
||||
title="Nobel Prize in Chemistry 2025 - NewsSite.com",
|
||||
url="https://www.newssite.com/prizes/chemistry/2025/summary/",
|
||||
page_age=None,
|
||||
encrypted_content="ABCDEFG",
|
||||
),
|
||||
]
|
||||
|
||||
content = EncryptedCodeExecutionResultBlock(
|
||||
type="encrypted_code_execution_result",
|
||||
content=[],
|
||||
encrypted_stdout="EuQJCioIDRgCIiRj",
|
||||
return_code=0,
|
||||
stderr="",
|
||||
)
|
||||
|
||||
mock_create_stream.return_value = [
|
||||
(
|
||||
*create_thinking_block(
|
||||
0, ["Let", " me search", " for this", " information.", ""]
|
||||
),
|
||||
*create_server_tool_use_block(
|
||||
1,
|
||||
"srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
|
||||
"code_execution",
|
||||
[
|
||||
"",
|
||||
'{"code": "\\nimport',
|
||||
" json",
|
||||
"\\nresult",
|
||||
" = await",
|
||||
" web",
|
||||
'_search({\\"',
|
||||
'query\\": \\"Nobel Prize chemistry',
|
||||
" 2025 ",
|
||||
'winner\\"})\\nparsed',
|
||||
" = json.loads(result)",
|
||||
"\\nfor",
|
||||
" r",
|
||||
" in parsed[:",
|
||||
"3",
|
||||
"]:\\n print(r.",
|
||||
'get(\\"title',
|
||||
'\\", \\"\\"))',
|
||||
'\\n print(r.get(\\"',
|
||||
"content",
|
||||
'\\", \\"\\")',
|
||||
"[:300",
|
||||
'])\\n print(\\"---\\")',
|
||||
"\\n",
|
||||
'"}',
|
||||
],
|
||||
),
|
||||
*create_server_tool_use_block(
|
||||
2,
|
||||
"srvtoolu_016vjte6G4Lj6yzLc2ak1vY4",
|
||||
"web_search",
|
||||
{"query": "Nobel Prize chemistry 2025 winner"},
|
||||
caller=ServerToolCaller20260120(
|
||||
type="code_execution_20260120",
|
||||
tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
|
||||
),
|
||||
),
|
||||
*create_web_search_result_block(
|
||||
3,
|
||||
"srvtoolu_016vjte6G4Lj6yzLc2ak1vY4",
|
||||
web_search_results,
|
||||
caller=ServerToolCaller20260120(
|
||||
type="code_execution_20260120",
|
||||
tool_id="srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT",
|
||||
),
|
||||
),
|
||||
*create_code_execution_result_block(
|
||||
4, "srvtoolu_01Qh4oTgPkNfRxjJA3N6jgzT", content
|
||||
),
|
||||
*create_content_block(
|
||||
5,
|
||||
[
|
||||
"The ",
|
||||
"2025 Nobel Prize in Chemistry was",
|
||||
" awarded jointly to **",
|
||||
"Susumu Kitagawa**,",
|
||||
" **",
|
||||
"Richard Robson**, and **Omar",
|
||||
' M. Yaghi** "',
|
||||
"for the development of metal–organic frameworks",
|
||||
'."',
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"Who won the Nobel for Chemistry in 2025?",
|
||||
None,
|
||||
Context(),
|
||||
agent_id="conversation.claude_conversation",
|
||||
)
|
||||
|
||||
chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get(
|
||||
result.conversation_id
|
||||
)
|
||||
# Don't test the prompt because it's not deterministic
|
||||
assert chat_log.content[1:] == snapshot
|
||||
assert mock_create_stream.call_args.kwargs["messages"] == snapshot
|
||||
|
||||
|
||||
@freeze_time("2025-10-31 12:00:00")
|
||||
async def test_bash_code_execution(
|
||||
hass: HomeAssistant,
|
||||
@@ -1014,9 +1237,10 @@ async def test_bash_code_execution(
|
||||
"tmp/number.txt'.",
|
||||
],
|
||||
),
|
||||
*create_bash_code_execution_block(
|
||||
*create_server_tool_use_block(
|
||||
1,
|
||||
"srvtoolu_12345ABC",
|
||||
"bash_code_execution",
|
||||
[
|
||||
"",
|
||||
'{"c',
|
||||
@@ -1093,9 +1317,10 @@ async def test_bash_code_execution_error(
|
||||
"tmp/number.txt'.",
|
||||
],
|
||||
),
|
||||
*create_bash_code_execution_block(
|
||||
*create_server_tool_use_block(
|
||||
1,
|
||||
"srvtoolu_12345ABC",
|
||||
"bash_code_execution",
|
||||
[
|
||||
"",
|
||||
'{"c',
|
||||
@@ -1252,8 +1477,8 @@ async def test_text_editor_code_execution(
|
||||
mock_create_stream.return_value = [
|
||||
(
|
||||
*create_content_block(0, ["I'll do it", "."]),
|
||||
*create_text_editor_code_execution_block(
|
||||
1, "srvtoolu_12345ABC", args_parts
|
||||
*create_server_tool_use_block(
|
||||
1, "srvtoolu_12345ABC", "text_editor_code_execution", args_parts
|
||||
),
|
||||
*create_text_editor_code_execution_result_block(
|
||||
2, "srvtoolu_12345ABC", content=content
|
||||
@@ -1287,9 +1512,10 @@ async def test_container_reused(
|
||||
"""Test that container is reused."""
|
||||
mock_create_stream.return_value = [
|
||||
(
|
||||
*create_bash_code_execution_block(
|
||||
*create_server_tool_use_block(
|
||||
0,
|
||||
"srvtoolu_12345ABC",
|
||||
"bash_code_execution",
|
||||
['{"command": "echo $RANDOM"}'],
|
||||
),
|
||||
*create_bash_code_execution_result_block(
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Tests for the fail2ban component."""
|
||||
"""Tests for the Fail2Ban integration."""
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"wifi_ssid": "My Wi-Fi",
|
||||
"wifi_strength": 92,
|
||||
"total_power_import_t1_kwh": 0.003,
|
||||
"total_power_export_t1_kwh": 0.0,
|
||||
"active_power_w": 0.0,
|
||||
"active_power_l1_w": 0.0,
|
||||
"active_voltage_v": 228.472,
|
||||
"active_current_a": 0.273,
|
||||
"active_apparent_current_a": 0.0,
|
||||
"active_reactive_current_a": 0.0,
|
||||
"active_apparent_power_va": 9.0,
|
||||
"active_reactive_power_var": -9.0,
|
||||
"active_power_factor": 0.611,
|
||||
"active_frequency_hz": 50
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"product_type": "HWE-KWH1",
|
||||
"product_name": "kWh meter",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "5.0103",
|
||||
"api_version": "v2"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"wifi_ssid": "My Wi-Fi",
|
||||
"wifi_strength": 100,
|
||||
"total_power_import_kwh": 0.003,
|
||||
"total_power_import_t1_kwh": 0.003,
|
||||
"total_power_export_kwh": 0.0,
|
||||
"total_power_export_t1_kwh": 0.0,
|
||||
"active_power_w": 0.0,
|
||||
"active_power_l1_w": 0.0,
|
||||
"active_voltage_v": 231.539,
|
||||
"active_current_a": 0.0,
|
||||
"active_reactive_power_var": 0.0,
|
||||
"active_apparent_power_va": 0.0,
|
||||
"active_power_factor": 0.0,
|
||||
"active_frequency_hz": 50.005
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"product_type": "HWE-SKT",
|
||||
"product_name": "Energy Socket",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "4.07",
|
||||
"api_version": "v1"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"power_on": true,
|
||||
"switch_lock": false,
|
||||
"brightness": 255
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -92,7 +92,56 @@
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_manual_flow_works[HWE-P1]
|
||||
# name: test_discovery_flow_works
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'confirm_only': True,
|
||||
'dismiss_protected': True,
|
||||
'source': 'zeroconf',
|
||||
'title_placeholders': dict({
|
||||
'name': 'Energy Socket (5c2fafabcdef)',
|
||||
}),
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'zeroconf',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Energy Socket',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Energy Socket',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_manual_flow_works
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'source': 'user',
|
||||
@@ -136,238 +185,3 @@
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_manual_flow_works_device_energy_monitoring[consumption-HWE-SKT-21]
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'source': 'user',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '2.2.2.2',
|
||||
'usage': 'consumption',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '2.2.2.2',
|
||||
'usage': 'consumption',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Energy Socket',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Energy Socket',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_manual_flow_works_device_energy_monitoring[generation-HWE-SKT-21]
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'source': 'user',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '2.2.2.2',
|
||||
'usage': 'generation',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '2.2.2.2',
|
||||
'usage': 'generation',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'user',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Energy Socket',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Energy Socket',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_power_monitoring_discovery_flow_works[consumption]
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'dismiss_protected': True,
|
||||
'source': 'zeroconf',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
'usage': 'consumption',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
'usage': 'consumption',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'zeroconf',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Energy Socket',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Energy Socket',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_power_monitoring_discovery_flow_works[generation]
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'dismiss_protected': True,
|
||||
'source': 'zeroconf',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
'usage': 'generation',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
'usage': 'generation',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'zeroconf',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Energy Socket',
|
||||
'unique_id': 'HWE-SKT_5c2fafabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Energy Socket',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
# name: test_water_monitoring_discovery_flow_works
|
||||
FlowResultSnapshot({
|
||||
'context': dict({
|
||||
'confirm_only': True,
|
||||
'dismiss_protected': True,
|
||||
'source': 'zeroconf',
|
||||
'title_placeholders': dict({
|
||||
'name': 'Watermeter',
|
||||
}),
|
||||
'unique_id': 'HWE-WTR_3c39efabcdef',
|
||||
}),
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
}),
|
||||
'description': None,
|
||||
'description_placeholders': None,
|
||||
'flow_id': <ANY>,
|
||||
'handler': 'homewizard',
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'result': ConfigEntrySnapshot({
|
||||
'data': dict({
|
||||
'ip_address': '127.0.0.1',
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'discovery_keys': dict({
|
||||
}),
|
||||
'domain': 'homewizard',
|
||||
'entry_id': <ANY>,
|
||||
'minor_version': 1,
|
||||
'options': dict({
|
||||
}),
|
||||
'pref_disable_new_entities': False,
|
||||
'pref_disable_polling': False,
|
||||
'source': 'zeroconf',
|
||||
'subentries': list([
|
||||
]),
|
||||
'title': 'Watermeter',
|
||||
'unique_id': 'HWE-WTR_3c39efabcdef',
|
||||
'version': 1,
|
||||
}),
|
||||
'subentries': tuple(
|
||||
),
|
||||
'title': 'Watermeter',
|
||||
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -24,7 +24,6 @@ from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
|
||||
async def test_manual_flow_works(
|
||||
hass: HomeAssistant,
|
||||
mock_homewizardenergy: MagicMock,
|
||||
@@ -52,50 +51,12 @@ async def test_manual_flow_works(
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(("device_fixture"), ["HWE-SKT-21"])
|
||||
@pytest.mark.parametrize(("usage"), ["consumption", "generation"])
|
||||
async def test_manual_flow_works_device_energy_monitoring(
|
||||
hass: HomeAssistant,
|
||||
mock_homewizardenergy: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
usage: str,
|
||||
) -> None:
|
||||
"""Test config flow accepts user configuration for energy plug."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "usage"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"usage": usage}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result == snapshot
|
||||
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_homewizardenergy.close.mock_calls) == 1
|
||||
assert len(mock_homewizardenergy.device.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
|
||||
@pytest.mark.parametrize("usage", ["consumption", "generation"])
|
||||
async def test_power_monitoring_discovery_flow_works(
|
||||
hass: HomeAssistant, snapshot: SnapshotAssertion, usage: str
|
||||
async def test_discovery_flow_works(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test discovery energy monitoring setup flow works."""
|
||||
"""Test discovery setup flow works."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
@@ -116,42 +77,6 @@ async def test_power_monitoring_discovery_flow_works(
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "usage"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={"usage": usage}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result == snapshot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
|
||||
async def test_water_monitoring_discovery_flow_works(
|
||||
hass: HomeAssistant, snapshot: SnapshotAssertion
|
||||
) -> None:
|
||||
"""Test discovery energy monitoring setup flow works."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=ZeroconfServiceInfo(
|
||||
ip_address=ip_address("127.0.0.1"),
|
||||
ip_addresses=[ip_address("127.0.0.1")],
|
||||
port=80,
|
||||
hostname="watermeter-ddeeff.local.",
|
||||
type="",
|
||||
name="",
|
||||
properties={
|
||||
"api_enabled": "1",
|
||||
"path": "/api/v1",
|
||||
"product_name": "Watermeter",
|
||||
"product_type": "HWE-WTR",
|
||||
"serial": "3c39efabcdef",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
|
||||
@@ -695,7 +620,7 @@ async def test_reconfigure_cannot_connect(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
|
||||
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"])
|
||||
async def test_manual_flow_works_with_v2_api_support(
|
||||
hass: HomeAssistant,
|
||||
mock_homewizardenergy_v2: MagicMock,
|
||||
@@ -727,70 +652,7 @@ async def test_manual_flow_works_with_v2_api_support(
|
||||
mock_homewizardenergy_v2.device.side_effect = None
|
||||
mock_homewizardenergy_v2.get_token.side_effect = None
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
@pytest.mark.parametrize(("device_fixture"), ["HWE-KWH1"])
|
||||
async def test_manual_flow_energy_monitoring_works_with_v2_api_support(
|
||||
hass: HomeAssistant,
|
||||
mock_homewizardenergy_v2: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test config flow accepts user configuration for energy monitoring.
|
||||
|
||||
This should trigger authorization when v2 support is detected.
|
||||
It should ask for usage if a energy monitoring device is configured.
|
||||
"""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Simulate v2 support but not authorized
|
||||
mock_homewizardenergy_v2.device.side_effect = UnauthorizedError
|
||||
mock_homewizardenergy_v2.get_token.side_effect = DisabledError
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.homewizard.config_flow.has_v2_api",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
# Simulate user authorizing
|
||||
mock_homewizardenergy_v2.device.side_effect = None
|
||||
mock_homewizardenergy_v2.get_token.side_effect = None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.homewizard.config_flow.has_v2_api",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "usage"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"usage": "generation"},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
@@ -838,16 +700,7 @@ async def test_manual_flow_detects_failed_user_authorization(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
# Energy monitoring devices with an with configurable usage have an extra flow step
|
||||
assert (
|
||||
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
|
||||
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")
|
||||
|
||||
if result["type"] is FlowResultType.FORM and result["step_id"] == "usage":
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {"usage": "generation"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -977,12 +830,10 @@ async def test_discovery_with_v2_api_ask_authorization(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authorize"
|
||||
|
||||
mock_homewizardenergy_v2.device.side_effect = None
|
||||
mock_homewizardenergy_v2.get_token.side_effect = None
|
||||
mock_homewizardenergy_v2.get_token.return_value = "cool_token"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
# Energy monitoring devices with an with configurable usage have an extra flow step
|
||||
assert (
|
||||
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
|
||||
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_TOKEN] == "cool_token"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for the homewizard component."""
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
import weakref
|
||||
@@ -12,14 +11,8 @@ import pytest
|
||||
from homeassistant.components.homewizard import get_main_device
|
||||
from homeassistant.components.homewizard.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import EntityRegistry
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
@@ -261,203 +254,3 @@ async def test_disablederror_reloads_integration(
|
||||
flow = flows[0]
|
||||
assert flow.get("step_id") == "reauth_enable_api"
|
||||
assert flow.get("handler") == DOMAIN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_homewizardenergy")
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "mock_config_entry", "enabled", "disabled"),
|
||||
[
|
||||
(
|
||||
"HWE-SKT-21-initial",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "consumption",
|
||||
},
|
||||
unique_id="HWE-SKT_5c2fafabcdef",
|
||||
),
|
||||
("sensor.device_power",),
|
||||
("sensor.device_production_power",),
|
||||
),
|
||||
(
|
||||
"HWE-SKT-21-initial",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "generation",
|
||||
},
|
||||
unique_id="HWE-SKT_5c2fafabcdef",
|
||||
),
|
||||
# we explicitly indicated that the device was monitoring
|
||||
# generated energy, so we ignore power sensor to avoid confusion
|
||||
("sensor.device_production_power",),
|
||||
("sensor.device_power",),
|
||||
),
|
||||
(
|
||||
"HWE-SKT-21",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "consumption",
|
||||
},
|
||||
unique_id="HWE-SKT_5c2fafabcdef",
|
||||
),
|
||||
# device has a non zero export, so both sensors are enabled
|
||||
(
|
||||
"sensor.device_power",
|
||||
"sensor.device_production_power",
|
||||
),
|
||||
(),
|
||||
),
|
||||
(
|
||||
"HWE-SKT-21",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "generation",
|
||||
},
|
||||
unique_id="HWE-SKT_5c2fafabcdef",
|
||||
),
|
||||
# we explicitly indicated that the device was monitoring
|
||||
# generated energy, so we ignore power sensor to avoid confusion
|
||||
("sensor.device_production_power",),
|
||||
("sensor.device_power",),
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"consumption_intital",
|
||||
"generation_initial",
|
||||
"consumption_used",
|
||||
"generation_used",
|
||||
],
|
||||
)
|
||||
async def test_setup_device_energy_monitoring_v1(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
enabled: Iterable[str],
|
||||
disabled: Iterable[str],
|
||||
) -> None:
|
||||
"""Test correct entities are enabled by default."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for enabled_item in enabled:
|
||||
assert (entry := entity_registry.async_get(enabled_item))
|
||||
assert not entry.disabled
|
||||
|
||||
for disabled_item in disabled:
|
||||
assert (entry := entity_registry.async_get(disabled_item))
|
||||
assert entry.disabled
|
||||
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_homewizardenergy")
|
||||
@pytest.mark.parametrize(
|
||||
("device_fixture", "mock_config_entry", "enabled", "disabled"),
|
||||
[
|
||||
(
|
||||
"HWE-KWH1-initial",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "consumption",
|
||||
},
|
||||
unique_id="HWE-KWH1_5c2fafabcdef",
|
||||
),
|
||||
("sensor.device_power",),
|
||||
("sensor.device_production_power",),
|
||||
),
|
||||
(
|
||||
"HWE-KWH1-initial",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "generation",
|
||||
},
|
||||
unique_id="HWE-KWH1_5c2fafabcdef",
|
||||
),
|
||||
# we explicitly indicated that the device was monitoring
|
||||
# generated energy, so we ignore power sensor to avoid confusion
|
||||
("sensor.device_production_power",),
|
||||
("sensor.device_power",),
|
||||
),
|
||||
(
|
||||
"HWE-KWH1",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "consumption",
|
||||
},
|
||||
unique_id="HWE-KWH1_5c2fafabcdef",
|
||||
),
|
||||
# device has a non zero export, so both sensors are enabled
|
||||
(
|
||||
"sensor.device_power",
|
||||
"sensor.device_production_power",
|
||||
),
|
||||
(),
|
||||
),
|
||||
(
|
||||
"HWE-KWH1",
|
||||
MockConfigEntry(
|
||||
title="Device",
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
"usage": "generation",
|
||||
},
|
||||
unique_id="HWE-KWH1_5c2fafabcdef",
|
||||
),
|
||||
# we explicitly indicated that the device was monitoring
|
||||
# generated energy, so we ignore power sensor to avoid confusion
|
||||
("sensor.device_production_power",),
|
||||
("sensor.device_power",),
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"consumption_intital",
|
||||
"generation_initial",
|
||||
"consumption_used",
|
||||
"generation_used",
|
||||
],
|
||||
)
|
||||
async def test_setup_device_energy_monitoring_v2(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
enabled: Iterable[str],
|
||||
disabled: Iterable[str],
|
||||
) -> None:
|
||||
"""Test correct entities are enabled by default."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
for enabled_item in enabled:
|
||||
assert (entry := entity_registry.async_get(enabled_item))
|
||||
assert not entry.disabled
|
||||
|
||||
for disabled_item in disabled:
|
||||
assert (entry := entity_registry.async_get(disabled_item))
|
||||
assert entry.disabled
|
||||
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Generator
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, mock_open, patch
|
||||
|
||||
from aiohttp import ClientError
|
||||
@@ -11,9 +12,28 @@ import pytest
|
||||
from pywebpush import WebPushException
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.html5 import notify as html5
|
||||
from homeassistant.components.html5 import DOMAIN, notify as html5
|
||||
from homeassistant.components.html5.const import (
|
||||
ATTR_ACTIONS,
|
||||
ATTR_BADGE,
|
||||
ATTR_DIR,
|
||||
ATTR_ICON,
|
||||
ATTR_IMAGE,
|
||||
ATTR_LANG,
|
||||
ATTR_RENOTIFY,
|
||||
ATTR_REQUIRE_INTERACTION,
|
||||
ATTR_SILENT,
|
||||
ATTR_TAG,
|
||||
ATTR_TIMESTAMP,
|
||||
ATTR_TTL,
|
||||
ATTR_URGENCY,
|
||||
ATTR_VIBRATE,
|
||||
)
|
||||
from homeassistant.components.html5.notify import ATTR_ACTION, DEFAULT_TTL
|
||||
from homeassistant.components.notify import (
|
||||
ATTR_DATA,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_TARGET,
|
||||
ATTR_TITLE,
|
||||
DOMAIN as NOTIFY_DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
@@ -27,7 +47,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
@@ -817,19 +837,16 @@ async def test_send_message(
|
||||
|
||||
webpush_async.assert_awaited_once()
|
||||
assert webpush_async.await_args
|
||||
assert webpush_async.await_args.args == (
|
||||
{
|
||||
"endpoint": "https://googleapis.com",
|
||||
"keys": {"auth": "auth", "p256dh": "p256dh"},
|
||||
},
|
||||
'{"badge": "/static/images/notification-badge.png", "body": "World", "icon": "/static/icons/favicon-192x192.png", "tag": "12345678-1234-5678-1234-567812345678", "title": "Hello", "timestamp": 1234567890000, "data": {"jwt": "JWT"}}',
|
||||
"h6acSRds8_KR8hT9djD8WucTL06Gfe29XXyZ1KcUjN8",
|
||||
{
|
||||
"sub": "mailto:test@example.com",
|
||||
"aud": "https://googleapis.com",
|
||||
"exp": 1234611090,
|
||||
},
|
||||
)
|
||||
_, payload, _, _ = webpush_async.await_args.args
|
||||
assert json.loads(payload) == {
|
||||
"title": "Hello",
|
||||
"body": "World",
|
||||
"badge": "/static/images/notification-badge.png",
|
||||
"icon": "/static/icons/favicon-192x192.png",
|
||||
"tag": "12345678-1234-5678-1234-567812345678",
|
||||
"timestamp": 1234567890000,
|
||||
"data": {"jwt": "JWT"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -849,6 +866,7 @@ async def test_send_message(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("domain", [NOTIFY_DOMAIN, DOMAIN])
|
||||
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
|
||||
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
|
||||
async def test_send_message_exceptions(
|
||||
@@ -858,6 +876,7 @@ async def test_send_message_exceptions(
|
||||
load_config: MagicMock,
|
||||
exception: Exception,
|
||||
translation_key: str,
|
||||
domain: str,
|
||||
) -> None:
|
||||
"""Test sending a message with exceptions."""
|
||||
load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
|
||||
@@ -872,7 +891,7 @@ async def test_send_message_exceptions(
|
||||
|
||||
with pytest.raises(HomeAssistantError) as e:
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
domain,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_ENTITY_ID: "notify.my_desktop",
|
||||
@@ -963,3 +982,174 @@ async def test_send_message_unavailable(
|
||||
state = hass.states.get("notify.my_desktop")
|
||||
assert state
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_data", "expected_payload", "expected_ttl", "expected_headers"),
|
||||
[
|
||||
({ATTR_MESSAGE: "World"}, {"body": "World"}, DEFAULT_TTL, None),
|
||||
(
|
||||
{ATTR_ICON: "/static/icons/favicon-192x192.png"},
|
||||
{"icon": "/static/icons/favicon-192x192.png"},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
(
|
||||
{ATTR_BADGE: "/static/images/notification-badge.png"},
|
||||
{"badge": "/static/images/notification-badge.png"},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
(
|
||||
{ATTR_IMAGE: "/static/images/image.jpg"},
|
||||
{"image": "/static/images/image.jpg"},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
({ATTR_TAG: "message-group-1"}, {"tag": "message-group-1"}, DEFAULT_TTL, None),
|
||||
({ATTR_DIR: "rtl"}, {"dir": "rtl"}, DEFAULT_TTL, None),
|
||||
({ATTR_RENOTIFY: True}, {"renotify": True}, DEFAULT_TTL, None),
|
||||
({ATTR_SILENT: True}, {"silent": True}, DEFAULT_TTL, None),
|
||||
(
|
||||
{ATTR_REQUIRE_INTERACTION: True},
|
||||
{"requireInteraction": True},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
(
|
||||
{ATTR_VIBRATE: [200, 100, 200]},
|
||||
{"vibrate": [200, 100, 200]},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
({ATTR_LANG: "es-419"}, {"lang": "es-419"}, DEFAULT_TTL, None),
|
||||
({ATTR_TIMESTAMP: "1970-01-01 00:00:00"}, {"timestamp": 0}, DEFAULT_TTL, None),
|
||||
({ATTR_TTL: {"days": 28}}, {}, 2419200, None),
|
||||
({ATTR_TTL: {"seconds": 0}}, {}, 0, None),
|
||||
(
|
||||
{ATTR_URGENCY: "high"},
|
||||
{},
|
||||
DEFAULT_TTL,
|
||||
{"Urgency": "high"},
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_ACTIONS: [
|
||||
{
|
||||
ATTR_ACTION: "callback-event",
|
||||
ATTR_TITLE: "Callback Event",
|
||||
ATTR_ICON: "/static/icons/favicon-192x192.png",
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"actions": [
|
||||
{
|
||||
"action": "callback-event",
|
||||
"title": "Callback Event",
|
||||
"icon": "/static/icons/favicon-192x192.png",
|
||||
}
|
||||
]
|
||||
},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
(
|
||||
{ATTR_DATA: {"customKey": "customValue"}},
|
||||
{"data": {"jwt": "JWT", "customKey": "customValue"}},
|
||||
DEFAULT_TTL,
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_jwt", "mock_vapid", "mock_uuid")
|
||||
@pytest.mark.freeze_time("2009-02-13T23:31:30.000Z")
|
||||
async def test_html5_send_message(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
webpush_async: AsyncMock,
|
||||
load_config: MagicMock,
|
||||
service_data: dict[str, Any],
|
||||
expected_payload: dict[str, Any],
|
||||
expected_ttl: int,
|
||||
expected_headers: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""Test sending a message via html5.send_message action."""
|
||||
load_config.return_value = {"my-desktop": SUBSCRIPTION_1}
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get("notify.my_desktop")
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{ATTR_ENTITY_ID: "notify.my_desktop", ATTR_TITLE: "Hello", **service_data},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get("notify.my_desktop")
|
||||
assert state
|
||||
assert state.state == "2009-02-13T23:31:30+00:00"
|
||||
|
||||
webpush_async.assert_awaited_once()
|
||||
assert webpush_async.await_args
|
||||
_, payload, _, _ = webpush_async.await_args.args
|
||||
assert json.loads(payload) == {
|
||||
"title": "Hello",
|
||||
"tag": "12345678-1234-5678-1234-567812345678",
|
||||
"timestamp": 1234567890000,
|
||||
"data": {"jwt": "JWT"},
|
||||
**expected_payload,
|
||||
}
|
||||
|
||||
assert webpush_async.await_args.kwargs["ttl"] == expected_ttl
|
||||
assert webpush_async.await_args.kwargs["headers"] == expected_headers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "issue_id"),
|
||||
[
|
||||
(["my-desktop"], "deprecated_notify_action_notify.html5_my_desktop"),
|
||||
(None, "deprecated_notify_action_notify.html5"),
|
||||
(["my-desktop", "my-phone"], "deprecated_notify_action_notify.html5"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_wp", "mock_jwt", "mock_vapid", "mock_uuid")
|
||||
async def test_deprecation_action_call(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
load_config: MagicMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
target: list[str] | None,
|
||||
issue_id: str,
|
||||
) -> None:
|
||||
"""Test deprecation action call."""
|
||||
load_config.return_value = {
|
||||
"my-desktop": SUBSCRIPTION_1,
|
||||
"my-phone": SUBSCRIPTION_2,
|
||||
}
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.services.async_call(
|
||||
NOTIFY_DOMAIN,
|
||||
DOMAIN,
|
||||
{ATTR_MESSAGE: "Hello", ATTR_TARGET: target},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain=DOMAIN,
|
||||
issue_id=issue_id,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -27,9 +27,15 @@ from homeassistant.components.vacuum import (
|
||||
SERVICE_STOP,
|
||||
VacuumActivity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
CONF_NAME,
|
||||
ENTITY_MATCH_ALL,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
|
||||
from .common import (
|
||||
help_custom_config,
|
||||
@@ -63,7 +69,11 @@ from .common import (
|
||||
|
||||
from tests.common import async_fire_mqtt_message
|
||||
from tests.components.vacuum import common
|
||||
from tests.typing import MqttMockHAClientGenerator, MqttMockPahoClient
|
||||
from tests.typing import (
|
||||
MqttMockHAClientGenerator,
|
||||
MqttMockPahoClient,
|
||||
WebSocketGenerator,
|
||||
)
|
||||
|
||||
COMMAND_TOPIC = "vacuum/command"
|
||||
SEND_COMMAND_TOPIC = "vacuum/send_command"
|
||||
@@ -82,6 +92,17 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
CONFIG_CLEAN_SEGMENTS = {
|
||||
mqtt.DOMAIN: {
|
||||
vacuum.DOMAIN: {
|
||||
CONF_NAME: "test",
|
||||
CONF_STATE_TOPIC: STATE_TOPIC,
|
||||
"unique_id": "veryunique",
|
||||
mqttvacuum.CONF_CLEAN_SEGMENTS_COMMAND_TOPIC: "vacuum/clean_segment",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_CONFIG_2 = {mqtt.DOMAIN: {vacuum.DOMAIN: {"name": "test"}}}
|
||||
|
||||
CONFIG_ALL_SERVICES = help_custom_config(
|
||||
@@ -294,6 +315,283 @@ async def test_command_without_command_topic(
|
||||
mqtt_mock.async_publish.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS])
|
||||
async def test_clean_segments_initial_setup_without_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test initial setup does not fire repair flow after cleanable segments are received."""
|
||||
await mqtt_mock_entry()
|
||||
# Receive a valid state
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
message = """{
|
||||
"battery_level": 54,
|
||||
"state": "cleaning",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen",
|
||||
"3":"Diningroom"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == VacuumActivity.CLEANING
|
||||
assert (
|
||||
state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
& vacuum.VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_CLEAN_SEGMENTS])
|
||||
async def test_clean_segments_command(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test cleaning segments and repair flow."""
|
||||
config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
entity_registry.async_get_or_create(
|
||||
vacuum.DOMAIN,
|
||||
mqtt.DOMAIN,
|
||||
"veryunique",
|
||||
config_entry=config_entry,
|
||||
suggested_object_id="test",
|
||||
)
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Nabu Casa": ["1", "2"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "1", "name": "Livingroom"},
|
||||
{"id": "2", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
await hass.async_block_till_done()
|
||||
message = """{
|
||||
"battery_level": 54,
|
||||
"state": "idle",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == VacuumActivity.IDLE
|
||||
assert (
|
||||
state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
& vacuum.VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
# We do not expect a repair flow as the segments did not change
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
await common.async_clean_area(hass, ["Nabu Casa"], entity_id="vacuum.test")
|
||||
assert (
|
||||
call("vacuum/clean_segment", '["1","2"]', 0, False)
|
||||
in mqtt_mock.async_publish.mock_calls
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
message = """{
|
||||
"battery_level": 54,
|
||||
"state": "cleaning",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen",
|
||||
"3": "Diningroom"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
# We expect a repair issue now as the available segments have changed
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "1", "name": "Livingroom", "group": None},
|
||||
{"id": "2", "name": "Kitchen", "group": None},
|
||||
{"id": "3", "name": "Diningroom", "group": None},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
CONFIG_CLEAN_SEGMENTS,
|
||||
({"clean_segments_command_template": "{{ ';'.join(value) }}"},),
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_clean_segments_command_template(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test clean segments with command template."""
|
||||
mqtt_mock = await mqtt_mock_entry()
|
||||
entity_registry.async_update_entity_options(
|
||||
"vacuum.test",
|
||||
vacuum.DOMAIN,
|
||||
{
|
||||
"area_mapping": {"Livingroom": ["1"], "Kitchen": ["2"]},
|
||||
"last_seen_segments": [
|
||||
{"id": "1", "name": "Livingroom"},
|
||||
{"id": "2", "name": "Kitchen"},
|
||||
],
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
message = """{
|
||||
"battery_level": 54,
|
||||
"state": "idle",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == VacuumActivity.IDLE
|
||||
assert (
|
||||
state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
& vacuum.VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
|
||||
await common.async_clean_area(
|
||||
hass, ["Livingroom", "Kitchen"], entity_id="vacuum.test"
|
||||
)
|
||||
assert (
|
||||
call("vacuum/clean_segment", "1;2", 0, False)
|
||||
in mqtt_mock.async_publish.mock_calls
|
||||
)
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(
|
||||
{"type": "vacuum/get_segments", "entity_id": "vacuum.test"}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
assert msg["result"]["segments"] == [
|
||||
{"id": "1", "name": "Livingroom", "group": None},
|
||||
{"id": "2", "name": "Kitchen", "group": None},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hass")
|
||||
@pytest.mark.parametrize(
|
||||
("hass_config", "error_message"),
|
||||
[
|
||||
(
|
||||
help_custom_config(
|
||||
vacuum.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
(
|
||||
{
|
||||
"clean_segments_command_topic": "test-topic",
|
||||
},
|
||||
),
|
||||
),
|
||||
"Option `clean_segments_command_topic` requires `unique_id` to be configured",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_clean_segments_config_validation(
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
error_message: str,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test status clean segment config validation."""
|
||||
await mqtt_mock_entry()
|
||||
assert error_message in caplog.text
|
||||
|
||||
|
||||
async def test_removing_clean_segments_command_topic_resets_feature(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
) -> None:
|
||||
"""Test the clean area feature is reset if the vacuum is reconfigured.
|
||||
|
||||
The `clean_segments_command_topic` is required to support clean area support.
|
||||
When this option is removed, the clean area feature should be reset.
|
||||
"""
|
||||
await mqtt_mock_entry()
|
||||
|
||||
config_with_clean_segments_command_topic = CONFIG_CLEAN_SEGMENTS[mqtt.DOMAIN][
|
||||
vacuum.DOMAIN
|
||||
]
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
"homeassistant/vacuum/bla/config",
|
||||
json.dumps(config_with_clean_segments_command_topic),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
message = """{
|
||||
"battery_level": 54,
|
||||
"state": "idle",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == VacuumActivity.IDLE
|
||||
assert (
|
||||
state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
& vacuum.VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
|
||||
config_without_clean_segments_command_topic = (
|
||||
config_with_clean_segments_command_topic.copy()
|
||||
)
|
||||
config_without_clean_segments_command_topic.pop(
|
||||
mqttvacuum.CONF_CLEAN_SEGMENTS_COMMAND_TOPIC
|
||||
)
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
"homeassistant/vacuum/bla/config",
|
||||
json.dumps(config_without_clean_segments_command_topic),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
message = """{
|
||||
"battery_level": 30,
|
||||
"state": "cleaning",
|
||||
"segments":{
|
||||
"1":"Livingroom",
|
||||
"2":"Kitchen"
|
||||
}
|
||||
}"""
|
||||
async_fire_mqtt_message(hass, "vacuum/state", message)
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("vacuum.test")
|
||||
assert state.state == VacuumActivity.CLEANING
|
||||
assert not (
|
||||
state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
& vacuum.VacuumEntityFeature.CLEAN_AREA
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hass_config", [CONFIG_ALL_SERVICES])
|
||||
async def test_status(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
|
||||
@@ -9,9 +9,13 @@ from openai.types import CompletionUsage
|
||||
from openai.types.chat import ChatCompletion, ChatCompletionMessage
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
import pytest
|
||||
from python_open_router import ModelsDataWrapper
|
||||
from python_open_router import KeyData, ModelsDataWrapper
|
||||
|
||||
from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN
|
||||
from homeassistant.components.open_router.const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -38,11 +42,18 @@ def enable_assist() -> bool:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_subentry_data(enable_assist: bool) -> dict[str, Any]:
|
||||
def web_search() -> bool:
|
||||
"""Mock web search setting."""
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def conversation_subentry_data(enable_assist: bool, web_search: bool) -> dict[str, Any]:
|
||||
"""Mock conversation subentry data."""
|
||||
res: dict[str, Any] = {
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "You are a helpful assistant.",
|
||||
CONF_WEB_SEARCH: web_search,
|
||||
}
|
||||
if enable_assist:
|
||||
res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST]
|
||||
@@ -137,6 +148,13 @@ async def mock_open_router_client(hass: HomeAssistant) -> AsyncGenerator[AsyncMo
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.get_key_data.return_value = KeyData(
|
||||
label="Test account",
|
||||
usage=0,
|
||||
is_provisioning_key=False,
|
||||
limit_remaining=None,
|
||||
is_free_tier=True,
|
||||
)
|
||||
models = await async_load_fixture(hass, "models.json", DOMAIN)
|
||||
client.get_models.return_value = ModelsDataWrapper.from_json(models).data
|
||||
yield client
|
||||
|
||||
@@ -211,6 +211,35 @@ async def test_generate_invalid_structured_data(
|
||||
)
|
||||
|
||||
|
||||
async def test_generate_data_empty_response(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test AI Task raises HomeAssistantError when API returns empty choices."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
return_value=ChatCompletion(
|
||||
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
|
||||
choices=[],
|
||||
created=1700000000,
|
||||
model="x-ai/grok-3",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8),
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="API returned empty response"):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.gemini_1_5_pro",
|
||||
instructions="Generate test data",
|
||||
)
|
||||
|
||||
|
||||
async def test_generate_data_with_attachments(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
@@ -5,7 +5,11 @@ from unittest.mock import AsyncMock
|
||||
import pytest
|
||||
from python_open_router import OpenRouterError
|
||||
|
||||
from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN
|
||||
from homeassistant.components.open_router.const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -35,9 +39,33 @@ async def test_full_flow(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Test account"
|
||||
assert result["data"] == {CONF_API_KEY: "bla"}
|
||||
|
||||
|
||||
async def test_second_account(
|
||||
hass: HomeAssistant,
|
||||
mock_open_router_client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that a second account with a different API key can be added."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_API_KEY: "different_key"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Test account"
|
||||
assert result["data"] == {CONF_API_KEY: "different_key"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
@@ -131,6 +159,7 @@ async def test_create_conversation_agent(
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
CONF_WEB_SEARCH: False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -139,6 +168,7 @@ async def test_create_conversation_agent(
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
CONF_WEB_SEARCH: False,
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +200,7 @@ async def test_create_conversation_agent_no_control(
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: [],
|
||||
CONF_WEB_SEARCH: False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -177,6 +208,7 @@ async def test_create_conversation_agent_no_control(
|
||||
assert result["data"] == {
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_WEB_SEARCH: False,
|
||||
}
|
||||
|
||||
|
||||
@@ -263,12 +295,19 @@ async def test_reconfigure_conversation_agent(
|
||||
CONF_MODEL: "openai/gpt-4",
|
||||
CONF_PROMPT: "updated prompt",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
CONF_WEB_SEARCH: True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
subentry = mock_config_entry.subentries[subentry_id]
|
||||
assert subentry.data[CONF_MODEL] == "openai/gpt-4"
|
||||
assert subentry.data[CONF_PROMPT] == "updated prompt"
|
||||
assert subentry.data[CONF_LLM_HASS_API] == ["assist"]
|
||||
assert subentry.data[CONF_WEB_SEARCH] is True
|
||||
|
||||
|
||||
async def test_reconfigure_ai_task(
|
||||
hass: HomeAssistant,
|
||||
@@ -367,6 +406,83 @@ async def test_reconfigure_ai_task_abort(
|
||||
assert result["reason"] == reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_search", "expected_web_search"),
|
||||
[(True, True), (False, False)],
|
||||
indirect=["web_search"],
|
||||
)
|
||||
async def test_create_conversation_agent_web_search(
|
||||
hass: HomeAssistant,
|
||||
mock_open_router_client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
web_search: bool,
|
||||
expected_web_search: bool,
|
||||
) -> None:
|
||||
"""Test creating a conversation agent with web search enabled/disabled."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "conversation"),
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
# Verify web_search field is present in schema with correct default
|
||||
schema = result["data_schema"].schema
|
||||
key = next(k for k in schema if k == CONF_WEB_SEARCH)
|
||||
assert key.default() is False
|
||||
|
||||
result = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "you are an assistant",
|
||||
CONF_LLM_HASS_API: ["assist"],
|
||||
CONF_WEB_SEARCH: expected_web_search,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"][CONF_WEB_SEARCH] is expected_web_search
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_web_search", "expected_default"),
|
||||
[(True, True), (False, False)],
|
||||
)
|
||||
async def test_reconfigure_conversation_subentry_web_search_default(
|
||||
hass: HomeAssistant,
|
||||
mock_open_router_client: AsyncMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
current_web_search: bool,
|
||||
expected_default: bool,
|
||||
) -> None:
|
||||
"""Test web_search field default reflects existing value when reconfiguring."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
subentry,
|
||||
data={**subentry.data, CONF_WEB_SEARCH: current_web_search},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await mock_config_entry.start_subentry_reconfigure_flow(
|
||||
hass, subentry.subentry_id
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
schema = result["data_schema"].schema
|
||||
key = next(k for k in schema if k == CONF_WEB_SEARCH)
|
||||
assert key.default() is expected_default
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_llm_apis", "suggested_llm_apis", "expected_options"),
|
||||
[
|
||||
|
||||
@@ -79,6 +79,66 @@ async def test_default_prompt(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("web_search", "expected_model_suffix"),
|
||||
[(True, ":online"), (False, "")],
|
||||
ids=["web_search_enabled", "web_search_disabled"],
|
||||
)
|
||||
async def test_web_search(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
web_search: bool,
|
||||
expected_model_suffix: str,
|
||||
) -> None:
|
||||
"""Test that web search adds :online suffix to model."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id="conversation.gpt_3_5_turbo",
|
||||
)
|
||||
|
||||
call = mock_openai_client.chat.completions.create.call_args_list[0][1]
|
||||
expected_model = f"openai/gpt-3.5-turbo{expected_model_suffix}"
|
||||
assert call["model"] == expected_model
|
||||
|
||||
|
||||
async def test_empty_api_response(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_openai_client: AsyncMock,
|
||||
mock_chat_log: MockChatLog, # noqa: F811
|
||||
) -> None:
|
||||
"""Test that an empty choices response raises HomeAssistantError."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_openai_client.chat.completions.create = AsyncMock(
|
||||
return_value=ChatCompletion(
|
||||
id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS",
|
||||
choices=[],
|
||||
created=1700000000,
|
||||
model="gpt-3.5-turbo-0613",
|
||||
object="chat.completion",
|
||||
system_fingerprint=None,
|
||||
usage=CompletionUsage(completion_tokens=0, prompt_tokens=8, total_tokens=8),
|
||||
)
|
||||
)
|
||||
|
||||
result = await conversation.async_converse(
|
||||
hass,
|
||||
"hello",
|
||||
mock_chat_log.conversation_id,
|
||||
Context(),
|
||||
agent_id="conversation.gpt_3_5_turbo",
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_assist", [True])
|
||||
async def test_function_call(
|
||||
hass: HomeAssistant,
|
||||
|
||||
136
tests/components/open_router/test_init.py
Normal file
136
tests/components/open_router/test_init.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Tests for the OpenRouter integration."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.open_router.const import (
|
||||
CONF_PROMPT,
|
||||
CONF_WEB_SEARCH,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_migrate_entry_from_v1_1_to_v1_2(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test migration from version 1.1 to 1.2."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_API_KEY: "bla",
|
||||
},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "You are a helpful assistant.",
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
},
|
||||
subentry_id="conversation_subentry",
|
||||
subentry_type="conversation",
|
||||
title="GPT-3.5 Turbo",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_MODEL: "openai/gpt-4",
|
||||
},
|
||||
subentry_id="ai_task_subentry",
|
||||
subentry_type="ai_task_data",
|
||||
title="GPT-4",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.open_router.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 1
|
||||
assert entry.minor_version == 2
|
||||
|
||||
conversation_subentry = entry.subentries["conversation_subentry"]
|
||||
assert conversation_subentry.data[CONF_MODEL] == "openai/gpt-3.5-turbo"
|
||||
assert conversation_subentry.data[CONF_PROMPT] == "You are a helpful assistant."
|
||||
assert conversation_subentry.data[CONF_LLM_HASS_API] == [llm.LLM_API_ASSIST]
|
||||
assert conversation_subentry.data[CONF_WEB_SEARCH] is False
|
||||
|
||||
ai_task_subentry = entry.subentries["ai_task_subentry"]
|
||||
assert ai_task_subentry.data[CONF_MODEL] == "openai/gpt-4"
|
||||
assert ai_task_subentry.data[CONF_WEB_SEARCH] is False
|
||||
|
||||
|
||||
async def test_migrate_entry_already_migrated(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test migration is skipped when already on version 1.2."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_API_KEY: "bla",
|
||||
},
|
||||
version=1,
|
||||
minor_version=1,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data={
|
||||
CONF_MODEL: "openai/gpt-3.5-turbo",
|
||||
CONF_PROMPT: "You are a helpful assistant.",
|
||||
CONF_WEB_SEARCH: True,
|
||||
},
|
||||
subentry_id="conversation_subentry",
|
||||
subentry_type="conversation",
|
||||
title="GPT-3.5 Turbo",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.open_router.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 1
|
||||
assert entry.minor_version == 2
|
||||
|
||||
conversation_subentry = entry.subentries["conversation_subentry"]
|
||||
assert conversation_subentry.data[CONF_MODEL] == "openai/gpt-3.5-turbo"
|
||||
assert conversation_subentry.data[CONF_WEB_SEARCH] is True
|
||||
|
||||
|
||||
async def test_migrate_entry_from_future_version_fails(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test migration fails for future versions."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_API_KEY: "bla",
|
||||
},
|
||||
version=100,
|
||||
minor_version=99,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 100
|
||||
assert entry.minor_version == 99
|
||||
assert entry.state is ConfigEntryState.MIGRATION_ERROR
|
||||
65
tests/components/tasmota/test_update.py
Normal file
65
tests/components/tasmota/test_update.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Tests for the Tasmota update platform."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
|
||||
from aiogithubapi import GitHubReleaseModel
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.tasmota.const import DEFAULT_PREFIX
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .test_common import DEFAULT_CONFIG
|
||||
|
||||
from tests.common import async_fire_mqtt_message
|
||||
from tests.typing import MqttMockHAClient
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("candidate_version", "update_available"),
|
||||
[
|
||||
("0.0.0", False),
|
||||
(".".join(str(int(x) + 1) for x in DEFAULT_CONFIG["sw"].split(".")), True),
|
||||
],
|
||||
)
|
||||
async def test_update_state(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock: MqttMockHAClient,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
setup_tasmota,
|
||||
candidate_version: str,
|
||||
update_available: bool,
|
||||
) -> None:
|
||||
"""Test setting up a device."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
mac = config["mac"]
|
||||
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
f"{DEFAULT_PREFIX}/{mac}/config",
|
||||
json.dumps(config),
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
|
||||
)
|
||||
|
||||
# TODO mock coordinator.client.repos.releases.latest("arendst/Tasmota") to return this
|
||||
data = GitHubReleaseModel(
|
||||
tag_name=f"v{candidate_version}",
|
||||
name=f"Tasmota v{candidate_version} Foo",
|
||||
html_url=f"https://github.com/arendst/Tasmota/releases/tag/v{candidate_version}",
|
||||
body="""\
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./tools/logo/TASMOTA_FullLogo_Vector_White.svg">
|
||||
<img alt="Logo" src="./tools/logo/TASMOTA_FullLogo_Vector.svg" align="right" height="76">
|
||||
</picture>
|
||||
|
||||
# RELEASE NOTES
|
||||
|
||||
... """,
|
||||
)
|
||||
|
||||
# TODO update_available test, device_entry.sw_version has the current version
|
||||
@@ -23,8 +23,12 @@ from unifi_access_api.models.websocket import (
|
||||
WebsocketMessage,
|
||||
)
|
||||
|
||||
from homeassistant.components.unifi_access.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import _make_door
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -353,3 +357,92 @@ async def test_ws_location_update_thumbnail_only_no_state(
|
||||
# Door state unchanged, thumbnail updated
|
||||
assert hass.states.get(FRONT_DOOR_BINARY_SENSOR).state == state_before
|
||||
assert hass.states.get(FRONT_DOOR_IMAGE).state != image_state_before
|
||||
|
||||
|
||||
async def test_new_door_entities_created_on_refresh(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that new door entities are added dynamically via coordinator listener."""
|
||||
# Verify new door entities do not exist yet
|
||||
assert not hass.states.get("binary_sensor.garage_door")
|
||||
assert not hass.states.get("button.garage_door_unlock")
|
||||
assert not hass.states.get("event.garage_door_doorbell")
|
||||
assert not hass.states.get("event.garage_door_access")
|
||||
assert not hass.states.get("image.garage_door_thumbnail")
|
||||
|
||||
# Add a new door to the API response
|
||||
mock_client.get_doors.return_value = [
|
||||
*mock_client.get_doors.return_value,
|
||||
_make_door("door-003", "Garage Door"),
|
||||
]
|
||||
|
||||
# Trigger natural refresh via WebSocket reconnect
|
||||
on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"]
|
||||
on_connect = mock_client.start_websocket.call_args[1]["on_connect"]
|
||||
on_disconnect()
|
||||
await hass.async_block_till_done()
|
||||
on_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entities for the new door should now exist
|
||||
assert hass.states.get("binary_sensor.garage_door")
|
||||
assert hass.states.get("button.garage_door_unlock")
|
||||
assert hass.states.get("event.garage_door_doorbell")
|
||||
assert hass.states.get("event.garage_door_access")
|
||||
assert hass.states.get("image.garage_door_thumbnail")
|
||||
|
||||
|
||||
async def test_stale_device_removed_on_refresh(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that stale devices are automatically removed on data refresh."""
|
||||
# Verify both doors exist after initial setup
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")})
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")})
|
||||
|
||||
# Simulate door-002 being removed from the hub
|
||||
mock_client.get_doors.return_value = [
|
||||
door for door in mock_client.get_doors.return_value if door.id != "door-002"
|
||||
]
|
||||
|
||||
# Trigger natural refresh via WebSocket reconnect
|
||||
on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"]
|
||||
on_connect = mock_client.start_websocket.call_args[1]["on_connect"]
|
||||
on_disconnect()
|
||||
await hass.async_block_till_done()
|
||||
on_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# door-001 still exists, door-002 was removed
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")})
|
||||
assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-002")})
|
||||
|
||||
|
||||
async def test_stale_device_removed_on_startup(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test stale devices present before setup are removed on initial refresh."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Create a stale door device that no longer exists on the hub
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "door-003")},
|
||||
)
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-003")})
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Valid doors from the hub should exist, stale device should be removed
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")})
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")})
|
||||
assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-003")})
|
||||
|
||||
@@ -9,7 +9,12 @@ import pytest
|
||||
from zwave_js_server.event import Event
|
||||
from zwave_js_server.model.node import Node
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
BinarySensorDeviceClass,
|
||||
)
|
||||
from homeassistant.components.zwave_js.const import DOMAIN
|
||||
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
@@ -20,7 +25,9 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import (
|
||||
@@ -144,6 +151,22 @@ def _add_lock_state_notification_states(node_state: dict[str, Any]) -> dict[str,
|
||||
return updated_state
|
||||
|
||||
|
||||
def _set_opening_state_metadata_states(
|
||||
node_state: dict[str, Any], states: dict[str, str]
|
||||
) -> dict[str, Any]:
|
||||
"""Return a node state with updated Opening state metadata states."""
|
||||
updated_state = copy.deepcopy(node_state)
|
||||
for value_data in updated_state["values"]:
|
||||
if (
|
||||
value_data.get("commandClass") == 113
|
||||
and value_data.get("property") == "Access Control"
|
||||
and value_data.get("propertyKey") == "Opening state"
|
||||
):
|
||||
value_data["metadata"]["states"] = states
|
||||
break
|
||||
return updated_state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
@@ -418,12 +441,12 @@ async def test_property_sensor_door_status(
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_opening_state_notification_does_not_create_binary_sensors(
|
||||
async def test_opening_state_creates_open_binary_sensor(
|
||||
hass: HomeAssistant,
|
||||
client,
|
||||
hoppe_ehandle_connectsense_state,
|
||||
) -> None:
|
||||
"""Test Opening state does not fan out into per-state binary sensors."""
|
||||
"""Test Opening state creates the Open binary sensor."""
|
||||
# The eHandle fixture has a Binary Sensor CC value for tilt, which we
|
||||
# want to ignore in the assertion below
|
||||
state = copy.deepcopy(hoppe_ehandle_connectsense_state)
|
||||
@@ -440,7 +463,12 @@ async def test_opening_state_notification_does_not_create_binary_sensors(
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not hass.states.async_all("binary_sensor")
|
||||
open_state = hass.states.get("binary_sensor.ehandle_connectsense")
|
||||
assert open_state is not None
|
||||
assert open_state.state == STATE_OFF
|
||||
assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR
|
||||
|
||||
assert hass.states.get("binary_sensor.ehandle_connectsense_tilt") is None
|
||||
|
||||
|
||||
async def test_opening_state_disables_legacy_window_door_notification_sensors(
|
||||
@@ -476,7 +504,7 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors(
|
||||
}
|
||||
or (
|
||||
entry.original_name == "Window/door is tilted"
|
||||
and entry.original_device_class != BinarySensorDeviceClass.WINDOW
|
||||
and entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
|
||||
)
|
||||
)
|
||||
]
|
||||
@@ -488,6 +516,162 @@ async def test_opening_state_disables_legacy_window_door_notification_sensors(
|
||||
)
|
||||
assert all(hass.states.get(entry.entity_id) is None for entry in legacy_entries)
|
||||
|
||||
open_state = hass.states.get("binary_sensor.ehandle_connectsense")
|
||||
assert open_state is not None
|
||||
assert open_state.state == STATE_OFF
|
||||
assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR
|
||||
|
||||
|
||||
async def test_opening_state_binary_sensors_with_tilted(
|
||||
hass: HomeAssistant,
|
||||
client,
|
||||
hoppe_ehandle_connectsense_state,
|
||||
) -> None:
|
||||
"""Test Opening state creates Open and Tilt binary sensors when supported."""
|
||||
node = Node(
|
||||
client,
|
||||
_set_opening_state_metadata_states(
|
||||
hoppe_ehandle_connectsense_state,
|
||||
{"0": "Closed", "1": "Open", "2": "Tilted"},
|
||||
),
|
||||
)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
open_entity_id = "binary_sensor.ehandle_connectsense"
|
||||
tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt"
|
||||
|
||||
open_state = hass.states.get(open_entity_id)
|
||||
tilted_state = hass.states.get(tilted_entity_id)
|
||||
assert open_state is not None
|
||||
assert tilted_state is not None
|
||||
assert open_state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR
|
||||
assert ATTR_DEVICE_CLASS not in tilted_state.attributes
|
||||
assert open_state.state == STATE_OFF
|
||||
assert tilted_state.state == STATE_OFF
|
||||
|
||||
node.receive_event(
|
||||
Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Notification",
|
||||
"commandClass": 113,
|
||||
"endpoint": 0,
|
||||
"property": "Access Control",
|
||||
"propertyKey": "Opening state",
|
||||
"newValue": 1,
|
||||
"prevValue": 0,
|
||||
"propertyName": "Access Control",
|
||||
"propertyKeyName": "Opening state",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(open_entity_id).state == STATE_ON
|
||||
assert hass.states.get(tilted_entity_id).state == STATE_OFF
|
||||
|
||||
node.receive_event(
|
||||
Event(
|
||||
type="value updated",
|
||||
data={
|
||||
"source": "node",
|
||||
"event": "value updated",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Notification",
|
||||
"commandClass": 113,
|
||||
"endpoint": 0,
|
||||
"property": "Access Control",
|
||||
"propertyKey": "Opening state",
|
||||
"newValue": 2,
|
||||
"prevValue": 1,
|
||||
"propertyName": "Access Control",
|
||||
"propertyKeyName": "Opening state",
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(open_entity_id).state == STATE_ON
|
||||
assert hass.states.get(tilted_entity_id).state == STATE_ON
|
||||
|
||||
|
||||
async def test_opening_state_tilted_appears_via_metadata_update(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client,
|
||||
hoppe_ehandle_connectsense_state,
|
||||
) -> None:
|
||||
"""Test tilt binary sensor is added without recreating the main entity."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
open_entity_id = "binary_sensor.ehandle_connectsense"
|
||||
tilted_entity_id = "binary_sensor.ehandle_connectsense_tilt"
|
||||
open_entry = entity_registry.async_get(open_entity_id)
|
||||
assert open_entry is not None
|
||||
|
||||
assert hass.states.get(open_entity_id) is not None
|
||||
assert hass.states.get(tilted_entity_id) is None
|
||||
|
||||
node.receive_event(
|
||||
Event(
|
||||
"metadata updated",
|
||||
{
|
||||
"source": "node",
|
||||
"event": "metadata updated",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Notification",
|
||||
"commandClass": 113,
|
||||
"endpoint": 0,
|
||||
"property": "Access Control",
|
||||
"propertyKey": "Opening state",
|
||||
"propertyName": "Access Control",
|
||||
"propertyKeyName": "Opening state",
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": True,
|
||||
"writeable": False,
|
||||
"label": "Opening state",
|
||||
"ccSpecific": {"notificationType": 6},
|
||||
"min": 0,
|
||||
"max": 255,
|
||||
"states": {
|
||||
"0": "Closed",
|
||||
"1": "Open",
|
||||
"2": "Tilted",
|
||||
},
|
||||
"stateful": True,
|
||||
"secret": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(open_entity_id) is not None
|
||||
tilted_state = hass.states.get(tilted_entity_id)
|
||||
assert tilted_state is not None
|
||||
assert entity_registry.async_get(open_entity_id) == open_entry
|
||||
|
||||
|
||||
async def test_reenabled_legacy_door_state_entity_follows_opening_state(
|
||||
hass: HomeAssistant,
|
||||
@@ -983,3 +1167,347 @@ async def test_hoppe_ehandle_connectsense(
|
||||
assert entry.original_name == "Window/door is tilted"
|
||||
assert entry.original_device_class == BinarySensorDeviceClass.WINDOW
|
||||
assert entry.disabled_by is None, "Entity should be enabled by default"
|
||||
|
||||
|
||||
async def test_legacy_door_open_state_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test an open-state legacy entity creates the open-state repair issue."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-113-0-Access Control-Door state.22",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_open",
|
||||
original_name="Window/door is open",
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "test_automation",
|
||||
"alias": "test",
|
||||
"trigger": {"platform": "state", "entity_id": entity_id},
|
||||
"action": {
|
||||
"action": "automation.turn_on",
|
||||
"target": {"entity_id": "automation.test_automation"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
issue = issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "deprecated_legacy_door_open_state"
|
||||
assert issue.translation_placeholders["entity_id"] == entity_id
|
||||
assert issue.translation_placeholders["entity_name"] == "Window/door is open"
|
||||
assert (
|
||||
issue.translation_placeholders["replacement_entity_id"]
|
||||
== "binary_sensor.ehandle_connectsense"
|
||||
)
|
||||
assert "test" in issue.translation_placeholders["items"]
|
||||
|
||||
|
||||
async def test_legacy_door_tilt_state_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test a tilt-state legacy entity creates the tilt-state repair issue."""
|
||||
node = Node(
|
||||
client,
|
||||
_set_opening_state_metadata_states(
|
||||
hoppe_ehandle_connectsense_state,
|
||||
{"0": "Closed", "1": "Open", "2": "Tilted"},
|
||||
),
|
||||
)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-113-0-Access Control-Door state.5633",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_open_in_tilt_position",
|
||||
original_name="Window/door is open in tilt position",
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "test_automation",
|
||||
"alias": "test",
|
||||
"trigger": {"platform": "state", "entity_id": entity_id},
|
||||
"action": {
|
||||
"action": "automation.turn_on",
|
||||
"target": {"entity_id": "automation.test_automation"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
issue = issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}"
|
||||
)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "deprecated_legacy_door_tilt_state"
|
||||
assert issue.translation_placeholders["entity_id"] == entity_id
|
||||
assert (
|
||||
issue.translation_placeholders["entity_name"]
|
||||
== "Window/door is open in tilt position"
|
||||
)
|
||||
assert (
|
||||
issue.translation_placeholders["replacement_entity_id"]
|
||||
== "binary_sensor.ehandle_connectsense_tilt"
|
||||
)
|
||||
assert "test" in issue.translation_placeholders["items"]
|
||||
|
||||
|
||||
async def test_legacy_door_open_state_no_repair_issue_when_disabled(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test no repair issue is created when the legacy entity is disabled."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-113-0-Access Control-Door state.22",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_open",
|
||||
original_name="Window/door is open",
|
||||
disabled_by=er.RegistryEntryDisabler.INTEGRATION,
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "test_automation",
|
||||
"alias": "test",
|
||||
"trigger": {"platform": "state", "entity_id": entity_id},
|
||||
"action": {
|
||||
"action": "automation.turn_on",
|
||||
"target": {"entity_id": "automation.test_automation"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_closed_door_state_does_not_create_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test closed-state legacy entities are excluded from repair issues."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-113-0-Access Control-Door state.23",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_closed",
|
||||
original_name="Window/door is closed",
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "test_automation",
|
||||
"alias": "test",
|
||||
"trigger": {"platform": "state", "entity_id": entity_id},
|
||||
"action": {
|
||||
"action": "automation.turn_on",
|
||||
"target": {"entity_id": "automation.test_automation"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_hoppe_custom_tilt_sensor_no_repair_issue(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test no repair issue for the custom Binary Sensor CC tilt entity."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-48-0-Tilt",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_tilted",
|
||||
original_name="Window/door is tilted",
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "test_automation",
|
||||
"alias": "test",
|
||||
"trigger": {"platform": "state", "entity_id": entity_id},
|
||||
"action": {
|
||||
"action": "automation.turn_on",
|
||||
"target": {"entity_id": "automation.test_automation"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_tilt_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_door_open_state_stale_repair_issue_cleaned_up(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
client: MagicMock,
|
||||
hoppe_ehandle_connectsense_state: NodeDataType,
|
||||
) -> None:
|
||||
"""Test stale open-state repair issues are deleted when no references remain."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
home_id = client.driver.controller.home_id
|
||||
|
||||
entity_entry = entity_registry.async_get_or_create(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{home_id}.20-113-0-Access Control-Door state.22",
|
||||
suggested_object_id="ehandle_connectsense_window_door_is_open",
|
||||
original_name="Window/door is open",
|
||||
)
|
||||
entity_id = entity_entry.entity_id
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_legacy_door_open_state.{entity_id}",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_legacy_door_open_state",
|
||||
translation_placeholders={
|
||||
"entity_id": entity_id,
|
||||
"entity_name": "Window/door is open",
|
||||
"replacement_entity_id": "binary_sensor.ehandle_connectsense",
|
||||
"items": "- [test](/config/automation/edit/test_automation)",
|
||||
},
|
||||
)
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"deprecated_legacy_door_open_state.{entity_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ from zwave_js_server.exceptions import FailedZWaveCommand
|
||||
from zwave_js_server.model.node import Node
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_OPTIONS,
|
||||
ATTR_STATE_CLASS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
@@ -895,137 +894,6 @@ async def test_new_sensor_invalid_scale(
|
||||
mock_schedule_reload.assert_called_once_with(integration.entry_id)
|
||||
|
||||
|
||||
async def test_opening_state_sensor(
|
||||
hass: HomeAssistant,
|
||||
client,
|
||||
hoppe_ehandle_connectsense_state,
|
||||
) -> None:
|
||||
"""Test Opening state is exposed as an enum sensor."""
|
||||
node = Node(client, hoppe_ehandle_connectsense_state)
|
||||
client.driver.controller.nodes[node.node_id] = node
|
||||
|
||||
entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"})
|
||||
entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.ehandle_connectsense_opening_state")
|
||||
assert state
|
||||
assert state.state == "Closed"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
|
||||
assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"]
|
||||
assert state.attributes[ATTR_VALUE] == 0
|
||||
|
||||
# Make sure we're not accidentally creating enum sensors for legacy
|
||||
# Door/Window notification variables.
|
||||
legacy_sensor_ids = [
|
||||
"sensor.ehandle_connectsense_door_state",
|
||||
"sensor.ehandle_connectsense_door_state_simple",
|
||||
]
|
||||
for entity_id in legacy_sensor_ids:
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
|
||||
async def test_opening_state_sensor_metadata_options_change(
|
||||
hass: HomeAssistant,
|
||||
hoppe_ehandle_connectsense: Node,
|
||||
integration: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Opening state sensor is rediscovered when metadata options change."""
|
||||
entity_id = "sensor.ehandle_connectsense_opening_state"
|
||||
node = hoppe_ehandle_connectsense
|
||||
|
||||
# Verify initial state with 2 options
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == "Closed"
|
||||
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
|
||||
assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"]
|
||||
|
||||
# Simulate metadata update adding "Tilted" state
|
||||
event = Event(
|
||||
"metadata updated",
|
||||
{
|
||||
"source": "node",
|
||||
"event": "metadata updated",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Notification",
|
||||
"commandClass": 113,
|
||||
"endpoint": 0,
|
||||
"property": "Access Control",
|
||||
"propertyKey": "Opening state",
|
||||
"propertyName": "Access Control",
|
||||
"propertyKeyName": "Opening state",
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": True,
|
||||
"writeable": False,
|
||||
"label": "Opening state",
|
||||
"ccSpecific": {"notificationType": 6},
|
||||
"min": 0,
|
||||
"max": 255,
|
||||
"states": {
|
||||
"0": "Closed",
|
||||
"1": "Open",
|
||||
"2": "Tilted",
|
||||
},
|
||||
"stateful": True,
|
||||
"secret": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entity should be rediscovered with 3 options
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open", "Tilted"]
|
||||
|
||||
# Simulate metadata update removing "Tilted" state
|
||||
event = Event(
|
||||
"metadata updated",
|
||||
{
|
||||
"source": "node",
|
||||
"event": "metadata updated",
|
||||
"nodeId": node.node_id,
|
||||
"args": {
|
||||
"commandClassName": "Notification",
|
||||
"commandClass": 113,
|
||||
"endpoint": 0,
|
||||
"property": "Access Control",
|
||||
"propertyKey": "Opening state",
|
||||
"propertyName": "Access Control",
|
||||
"propertyKeyName": "Opening state",
|
||||
"metadata": {
|
||||
"type": "number",
|
||||
"readable": True,
|
||||
"writeable": False,
|
||||
"label": "Opening state",
|
||||
"ccSpecific": {"notificationType": 6},
|
||||
"min": 0,
|
||||
"max": 255,
|
||||
"states": {
|
||||
"0": "Closed",
|
||||
"1": "Open",
|
||||
},
|
||||
"stateful": True,
|
||||
"secret": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
node.receive_event(event)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Entity should be rediscovered with 2 options again
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.attributes[ATTR_OPTIONS] == ["Closed", "Open"]
|
||||
|
||||
|
||||
CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_"
|
||||
# controller statistics with initial state of 0
|
||||
CONTROLLER_STATISTICS_SUFFIXES = {
|
||||
|
||||
Reference in New Issue
Block a user