Compare commits

..

19 Commits

Author SHA1 Message Date
Ville Skyttä
5e7e299876 Add Tasmota firmware update availability support 2026-03-30 23:08:38 +03:00
smarthome-10
c12b7bfd18 Rename component to integration in Bitcoin (#166882) 2026-03-30 20:41:26 +01:00
smarthome-10
1c2f583587 Rename component to integration in FortiOS (#166887)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 20:33:06 +01:00
Raj Laud
58a376e68b Bump victron-ble-ha-parser (#166906) 2026-03-30 20:23:22 +01:00
Jan Bouwhuis
78b251e7cb Add clean segment support to MQTT vacuum entities (#166794) 2026-03-30 21:20:17 +02:00
Abílio Costa
a2c65b9126 Remove checkout requirement from PR review skill (#166902) 2026-03-30 19:12:59 +01:00
Denis Shulyaka
5e443681c3 Add troubleshooting documentation for Anthropic integration (#166766) 2026-03-30 20:10:49 +02:00
smarthome-10
13756863f1 Rename component to integration in Fail2Ban (#166901) 2026-03-30 20:08:56 +02:00
Raphael Hehl
fd54e45aeb Add dynamic device support for UniFi Access door platforms (#166793) 2026-03-30 19:51:05 +02:00
Manu
52af74c3b6 Add entity action html5.send_message to HTML5 integration (#166349)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:49:59 +02:00
Denis Shulyaka
dc111a475e Add support for web search dynamic filtering for Anthropic (#164116) 2026-03-30 19:40:56 +02:00
Chase
14cb42349a OpenRouter: Add WebSearch Support (#164293)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-30 19:40:02 +02:00
Raphael Hehl
c42b50418e Add stale device removal support to UniFi Access (#166792)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-30 19:19:20 +02:00
AlCalzone
501b4e6efb Convert Z-Wave Opening state to separate Open/Closed and Tilted sensors (#166635)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 19:17:05 +02:00
smarthome-10
ca2099b165 Rename component to integration in Panasonic Blu-Ray (#166890)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 18:13:17 +02:00
smarthome-10
69b55c295d Rename component to integration in OhmConnect (#166881) 2026-03-30 17:47:38 +02:00
smarthome-10
13709b1c90 Rename component to integration in Sky Hub (#166888) 2026-03-30 17:45:18 +02:00
smarthome-10
2c013777db Rename component to integration in Opple (#166891) 2026-03-30 17:43:56 +02:00
Raphael Hehl
91099ea489 Update UniFi Access quality scale: mark fulfilled Gold rules (#166789)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-30 17:19:07 +02:00
75 changed files with 3397 additions and 1137 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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",
]

View File

@@ -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",

View File

@@ -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

View File

@@ -1 +1 @@
"""The bitcoin component."""
"""The Bitcoin integration."""

View File

@@ -1 +1 @@
"""The fail2ban component."""
"""The Fail2Ban integration."""

View File

@@ -1 +1 @@
"""Fortinet FortiOS components."""
"""Fortinet FortiOS integration."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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"
}
}
}
}

View File

@@ -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(

View File

@@ -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"

View File

@@ -9,6 +9,9 @@
"services": {
"dismiss": {
"service": "mdi:bell-off"
},
"send_message": {
"service": "mdi:message-arrow-right"
}
}
}

View 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},
)

View File

@@ -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()

View 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",
)

View File

@@ -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'}"

View File

@@ -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"
}
}
}

View File

@@ -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",

View File

@@ -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:

View File

@@ -1 +1 @@
"""The ohmconnect component."""
"""The OhmConnect integration."""

View File

@@ -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

View File

@@ -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."""

View File

@@ -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,
}

View File

@@ -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(

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -1 +1 @@
"""The opple component."""
"""The Opple integration."""

View File

@@ -1 +1 @@
"""The panasonic_bluray component."""
"""The Panasonic Blu-Ray Player integration."""

View File

@@ -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

View File

@@ -1 +1 @@
"""The sky_hub component."""
"""The Sky Hub integration."""

View File

@@ -19,6 +19,7 @@ PLATFORMS = [
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
TASMOTA_EVENT = "tasmota_event"

View 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

View File

@@ -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"]
}

View 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
)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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")

View File

@@ -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):

View File

@@ -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):

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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,

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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 metalorganic 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 metalorganic 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',
}),
])
# ---

View File

@@ -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 metalorganic 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(

View File

@@ -1 +1 @@
"""Tests for the fail2ban component."""
"""Tests for the Fail2Ban integration."""

View File

@@ -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
}

View File

@@ -1,7 +0,0 @@
{
"product_type": "HWE-KWH1",
"product_name": "kWh meter",
"serial": "5c2fafabcdef",
"firmware_version": "5.0103",
"api_version": "v2"
}

View File

@@ -1,3 +0,0 @@
{
"cloud_enabled": true
}

View File

@@ -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
}

View File

@@ -1,7 +0,0 @@
{
"product_type": "HWE-SKT",
"product_name": "Energy Socket",
"serial": "5c2fafabcdef",
"firmware_version": "4.07",
"api_version": "v1"
}

View File

@@ -1,5 +0,0 @@
{
"power_on": true,
"switch_lock": false,
"brightness": 255
}

View File

@@ -1,3 +0,0 @@
{
"cloud_enabled": true
}

View File

@@ -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,
})
# ---

View File

@@ -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"

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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"),
[

View File

@@ -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,

View 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

View 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

View File

@@ -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")})

View File

@@ -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
)

View File

@@ -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 = {