Compare commits

...

31 Commits

Author SHA1 Message Date
Paulus Schoutsen
6c2fc12b6a Validate Platform constant up to date 2025-09-12 11:04:25 -04:00
Artur Pragacz
54fd55a1c6 Remove unused ATTR_STEP_VALIDATION from number (#152179) 2025-09-12 16:46:42 +02:00
Matthias Alphart
cc64fa639d Add KNX UI entity config to diagnostics (#151620) 2025-09-12 15:41:17 +02:00
Benjamin Pearce
84140ba414 Add remote codes which can be used with remote.send_command to diagnostics (#152017)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2025-09-12 14:41:15 +02:00
Marc Mueller
d1726b84c8 Update pytest-cov to 7.0.0 (#152157) 2025-09-12 13:34:20 +02:00
Jan Bouwhuis
4724ecbc38 Suppress warning if object_id is still added when default_entity_id is used in MQTT discovery (#151996) 2025-09-12 13:11:10 +02:00
Marc Mueller
85afe87b5e Update coverage to 7.10.6 (#152158) 2025-09-12 13:02:01 +02:00
Nathan Spencer
5960179844 Add food dispensed today and next feeding sensors to litterrobot (#152016)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-09-12 11:17:45 +01:00
Thomas55555
9f8f7d2fde Add event entity on websocket ready in Husqvarna Automower (#151428) 2025-09-12 11:52:29 +02:00
ekobres
4c22264b13 Add support for inH₂O pressure unit (#148289)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-12 11:24:45 +02:00
Andrea Turri
baf4382724 Miele consumption sensors consistent behavior with RestoreSensor (#151098) 2025-09-12 11:17:10 +02:00
Manu
8263ea4a4a Don't try to connect after exiting loop in ntfy (#152011) 2025-09-12 11:14:01 +02:00
Bouwe Westerdijk
8412581be4 Implement snapshot-testing for Plugwise climate platform (#151070) 2025-09-12 11:10:49 +02:00
J. Nick Koston
207c848438 Improve SwitchBot device discovery when Bluetooth adapter is in passive mode (#152074) 2025-09-12 11:08:51 +02:00
Paulus Schoutsen
2b61601fd7 Remove the host from the AI Task generated image URL (#151887) 2025-09-12 11:03:01 +02:00
Denis Shulyaka
ee506e6c14 Implement thinking content for Gemini (#150347)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-09-12 05:02:39 -04:00
Brett Adams
8003a49571 Add guest mode switch to Teslemetry (#151550) 2025-09-12 10:44:38 +02:00
Maciej Bieniek
e438b11afb Use native_visibility property instead of visibility for OpenWeatherMap weather entity (#151867) 2025-09-12 10:44:22 +02:00
Matthias Alphart
64ba43703c Fix KNX Light - individual color initialisation from UI config (#151815) 2025-09-12 10:43:45 +02:00
Jeremy Cook
1d214ae120 For the met integration Increase the hourly forecast limit to 48 hours in coordinator. (#150486) 2025-09-12 10:19:29 +02:00
Joakim Sørensen
68d987f866 Bump hass-nabucasa from 1.1.0 to 1.1.1 (#152147) 2025-09-12 10:18:58 +02:00
Norbert Rittel
299cc5e40c Fix sentence-casing of "CPU temperature" in fritz (#152149) 2025-09-12 10:18:12 +02:00
Retha Runolfsson
2c3456177e Add humidifier support for switchbot cloud integration (#149039) 2025-09-12 10:08:01 +02:00
Retha Runolfsson
1ef90180cc Add plug mini eu for switchbot integration (#151130) 2025-09-12 10:05:15 +02:00
Erik Montnemery
4c1364dfd1 Fix wrong type annotation in exposed_entities (#152142) 2025-09-12 09:32:17 +02:00
Norbert Rittel
09a44a6a30 Fix spelling of "H.265" encoding standard in reolink (#152130) 2025-09-12 08:05:01 +02:00
Maciej Bieniek
63303bdcde Allow port and SNMP community configuration for Brother printer (#151506) 2025-09-12 00:21:05 +02:00
Markus Adrario
59cd24f54b Add dynamic devices to Homee (#151934) 2025-09-12 00:19:37 +02:00
Duco Sebel
82b9fead39 Add support for controlling LED brightness on HomeWizard Plug-In Battery and P1 Meter (#151186) 2025-09-11 23:46:00 +02:00
karwosts
a879e36e9b Designate helpers as internal quality (#149021) 2025-09-11 23:41:06 +02:00
HarvsG
b12c458188 Log bayesian sensor name for unavailable observations (#152039) 2025-09-11 22:14:51 +01:00
106 changed files with 5406 additions and 581 deletions

View File

@@ -19,7 +19,6 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import get_url
from homeassistant.util import RE_SANITIZE_FILENAME, slugify
from .const import (
@@ -249,7 +248,7 @@ async def async_generate_image(
if IMAGE_EXPIRY_TIME > 0:
async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename))
service_result["url"] = get_url(hass) + async_sign_path(
service_result["url"] = async_sign_path(
hass,
f"/api/{DOMAIN}/images/{filename}",
timedelta(seconds=IMAGE_EXPIRY_TIME or 1800),

View File

@@ -497,16 +497,18 @@ class BayesianBinarySensor(BinarySensorEntity):
_LOGGER.debug(
(
"Observation for entity '%s' returned None, it will not be used"
" for Bayesian updating"
" for updating Bayesian sensor '%s'"
),
observation.entity_id,
self.entity_id,
)
continue
_LOGGER.debug(
(
"Observation for template entity returned None rather than a valid"
" boolean, it will not be used for Bayesian updating"
" boolean, it will not be used for updating Bayesian sensor '%s'"
),
self.entity_id,
)
# the prior has been updated and is now the posterior
return prior

View File

@@ -18,8 +18,10 @@ async def async_get_config_entry_diagnostics(
coordinator = config_entry.runtime_data
device_info = await coordinator.client.get_system_info()
command_list = await coordinator.client.get_command_list()
return {
"remote_command_list": command_list,
"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT),
"device_info": async_redact_data(device_info, TO_REDACT),
}

View File

@@ -2,28 +2,40 @@
from __future__ import annotations
import logging
from brother import Brother, SnmpError
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN
from .const import (
CONF_COMMUNITY,
DEFAULT_COMMUNITY,
DEFAULT_PORT,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Set up Brother from a config entry."""
host = entry.data[CONF_HOST]
port = entry.data[SECTION_ADVANCED_SETTINGS][CONF_PORT]
community = entry.data[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY]
printer_type = entry.data[CONF_TYPE]
snmp_engine = await async_get_snmp_engine(hass)
try:
brother = await Brother.create(
host, printer_type=printer_type, snmp_engine=snmp_engine
host, port, community, printer_type=printer_type, snmp_engine=snmp_engine
)
except (ConnectionError, SnmpError, TimeoutError) as error:
raise ConfigEntryNotReady(
@@ -48,3 +60,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> bool:
"""Migrate an old entry."""
if entry.version == 1 and entry.minor_version < 2:
new_data = entry.data.copy()
new_data[SECTION_ADVANCED_SETTINGS] = {
CONF_PORT: DEFAULT_PORT,
CONF_COMMUNITY: DEFAULT_COMMUNITY,
}
hass.config_entries.async_update_entry(entry, data=new_data, minor_version=2)
_LOGGER.info(
"Migration to configuration version %s.%s successful",
entry.version,
entry.minor_version,
)
return True

View File

@@ -9,21 +9,65 @@ import voluptuous as vol
from homeassistant.components.snmp import async_get_snmp_engine
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import section
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.util.network import is_host_valid
from .const import DOMAIN, PRINTER_TYPES
from .const import (
CONF_COMMUNITY,
DEFAULT_COMMUNITY,
DEFAULT_PORT,
DOMAIN,
PRINTER_TYPES,
SECTION_ADVANCED_SETTINGS,
)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
},
),
{"collapsed": True},
),
}
)
ZEROCONF_SCHEMA = vol.Schema(
{
vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
},
),
{"collapsed": True},
),
}
)
RECONFIGURE_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
vol.Required(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): str,
},
),
{"collapsed": True},
),
}
)
RECONFIGURE_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
async def validate_input(
@@ -35,7 +79,12 @@ async def validate_input(
snmp_engine = await async_get_snmp_engine(hass)
brother = await Brother.create(user_input[CONF_HOST], snmp_engine=snmp_engine)
brother = await Brother.create(
user_input[CONF_HOST],
user_input[SECTION_ADVANCED_SETTINGS][CONF_PORT],
user_input[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY],
snmp_engine=snmp_engine,
)
await brother.async_update()
if expected_mac is not None and brother.serial.lower() != expected_mac:
@@ -48,6 +97,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Brother Printer."""
VERSION = 1
MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize."""
@@ -126,13 +176,11 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
title = f"{self.brother.model} {self.brother.serial}"
return self.async_create_entry(
title=title,
data={CONF_HOST: self.host, CONF_TYPE: user_input[CONF_TYPE]},
data={CONF_HOST: self.host, **user_input},
)
return self.async_show_form(
step_id="zeroconf_confirm",
data_schema=vol.Schema(
{vol.Optional(CONF_TYPE, default="laser"): vol.In(PRINTER_TYPES)}
),
data_schema=ZEROCONF_SCHEMA,
description_placeholders={
"serial_number": self.brother.serial,
"model": self.brother.model,
@@ -160,7 +208,7 @@ class BrotherConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_update_reload_and_abort(
entry,
data_updates={CONF_HOST: user_input[CONF_HOST]},
data_updates=user_input,
)
return self.async_show_form(

View File

@@ -10,3 +10,10 @@ DOMAIN: Final = "brother"
PRINTER_TYPES: Final = ["laser", "ink"]
UPDATE_INTERVAL = timedelta(seconds=30)
SECTION_ADVANCED_SETTINGS = "advanced_settings"
CONF_COMMUNITY = "community"
DEFAULT_COMMUNITY = "public"
DEFAULT_PORT = 161

View File

@@ -8,7 +8,21 @@
"type": "Type of the printer"
},
"data_description": {
"host": "The hostname or IP address of the Brother printer to control."
"host": "The hostname or IP address of the Brother printer to control.",
"type": "Brother printer type: ink or laser."
},
"sections": {
"advanced_settings": {
"name": "Advanced settings",
"data": {
"port": "[%key:common::config_flow::data::port%]",
"community": "SNMP Community"
},
"data_description": {
"port": "The SNMP port of the Brother printer.",
"community": "A simple password for devices to communicate to each other."
}
}
}
},
"zeroconf_confirm": {
@@ -16,6 +30,22 @@
"title": "Discovered Brother Printer",
"data": {
"type": "[%key:component::brother::config::step::user::data::type%]"
},
"data_description": {
"type": "[%key:component::brother::config::step::user::data_description::type%]"
},
"sections": {
"advanced_settings": {
"name": "Advanced settings",
"data": {
"port": "[%key:common::config_flow::data::port%]",
"community": "SNMP Community"
},
"data_description": {
"port": "The SNMP port of the Brother printer.",
"community": "A simple password for devices to communicate to each other."
}
}
}
},
"reconfigure": {
@@ -25,6 +55,19 @@
},
"data_description": {
"host": "[%key:component::brother::config::step::user::data_description::host%]"
},
"sections": {
"advanced_settings": {
"name": "Advanced settings",
"data": {
"port": "[%key:common::config_flow::data::port%]",
"community": "SNMP Community"
},
"data_description": {
"port": "The SNMP port of the Brother printer.",
"community": "A simple password for devices to communicate to each other."
}
}
}
}
},

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.1.0"],
"requirements": ["hass-nabucasa==1.1.1"],
"single_config_entry": true
}

View File

@@ -6,5 +6,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/derivative",
"integration_type": "helper",
"iot_class": "calculated"
"iot_class": "calculated",
"quality_scale": "internal"
}

View File

@@ -176,7 +176,7 @@
"name": "Max connection upload throughput"
},
"cpu_temperature": {
"name": "CPU Temperature"
"name": "CPU temperature"
}
}
},

View File

@@ -6,5 +6,6 @@
"dependencies": ["sensor", "switch"],
"documentation": "https://www.home-assistant.io/integrations/generic_thermostat",
"integration_type": "helper",
"iot_class": "local_polling"
"iot_class": "local_polling",
"quality_scale": "internal"
}

View File

@@ -3,12 +3,13 @@
from __future__ import annotations
import asyncio
import base64
import codecs
from collections.abc import AsyncGenerator, AsyncIterator, Callable
from dataclasses import replace
from dataclasses import dataclass, replace
import mimetypes
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from google.genai import Client
from google.genai.errors import APIError, ClientError
@@ -27,6 +28,7 @@ from google.genai.types import (
PartUnionDict,
SafetySetting,
Schema,
ThinkingConfig,
Tool,
ToolListUnion,
)
@@ -201,6 +203,30 @@ def _create_google_tool_response_content(
)
@dataclass(slots=True)
class PartDetails:
"""Additional data for a content part."""
part_type: Literal["text", "thought", "function_call"]
"""The part type for which this data is relevant for."""
index: int
"""Start position or number of the tool."""
length: int = 0
"""Length of the relevant data."""
thought_signature: str | None = None
"""Base64 encoded thought signature, if available."""
@dataclass(slots=True)
class ContentDetails:
"""Native data for AssistantContent."""
part_details: list[PartDetails]
def _convert_content(
content: (
conversation.UserContent
@@ -209,32 +235,91 @@ def _convert_content(
),
) -> Content:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
if content.role != "assistant":
return Content(
role=role,
parts=[
Part.from_text(text=content.content if content.content else ""),
],
role=content.role,
parts=[Part.from_text(text=content.content if content.content else "")],
)
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts: list[Part] = []
part_details: list[PartDetails] = (
content.native.part_details
if isinstance(content.native, ContentDetails)
else []
)
details: PartDetails | None = None
if content.content:
parts.append(Part.from_text(text=content.content))
index = 0
for details in part_details:
if details.part_type == "text":
if index < details.index:
parts.append(
Part.from_text(text=content.content[index : details.index])
)
index = details.index
parts.append(
Part.from_text(
text=content.content[index : index + details.length],
)
)
if details.thought_signature:
parts[-1].thought_signature = base64.b64decode(
details.thought_signature
)
index += details.length
if index < len(content.content):
parts.append(Part.from_text(text=content.content[index:]))
if content.thinking_content:
index = 0
for details in part_details:
if details.part_type == "thought":
if index < details.index:
parts.append(
Part.from_text(
text=content.thinking_content[index : details.index]
)
)
parts[-1].thought = True
index = details.index
parts.append(
Part.from_text(
text=content.thinking_content[index : index + details.length],
)
)
parts[-1].thought = True
if details.thought_signature:
parts[-1].thought_signature = base64.b64decode(
details.thought_signature
)
index += details.length
if index < len(content.thinking_content):
parts.append(Part.from_text(text=content.thinking_content[index:]))
parts[-1].thought = True
if content.tool_calls:
parts.extend(
[
for index, tool_call in enumerate(content.tool_calls):
parts.append(
Part.from_function_call(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
)
if details := next(
(
d
for d in part_details
if d.part_type == "function_call" and d.index == index
),
None,
):
if details.thought_signature:
parts[-1].thought_signature = base64.b64decode(
details.thought_signature
)
return Content(role="model", parts=parts)
@@ -243,14 +328,20 @@ async def _transform_stream(
result: AsyncIterator[GenerateContentResponse],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
new_message = True
part_details: list[PartDetails] = []
try:
async for response in result:
LOGGER.debug("Received response chunk: %s", response)
chunk: conversation.AssistantContentDeltaDict = {}
if new_message:
chunk["role"] = "assistant"
if part_details:
yield {"native": ContentDetails(part_details=part_details)}
part_details = []
yield {"role": "assistant"}
new_message = False
content_index = 0
thinking_content_index = 0
tool_call_index = 0
# According to the API docs, this would mean no candidate is returned, so we can safely throw an error here.
if response.prompt_feedback or not response.candidates:
@@ -284,23 +375,62 @@ async def _transform_stream(
else []
)
content = "".join([part.text for part in response_parts if part.text])
tool_calls = []
for part in response_parts:
if not part.function_call:
continue
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
chunk: conversation.AssistantContentDeltaDict = {}
if tool_calls:
chunk["tool_calls"] = tool_calls
if part.text:
if part.thought:
chunk["thinking_content"] = part.text
if part.thought_signature:
part_details.append(
PartDetails(
part_type="thought",
index=thinking_content_index,
length=len(part.text),
thought_signature=base64.b64encode(
part.thought_signature
).decode("utf-8"),
)
)
thinking_content_index += len(part.text)
else:
chunk["content"] = part.text
if part.thought_signature:
part_details.append(
PartDetails(
part_type="text",
index=content_index,
length=len(part.text),
thought_signature=base64.b64encode(
part.thought_signature
).decode("utf-8"),
)
)
content_index += len(part.text)
if part.function_call:
tool_call = part.function_call
tool_name = tool_call.name if tool_call.name else ""
tool_args = _escape_decode(tool_call.args)
chunk["tool_calls"] = [
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
]
if part.thought_signature:
part_details.append(
PartDetails(
part_type="function_call",
index=tool_call_index,
thought_signature=base64.b64encode(
part.thought_signature
).decode("utf-8"),
)
)
yield chunk
if part_details:
yield {"native": ContentDetails(part_details=part_details)}
chunk["content"] = content
yield chunk
except (
APIError,
ValueError,
@@ -522,6 +652,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
),
),
],
thinking_config=ThinkingConfig(include_thoughts=True),
)

View File

@@ -406,7 +406,7 @@ def ws_expose_entity(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Expose an entity to an assistant."""
entity_ids: str = msg["entity_ids"]
entity_ids: list[str] = msg["entity_ids"]
if blocked := next(
(

View File

@@ -3,7 +3,7 @@
from dataclasses import dataclass
from pyHomee.const import AttributeChangedBy, AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import get_name_for_enum
from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
@@ -60,18 +60,29 @@ def get_supported_features(
return supported_features
async def add_alarm_control_panel_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee alarm control panel entities."""
async_add_entities(
HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the alarm control panel component."""
"""Add the homee platform for the alarm control panel component."""
async_add_entities(
HomeeAlarmPanel(attribute, config_entry, ALARM_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in ALARM_DESCRIPTIONS and attribute.editable
await setup_homee_platform(
add_alarm_control_panel_entities, async_add_entities, config_entry
)

View File

@@ -1,7 +1,7 @@
"""The Homee binary sensor platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -152,23 +153,34 @@ BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] =
}
async def async_setup_entry(
hass: HomeAssistant,
async def add_binary_sensor_entities(
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add the Homee platform for the binary sensor component."""
async_add_devices(
"""Add homee binary sensor entities."""
async_add_entities(
HomeeBinarySensor(
attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type]
)
for node in config_entry.runtime_data.nodes
for node in nodes
for attribute in node.attributes
if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the binary sensor component."""
await setup_homee_platform(
add_binary_sensor_entities, async_add_entities, config_entry
)
class HomeeBinarySensor(HomeeEntity, BinarySensorEntity):
"""Representation of a Homee binary sensor."""

View File

@@ -1,7 +1,7 @@
"""The homee button platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -39,19 +40,28 @@ BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
}
async def add_button_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee button entities."""
async_add_entities(
HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the button component."""
"""Add the homee platform for the button component."""
async_add_entities(
HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
)
await setup_homee_platform(add_button_entities, async_add_entities, config_entry)
class HomeeButton(HomeeEntity, ButtonEntity):

View File

@@ -21,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL
from .entity import HomeeNodeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -31,18 +32,27 @@ ROOM_THERMOSTATS = {
}
async def add_climate_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee climate entities."""
async_add_entities(
HomeeClimate(node, config_entry)
for node in nodes
if node.profile in CLIMATE_PROFILES
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the climate component."""
async_add_devices(
HomeeClimate(node, config_entry)
for node in config_entry.runtime_data.nodes
if node.profile in CLIMATE_PROFILES
)
await setup_homee_platform(add_climate_entities, async_add_entities, config_entry)
class HomeeClimate(HomeeNodeEntity, ClimateEntity):

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeNodeEntity
from .helpers import setup_homee_platform
_LOGGER = logging.getLogger(__name__)
@@ -77,18 +78,25 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
return COVER_DEVICE_PROFILES.get(node.profile)
async def add_cover_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee cover entities."""
async_add_entities(
HomeeCover(node, config_entry) for node in nodes if is_cover_node(node)
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the cover integration."""
async_add_devices(
HomeeCover(node, config_entry)
for node in config_entry.runtime_data.nodes
if is_cover_node(node)
)
await setup_homee_platform(add_cover_entities, async_add_entities, config_entry)
def is_cover_node(node: HomeeNode) -> bool:

View File

@@ -1,7 +1,7 @@
"""The homee event platform."""
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.event import (
EventDeviceClass,
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -49,6 +50,22 @@ EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = {
}
async def add_event_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee event entities."""
async_add_entities(
HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in EVENT_DESCRIPTIONS
and node.profile in REMOTE_PROFILES
and not attribute.editable
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
@@ -56,14 +73,7 @@ async def async_setup_entry(
) -> None:
"""Add event entities for homee."""
async_add_entities(
HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in EVENT_DESCRIPTIONS
and node.profile in REMOTE_PROFILES
and not attribute.editable
)
await setup_homee_platform(add_event_entities, async_add_entities, config_entry)
class HomeeEvent(HomeeEntity, EventEntity):

View File

@@ -19,22 +19,32 @@ from homeassistant.util.scaling import int_states_in_range
from . import HomeeConfigEntry
from .const import DOMAIN, PRESET_AUTO, PRESET_MANUAL, PRESET_SUMMER
from .entity import HomeeNodeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
async def add_fan_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee fan entities."""
async_add_entities(
HomeeFan(node, config_entry)
for node in nodes
if node.profile == NodeProfile.VENTILATION_CONTROL
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Homee fan platform."""
async_add_devices(
HomeeFan(node, config_entry)
for node in config_entry.runtime_data.nodes
if node.profile == NodeProfile.VENTILATION_CONTROL
)
await setup_homee_platform(add_fan_entities, async_add_entities, config_entry)
class HomeeFan(HomeeNodeEntity, FanEntity):

View File

@@ -1,11 +1,42 @@
"""Helper functions for the homee custom component."""
from collections.abc import Callable, Coroutine
from enum import IntEnum
import logging
from typing import Any
from pyHomee.model import HomeeNode
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
_LOGGER = logging.getLogger(__name__)
async def setup_homee_platform(
add_platform_entities: Callable[
[HomeeConfigEntry, AddConfigEntryEntitiesCallback, list[HomeeNode]],
Coroutine[Any, Any, None],
],
async_add_entities: AddConfigEntryEntitiesCallback,
config_entry: HomeeConfigEntry,
) -> None:
"""Set up a homee platform."""
await add_platform_entities(
config_entry, async_add_entities, config_entry.runtime_data.nodes
)
async def add_device(node: HomeeNode, add: bool) -> None:
"""Dynamically add entities."""
if add:
await add_platform_entities(config_entry, async_add_entities, [node])
config_entry.async_on_unload(
config_entry.runtime_data.add_nodes_listener(add_device)
)
def get_name_for_enum(att_class: type[IntEnum], att_id: int) -> str | None:
"""Return the enum item name for a given integer."""
try:

View File

@@ -24,6 +24,7 @@ from homeassistant.util.color import (
from . import HomeeConfigEntry
from .const import LIGHT_PROFILES
from .entity import HomeeNodeEntity
from .helpers import setup_homee_platform
LIGHT_ATTRIBUTES = [
AttributeType.COLOR,
@@ -85,19 +86,28 @@ def decimal_to_rgb_list(color: float) -> list[int]:
]
async def add_light_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee light entities."""
async_add_entities(
HomeeLight(node, light, config_entry)
for node in nodes
for light in get_light_attribute_sets(node)
if is_light_node(node)
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the light entity."""
"""Add the homee platform for the light entity."""
async_add_entities(
HomeeLight(node, light, config_entry)
for node in config_entry.runtime_data.nodes
for light in get_light_attribute_sets(node)
if is_light_node(node)
)
await setup_homee_platform(add_light_entities, async_add_entities, config_entry)
class HomeeLight(HomeeNodeEntity, LightEntity):

View File

@@ -3,6 +3,7 @@
from typing import Any
from pyHomee.const import AttributeChangedBy, AttributeType
from pyHomee.model import HomeeNode
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
@@ -10,24 +11,33 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import get_name_for_enum
from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
async def add_lock_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee lock entities."""
async_add_entities(
HomeeLock(attribute, config_entry)
for node in nodes
for attribute in node.attributes
if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the lock component."""
"""Add the homee platform for the lock component."""
async_add_devices(
HomeeLock(attribute, config_entry)
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
)
await setup_homee_platform(add_lock_entities, async_add_entities, config_entry)
class HomeeLock(HomeeEntity, LockEntity):

View File

@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.number import (
NumberDeviceClass,
@@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import HOMEE_UNIT_TO_HA_UNIT
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -136,19 +137,28 @@ NUMBER_DESCRIPTIONS = {
}
async def add_number_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee number entities."""
async_add_entities(
HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the number component."""
"""Add the homee platform for the number component."""
async_add_entities(
HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
)
await setup_homee_platform(add_number_entities, async_add_entities, config_entry)
class HomeeNumber(HomeeEntity, NumberEntity):

View File

@@ -54,7 +54,7 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done

View File

@@ -1,7 +1,7 @@
"""The Homee select platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
@@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -27,19 +28,28 @@ SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = {
}
async def add_select_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee select entities."""
async_add_entities(
HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the select component."""
"""Add the homee platform for the select component."""
async_add_entities(
HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
)
await setup_homee_platform(add_select_entities, async_add_entities, config_entry)
class HomeeSelect(HomeeEntity, SelectEntity):

View File

@@ -35,7 +35,7 @@ from .const import (
WINDOW_MAP_REVERSED,
)
from .entity import HomeeEntity, HomeeNodeEntity
from .helpers import get_name_for_enum
from .helpers import get_name_for_enum, setup_homee_platform
PARALLEL_UPDATES = 0
@@ -304,16 +304,16 @@ def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
ent_reg = er.async_get(hass)
devices: list[HomeeSensor | HomeeNodeSensor] = []
def add_deprecated_entity(
attribute: HomeeAttribute, description: HomeeSensorEntityDescription
) -> None:
) -> list[HomeeSensor]:
"""Add deprecated entities."""
deprecated_entities: list[HomeeSensor] = []
entity_uid = f"{config_entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, entity_uid):
entity_entry = ent_reg.async_get(entity_id)
@@ -325,7 +325,9 @@ async def async_setup_entry(
f"deprecated_entity_{entity_uid}",
)
elif entity_entry:
devices.append(HomeeSensor(attribute, config_entry, description))
deprecated_entities.append(
HomeeSensor(attribute, config_entry, description)
)
if entity_used_in(hass, entity_id):
async_create_issue(
hass,
@@ -342,27 +344,42 @@ async def async_setup_entry(
"entity": entity_id,
},
)
return deprecated_entities
for node in config_entry.runtime_data.nodes:
# Node properties that are sensors.
devices.extend(
HomeeNodeSensor(node, config_entry, description)
for description in NODE_SENSOR_DESCRIPTIONS
)
async def add_sensor_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee sensor entities."""
entities: list[HomeeSensor | HomeeNodeSensor] = []
# Node attributes that are sensors.
for attribute in node.attributes:
if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
add_deprecated_entity(attribute, SENSOR_DESCRIPTIONS[attribute.type])
elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
devices.append(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
for node in nodes:
# Node properties that are sensors.
entities.extend(
HomeeNodeSensor(node, config_entry, description)
for description in NODE_SENSOR_DESCRIPTIONS
)
# Node attributes that are sensors.
for attribute in node.attributes:
if attribute.type == AttributeType.CURRENT_VALVE_POSITION:
entities.extend(
add_deprecated_entity(
attribute, SENSOR_DESCRIPTIONS[attribute.type]
)
)
elif attribute.type in SENSOR_DESCRIPTIONS and not attribute.editable:
entities.append(
HomeeSensor(
attribute, config_entry, SENSOR_DESCRIPTIONS[attribute.type]
)
)
)
if devices:
async_add_devices(devices)
if entities:
async_add_entities(entities)
await setup_homee_platform(add_sensor_entities, async_add_entities, config_entry)
class HomeeSensor(HomeeEntity, SensorEntity):

View File

@@ -3,6 +3,7 @@
from typing import Any
from pyHomee.const import AttributeType
from pyHomee.model import HomeeNode
from homeassistant.components.siren import SirenEntity, SirenEntityFeature
from homeassistant.core import HomeAssistant
@@ -10,23 +11,33 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
async def add_siren_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee siren entities."""
async_add_entities(
HomeeSiren(attribute, config_entry)
for node in nodes
for attribute in node.attributes
if attribute.type == AttributeType.SIREN
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add siren entities for homee."""
async_add_devices(
HomeeSiren(attribute, config_entry)
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type == AttributeType.SIREN
)
await setup_homee_platform(add_siren_entities, async_add_entities, config_entry)
class HomeeSiren(HomeeEntity, SirenEntity):

View File

@@ -5,7 +5,7 @@ from dataclasses import dataclass
from typing import Any
from pyHomee.const import AttributeType, NodeProfile
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -19,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import CLIMATE_PROFILES, LIGHT_PROFILES
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -65,27 +66,35 @@ SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = {
}
async def add_switch_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee switch entities."""
async_add_entities(
HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
and not (
attribute.type == AttributeType.ON_OFF and node.profile in LIGHT_PROFILES
)
and not (
attribute.type == AttributeType.MANUAL_OPERATION
and node.profile in CLIMATE_PROFILES
)
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_devices: AddConfigEntryEntitiesCallback,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform for the Homee component."""
for node in config_entry.runtime_data.nodes:
async_add_devices(
HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
for attribute in node.attributes
if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
and not (
attribute.type == AttributeType.ON_OFF
and node.profile in LIGHT_PROFILES
)
and not (
attribute.type == AttributeType.MANUAL_OPERATION
and node.profile in CLIMATE_PROFILES
)
)
await setup_homee_platform(add_switch_entities, async_add_entities, config_entry)
class HomeeSwitch(HomeeEntity, SwitchEntity):

View File

@@ -1,7 +1,7 @@
"""The Homee valve platform."""
from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute
from pyHomee.model import HomeeAttribute, HomeeNode
from homeassistant.components.valve import (
ValveDeviceClass,
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeEntity
from .helpers import setup_homee_platform
PARALLEL_UPDATES = 0
@@ -25,19 +26,28 @@ VALVE_DESCRIPTIONS = {
}
async def add_valve_entities(
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
nodes: list[HomeeNode],
) -> None:
"""Add homee valve entities."""
async_add_entities(
HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
for node in nodes
for attribute in node.attributes
if attribute.type in VALVE_DESCRIPTIONS
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the Homee platform for the valve component."""
"""Add the homee platform for the valve component."""
async_add_entities(
HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
for node in config_entry.runtime_data.nodes
for attribute in node.attributes
if attribute.type in VALVE_DESCRIPTIONS
)
await setup_homee_platform(add_valve_entities, async_add_entities, config_entry)
class HomeeValve(HomeeEntity, ValveEntity):

View File

@@ -20,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
if entry.runtime_data.data.device.supports_state():
if entry.runtime_data.data.device.supports_led_brightness():
async_add_entities([HWEnergyNumberEntity(entry.runtime_data)])

View File

@@ -36,12 +36,13 @@ async def async_setup_entry(
"""Set up Automower message event entities.
Entities are created dynamically based on messages received from the API,
but only for mowers that support message events.
but only for mowers that support message events after the WebSocket connection
is ready.
"""
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
restored_mowers = {
restored_mowers: set[str] = {
entry.unique_id.removesuffix("_message")
for entry in er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
@@ -49,14 +50,20 @@ async def async_setup_entry(
if entry.domain == EVENT_DOMAIN
}
async_add_entities(
AutomowerMessageEventEntity(mower_id, coordinator)
for mower_id in restored_mowers
if mower_id in coordinator.data
)
@callback
def _on_ws_ready() -> None:
async_add_entities(
AutomowerMessageEventEntity(mower_id, coordinator, websocket_alive=True)
for mower_id in restored_mowers
if mower_id in coordinator.data
)
coordinator.api.unregister_ws_ready_callback(_on_ws_ready)
coordinator.api.register_ws_ready_callback(_on_ws_ready)
@callback
def _handle_message(msg: SingleMessageData) -> None:
"""Add entity dynamically if a new mower sends messages."""
if msg.id in restored_mowers:
return
@@ -78,11 +85,17 @@ class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity):
self,
mower_id: str,
coordinator: AutomowerDataUpdateCoordinator,
*,
websocket_alive: bool | None = None,
) -> None:
"""Initialize Automower message event entity."""
super().__init__(mower_id, coordinator)
self._attr_unique_id = f"{mower_id}_message"
self.websocket_alive: bool = coordinator.websocket_alive
self.websocket_alive: bool = (
websocket_alive
if websocket_alive is not None
else coordinator.websocket_alive
)
@property
def available(self) -> bool:

View File

@@ -52,8 +52,10 @@ async def async_get_config_entry_diagnostics(
try:
CONFIG_SCHEMA(raw_config)
except vol.Invalid as ex:
diag["configuration_error"] = str(ex)
diag["yaml_configuration_error"] = str(ex)
else:
diag["configuration_error"] = None
diag["yaml_configuration_error"] = None
diag["config_store"] = knx_module.config_store.data
return diag

View File

@@ -285,13 +285,19 @@ def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight
group_address_switch_green_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_GREEN_SWITCH
),
group_address_brightness_green=conf.get_write(CONF_GA_GREEN_BRIGHTNESS),
group_address_brightness_green=conf.get_write(
CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
),
group_address_brightness_green_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_GREEN_BRIGHTNESS
),
group_address_switch_blue=conf.get_write(CONF_GA_BLUE_SWITCH),
group_address_switch_blue_state=conf.get_state_and_passive(CONF_GA_BLUE_SWITCH),
group_address_brightness_blue=conf.get_write(CONF_GA_BLUE_BRIGHTNESS),
group_address_switch_blue=conf.get_write(CONF_COLOR, CONF_GA_BLUE_SWITCH),
group_address_switch_blue_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_BLUE_SWITCH
),
group_address_brightness_blue=conf.get_write(
CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
),
group_address_brightness_blue_state=conf.get_state_and_passive(
CONF_COLOR, CONF_GA_BLUE_BRIGHTNESS
),

View File

@@ -240,19 +240,19 @@ LIGHT_KNX_SCHEMA = AllSerializeFirst(
write_required=True, valid_dpt="5.001"
),
"section_blue": KNXSectionFlat(),
vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
write_required=True, valid_dpt="5.001"
),
vol.Optional(CONF_GA_BLUE_SWITCH): GASelector(
write_required=False, valid_dpt="1"
),
"section_white": KNXSectionFlat(),
vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
vol.Required(CONF_GA_BLUE_BRIGHTNESS): GASelector(
write_required=True, valid_dpt="5.001"
),
"section_white": KNXSectionFlat(),
vol.Optional(CONF_GA_WHITE_SWITCH): GASelector(
write_required=False, valid_dpt="1"
),
vol.Optional(CONF_GA_WHITE_BRIGHTNESS): GASelector(
write_required=True, valid_dpt="5.001"
),
},
),
GroupSelectOption(

View File

@@ -36,6 +36,9 @@
}
},
"sensor": {
"food_dispensed_today": {
"default": "mdi:counter"
},
"hopper_status": {
"default": "mdi:filter",
"state": {

View File

@@ -163,6 +163,17 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
),
],
FeederRobot: [
RobotSensorEntityDescription[FeederRobot](
key="food_dispensed_today",
translation_key="food_dispensed_today",
state_class=SensorStateClass.TOTAL,
last_reset_fn=dt_util.start_of_local_day,
value_fn=(
lambda robot: (
robot.get_food_dispensed_since(dt_util.start_of_local_day())
)
),
),
RobotSensorEntityDescription[FeederRobot](
key="food_level",
translation_key="food_level",
@@ -181,6 +192,12 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = {
)
),
),
RobotSensorEntityDescription[FeederRobot](
key="next_feeding",
translation_key="next_feeding",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda robot: robot.next_feeding,
),
],
}

View File

@@ -59,6 +59,10 @@
}
},
"sensor": {
"food_dispensed_today": {
"name": "Food dispensed today",
"unit_of_measurement": "cups"
},
"food_level": {
"name": "Food level"
},
@@ -82,6 +86,9 @@
"litter_level": {
"name": "Litter level"
},
"next_feeding": {
"name": "Next feeding"
},
"pet_weight": {
"name": "Pet weight"
},

View File

@@ -83,7 +83,9 @@ class MetWeatherData:
self.current_weather_data = self._weather_data.get_current_weather()
time_zone = dt_util.get_default_time_zone()
self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0)
self.hourly_forecast = self._weather_data.get_forecast(time_zone, True)
self.hourly_forecast = self._weather_data.get_forecast(
time_zone, True, range_stop=49
)
return self

View File

@@ -270,6 +270,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
@@ -307,6 +308,7 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.WATER,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfVolume.LITERS,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
),
),
@@ -618,6 +620,8 @@ async def async_setup_entry(
"state_elapsed_time": MieleTimeSensor,
"state_remaining_time": MieleTimeSensor,
"state_start_time": MieleTimeSensor,
"current_energy_consumption": MieleConsumptionSensor,
"current_water_consumption": MieleConsumptionSensor,
}.get(definition.description.key, MieleSensor)
def _is_entity_registered(unique_id: str) -> bool:
@@ -924,3 +928,58 @@ class MieleTimeSensor(MieleRestorableSensor):
# otherwise, cache value and return it
else:
self._last_value = current_value
class MieleConsumptionSensor(MieleRestorableSensor):
"""Representation of consumption sensors keeping state from cache."""
_is_reporting: bool = False
def _update_last_value(self) -> None:
"""Update the last value of the sensor."""
current_value = self.entity_description.value_fn(self.device)
current_status = StateStatus(self.device.state_status)
last_value = (
float(cast(str, self._last_value))
if self._last_value is not None and self._last_value != STATE_UNKNOWN
else 0
)
# force unknown when appliance is not able to report consumption
if current_status in (
StateStatus.ON,
StateStatus.OFF,
StateStatus.PROGRAMMED,
StateStatus.WAITING_TO_START,
StateStatus.IDLE,
StateStatus.SERVICE,
):
self._is_reporting = False
self._last_value = None
# appliance might report the last value for consumption of previous cycle and it will report 0
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
# we already saw a valid value in this cycle from cache
elif (
current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
and not self._is_reporting
and last_value > 0
):
self._last_value = current_value
self._is_reporting = True
elif (
current_status in (StateStatus.IN_USE, StateStatus.PAUSE)
and not self._is_reporting
and current_value is not None
and cast(int, current_value) > 0
):
self._last_value = 0
# keep value when program ends
elif current_status == StateStatus.PROGRAM_ENDED:
pass
else:
self._last_value = current_value
self._is_reporting = True

View File

@@ -1445,7 +1445,7 @@ class MqttEntity(
},
translation_key="deprecated_object_id",
)
else:
elif CONF_DEFAULT_ENTITY_ID not in self._config:
if CONF_ORIGIN in self._config:
origin_name = self._config[CONF_ORIGIN][CONF_NAME]
url = self._config[CONF_ORIGIN].get(CONF_URL)

View File

@@ -43,6 +43,8 @@ LOG_NAME = "Tag"
TAG = "tag"
PARALLEL_UPDATES = 0
DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,

View File

@@ -146,20 +146,20 @@ class NtfyEventEntity(NtfyBaseEntity, EventEntity):
)
self._attr_available = False
finally:
if self._ws is None or self._ws.done():
self._ws = self.config_entry.async_create_background_task(
self.hass,
target=self.ntfy.subscribe(
topics=[self.topic],
callback=self._async_handle_event,
title=self.subentry.data.get(CONF_TITLE),
message=self.subentry.data.get(CONF_MESSAGE),
priority=self.subentry.data.get(CONF_PRIORITY),
tags=self.subentry.data.get(CONF_TAGS),
),
name="ntfy_websocket",
)
self.async_write_ha_state()
if self._ws is None or self._ws.done():
self._ws = self.config_entry.async_create_background_task(
self.hass,
target=self.ntfy.subscribe(
topics=[self.topic],
callback=self._async_handle_event,
title=self.subentry.data.get(CONF_TITLE),
message=self.subentry.data.get(CONF_MESSAGE),
priority=self.subentry.data.get(CONF_PRIORITY),
tags=self.subentry.data.get(CONF_TAGS),
),
name="ntfy_websocket",
)
await asyncio.sleep(RECONNECT_INTERVAL)
@property

View File

@@ -35,7 +35,6 @@ from .const import ( # noqa: F401
ATTR_MAX,
ATTR_MIN,
ATTR_STEP,
ATTR_STEP_VALIDATION,
ATTR_VALUE,
DEFAULT_MAX_VALUE,
DEFAULT_MIN_VALUE,
@@ -184,7 +183,7 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of a Number entity."""
_entity_component_unrecorded_attributes = frozenset(
{ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_STEP_VALIDATION, ATTR_MODE}
{ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_MODE}
)
entity_description: NumberEntityDescription

View File

@@ -57,7 +57,6 @@ ATTR_VALUE = "value"
ATTR_MIN = "min"
ATTR_MAX = "max"
ATTR_STEP = "step"
ATTR_STEP_VALIDATION = "step_validation"
DEFAULT_MIN_VALUE = 0.0
DEFAULT_MAX_VALUE = 100.0
@@ -328,6 +327,7 @@ class NumberDeviceClass(StrEnum):
- `Pa`, `hPa`, `kPa`
- `inHg`
- `psi`
- `inH₂O`
"""
REACTIVE_ENERGY = "reactive_energy"

View File

@@ -181,7 +181,7 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[OWMUpdateCoordinator]
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_WIND_BEARING)
@property
def visibility(self) -> float | str | None:
def native_visibility(self) -> float | None:
"""Return visibility."""
return self.coordinator.data[ATTR_API_CURRENT].get(ATTR_API_VISIBILITY_DISTANCE)

View File

@@ -50,7 +50,7 @@
"protocol": "Protocol"
},
"data_description": {
"protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (h265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera."
"protocol": "Streaming protocol to use for the camera entities. RTSP supports 4K streams (H.265 encoding) while RTMP and FLV do not. FLV is the least demanding on the camera."
}
}
}

View File

@@ -361,6 +361,7 @@ class SensorDeviceClass(StrEnum):
- `Pa`, `hPa`, `kPa`
- `inHg`
- `psi`
- `inH₂O`
"""
REACTIVE_ENERGY = "reactive_energy"

View File

@@ -97,6 +97,7 @@ PLATFORMS_BY_TYPE = {
SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -127,6 +128,7 @@ CLASS_BY_DEVICE = {
SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3,
SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight,
SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight,
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
}

View File

@@ -11,12 +11,15 @@ from switchbot import (
SwitchbotApiError,
SwitchbotAuthenticationError,
SwitchbotModel,
fetch_cloud_devices,
parse_advertisement_data,
)
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
async_current_scanners,
async_discovered_service_info,
)
from homeassistant.config_entries import (
@@ -87,6 +90,8 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovered_adv: SwitchBotAdvertisement | None = None
self._discovered_advs: dict[str, SwitchBotAdvertisement] = {}
self._cloud_username: str | None = None
self._cloud_password: str | None = None
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@@ -176,9 +181,17 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the SwitchBot API auth step."""
errors = {}
errors: dict[str, str] = {}
assert self._discovered_adv is not None
description_placeholders = {}
description_placeholders: dict[str, str] = {}
# If we have saved credentials from cloud login, try them first
if user_input is None and self._cloud_username and self._cloud_password:
user_input = {
CONF_USERNAME: self._cloud_username,
CONF_PASSWORD: self._cloud_password,
}
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
cls = ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS[model]
@@ -200,6 +213,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
# Clear saved credentials if auth failed
self._cloud_username = None
self._cloud_password = None
else:
return await self.async_step_encrypted_key(key_details)
@@ -239,7 +255,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the encryption key step."""
errors = {}
errors: dict[str, str] = {}
assert self._discovered_adv is not None
if user_input is not None:
model: SwitchbotModel = self._discovered_adv.data["modelName"]
@@ -308,7 +324,73 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
"""Handle the user step to choose cloud login or direct discovery."""
# Check if all scanners are in active mode
# If so, skip the menu and go directly to device selection
scanners = async_current_scanners(self.hass)
if scanners and all(
scanner.current_mode == BluetoothScanningMode.ACTIVE for scanner in scanners
):
# All scanners are active, skip the menu
return await self.async_step_select_device()
return self.async_show_menu(
step_id="user",
menu_options=["cloud_login", "select_device"],
)
async def async_step_cloud_login(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the cloud login step."""
errors: dict[str, str] = {}
description_placeholders: dict[str, str] = {}
if user_input is not None:
try:
await fetch_cloud_devices(
async_get_clientsession(self.hass),
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex:
_LOGGER.debug(
"Failed to connect to SwitchBot API: %s", ex, exc_info=True
)
raise AbortFlow(
"api_error", description_placeholders={"error_detail": str(ex)}
) from ex
except SwitchbotAuthenticationError as ex:
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}
description_placeholders = {"error_detail": str(ex)}
else:
# Save credentials temporarily for the duration of this flow
# to avoid re-prompting if encrypted device auth is needed
# These will be discarded when the flow completes
self._cloud_username = user_input[CONF_USERNAME]
self._cloud_password = user_input[CONF_PASSWORD]
return await self.async_step_select_device()
user_input = user_input or {}
return self.async_show_form(
step_id="cloud_login",
errors=errors,
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
): str,
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders=description_placeholders,
)
async def async_step_select_device(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the step to pick discovered device."""
errors: dict[str, str] = {}
device_adv: SwitchBotAdvertisement | None = None
if user_input is not None:
@@ -333,7 +415,7 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_confirm()
return self.async_show_form(
step_id="user",
step_id="select_device",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(

View File

@@ -53,6 +53,7 @@ class SupportedModels(StrEnum):
STRIP_LIGHT_3 = "strip_light_3"
RGBICWW_STRIP_LIGHT = "rgbicww_strip_light"
RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp"
PLUG_MINI_EU = "plug_mini_eu"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -85,6 +86,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3,
SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT,
SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP,
SwitchbotModel.PLUG_MINI_EU: SupportedModels.PLUG_MINI_EU,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -118,6 +120,7 @@ ENCRYPTED_MODELS = {
SwitchbotModel.STRIP_LIGHT_3,
SwitchbotModel.RGBICWW_STRIP_LIGHT,
SwitchbotModel.RGBICWW_FLOOR_LAMP,
SwitchbotModel.PLUG_MINI_EU,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -136,6 +139,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3,
SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight,
SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight,
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {

View File

@@ -3,6 +3,24 @@
"flow_title": "{name} ({address})",
"step": {
"user": {
"description": "One or more of your Bluetooth adapters is using passive scanning, which may not discover all SwitchBot devices. Would you like to sign in to your SwitchBot account to download device information and automate discovery? If you're not sure, we recommend signing in.",
"menu_options": {
"cloud_login": "Sign in to SwitchBot account",
"select_device": "Continue without signing in"
}
},
"cloud_login": {
"description": "Please provide your SwitchBot app username and password. This data won't be saved and is only used to retrieve device model information to automate discovery. Usernames and passwords are case-sensitive.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::switchbot::config::step::encrypted_auth::data_description::username%]",
"password": "[%key:component::switchbot::config::step::encrypted_auth::data_description::password%]"
}
},
"select_device": {
"data": {
"address": "MAC address"
},

View File

@@ -31,6 +31,7 @@ PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.LOCK,
Platform.SENSOR,
@@ -57,6 +58,7 @@ class SwitchbotDevices:
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
lights: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
humidifiers: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@dataclass
@@ -255,6 +257,19 @@ async def make_device_data(
)
devices_data.lights.append((device, coordinator))
if isinstance(device, Device) and device.device_type == "Humidifier2":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.humidifiers.append((device, coordinator))
if isinstance(device, Device) and device.device_type == "Humidifier":
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.humidifiers.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry."""

View File

@@ -20,6 +20,12 @@ VACUUM_FAN_SPEED_MAX = "max"
AFTER_COMMAND_REFRESH = 5
COVER_ENTITY_AFTER_COMMAND_REFRESH = 10
HUMIDITY_LEVELS = {
34: 101, # Low humidity mode
67: 102, # Medium humidity mode
100: 103, # High humidity mode
}
class AirPurifierMode(Enum):
"""Air Purifier Modes."""
@@ -33,3 +39,21 @@ class AirPurifierMode(Enum):
def get_modes(cls) -> list[str]:
"""Return a list of available air purifier modes as lowercase strings."""
return [mode.name.lower() for mode in cls]
class Humidifier2Mode(Enum):
"""Enumerates the available modes for a SwitchBot humidifier2."""
HIGH = 1
MEDIUM = 2
LOW = 3
QUIET = 4
TARGET_HUMIDITY = 5
SLEEP = 6
AUTO = 7
DRYING_FILTER = 8
@classmethod
def get_modes(cls) -> list[str]:
"""Return a list of available humidifier2 modes as lowercase strings."""
return [mode.name.lower() for mode in cls]

View File

@@ -0,0 +1,155 @@
"""Support for Switchbot humidifier."""
import asyncio
from typing import Any
from switchbot_api import CommonCommands, HumidifierCommands, HumidifierV2Commands
from homeassistant.components.humidifier import (
MODE_AUTO,
MODE_NORMAL,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import AFTER_COMMAND_REFRESH, DOMAIN, HUMIDITY_LEVELS, Humidifier2Mode
from .entity import SwitchBotCloudEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot based on a config entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SwitchBotHumidifier(data.api, device, coordinator)
if device.device_type == "Humidifier"
else SwitchBotEvaporativeHumidifier(data.api, device, coordinator)
for device, coordinator in data.devices.humidifiers
)
class SwitchBotHumidifier(SwitchBotCloudEntity, HumidifierEntity):
"""Representation of a Switchbot humidifier."""
_attr_supported_features = HumidifierEntityFeature.MODES
_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
_attr_available_modes = [MODE_NORMAL, MODE_AUTO]
_attr_min_humidity = 1
_attr_translation_key = "humidifier"
_attr_name = None
_attr_target_humidity = 50
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
self._attr_is_on = coord_data.get("power") == STATE_ON
self._attr_mode = MODE_AUTO if coord_data.get("auto") else MODE_NORMAL
self._attr_current_humidity = coord_data.get("humidity")
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
self.target_humidity, parameters = self._map_humidity_to_supported_level(
humidity
)
await self.send_api_command(
HumidifierCommands.SET_MODE, parameters=str(parameters)
)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_mode(self, mode: str) -> None:
"""Set new target humidity."""
if mode == MODE_AUTO:
await self.send_api_command(HumidifierCommands.SET_MODE, parameters=mode)
else:
await self.send_api_command(
HumidifierCommands.SET_MODE, parameters=str(102)
)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
def _map_humidity_to_supported_level(self, humidity: int) -> tuple[int, int]:
"""Map any humidity to the closest supported level and its parameter."""
if humidity <= 34:
return 34, HUMIDITY_LEVELS[34]
if humidity <= 67:
return 67, HUMIDITY_LEVELS[67]
return 100, HUMIDITY_LEVELS[100]
class SwitchBotEvaporativeHumidifier(SwitchBotCloudEntity, HumidifierEntity):
"""Representation of a Switchbot humidifier v2."""
_attr_supported_features = HumidifierEntityFeature.MODES
_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
_attr_available_modes = Humidifier2Mode.get_modes()
_attr_translation_key = "evaporative_humidifier"
_attr_name = None
_attr_target_humidity = 50
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if coord_data := self.coordinator.data:
self._attr_is_on = coord_data.get("power") == STATE_ON
self._attr_mode = (
Humidifier2Mode(coord_data.get("mode")).name.lower()
if coord_data.get("mode") is not None
else None
)
self._attr_current_humidity = (
coord_data.get("humidity")
if coord_data.get("humidity") != 127
else None
)
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
assert self.coordinator.data is not None
self._attr_target_humidity = humidity
params = {"mode": self.coordinator.data["mode"], "humidity": humidity}
await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_set_mode(self, mode: str) -> None:
"""Set new target mode."""
assert self.coordinator.data is not None
params = {"mode": Humidifier2Mode[mode.upper()].value}
await self.send_api_command(HumidifierV2Commands.SET_MODE, parameters=params)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
await self.send_api_command(CommonCommands.ON)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(AFTER_COMMAND_REFRESH)
await self.coordinator.async_request_refresh()

View File

@@ -34,6 +34,22 @@
"10": "mdi:brightness-7"
}
}
},
"humidifier": {
"evaporative_humidifier": {
"state_attributes": {
"mode": {
"state": {
"high": "mdi:water-plus",
"medium": "mdi:water",
"low": "mdi:water-outline",
"quiet": "mdi:volume-off",
"target_humidity": "mdi:target",
"drying_filter": "mdi:water-remove"
}
}
}
}
}
}
}

View File

@@ -160,6 +160,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Motion Sensor": (BATTERY_DESCRIPTION,),
"Contact Sensor": (BATTERY_DESCRIPTION,),
"Water Detector": (BATTERY_DESCRIPTION,),
"Humidifier": (TEMPERATURE_DESCRIPTION,),
}

View File

@@ -36,6 +36,22 @@
"light_level": {
"name": "Light level"
}
},
"humidifier": {
"evaporative_humidifier": {
"state_attributes": {
"mode": {
"state": {
"high": "[%key:common::state::high%]",
"medium": "[%key:common::state::medium%]",
"low": "[%key:common::state::low%]",
"quiet": "Quiet",
"target_humidity": "Target humidity",
"drying_filter": "Drying filter"
}
}
}
}
}
}
}

View File

@@ -752,6 +752,9 @@
},
"vehicle_state_valet_mode": {
"default": "mdi:speedometer-slow"
},
"guest_mode_enabled": {
"default": "mdi:account-group"
}
}
},

View File

@@ -1084,6 +1084,9 @@
},
"vehicle_state_valet_mode": {
"name": "Valet mode"
},
"guest_mode_enabled": {
"name": "Guest mode"
}
},
"update": {

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from itertools import chain
from typing import Any
from tesla_fleet_api.const import AutoSeat, Scope
@@ -38,6 +37,7 @@ PARALLEL_UPDATES = 0
class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
"""Describes Teslemetry Switch entity."""
polling: bool = False
on_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
off_func: Callable[[Vehicle], Awaitable[dict[str, Any]]]
scopes: list[Scope]
@@ -53,6 +53,7 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
TeslemetrySwitchEntityDescription(
key="vehicle_state_sentry_mode",
polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_SentryMode(
lambda value: callback(None if value is None else value != "Off")
),
@@ -62,6 +63,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="vehicle_state_valet_mode",
polling=True,
streaming_listener=lambda vehicle, value: vehicle.listen_ValetModeEnabled(
value
),
@@ -72,6 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_left",
polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_AutoSeatClimateLeft(
callback
),
@@ -85,6 +88,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_right",
polling=True,
streaming_listener=lambda vehicle,
callback: vehicle.listen_AutoSeatClimateRight(callback),
on_func=lambda api: api.remote_auto_seat_climate_request(
@@ -97,6 +101,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_steering_wheel_heat",
polling=True,
streaming_listener=lambda vehicle,
callback: vehicle.listen_HvacSteeringWheelHeatAuto(callback),
on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request(
@@ -109,6 +114,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_defrost_mode",
polling=True,
streaming_listener=lambda vehicle, callback: vehicle.listen_DefrostMode(
lambda value: callback(None if value is None else value != "Off")
),
@@ -120,6 +126,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="charge_state_charging_state",
polling=True,
unique_id="charge_state_user_charge_enable_request",
value_func=lambda state: state in {"Starting", "Charging"},
streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState(
@@ -131,6 +138,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
off_func=lambda api: api.charge_stop(),
scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS],
),
TeslemetrySwitchEntityDescription(
key="guest_mode_enabled",
polling=False,
unique_id="guest_mode_enabled",
streaming_listener=lambda vehicle, callback: vehicle.listen_GuestModeEnabled(
callback
),
on_func=lambda api: api.guest_mode(True),
off_func=lambda api: api.guest_mode(False),
scopes=[Scope.VEHICLE_CMDS],
),
)
@@ -141,35 +159,40 @@ async def async_setup_entry(
) -> None:
"""Set up the Teslemetry Switch platform from a config entry."""
async_add_entities(
chain(
(
TeslemetryVehiclePollingVehicleSwitchEntity(
vehicle, description, entry.runtime_data.scopes
entities: list[SwitchEntity] = []
for vehicle in entry.runtime_data.vehicles:
for description in VEHICLE_DESCRIPTIONS:
if vehicle.poll or vehicle.firmware < description.streaming_firmware:
if description.polling:
entities.append(
TeslemetryVehiclePollingVehicleSwitchEntity(
vehicle, description, entry.runtime_data.scopes
)
)
else:
entities.append(
TeslemetryStreamingVehicleSwitchEntity(
vehicle, description, entry.runtime_data.scopes
)
)
if vehicle.poll or vehicle.firmware < description.streaming_firmware
else TeslemetryStreamingVehicleSwitchEntity(
vehicle, description, entry.runtime_data.scopes
)
for vehicle in entry.runtime_data.vehicles
for description in VEHICLE_DESCRIPTIONS
),
(
TeslemetryChargeFromGridSwitchEntity(
energysite,
entry.runtime_data.scopes,
)
for energysite in entry.runtime_data.energysites
if energysite.info_coordinator.data.get("components_battery")
and energysite.info_coordinator.data.get("components_solar")
),
(
TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes)
for energysite in entry.runtime_data.energysites
if energysite.info_coordinator.data.get("components_storm_mode_capable")
),
entities.extend(
TeslemetryChargeFromGridSwitchEntity(
energysite,
entry.runtime_data.scopes,
)
for energysite in entry.runtime_data.energysites
if energysite.info_coordinator.data.get("components_battery")
and energysite.info_coordinator.data.get("components_solar")
)
entities.extend(
TeslemetryStormModeSwitchEntity(energysite, entry.runtime_data.scopes)
for energysite in entry.runtime_data.energysites
if energysite.info_coordinator.data.get("components_storm_mode_capable")
)
async_add_entities(entities)
class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity):

View File

@@ -75,6 +75,7 @@ class Platform(StrEnum):
SWITCH = "switch"
TEXT = "text"
TIME = "time"
TAG = "tag"
TODO = "todo"
TTS = "tts"
UPDATE = "update"
@@ -749,6 +750,7 @@ class UnitOfPressure(StrEnum):
MBAR = "mbar"
MMHG = "mmHg"
INHG = "inHg"
INH2O = "inH₂O"
PSI = "psi"

View File

@@ -35,7 +35,7 @@ fnv-hash-fast==1.5.0
go2rtc-client==0.2.1
ha-ffmpeg==3.2.2
habluetooth==5.6.2
hass-nabucasa==1.1.0
hass-nabucasa==1.1.1
hassil==3.2.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250903.3

View File

@@ -82,6 +82,7 @@ _STONE_TO_G = _POUND_TO_G * 14 # 14 pounds to a stone
# Pressure conversion constants
_STANDARD_GRAVITY = 9.80665
_MERCURY_DENSITY = 13.5951
_INH2O_TO_PA = 249.0889083333348 # 1 inH₂O = 249.0889083333348 Pa at 4°C
# Volume conversion constants
_L_TO_CUBIC_METER = 0.001 # 1 L = 0.001 m³
@@ -435,6 +436,7 @@ class PressureConverter(BaseUnitConverter):
UnitOfPressure.MBAR: 1 / 100,
UnitOfPressure.INHG: 1
/ (_IN_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
UnitOfPressure.INH2O: 1 / _INH2O_TO_PA,
UnitOfPressure.PSI: 1 / 6894.757,
UnitOfPressure.MMHG: 1
/ (_MM_TO_M * 1000 * _STANDARD_GRAVITY * _MERCURY_DENSITY),
@@ -447,6 +449,7 @@ class PressureConverter(BaseUnitConverter):
UnitOfPressure.CBAR,
UnitOfPressure.MBAR,
UnitOfPressure.INHG,
UnitOfPressure.INH2O,
UnitOfPressure.PSI,
UnitOfPressure.MMHG,
}

View File

@@ -296,6 +296,7 @@ METRIC_SYSTEM = UnitSystem(
# Convert non-metric pressure
("pressure", UnitOfPressure.PSI): UnitOfPressure.KPA,
("pressure", UnitOfPressure.INHG): UnitOfPressure.HPA,
("pressure", UnitOfPressure.INH2O): UnitOfPressure.KPA,
# Convert non-metric speeds except knots to km/h
("speed", UnitOfSpeed.FEET_PER_SECOND): UnitOfSpeed.KILOMETERS_PER_HOUR,
("speed", UnitOfSpeed.INCHES_PER_SECOND): UnitOfSpeed.MILLIMETERS_PER_SECOND,
@@ -379,6 +380,7 @@ US_CUSTOMARY_SYSTEM = UnitSystem(
("pressure", UnitOfPressure.HPA): UnitOfPressure.PSI,
("pressure", UnitOfPressure.KPA): UnitOfPressure.PSI,
("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG,
("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI,
# Convert non-USCS speeds, except knots, to mph
("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR,
("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND,

View File

@@ -47,7 +47,7 @@ dependencies = [
"fnv-hash-fast==1.5.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==1.1.0",
"hass-nabucasa==1.1.1",
# When bumping httpx, please check the version pins of
# httpcore, anyio, and h11 in gen_requirements_all
"httpx==0.28.1",

2
requirements.txt generated
View File

@@ -22,7 +22,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.6
fnv-hash-fast==1.5.0
hass-nabucasa==1.1.0
hass-nabucasa==1.1.1
httpx==0.28.1
home-assistant-bluetooth==1.13.1
ifaddr==0.2.0

2
requirements_all.txt generated
View File

@@ -1140,7 +1140,7 @@ habiticalib==0.4.5
habluetooth==5.6.2
# homeassistant.components.cloud
hass-nabucasa==1.1.0
hass-nabucasa==1.1.1
# homeassistant.components.splunk
hass-splunk==0.1.1

View File

@@ -8,7 +8,7 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
astroid==3.3.11
coverage==7.10.0
coverage==7.10.6
freezegun==1.5.2
go2rtc-client==0.2.1
license-expression==30.4.3
@@ -21,7 +21,7 @@ pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1
pytest-asyncio==1.1.0
pytest-aiohttp==1.1.0
pytest-cov==6.2.1
pytest-cov==7.0.0
pytest-freezer==0.4.9
pytest-github-actions-annotate-failures==0.3.0
pytest-socket==0.7.0

View File

@@ -1001,7 +1001,7 @@ habiticalib==0.4.5
habluetooth==5.6.2
# homeassistant.components.cloud
hass-nabucasa==1.1.0
hass-nabucasa==1.1.1
# homeassistant.components.assist_satellite
# homeassistant.components.conversation

View File

@@ -342,6 +342,15 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
"manifest", "Domain collides with built-in core integration"
)
if (
integration.manifest.get("integration_type") == "entity"
and integration.domain not in Platform
):
integration.add_error(
"manifest",
"Integration should be added to Platform constant in homeassistant/const.py",
)
if domain in NO_IOT_CLASS and "iot_class" in integration.manifest:
integration.add_error("manifest", "Domain should not have an IoT Class")

View File

@@ -286,7 +286,7 @@ async def test_generate_image(
assert "image_data" not in result
assert result["media_source_id"].startswith("media-source://ai_task/images/")
assert result["media_source_id"].endswith("_test_task.png")
assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/")
assert result["url"].startswith("/api/ai_task/images/")
assert result["url"].count("_test_task.png?authSig=") == 1
assert result["mime_type"] == "image/png"
assert result["model"] == "mock_model"

View File

@@ -37,5 +37,7 @@
'region': 'XEU',
'serial': 'serial_number',
}),
'remote_command_list': list([
]),
})
# ---

View File

@@ -69,6 +69,7 @@ async def test_entry_diagnostics(
patch("pybravia.BraviaClient.get_playing_info", return_value={}),
patch("pybravia.BraviaClient.get_app_list", return_value=[]),
patch("pybravia.BraviaClient.get_content_list_all", return_value=[]),
patch("pybravia.BraviaClient.get_command_list", return_value=[]),
):
assert await async_setup_component(hass, DOMAIN, {})
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)

View File

@@ -7,8 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, patch
from brother import BrotherSensors
import pytest
from homeassistant.components.brother.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.components.brother.const import (
CONF_COMMUNITY,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from tests.common import MockConfigEntry
@@ -122,5 +126,10 @@ def mock_config_entry() -> MockConfigEntry:
domain=DOMAIN,
title="HL-L2340DW 0123456789",
unique_id="0123456789",
data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
data={
CONF_HOST: "localhost",
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
minor_version=2,
)

View File

@@ -66,6 +66,10 @@
}),
'firmware': '1.2.3',
'info': dict({
'advanced_settings': dict({
'community': 'public',
'port': 161,
}),
'host': 'localhost',
'type': 'laser',
}),

View File

@@ -6,9 +6,13 @@ from unittest.mock import AsyncMock, patch
from brother import SnmpError, UnsupportedModelError
import pytest
from homeassistant.components.brother.const import DOMAIN
from homeassistant.components.brother.const import (
CONF_COMMUNITY,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_HOST, CONF_TYPE
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -17,7 +21,11 @@ from . import init_integration
from tests.common import MockConfigEntry
CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"}
CONFIG = {
CONF_HOST: "127.0.0.1",
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_unload_entry")
@@ -37,16 +45,21 @@ async def test_create_entry(
hass: HomeAssistant, host: str, mock_brother_client: AsyncMock
) -> None:
"""Test that the user step works with printer hostname/IPv4/IPv6."""
config = CONFIG.copy()
config[CONF_HOST] = host
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: host, CONF_TYPE: "laser"},
data=config,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == host
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
async def test_invalid_hostname(hass: HomeAssistant) -> None:
@@ -54,7 +67,11 @@ async def test_invalid_hostname(hass: HomeAssistant) -> None:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_HOST: "invalid/hostname", CONF_TYPE: "laser"},
data={
CONF_HOST: "invalid/hostname",
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["errors"] == {CONF_HOST: "wrong_host"}
@@ -241,13 +258,19 @@ async def test_zeroconf_confirm_create_entry(
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_TYPE: "laser"}
result["flow_id"],
user_input={
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "HL-L2340DW 0123456789"
assert result["data"][CONF_HOST] == "127.0.0.1"
assert result["data"][CONF_TYPE] == "laser"
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert result["data"][SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"
async def test_reconfigure_successful(
@@ -265,7 +288,10 @@ async def test_reconfigure_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "10.10.10.10"},
user_input={
CONF_HOST: "10.10.10.10",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.ABORT
@@ -273,6 +299,7 @@ async def test_reconfigure_successful(
assert mock_config_entry.data == {
CONF_HOST: "10.10.10.10",
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
@@ -303,7 +330,10 @@ async def test_reconfigure_not_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "10.10.10.10"},
user_input={
CONF_HOST: "10.10.10.10",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.FORM
@@ -314,7 +344,10 @@ async def test_reconfigure_not_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "10.10.10.10"},
user_input={
CONF_HOST: "10.10.10.10",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.ABORT
@@ -322,6 +355,7 @@ async def test_reconfigure_not_successful(
assert mock_config_entry.data == {
CONF_HOST: "10.10.10.10",
CONF_TYPE: "laser",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
}
@@ -340,7 +374,10 @@ async def test_reconfigure_invalid_hostname(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "invalid/hostname"},
user_input={
CONF_HOST: "invalid/hostname",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.FORM
@@ -365,7 +402,10 @@ async def test_reconfigure_not_the_same_device(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "10.10.10.10"},
user_input={
CONF_HOST: "10.10.10.10",
SECTION_ADVANCED_SETTINGS: {CONF_PORT: 161, CONF_COMMUNITY: "public"},
},
)
assert result["type"] is FlowResultType.FORM

View File

@@ -5,8 +5,13 @@ from unittest.mock import AsyncMock, patch
from brother import SnmpError
import pytest
from homeassistant.components.brother.const import DOMAIN
from homeassistant.components.brother.const import (
CONF_COMMUNITY,
DOMAIN,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TYPE
from homeassistant.core import HomeAssistant
from . import init_integration
@@ -68,3 +73,26 @@ async def test_unload_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_migrate_entry(
hass: HomeAssistant,
mock_brother_client: AsyncMock,
) -> None:
"""Test entry migration to minor_version=2."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title="HL-L2340DW 0123456789",
unique_id="0123456789",
data={CONF_HOST: "localhost", CONF_TYPE: "laser"},
minor_version=1,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.minor_version == 2
assert config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_PORT] == 161
assert config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_COMMUNITY] == "public"

View File

@@ -855,7 +855,7 @@
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'CPU Temperature',
'original_name': 'CPU temperature',
'platform': 'fritz',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -869,7 +869,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Mock Title CPU Temperature',
'friendly_name': 'Mock Title CPU temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),

View File

@@ -0,0 +1,66 @@
# serializer version: 1
# name: test_function_call
list([
Content(
parts=[
Part(
text='Please call the test function'
),
],
role='user'
),
Content(
parts=[
Part(
text='Hi there!',
thought_signature=b'_thought_signature_2'
),
Part(
text='The user asked me to call a function',
thought=True,
thought_signature=b'_thought_signature_1'
),
Part(
function_call=FunctionCall(
args={
'param1': [
'test_value',
"param1's value",
],
'param2': 2.7
},
name='test_tool'
),
thought_signature=b'_thought_signature_3'
),
],
role='model'
),
Content(
parts=[
Part(
function_response=FunctionResponse(
name='test_tool',
response={
'result': 'Test response'
}
)
),
],
role='user'
),
Content(
parts=[
Part(
text="I've called the ",
thought_signature=b'_thought_signature_4'
),
Part(
text='test function with the provided parameters.',
thought_signature=b'_thought_signature_5'
),
],
role='model'
),
])
# ---

View File

@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
from google.genai.types import GenerateContentResponse
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import conversation
from homeassistant.components.conversation import UserContent
@@ -80,6 +81,7 @@ async def test_function_call(
mock_config_entry_with_assist: MockConfigEntry,
mock_chat_log: MockChatLog, # noqa: F811
mock_send_message_stream: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test function calling."""
agent_id = "conversation.google_ai_conversation"
@@ -93,9 +95,15 @@ async def test_function_call(
{
"content": {
"parts": [
{
"text": "The user asked me to call a function",
"thought": True,
"thought_signature": b"_thought_signature_1",
},
{
"text": "Hi there!",
}
"thought_signature": b"_thought_signature_2",
},
],
"role": "model",
}
@@ -118,6 +126,7 @@ async def test_function_call(
"param2": 2.7,
},
},
"thought_signature": b"_thought_signature_3",
}
],
"role": "model",
@@ -136,6 +145,7 @@ async def test_function_call(
"parts": [
{
"text": "I've called the ",
"thought_signature": b"_thought_signature_4",
}
],
"role": "model",
@@ -150,6 +160,25 @@ async def test_function_call(
"parts": [
{
"text": "test function with the provided parameters.",
"thought_signature": b"_thought_signature_5",
}
],
"role": "model",
},
"finish_reason": "STOP",
}
],
),
],
# Follow-up response
[
GenerateContentResponse(
candidates=[
{
"content": {
"parts": [
{
"text": "You are welcome!",
}
],
"role": "model",
@@ -205,6 +234,22 @@ async def test_function_call(
"video_metadata": None,
}
# Test history conversion for multi-turn conversation
with patch(
"google.genai.chats.AsyncChats.create", return_value=AsyncMock()
) as mock_create:
mock_create.return_value.send_message_stream = mock_send_message_stream
await conversation.async_converse(
hass,
"Thank you!",
mock_chat_log.conversation_id,
context,
agent_id=agent_id,
device_id="test_device",
)
assert mock_create.call_args[1].get("history") == snapshot
@pytest.mark.usefixtures("mock_init_component")
@pytest.mark.usefixtures("mock_ulid_tools")

View File

@@ -208,6 +208,7 @@ async def test_tts_service_speak(
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=types.ThinkingConfig(include_thoughts=True),
),
)
@@ -276,5 +277,6 @@ async def test_tts_service_speak_error(
threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD,
),
],
thinking_config=types.ThinkingConfig(include_thoughts=True),
),
)

View File

@@ -0,0 +1,176 @@
{
"id": 3,
"name": "Added Device",
"profile": 4010,
"image": "default",
"favorite": 0,
"order": 20,
"protocol": 1,
"routing": 0,
"state": 1,
"state_changed": 1709379826,
"added": 1676199446,
"history": 1,
"cube_type": 1,
"note": "",
"services": 5,
"phonetic_name": "",
"owner": 2,
"security": 0,
"attributes": [
{
"id": 21,
"node_id": 3,
"instance": 1,
"minimum": 0,
"maximum": 200000,
"current_value": 555.591,
"target_value": 555.591,
"last_value": 555.586,
"unit": "kWh",
"step_value": 1.0,
"editable": 0,
"type": 4,
"state": 1,
"last_changed": 1694175270,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": ""
},
{
"id": 22,
"node_id": 3,
"instance": 0,
"minimum": 0,
"maximum": 1,
"current_value": 0.0,
"target_value": 0.0,
"last_value": 0.0,
"unit": "",
"step_value": 1.0,
"editable": 0,
"type": 17,
"state": 1,
"last_changed": 1691668428,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": "",
"options": {
"automations": ["reset"],
"history": {
"day": 182,
"week": 26,
"month": 6,
"stepped": true
}
}
},
{
"id": 27,
"node_id": 3,
"instance": 0,
"minimum": 0,
"maximum": 100,
"current_value": 100.0,
"target_value": 100.0,
"last_value": 100.0,
"unit": "%",
"step_value": 0.5,
"editable": 1,
"type": 349,
"state": 1,
"last_changed": 1624446307,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": ""
},
{
"id": 28,
"node_id": 3,
"instance": 0,
"minimum": 0,
"maximum": 1,
"current_value": 0.0,
"target_value": 0.0,
"last_value": 0.0,
"unit": "",
"step_value": 1.0,
"editable": 1,
"type": 346,
"state": 1,
"last_changed": 1624806728,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": ""
},
{
"id": 29,
"node_id": 3,
"instance": 0,
"minimum": 0,
"maximum": 1,
"current_value": 0.0,
"target_value": 0.0,
"last_value": 0.0,
"unit": "n/a",
"step_value": 1.0,
"editable": 1,
"type": 13,
"state": 1,
"last_changed": 1736003985,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": "",
"options": {
"automations": ["toggle"],
"history": {
"day": 35,
"week": 5,
"month": 1,
"stepped": true
}
}
},
{
"id": 30,
"node_id": 3,
"instance": 0,
"minimum": 0,
"maximum": 1,
"current_value": 1.0,
"target_value": 0.0,
"last_value": 0.0,
"unit": "n/a",
"step_value": 1.0,
"editable": 1,
"type": 1,
"state": 1,
"last_changed": 1736743294,
"changed_by": 1,
"changed_by_id": 0,
"based_on": 1,
"data": "",
"name": "",
"options": {
"can_observe": [300],
"automations": ["toggle"],
"history": {
"day": 35,
"week": 5,
"month": 1,
"stepped": true
}
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -27,3 +27,26 @@ async def test_sensor_snapshot(
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_add_device(
hass: HomeAssistant,
mock_homee: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test adding a device."""
mock_homee.nodes = [build_mock_node("binary_sensors.json")]
mock_homee.get_node_by_id.return_value = mock_homee.nodes[0]
with patch("homeassistant.components.homee.PLATFORMS", [Platform.BINARY_SENSOR]):
await setup_integration(hass, mock_config_entry)
# Add a new device
added_node = build_mock_node("add_device.json")
mock_homee.nodes.append(added_node)
mock_homee.get_node_by_id.return_value = mock_homee.nodes[1]
await mock_homee.add_nodes_listener.call_args_list[0][0][0](added_node, True)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@@ -9,7 +9,10 @@
'rate_limit': 0,
'state_updater': True,
}),
'configuration_error': "extra keys not allowed @ data['knx']['wrong_key']",
'config_store': dict({
'entities': dict({
}),
}),
'configuration_yaml': dict({
'wrong_key': dict({
}),
@@ -19,6 +22,7 @@
'current_address': '0.0.0',
'version': '0.0.0',
}),
'yaml_configuration_error': "extra keys not allowed @ data['knx']['wrong_key']",
})
# ---
# name: test_diagnostic_redact[hass_config0]
@@ -35,13 +39,17 @@
'state_updater': True,
'user_password': '**REDACTED**',
}),
'configuration_error': None,
'config_store': dict({
'entities': dict({
}),
}),
'configuration_yaml': None,
'project_info': None,
'xknx': dict({
'current_address': '0.0.0',
'version': '0.0.0',
}),
'yaml_configuration_error': None,
})
# ---
# name: test_diagnostics[hass_config0]
@@ -54,13 +62,17 @@
'rate_limit': 0,
'state_updater': True,
}),
'configuration_error': None,
'config_store': dict({
'entities': dict({
}),
}),
'configuration_yaml': None,
'project_info': None,
'xknx': dict({
'current_address': '0.0.0',
'version': '0.0.0',
}),
'yaml_configuration_error': None,
})
# ---
# name: test_diagnostics_project[hass_config0]
@@ -73,7 +85,50 @@
'rate_limit': 0,
'state_updater': True,
}),
'configuration_error': None,
'config_store': dict({
'entities': dict({
'light': dict({
'knx_es_01J85ZKTFHSZNG4X9DYBE592TF': dict({
'entity': dict({
'device_info': None,
'entity_category': 'config',
'name': 'test',
}),
'knx': dict({
'color_temp_max': 6000,
'color_temp_min': 2700,
'ga_switch': dict({
'passive': list([
]),
'state': '1/0/21',
'write': '1/1/21',
}),
'sync_state': True,
}),
}),
}),
'switch': dict({
'knx_es_9d97829f47f1a2a3176a7c5b4216070c': dict({
'entity': dict({
'device_info': 'knx_vdev_4c80a564f5fe5da701ed293966d6384d',
'entity_category': None,
'name': 'test',
}),
'knx': dict({
'ga_switch': dict({
'passive': list([
]),
'state': '1/0/45',
'write': '1/1/45',
}),
'invert': False,
'respond_to_read': False,
'sync_state': True,
}),
}),
}),
}),
}),
'configuration_yaml': None,
'project_info': dict({
'created_by': 'ETS5',
@@ -91,5 +146,6 @@
'current_address': '0.0.0',
'version': '0.0.0',
}),
'yaml_configuration_error': None,
})
# ---

View File

@@ -574,26 +574,6 @@
'required': False,
'type': 'knx_section_flat',
}),
dict({
'name': 'ga_blue_brightness',
'options': dict({
'passive': True,
'state': dict({
'required': False,
}),
'validDPTs': list([
dict({
'main': 5,
'sub': 1,
}),
]),
'write': dict({
'required': True,
}),
}),
'required': True,
'type': 'knx_group_address',
}),
dict({
'name': 'ga_blue_switch',
'optional': True,
@@ -616,14 +596,7 @@
'type': 'knx_group_address',
}),
dict({
'collapsible': False,
'name': 'section_white',
'required': False,
'type': 'knx_section_flat',
}),
dict({
'name': 'ga_white_brightness',
'optional': True,
'name': 'ga_blue_brightness',
'options': dict({
'passive': True,
'state': dict({
@@ -639,9 +612,15 @@
'required': True,
}),
}),
'required': False,
'required': True,
'type': 'knx_group_address',
}),
dict({
'collapsible': False,
'name': 'section_white',
'required': False,
'type': 'knx_section_flat',
}),
dict({
'name': 'ga_white_switch',
'optional': True,
@@ -663,6 +642,27 @@
'required': False,
'type': 'knx_group_address',
}),
dict({
'name': 'ga_white_brightness',
'optional': True,
'options': dict({
'passive': True,
'state': dict({
'required': False,
}),
'validDPTs': list([
dict({
'main': 5,
'sub': 1,
}),
]),
'write': dict({
'required': True,
}),
}),
'required': False,
'type': 'knx_group_address',
}),
]),
'translation_key': 'individual_addresses',
'type': 'knx_group_select_option',

View File

@@ -120,9 +120,13 @@ async def test_diagnostics_project(
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await knx.setup_integration()
await knx.setup_integration(
config_store_fixture="config_store_light_switch.json",
state_updater=False,
)
knx.xknx.version = "0.0.0"
# snapshot will contain project specific fields in `project_info`
# and UI configuration in `config_store`
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot

View File

@@ -128,6 +128,25 @@ FEEDER_ROBOT_DATA = {
"mealInsertSize": 1,
},
"updated_at": "2022-09-08T15:07:00.000000+00:00",
"active_schedule": {
"id": "1",
"name": "Feeding",
"meals": [
{
"id": "1",
"days": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"hour": 6,
"name": "Breakfast",
"skip": None,
"minute": 30,
"paused": False,
"portions": 3,
"mealNumber": 1,
"scheduleId": None,
}
],
"created_at": "2021-12-17T07:07:31.047747+00:00",
},
},
"feeding_snack": [
{"timestamp": "2022-09-04T03:03:00.000000+00:00", "amount": 0.125},

View File

@@ -104,6 +104,7 @@ async def test_litter_robot_sensor(
assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING
@pytest.mark.freeze_time("2022-09-08 19:00:00+00:00")
async def test_feeder_robot_sensor(
hass: HomeAssistant, mock_account_with_feederrobot: MagicMock
) -> None:
@@ -117,6 +118,16 @@ async def test_feeder_robot_sensor(
assert sensor.state == "2022-09-08T18:00:00+00:00"
assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP
sensor = hass.states.get("sensor.test_next_feeding")
assert sensor.state == "2022-09-09T12:30:00+00:00"
assert sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP
sensor = hass.states.get("sensor.test_food_dispensed_today")
assert sensor.state == "0.375"
assert sensor.attributes["last_reset"] == "2022-09-08T00:00:00-07:00"
assert sensor.attributes["state_class"] == SensorStateClass.TOTAL
assert sensor.attributes["unit_of_measurement"] == "cups"
async def test_pet_weight_sensor(
hass: HomeAssistant, mock_account_with_pet: MagicMock

View File

@@ -3904,7 +3904,7 @@
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
@@ -3932,7 +3932,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_energy_forecast-entry]
@@ -4501,7 +4501,7 @@
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
@@ -4529,7 +4529,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine_water_forecast-entry]
@@ -6050,7 +6050,7 @@
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
@@ -6078,7 +6078,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_energy_forecast-entry]
@@ -6647,7 +6647,7 @@
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
@@ -6675,7 +6675,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_forecast-entry]

View File

@@ -315,6 +315,13 @@ async def test_laundry_wash_scenario(
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "unknown", step)
# OFF -> elapsed forced to unknown (some devices continue reporting last value of last cycle)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "unknown", step)
# consumption sensors have to report "unknown" when the device is not working
check_sensor_state(
hass, "sensor.washing_machine_energy_consumption", "unknown", step
)
check_sensor_state(
hass, "sensor.washing_machine_water_consumption", "unknown", step
)
# Simulate program started
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 5
@@ -337,10 +344,41 @@ async def test_laundry_wash_scenario(
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 12
device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_raw"] = 1200
device_fixture["DummyWasher"]["state"]["spinningSpeed"]["value_localized"] = "1200"
device_fixture["DummyWasher"]["state"]["ecoFeedback"] = {
"currentEnergyConsumption": {
"value": 0.9,
"unit": "kWh",
},
"currentWaterConsumption": {
"value": 52,
"unit": "l",
},
}
freezer.tick(timedelta(seconds=130))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# at this point, appliance is working, but it started reporting a value from last cycle, so it is forced to 0
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step)
# intermediate step, only to report new consumption values
device_fixture["DummyWasher"]["state"]["ecoFeedback"] = {
"currentEnergyConsumption": {
"value": 0.0,
"unit": "kWh",
},
"currentWaterConsumption": {
"value": 0,
"unit": "l",
},
}
freezer.tick(timedelta(seconds=130))
async_fire_time_changed(hass)
await hass.async_block_till_done()
step += 1
check_sensor_state(hass, "sensor.washing_machine", "in_use", step)
@@ -351,6 +389,28 @@ async def test_laundry_wash_scenario(
# IN_USE -> elapsed, remaining time from API (normal case)
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "105", step)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "12", step)
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.0", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "0", step)
# intermediate step, only to report new consumption values
device_fixture["DummyWasher"]["state"]["ecoFeedback"] = {
"currentEnergyConsumption": {
"value": 0.1,
"unit": "kWh",
},
"currentWaterConsumption": {
"value": 7,
"unit": "l",
},
}
freezer.tick(timedelta(seconds=130))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# at this point, it starts reporting value from API
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step)
# Simulate rinse hold phase
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 11
@@ -389,6 +449,7 @@ async def test_laundry_wash_scenario(
device_fixture["DummyWasher"]["state"]["remainingTime"][1] = 0
device_fixture["DummyWasher"]["state"]["elapsedTime"][0] = 0
device_fixture["DummyWasher"]["state"]["elapsedTime"][1] = 0
device_fixture["DummyWasher"]["state"]["ecoFeedback"] = None
freezer.tick(timedelta(seconds=130))
async_fire_time_changed(hass)
@@ -406,6 +467,9 @@ async def test_laundry_wash_scenario(
check_sensor_state(hass, "sensor.washing_machine_remaining_time", "0", step)
# PROGRAM_ENDED -> elapsed time kept from last program (some devices immediately go to 0)
check_sensor_state(hass, "sensor.washing_machine_elapsed_time", "109", step)
# consumption values now are reporting last known value, API might start reporting null object
check_sensor_state(hass, "sensor.washing_machine_energy_consumption", "0.1", step)
check_sensor_state(hass, "sensor.washing_machine_water_consumption", "7", step)
# Simulate when door is opened after program ended
device_fixture["DummyWasher"]["state"]["status"]["value_raw"] = 3

View File

@@ -1331,7 +1331,7 @@ async def test_discover_alarm_control_panel(
@pytest.mark.parametrize(
("topic", "config", "entity_id", "name", "domain"),
("topic", "config", "entity_id", "name", "domain", "deprecation_warning"),
[
(
"homeassistant/alarm_control_panel/object/bla/config",
@@ -1339,6 +1339,7 @@ async def test_discover_alarm_control_panel(
"alarm_control_panel.hello_id",
"Hello World 1",
"alarm_control_panel",
True,
),
(
"homeassistant/binary_sensor/object/bla/config",
@@ -1346,6 +1347,7 @@ async def test_discover_alarm_control_panel(
"binary_sensor.hello_id",
"Hello World 2",
"binary_sensor",
True,
),
(
"homeassistant/button/object/bla/config",
@@ -1353,6 +1355,7 @@ async def test_discover_alarm_control_panel(
"button.hello_id",
"Hello World button",
"button",
True,
),
(
"homeassistant/camera/object/bla/config",
@@ -1360,6 +1363,7 @@ async def test_discover_alarm_control_panel(
"camera.hello_id",
"Hello World 3",
"camera",
True,
),
(
"homeassistant/climate/object/bla/config",
@@ -1367,6 +1371,7 @@ async def test_discover_alarm_control_panel(
"climate.hello_id",
"Hello World 4",
"climate",
True,
),
(
"homeassistant/cover/object/bla/config",
@@ -1374,6 +1379,7 @@ async def test_discover_alarm_control_panel(
"cover.hello_id",
"Hello World 5",
"cover",
True,
),
(
"homeassistant/fan/object/bla/config",
@@ -1381,6 +1387,7 @@ async def test_discover_alarm_control_panel(
"fan.hello_id",
"Hello World 6",
"fan",
True,
),
(
"homeassistant/humidifier/object/bla/config",
@@ -1388,6 +1395,7 @@ async def test_discover_alarm_control_panel(
"humidifier.hello_id",
"Hello World 7",
"humidifier",
True,
),
(
"homeassistant/number/object/bla/config",
@@ -1395,6 +1403,7 @@ async def test_discover_alarm_control_panel(
"number.hello_id",
"Hello World 8",
"number",
True,
),
(
"homeassistant/scene/object/bla/config",
@@ -1402,6 +1411,7 @@ async def test_discover_alarm_control_panel(
"scene.hello_id",
"Hello World 9",
"scene",
True,
),
(
"homeassistant/select/object/bla/config",
@@ -1409,6 +1419,7 @@ async def test_discover_alarm_control_panel(
"select.hello_id",
"Hello World 10",
"select",
True,
),
(
"homeassistant/sensor/object/bla/config",
@@ -1416,6 +1427,7 @@ async def test_discover_alarm_control_panel(
"sensor.hello_id",
"Hello World 11",
"sensor",
True,
),
(
"homeassistant/switch/object/bla/config",
@@ -1423,6 +1435,7 @@ async def test_discover_alarm_control_panel(
"switch.hello_id",
"Hello World 12",
"switch",
True,
),
(
"homeassistant/light/object/bla/config",
@@ -1430,6 +1443,7 @@ async def test_discover_alarm_control_panel(
"light.hello_id",
"Hello World 13",
"light",
True,
),
(
"homeassistant/light/object/bla/config",
@@ -1437,6 +1451,7 @@ async def test_discover_alarm_control_panel(
"light.hello_id",
"Hello World 14",
"light",
True,
),
(
"homeassistant/light/object/bla/config",
@@ -1444,6 +1459,7 @@ async def test_discover_alarm_control_panel(
"light.hello_id",
"Hello World 15",
"light",
True,
),
(
"homeassistant/vacuum/object/bla/config",
@@ -1451,6 +1467,7 @@ async def test_discover_alarm_control_panel(
"vacuum.hello_id",
"Hello World 16",
"vacuum",
True,
),
(
"homeassistant/valve/object/bla/config",
@@ -1458,6 +1475,7 @@ async def test_discover_alarm_control_panel(
"valve.hello_id",
"Hello World 17",
"valve",
True,
),
(
"homeassistant/lock/object/bla/config",
@@ -1465,6 +1483,7 @@ async def test_discover_alarm_control_panel(
"lock.hello_id",
"Hello World 18",
"lock",
True,
),
(
"homeassistant/device_tracker/object/bla/config",
@@ -1472,6 +1491,7 @@ async def test_discover_alarm_control_panel(
"device_tracker.hello_id",
"Hello World 19",
"device_tracker",
True,
),
(
"homeassistant/binary_sensor/object/bla/config",
@@ -1480,6 +1500,7 @@ async def test_discover_alarm_control_panel(
"binary_sensor.hello_id",
"Hello World 2",
"binary_sensor",
True,
),
(
"homeassistant/button/object/bla/config",
@@ -1489,6 +1510,7 @@ async def test_discover_alarm_control_panel(
"button.hello_id",
"Hello World button",
"button",
True,
),
(
"homeassistant/alarm_control_panel/object/bla/config",
@@ -1497,6 +1519,7 @@ async def test_discover_alarm_control_panel(
"alarm_control_panel.hello_id",
"Hello World 1",
"alarm_control_panel",
False,
),
(
"homeassistant/binary_sensor/object/bla/config",
@@ -1505,6 +1528,7 @@ async def test_discover_alarm_control_panel(
"binary_sensor.hello_id",
"Hello World 2",
"binary_sensor",
False,
),
(
"homeassistant/button/object/bla/config",
@@ -1514,17 +1538,31 @@ async def test_discover_alarm_control_panel(
"button.hello_id",
"Hello World button",
"button",
False,
),
(
"homeassistant/button/object/bla/config",
'{ "name": "Hello World button", "def_ent_id": "button.hello_id", '
'"obj_id": "hello_id_old", '
'"o": {"name": "X2mqtt", "url": "https://example.com/x2mqtt"}, '
'"command_topic": "test-topic" }',
"button.hello_id",
"Hello World button",
"button",
False,
),
],
)
async def test_discovery_with_object_id(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
topic: str,
config: str,
entity_id: str,
name: str,
domain: str,
deprecation_warning: bool,
) -> None:
"""Test discovering an MQTT entity with object_id."""
await mqtt_mock_entry()
@@ -1537,6 +1575,11 @@ async def test_discovery_with_object_id(
assert state.name == name
assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered
assert (
f"The configuration for entity {domain}.hello_id uses the deprecated option `object_id`"
in caplog.text
) is deprecation_warning
async def test_discovery_with_default_entity_id_for_previous_deleted_entity(
hass: HomeAssistant,

View File

@@ -72,6 +72,7 @@
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'temperature': 6.8,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'visibility': 10.0,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 199,
'wind_gust_speed': 42.52,
@@ -136,6 +137,7 @@
'supported_features': <WeatherEntityFeature: 2>,
'temperature': 6.8,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'visibility': 10.0,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 199,
'wind_gust_speed': 42.52,
@@ -200,6 +202,7 @@
'supported_features': <WeatherEntityFeature: 3>,
'temperature': 6.8,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'visibility': 10.0,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_bearing': 199,
'wind_gust_speed': 42.52,

View File

@@ -0,0 +1,826 @@
# serializer version: 1
# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'no_frost',
'asleep',
'vacation',
'home',
'away',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.bathroom',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': 'plugwise',
'unique_id': 'f871b8c4d63549319221e294e4f88074-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.bathroom-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 17.9,
'friendly_name': 'Bathroom',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'home',
'preset_modes': list([
'no_frost',
'asleep',
'vacation',
'home',
'away',
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 0.1,
'temperature': 15.0,
}),
'context': <ANY>,
'entity_id': 'climate.bathroom',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 1.0,
'preset_modes': list([
'no_frost',
'asleep',
'vacation',
'home',
'away',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.living_room',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 401>,
'translation_key': 'plugwise',
'unique_id': 'f2bf9048bef64cc5b6d5110154e33c81-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_2_climate_snapshot[platforms0-False-m_adam_heating][climate.living_room-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.1,
'friendly_name': 'Living room',
'hvac_action': <HVACAction.PREHEATING: 'preheating'>,
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 1.0,
'preset_mode': 'home',
'preset_modes': list([
'no_frost',
'asleep',
'vacation',
'home',
'away',
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 0.1,
'temperature': 20.0,
}),
'context': <ANY>,
'entity_id': 'climate.living_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.badkamer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.badkamer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'plugwise',
'unique_id': '08963fec7c53423ca5680aa4cb502c63-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.badkamer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 18.9,
'friendly_name': 'Badkamer',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'away',
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 0.1,
'temperature': 14.0,
}),
'context': <ANY>,
'entity_id': 'climate.badkamer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.bios-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.bios',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'plugwise',
'unique_id': '12493538af164a409c6a1c79e38afe1c-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.bios-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 16.5,
'friendly_name': 'Bios',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'away',
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 0.1,
'temperature': 13.0,
}),
'context': <ANY>,
'entity_id': 'climate.bios',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.garage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.garage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'plugwise',
'unique_id': '446ac08dd04d4eff8ac57489757b7314-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.garage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 15.6,
'friendly_name': 'Garage',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'no_frost',
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 0.1,
'temperature': 5.5,
}),
'context': <ANY>,
'entity_id': 'climate.garage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.jessie-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.jessie',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'plugwise',
'unique_id': '82fa13f017d240daa0d0ea1775420f24-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.jessie-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 17.2,
'friendly_name': 'Jessie',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'asleep',
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 0.1,
'temperature': 15.0,
}),
'context': <ANY>,
'entity_id': 'climate.jessie',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.woonkamer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 17>,
'translation_key': 'plugwise',
'unique_id': 'c50f167537524366a5af7aa3942feb1e-climate',
'unit_of_measurement': None,
})
# ---
# name: test_adam_climate_snapshot[platforms0][climate.woonkamer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.9,
'friendly_name': 'Woonkamer',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 0.0,
'preset_mode': 'home',
'preset_modes': list([
'home',
'asleep',
'away',
'vacation',
'no_frost',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 0.1,
'temperature': 21.5,
}),
'context': <ANY>,
'entity_id': 'climate.woonkamer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.anna',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 18>,
'translation_key': 'plugwise',
'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate',
'unit_of_measurement': None,
})
# ---
# name: test_anna_2_climate_snapshot[platforms0-True-m_anna_heatpump_cooling][climate.anna-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 26.3,
'friendly_name': 'Anna',
'hvac_action': <HVACAction.COOLING: 'cooling'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_mode': 'home',
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'supported_features': <ClimateEntityFeature: 18>,
'target_temp_high': 30.0,
'target_temp_low': 20.5,
'target_temp_step': 0.1,
}),
'context': <ANY>,
'entity_id': 'climate.anna',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.anna',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 18>,
'translation_key': 'plugwise',
'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate',
'unit_of_measurement': None,
})
# ---
# name: test_anna_3_climate_snapshot[platforms0-True-m_anna_heatpump_idle][climate.anna-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 23.0,
'friendly_name': 'Anna',
'hvac_action': <HVACAction.IDLE: 'idle'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_mode': 'home',
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'supported_features': <ClimateEntityFeature: 18>,
'target_temp_high': 30.0,
'target_temp_low': 20.5,
'target_temp_step': 0.1,
}),
'context': <ANY>,
'entity_id': 'climate.anna',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---
# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'target_temp_step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.anna',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'plugwise',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 18>,
'translation_key': 'plugwise',
'unique_id': '3cb70739631c4d17a86b8b12e8a5161b-climate',
'unit_of_measurement': None,
})
# ---
# name: test_anna_climate_snapshot[platforms0-True-anna_heatpump_heating][climate.anna-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.3,
'friendly_name': 'Anna',
'hvac_action': <HVACAction.HEATING: 'heating'>,
'hvac_modes': list([
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT_COOL: 'heat_cool'>,
]),
'max_temp': 30.0,
'min_temp': 4.0,
'preset_mode': 'home',
'preset_modes': list([
'no_frost',
'home',
'away',
'asleep',
'vacation',
]),
'supported_features': <ClimateEntityFeature: 18>,
'target_temp_high': 30.0,
'target_temp_low': 20.5,
'target_temp_step': 0.1,
}),
'context': <ANY>,
'entity_id': 'climate.anna',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'auto',
})
# ---

View File

@@ -6,180 +6,46 @@ from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from plugwise.exceptions import PlugwiseError
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
ATTR_CURRENT_TEMPERATURE,
ATTR_HVAC_ACTION,
ATTR_HVAC_MODE,
ATTR_HVAC_MODES,
ATTR_MAX_TEMP,
ATTR_MIN_TEMP,
ATTR_PRESET_MODE,
ATTR_PRESET_MODES,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TARGET_TEMP_STEP,
DOMAIN as CLIMATE_DOMAIN,
PRESET_AWAY,
PRESET_HOME,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_PRESET_MODE,
SERVICE_SET_TEMPERATURE,
HVACAction,
HVACMode,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
HA_PLUGWISE_SMILE_ASYNC_UPDATE = (
"homeassistant.components.plugwise.coordinator.Smile.async_update"
)
async def test_adam_climate_entity_attributes(
hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
) -> None:
"""Test creation of adam climate device environment."""
state = hass.states.get("climate.woonkamer")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT]
assert ATTR_PRESET_MODES in state.attributes
assert "no_frost" in state.attributes[ATTR_PRESET_MODES]
assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES]
assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.9
assert state.attributes[ATTR_SUPPORTED_FEATURES] == 17
assert state.attributes[ATTR_TEMPERATURE] == 21.5
assert state.attributes[ATTR_MIN_TEMP] == 0.0
assert state.attributes[ATTR_MAX_TEMP] == 35.0
assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1
state = hass.states.get("climate.jessie")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT]
assert ATTR_PRESET_MODES in state.attributes
assert "no_frost" in state.attributes[ATTR_PRESET_MODES]
assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES]
assert state.attributes[ATTR_PRESET_MODE] == "asleep"
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 17.2
assert state.attributes[ATTR_TEMPERATURE] == 15.0
assert state.attributes[ATTR_MIN_TEMP] == 0.0
assert state.attributes[ATTR_MAX_TEMP] == 35.0
assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
async def test_adam_2_climate_entity_attributes(
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_adam_climate_snapshot(
hass: HomeAssistant,
mock_smile_adam_heat_cool: MagicMock,
init_integration: MockConfigEntry,
mock_smile_adam: MagicMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test creation of adam climate device environment."""
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.PREHEATING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.HEAT,
]
state = hass.states.get("climate.bathroom")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.HEAT,
]
@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
async def test_adam_3_climate_entity_attributes(
hass: HomeAssistant,
mock_smile_adam_heat_cool: MagicMock,
init_integration: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test creation of adam climate device environment."""
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.COOL
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
]
data = mock_smile_adam_heat_cool.async_update.return_value
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating"
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.HEAT,
]
data = mock_smile_adam_heat_cool.async_update.return_value
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling"
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.COOL
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
]
async def test_adam_climate_adjust_negative_testing(
hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
) -> None:
"""Test PlugwiseError exception."""
mock_smile_adam.set_temperature.side_effect = PlugwiseError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25},
blocking=True,
)
"""Test Adam climate snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
async def test_adam_climate_entity_climate_changes(
@@ -257,6 +123,95 @@ async def test_adam_climate_entity_climate_changes(
)
async def test_adam_climate_adjust_negative_testing(
hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry
) -> None:
"""Test PlugwiseError exception."""
mock_smile_adam.set_temperature.side_effect = PlugwiseError
with pytest.raises(HomeAssistantError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: "climate.woonkamer", ATTR_TEMPERATURE: 25},
blocking=True,
)
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_adam_2_climate_snapshot(
hass: HomeAssistant,
mock_smile_adam_heat_cool: MagicMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test Adam 2 climate snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize("chosen_env", ["m_adam_cooling"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
async def test_adam_3_climate_entity_attributes(
hass: HomeAssistant,
mock_smile_adam_heat_cool: MagicMock,
init_integration: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test creation of adam climate device environment."""
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.COOL
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
]
data = mock_smile_adam_heat_cool.async_update.return_value
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "heating"
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.HEATING
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = False
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = True
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.HEAT
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.HEAT,
]
data = mock_smile_adam_heat_cool.async_update.return_value
data["da224107914542988a88561b4452b0f6"]["select_regulation_mode"] = "cooling"
data["f2bf9048bef64cc5b6d5110154e33c81"]["control_state"] = HVACAction.COOLING
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["cooling_state"] = True
data["056ee145a816487eaa69243c3280f8bf"]["binary_sensors"]["heating_state"] = False
with patch(HA_PLUGWISE_SMILE_ASYNC_UPDATE, return_value=data):
freezer.tick(timedelta(minutes=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get("climate.living_room")
assert state
assert state.state == HVACMode.COOL
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.OFF,
HVACMode.AUTO,
HVACMode.COOL,
]
async def test_adam_climate_off_mode_change(
hass: HomeAssistant,
mock_smile_adam_jip: MagicMock,
@@ -313,68 +268,17 @@ async def test_adam_climate_off_mode_change(
@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
async def test_anna_climate_entity_attributes(
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_anna_climate_snapshot(
hass: HomeAssistant,
mock_smile_anna: MagicMock,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test creation of anna climate device environment."""
state = hass.states.get("climate.anna")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.AUTO, HVACMode.HEAT_COOL]
assert "no_frost" in state.attributes[ATTR_PRESET_MODES]
assert PRESET_HOME in state.attributes[ATTR_PRESET_MODES]
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 19.3
assert state.attributes[ATTR_PRESET_MODE] == PRESET_HOME
assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5
assert state.attributes[ATTR_MIN_TEMP] == 4
assert state.attributes[ATTR_MAX_TEMP] == 30
assert state.attributes[ATTR_TARGET_TEMP_STEP] == 0.1
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
async def test_anna_2_climate_entity_attributes(
hass: HomeAssistant,
mock_smile_anna: MagicMock,
init_integration: MockConfigEntry,
) -> None:
"""Test creation of anna climate device environment."""
state = hass.states.get("climate.anna")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.AUTO,
HVACMode.HEAT_COOL,
]
assert state.attributes[ATTR_SUPPORTED_FEATURES] == 18
assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 30
assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20.5
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
async def test_anna_3_climate_entity_attributes(
hass: HomeAssistant,
mock_smile_anna: MagicMock,
init_integration: MockConfigEntry,
) -> None:
"""Test creation of anna climate device environment."""
state = hass.states.get("climate.anna")
assert state
assert state.state == HVACMode.AUTO
assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE
assert state.attributes[ATTR_HVAC_MODES] == [
HVACMode.AUTO,
HVACMode.HEAT_COOL,
]
"""Test Anna climate snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize("chosen_env", ["anna_heatpump_heating"], indirect=True)
@@ -446,3 +350,33 @@ async def test_anna_climate_entity_climate_changes(
state = hass.states.get("climate.anna")
assert state.state == HVACMode.HEAT_COOL
assert state.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT_COOL]
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_cooling"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_anna_2_climate_snapshot(
hass: HomeAssistant,
mock_smile_anna: MagicMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test Anna 2 climate snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)
@pytest.mark.parametrize("chosen_env", ["m_anna_heatpump_idle"], indirect=True)
@pytest.mark.parametrize("cooling_present", [True], indirect=True)
@pytest.mark.parametrize("platforms", [(CLIMATE_DOMAIN,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_anna_3_climate_snapshot(
hass: HomeAssistant,
mock_smile_anna: MagicMock,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: MockConfigEntry,
) -> None:
"""Test Anna 3 climate snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id)

View File

@@ -4957,14 +4957,14 @@ async def async_record_states(
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
"Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
"Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi",
),
],
)
@@ -5175,14 +5175,14 @@ async def test_validate_statistics_unit_ignore_device_class(
PRESSURE_SENSOR_ATTRIBUTES,
"psi",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
"Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,
PRESSURE_SENSOR_ATTRIBUTES,
"Pa",
"bar",
"Pa, bar, cbar, hPa, inHg, kPa, mbar, mmHg, psi",
"Pa, bar, cbar, hPa, inHg, inH₂O, kPa, mbar, mmHg, psi",
),
(
METRIC_SYSTEM,

View File

@@ -1056,3 +1056,27 @@ RGBICWW_FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
PLUG_MINI_EU_SERVICE_INFO = BluetoothServiceInfoBleak(
name="Plug Mini (EU)",
manufacturer_data={
2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
address="AA:BB:CC:DD:EE:FF",
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="Plug Mini (EU)",
manufacturer_data={
2409: b"\x94\xa9\x90T\x85^?\xa1\x00\x00\x04\xe6\x00\x00\x00\x00",
},
service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"?\x00\x00\x00"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Plug Mini (EU)"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@@ -1,9 +1,12 @@
"""Test the switchbot config flow."""
from unittest.mock import patch
from collections.abc import Generator
from unittest.mock import Mock, patch
import pytest
from switchbot import SwitchbotAccountConnectionError, SwitchbotAuthenticationError
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.switchbot.const import (
CONF_ENCRYPTION_KEY,
CONF_KEY_ID,
@@ -41,6 +44,30 @@ from tests.common import MockConfigEntry
DOMAIN = "switchbot"
@pytest.fixture
def mock_scanners_all_active() -> Generator[None]:
"""Mock all scanners as active mode."""
mock_scanner = Mock()
mock_scanner.current_mode = BluetoothScanningMode.ACTIVE
with patch(
"homeassistant.components.switchbot.config_flow.async_current_scanners",
return_value=[mock_scanner],
):
yield
@pytest.fixture
def mock_scanners_all_passive() -> Generator[None]:
"""Mock all scanners as passive mode."""
mock_scanner = Mock()
mock_scanner.current_mode = BluetoothScanningMode.PASSIVE
with patch(
"homeassistant.components.switchbot.config_flow.async_current_scanners",
return_value=[mock_scanner],
):
yield
async def test_bluetooth_discovery(hass: HomeAssistant) -> None:
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
@@ -248,15 +275,23 @@ async def test_async_step_bluetooth_not_connectable(hass: HomeAssistant) -> None
assert result["reason"] == "not_supported"
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wohand(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -279,6 +314,7 @@ async def test_user_setup_wohand(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
entry = MockConfigEntry(
@@ -292,29 +328,46 @@ async def test_user_setup_wohand_already_configured(hass: HomeAssistant) -> None
unique_id="aabbccddeeff",
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None:
"""Test setting up a switchbot replaces an ignored entry."""
entry = MockConfigEntry(
domain=DOMAIN, data={}, unique_id="aabbccddeeff", source=SOURCE_IGNORE
)
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -336,15 +389,23 @@ async def test_user_setup_wohand_replaces_ignored(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wocurtain(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -367,9 +428,16 @@ async def test_user_setup_wocurtain(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
"""Test the user initiated form with valid address."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -379,11 +447,12 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
with patch_async_setup_entry() as mock_setup_entry:
@@ -403,9 +472,16 @@ async def test_user_setup_wocurtain_or_bot(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) -> None:
"""Test the user initiated form and valid address and a bot with a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -414,11 +490,12 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) ->
WOHAND_SERVICE_INFO_NOT_CONNECTABLE,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
@@ -447,15 +524,23 @@ async def test_user_setup_wocurtain_or_bot_with_password(hass: HomeAssistant) ->
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None:
"""Test the user initiated form for a bot with a password."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_ENCRYPTED_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "password"
@@ -479,15 +564,23 @@ async def test_user_setup_single_bot_with_password(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -545,15 +638,23 @@ async def test_user_setup_woencrypted_key(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -618,17 +719,25 @@ async def test_user_setup_woencrypted_auth(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_woencrypted_auth_switchbot_api_down(
hass: HomeAssistant,
) -> None:
"""Test the user initiated form for a lock when the switchbot api is down."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -658,9 +767,16 @@ async def test_user_setup_woencrypted_auth_switchbot_api_down(
assert result["description_placeholders"] == {"error_detail": "Switchbot API down"}
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
"""Test the user initiated form for a lock."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[
@@ -668,11 +784,12 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
WOHAND_SERVICE_ALT_ADDRESS_INFO,
],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "select_device"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
@@ -719,14 +836,22 @@ async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_wosensor(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOSENSORTH_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
@@ -749,19 +874,236 @@ async def test_user_setup_wosensor(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_cloud_login(hass: HomeAssistant) -> None:
"""Test the cloud login flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test successful cloud login
with (
patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
return_value=None,
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
# Should proceed to device selection with single device, so go to confirm
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
# Confirm device setup
with patch_async_setup_entry():
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot EEFF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_SENSOR_TYPE: "bot",
}
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_cloud_login_auth_failed(hass: HomeAssistant) -> None:
"""Test the cloud login flow with authentication failure."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test authentication failure
with patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
side_effect=SwitchbotAuthenticationError("Invalid credentials"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "wrongpass",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
assert result["errors"] == {"base": "auth_failed"}
assert "Invalid credentials" in result["description_placeholders"]["error_detail"]
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_cloud_login_api_error(hass: HomeAssistant) -> None:
"""Test the cloud login flow with API error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
# Test API connection error
with patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
side_effect=SwitchbotAccountConnectionError("API is down"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "api_error"
assert result["description_placeholders"] == {"error_detail": "API is down"}
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_cloud_login_then_encrypted_device(hass: HomeAssistant) -> None:
"""Test cloud login followed by encrypted device setup using saved credentials."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "cloud_login"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud_login"
with (
patch(
"homeassistant.components.switchbot.config_flow.fetch_cloud_devices",
return_value=None,
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOLOCK_SERVICE_INFO],
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
# Should go to encrypted device choice menu
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
# Choose encrypted auth
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "encrypted_auth"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encrypted_auth"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
None,
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encrypted_auth"
with (
patch_async_setup_entry() as mock_setup_entry,
patch(
"switchbot.SwitchbotLock.async_retrieve_encryption_key",
return_value={
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
},
),
patch("switchbot.SwitchbotLock.verify_encryption_key", return_value=True),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "test@example.com",
CONF_PASSWORD: "testpass",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Lock EEFF"
assert result["data"] == {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
CONF_SENSOR_TYPE: "lock",
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_no_devices(hass: HomeAssistant) -> None:
"""Test the user initiated form with password and valid mac."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_async_step_user_takes_precedence_over_discovery(
hass: HomeAssistant,
) -> None:
@@ -774,13 +1116,20 @@ async def test_async_step_user_takes_precedence_over_discovery(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOCURTAIN_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.FORM
@@ -928,15 +1277,23 @@ async def test_options_flow_lock_pro(hass: HomeAssistant) -> None:
assert entry.options[CONF_LOCK_NIGHTLATCH] is True
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None:
"""Test the user initiated form for a relay switch 1pm."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -976,15 +1333,23 @@ async def test_user_setup_worelay_switch_1pm_key(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None:
"""Test the user initiated form for a relay switch 1pm."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -1048,17 +1413,25 @@ async def test_user_setup_worelay_switch_1pm_auth(hass: HomeAssistant) -> None:
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_scanners_all_passive")
async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down(
hass: HomeAssistant,
) -> None:
"""Test the user initiated form for a relay switch 1pm when the switchbot api is down."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
with patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WORELAY_SWITCH_1PM_SERVICE_INFO],
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "select_device"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "encrypted_choose_method"
@@ -1086,3 +1459,128 @@ async def test_user_setup_worelay_switch_1pm_auth_switchbot_api_down(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "api_error"
assert result["description_placeholders"] == {"error_detail": "Switchbot API down"}
@pytest.mark.usefixtures("mock_scanners_all_active")
async def test_user_skip_menu_when_all_scanners_active(hass: HomeAssistant) -> None:
"""Test that menu is skipped when all scanners are in active mode."""
with (
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
),
patch_async_setup_entry() as mock_setup_entry,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Should skip menu and go directly to select_device -> confirm
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot EEFF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_show_menu_when_passive_scanner_present(hass: HomeAssistant) -> None:
"""Test that menu is shown when any scanner is in passive mode."""
mock_scanner_active = Mock()
mock_scanner_active.current_mode = BluetoothScanningMode.ACTIVE
mock_scanner_passive = Mock()
mock_scanner_passive.current_mode = BluetoothScanningMode.PASSIVE
with (
patch(
"homeassistant.components.switchbot.config_flow.async_current_scanners",
return_value=[mock_scanner_active, mock_scanner_passive],
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
),
patch_async_setup_entry() as mock_setup_entry,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Should show menu since not all scanners are active
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
assert set(result["menu_options"]) == {"cloud_login", "select_device"}
# Choose select_device from menu
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "select_device"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
# Confirm the device
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot EEFF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_show_menu_when_no_scanners(hass: HomeAssistant) -> None:
"""Test that menu is shown when no scanners are available."""
with (
patch(
"homeassistant.components.switchbot.config_flow.async_current_scanners",
return_value=[],
),
patch(
"homeassistant.components.switchbot.config_flow.async_discovered_service_info",
return_value=[WOHAND_SERVICE_INFO],
),
patch_async_setup_entry() as mock_setup_entry,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# Should show menu when no scanners are available
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
assert set(result["menu_options"]) == {"cloud_login", "select_device"}
# Choose select_device from menu
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "select_device"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "confirm"
# Confirm the device
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Bot EEFF"
assert result["data"] == {
CONF_ADDRESS: "AA:BB:CC:DD:EE:FF",
CONF_SENSOR_TYPE: "bot",
}
assert len(mock_setup_entry.mock_calls) == 1

View File

@@ -28,6 +28,7 @@ from . import (
HUB3_SERVICE_INFO,
HUBMINI_MATTER_SERVICE_INFO,
LEAK_SERVICE_INFO,
PLUG_MINI_EU_SERVICE_INFO,
REMOTE_SERVICE_INFO,
WOHAND_SERVICE_INFO,
WOHUB2_SERVICE_INFO,
@@ -542,3 +543,77 @@ async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None:
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_plug_mini_eu_sensor(hass: HomeAssistant) -> None:
"""Test setting up creates the plug mini eu sensor."""
await async_setup_component(hass, DOMAIN, {})
inject_bluetooth_service_info(hass, PLUG_MINI_EU_SERVICE_INFO)
with patch(
"homeassistant.components.switchbot.switch.switchbot.SwitchbotRelaySwitch.get_basic_info",
new=AsyncMock(
return_value={
"power": 500,
"current": 0.5,
"voltage": 230,
"energy": 0.4,
}
),
):
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_NAME: "test-name",
CONF_SENSOR_TYPE: "plug_mini_eu",
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
},
unique_id="aabbccddeeaa",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 5
power_sensor = hass.states.get("sensor.test_name_power")
power_sensor_attrs = power_sensor.attributes
assert power_sensor.state == "500"
assert power_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Power"
assert power_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "W"
assert power_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
voltage_sensor = hass.states.get("sensor.test_name_voltage")
voltage_sensor_attrs = voltage_sensor.attributes
assert voltage_sensor.state == "230"
assert voltage_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Voltage"
assert voltage_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "V"
assert voltage_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
current_sensor = hass.states.get("sensor.test_name_current")
current_sensor_attrs = current_sensor.attributes
assert current_sensor.state == "0.5"
assert current_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Current"
assert current_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "A"
assert current_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
energy_sensor = hass.states.get("sensor.test_name_energy")
energy_sensor_attrs = energy_sensor.attributes
assert energy_sensor.state == "0.4"
assert energy_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Energy"
assert energy_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "kWh"
assert energy_sensor_attrs[ATTR_STATE_CLASS] == "total_increasing"
rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal")
rssi_sensor_attrs = rssi_sensor.attributes
assert rssi_sensor.state == "-60"
assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal"
assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm"
assert rssi_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()

Some files were not shown because too many files have changed in this diff Show More