mirror of
https://github.com/home-assistant/core.git
synced 2026-01-13 11:07:59 +01:00
Compare commits
5 Commits
knx-expose
...
tibber_bin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
439f48c722 | ||
|
|
61180aa64f | ||
|
|
d2a55dec55 | ||
|
|
cddc4bdf8f | ||
|
|
adc201bb4e |
@@ -40,8 +40,7 @@
|
||||
"python.terminal.activateEnvInCurrentTerminal": true,
|
||||
"python.testing.pytestArgs": ["--no-cov"],
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"editor.formatOnPaste": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnType": true,
|
||||
|
||||
4
.vscode/settings.default.jsonc
vendored
4
.vscode/settings.default.jsonc
vendored
@@ -7,8 +7,8 @@
|
||||
"python.testing.pytestEnabled": false,
|
||||
// https://code.visualstudio.com/docs/python/linting#_general-settings
|
||||
"pylint.importStrategy": "fromEnvironment",
|
||||
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking
|
||||
"python.analysis.typeCheckingMode": "off",
|
||||
// Pyright is too pedantic for Home Assistant
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
STEP_SMS_CODE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_SMS_CODE): str,
|
||||
vol.Required(CONF_SMS_CODE): int,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,7 +75,7 @@ class FressnapfTrackerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return errors, False
|
||||
|
||||
async def _async_verify_sms_code(
|
||||
self, sms_code: str
|
||||
self, sms_code: int
|
||||
) -> tuple[dict[str, str], str | None]:
|
||||
"""Verify SMS code and return errors and access_token."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.1"]
|
||||
"requirements": ["fressnapftracker==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ from .const import (
|
||||
SUPPORTED_PLATFORMS_UI,
|
||||
SUPPORTED_PLATFORMS_YAML,
|
||||
)
|
||||
from .expose import create_combined_knx_exposure
|
||||
from .expose import create_knx_exposure
|
||||
from .knx_module import KNXModule
|
||||
from .project import STORAGE_KEY as PROJECT_STORAGE_KEY
|
||||
from .schema import (
|
||||
@@ -121,10 +121,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[KNX_MODULE_KEY] = knx_module
|
||||
|
||||
if CONF_KNX_EXPOSE in config:
|
||||
knx_module.yaml_exposures.extend(
|
||||
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
|
||||
)
|
||||
|
||||
for expose_config in config[CONF_KNX_EXPOSE]:
|
||||
knx_module.exposures.append(
|
||||
create_knx_exposure(hass, knx_module.xknx, expose_config)
|
||||
)
|
||||
configured_platforms_yaml = {
|
||||
platform for platform in SUPPORTED_PLATFORMS_YAML if platform in config
|
||||
}
|
||||
@@ -149,9 +149,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# if not loaded directly return
|
||||
return True
|
||||
|
||||
for exposure in knx_module.yaml_exposures:
|
||||
exposure.async_remove()
|
||||
for exposure in knx_module.service_exposures.values():
|
||||
for exposure in knx_module.exposures:
|
||||
exposure.async_remove()
|
||||
|
||||
configured_platforms_yaml = {
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from xknx import XKNX
|
||||
from xknx.devices import DateDevice, DateTimeDevice, ExposeSensor, TimeDevice
|
||||
from xknx.dpt import DPTBase, DPTNumeric, DPTString
|
||||
from xknx.dpt.dpt_1 import DPT1BitEnum, DPTSwitch
|
||||
from xknx.dpt import DPTNumeric, DPTString
|
||||
from xknx.exceptions import ConversionError
|
||||
from xknx.telegram.address import (
|
||||
GroupAddress,
|
||||
InternalGroupAddress,
|
||||
parse_device_group_address,
|
||||
)
|
||||
from xknx.remote_value import RemoteValueSensor
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_ID,
|
||||
@@ -48,159 +41,79 @@ _LOGGER = logging.getLogger(__name__)
|
||||
@callback
|
||||
def create_knx_exposure(
|
||||
hass: HomeAssistant, xknx: XKNX, config: ConfigType
|
||||
) -> KnxExposeEntity | KnxExposeTime:
|
||||
"""Create single exposure."""
|
||||
) -> KNXExposeSensor | KNXExposeTime:
|
||||
"""Create exposures from config."""
|
||||
|
||||
expose_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
|
||||
exposure: KnxExposeEntity | KnxExposeTime
|
||||
|
||||
exposure: KNXExposeSensor | KNXExposeTime
|
||||
if (
|
||||
isinstance(expose_type, str)
|
||||
and expose_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES
|
||||
):
|
||||
exposure = KnxExposeTime(
|
||||
exposure = KNXExposeTime(
|
||||
xknx=xknx,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
exposure = KnxExposeEntity(
|
||||
hass=hass,
|
||||
exposure = KNXExposeSensor(
|
||||
hass,
|
||||
xknx=xknx,
|
||||
entity_id=config[CONF_ENTITY_ID],
|
||||
options=(_yaml_config_to_expose_options(config),),
|
||||
config=config,
|
||||
)
|
||||
exposure.async_register()
|
||||
return exposure
|
||||
|
||||
|
||||
@callback
|
||||
def create_combined_knx_exposure(
|
||||
hass: HomeAssistant, xknx: XKNX, configs: list[ConfigType]
|
||||
) -> list[KnxExposeEntity | KnxExposeTime]:
|
||||
"""Create exposures from YAML config combined by entity_id."""
|
||||
exposures: list[KnxExposeEntity | KnxExposeTime] = []
|
||||
entity_exposure_map: dict[str, list[KnxExposeOptions]] = {}
|
||||
|
||||
for config in configs:
|
||||
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
|
||||
if value_type.lower() in ExposeSchema.EXPOSE_TIME_TYPES:
|
||||
time_exposure = KnxExposeTime(
|
||||
xknx=xknx,
|
||||
config=config,
|
||||
)
|
||||
time_exposure.async_register()
|
||||
exposures.append(time_exposure)
|
||||
continue
|
||||
|
||||
entity_id = config[CONF_ENTITY_ID]
|
||||
option = _yaml_config_to_expose_options(config)
|
||||
entity_exposure_map.setdefault(entity_id, []).append(option)
|
||||
|
||||
for entity_id, options in entity_exposure_map.items():
|
||||
entity_exposure = KnxExposeEntity(
|
||||
hass=hass,
|
||||
xknx=xknx,
|
||||
entity_id=entity_id,
|
||||
options=options,
|
||||
)
|
||||
entity_exposure.async_register()
|
||||
exposures.append(entity_exposure)
|
||||
return exposures
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KnxExposeOptions:
|
||||
"""Options for KNX Expose."""
|
||||
|
||||
attribute: str | None
|
||||
group_address: GroupAddress | InternalGroupAddress
|
||||
dpt: type[DPTBase]
|
||||
respond_to_read: bool
|
||||
cooldown: float
|
||||
default: Any | None
|
||||
value_template: Template | None
|
||||
|
||||
|
||||
def _yaml_config_to_expose_options(config: ConfigType) -> KnxExposeOptions:
|
||||
"""Convert single yaml expose config to KnxExposeOptions."""
|
||||
value_type = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
|
||||
dpt: type[DPTBase]
|
||||
if value_type == "binary":
|
||||
# HA yaml expose flag for DPT-1 (no explicit DPT 1 definitions in xknx back then)
|
||||
dpt = DPTSwitch
|
||||
else:
|
||||
dpt = DPTBase.parse_transcoder(config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]) # type: ignore[assignment] # checked by schema validation
|
||||
ga = parse_device_group_address(config[KNX_ADDRESS])
|
||||
return KnxExposeOptions(
|
||||
attribute=config.get(ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE),
|
||||
group_address=ga,
|
||||
dpt=dpt,
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
|
||||
default=config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT),
|
||||
value_template=config.get(CONF_VALUE_TEMPLATE),
|
||||
)
|
||||
|
||||
|
||||
class KnxExposeEntity:
|
||||
"""Expose Home Assistant entity values to KNX bus."""
|
||||
class KNXExposeSensor:
|
||||
"""Object to Expose Home Assistant entity to KNX bus."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
xknx: XKNX,
|
||||
entity_id: str,
|
||||
options: Iterable[KnxExposeOptions],
|
||||
config: ConfigType,
|
||||
) -> None:
|
||||
"""Initialize KnxExposeEntity class."""
|
||||
"""Initialize of Expose class."""
|
||||
self.hass = hass
|
||||
self.xknx = xknx
|
||||
self.entity_id = entity_id
|
||||
|
||||
self.entity_id: str = config[CONF_ENTITY_ID]
|
||||
self.expose_attribute: str | None = config.get(
|
||||
ExposeSchema.CONF_KNX_EXPOSE_ATTRIBUTE
|
||||
)
|
||||
self.expose_default = config.get(ExposeSchema.CONF_KNX_EXPOSE_DEFAULT)
|
||||
self.expose_type: int | str = config[ExposeSchema.CONF_KNX_EXPOSE_TYPE]
|
||||
self.value_template: Template | None = config.get(CONF_VALUE_TEMPLATE)
|
||||
|
||||
self._remove_listener: Callable[[], None] | None = None
|
||||
self._exposures = tuple(
|
||||
(
|
||||
option,
|
||||
ExposeSensor(
|
||||
xknx=self.xknx,
|
||||
name=f"{self.entity_id} {option.attribute or 'state'}",
|
||||
group_address=option.group_address,
|
||||
respond_to_read=option.respond_to_read,
|
||||
value_type=option.dpt,
|
||||
cooldown=option.cooldown,
|
||||
),
|
||||
)
|
||||
for option in options
|
||||
self.device: ExposeSensor = ExposeSensor(
|
||||
xknx=self.xknx,
|
||||
name=f"{self.entity_id}__{self.expose_attribute or 'state'}",
|
||||
group_address=config[KNX_ADDRESS],
|
||||
respond_to_read=config[CONF_RESPOND_TO_READ],
|
||||
value_type=self.expose_type,
|
||||
cooldown=config[ExposeSchema.CONF_KNX_EXPOSE_COOLDOWN],
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return name of the expose entity."""
|
||||
expose_names = [opt.attribute or "state" for opt, _ in self._exposures]
|
||||
return f"{self.entity_id}__{'__'.join(expose_names)}"
|
||||
|
||||
@callback
|
||||
def async_register(self) -> None:
|
||||
"""Register listener and XKNX devices."""
|
||||
"""Register listener."""
|
||||
self._remove_listener = async_track_state_change_event(
|
||||
self.hass, [self.entity_id], self._async_entity_changed
|
||||
)
|
||||
for _option, xknx_expose in self._exposures:
|
||||
self.xknx.devices.async_add(xknx_expose)
|
||||
self.xknx.devices.async_add(self.device)
|
||||
self._init_expose_state()
|
||||
|
||||
@callback
|
||||
def _init_expose_state(self) -> None:
|
||||
"""Initialize state of all exposures."""
|
||||
"""Initialize state of the exposure."""
|
||||
init_state = self.hass.states.get(self.entity_id)
|
||||
for option, xknx_expose in self._exposures:
|
||||
state_value = self._get_expose_value(init_state, option)
|
||||
try:
|
||||
xknx_expose.sensor_value.value = state_value
|
||||
except ConversionError:
|
||||
_LOGGER.exception(
|
||||
"Error setting value %s for expose sensor %s",
|
||||
state_value,
|
||||
xknx_expose.name,
|
||||
)
|
||||
state_value = self._get_expose_value(init_state)
|
||||
try:
|
||||
self.device.sensor_value.value = state_value
|
||||
except ConversionError:
|
||||
_LOGGER.exception("Error during sending of expose sensor value")
|
||||
|
||||
@callback
|
||||
def async_remove(self) -> None:
|
||||
@@ -208,57 +121,53 @@ class KnxExposeEntity:
|
||||
if self._remove_listener is not None:
|
||||
self._remove_listener()
|
||||
self._remove_listener = None
|
||||
for _option, xknx_expose in self._exposures:
|
||||
self.xknx.devices.async_remove(xknx_expose)
|
||||
self.xknx.devices.async_remove(self.device)
|
||||
|
||||
def _get_expose_value(
|
||||
self, state: State | None, option: KnxExposeOptions
|
||||
) -> bool | int | float | str | None:
|
||||
"""Extract value from state for a specific option."""
|
||||
def _get_expose_value(self, state: State | None) -> bool | int | float | str | None:
|
||||
"""Extract value from state."""
|
||||
if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
if option.default is None:
|
||||
if self.expose_default is None:
|
||||
return None
|
||||
value = option.default
|
||||
elif option.attribute is not None:
|
||||
_attr = state.attributes.get(option.attribute)
|
||||
value = _attr if _attr is not None else option.default
|
||||
value = self.expose_default
|
||||
elif self.expose_attribute is not None:
|
||||
_attr = state.attributes.get(self.expose_attribute)
|
||||
value = _attr if _attr is not None else self.expose_default
|
||||
else:
|
||||
value = state.state
|
||||
|
||||
if option.value_template is not None:
|
||||
if self.value_template is not None:
|
||||
try:
|
||||
value = option.value_template.async_render_with_possible_json_value(
|
||||
value = self.value_template.async_render_with_possible_json_value(
|
||||
value, error_value=None
|
||||
)
|
||||
except (TemplateError, TypeError, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"Error rendering value template for KNX expose %s %s %s: %s",
|
||||
self.entity_id,
|
||||
option.attribute or "state",
|
||||
option.value_template.template,
|
||||
"Error rendering value template for KNX expose %s %s: %s",
|
||||
self.device.name,
|
||||
self.value_template.template,
|
||||
err,
|
||||
)
|
||||
return None
|
||||
|
||||
if issubclass(option.dpt, DPT1BitEnum):
|
||||
if self.expose_type == "binary":
|
||||
if value in (1, STATE_ON, "True"):
|
||||
return True
|
||||
if value in (0, STATE_OFF, "False"):
|
||||
return False
|
||||
|
||||
# Handle numeric and string DPT conversions
|
||||
if value is not None:
|
||||
if value is not None and (
|
||||
isinstance(self.device.sensor_value, RemoteValueSensor)
|
||||
):
|
||||
try:
|
||||
if issubclass(option.dpt, DPTNumeric):
|
||||
if issubclass(self.device.sensor_value.dpt_class, DPTNumeric):
|
||||
return float(value)
|
||||
if issubclass(option.dpt, DPTString):
|
||||
if issubclass(self.device.sensor_value.dpt_class, DPTString):
|
||||
# DPT 16.000 only allows up to 14 Bytes
|
||||
return str(value)[:14]
|
||||
except (ValueError, TypeError) as err:
|
||||
_LOGGER.warning(
|
||||
'Could not expose %s %s value "%s" to KNX: Conversion failed: %s',
|
||||
self.entity_id,
|
||||
option.attribute or "state",
|
||||
self.expose_attribute or "state",
|
||||
value,
|
||||
err,
|
||||
)
|
||||
@@ -266,40 +175,32 @@ class KnxExposeEntity:
|
||||
return value # type: ignore[no-any-return]
|
||||
|
||||
async def _async_entity_changed(self, event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle entity change for all options."""
|
||||
"""Handle entity change."""
|
||||
new_state = event.data["new_state"]
|
||||
if (new_value := self._get_expose_value(new_state)) is None:
|
||||
return
|
||||
old_state = event.data["old_state"]
|
||||
# don't use default value for comparison on first state change (old_state is None)
|
||||
old_value = self._get_expose_value(old_state) if old_state is not None else None
|
||||
# don't send same value sequentially
|
||||
if new_value != old_value:
|
||||
await self._async_set_knx_value(new_value)
|
||||
|
||||
for option, xknx_expose in self._exposures:
|
||||
new_value = self._get_expose_value(new_state, option)
|
||||
if new_value is None:
|
||||
continue
|
||||
# Don't use default value for comparison on first state change
|
||||
old_value = (
|
||||
self._get_expose_value(old_state, option)
|
||||
if old_state is not None
|
||||
else None
|
||||
)
|
||||
# Don't send same value sequentially
|
||||
if new_value != old_value:
|
||||
await self._async_set_knx_value(xknx_expose, new_value)
|
||||
|
||||
async def _async_set_knx_value(
|
||||
self, xknx_expose: ExposeSensor, value: StateType
|
||||
) -> None:
|
||||
async def _async_set_knx_value(self, value: StateType) -> None:
|
||||
"""Set new value on xknx ExposeSensor."""
|
||||
try:
|
||||
await xknx_expose.set(value)
|
||||
await self.device.set(value)
|
||||
except ConversionError as err:
|
||||
_LOGGER.warning(
|
||||
'Could not expose %s value "%s" to KNX: %s',
|
||||
xknx_expose.name,
|
||||
'Could not expose %s %s value "%s" to KNX: %s',
|
||||
self.entity_id,
|
||||
self.expose_attribute or "state",
|
||||
value,
|
||||
err,
|
||||
)
|
||||
|
||||
|
||||
class KnxExposeTime:
|
||||
class KNXExposeTime:
|
||||
"""Object to Expose Time/Date object to KNX bus."""
|
||||
|
||||
def __init__(self, xknx: XKNX, config: ConfigType) -> None:
|
||||
@@ -321,11 +222,6 @@ class KnxExposeTime:
|
||||
group_address=config[KNX_ADDRESS],
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return name of the time expose object."""
|
||||
return f"expose_{self.device.name}"
|
||||
|
||||
@callback
|
||||
def async_register(self) -> None:
|
||||
"""Register listener."""
|
||||
|
||||
@@ -54,7 +54,7 @@ from .const import (
|
||||
TELEGRAM_LOG_DEFAULT,
|
||||
)
|
||||
from .device import KNXInterfaceDevice
|
||||
from .expose import KnxExposeEntity, KnxExposeTime
|
||||
from .expose import KNXExposeSensor, KNXExposeTime
|
||||
from .project import KNXProject
|
||||
from .repairs import data_secure_group_key_issue_dispatcher
|
||||
from .storage.config_store import KNXConfigStore
|
||||
@@ -73,8 +73,8 @@ class KNXModule:
|
||||
self.hass = hass
|
||||
self.config_yaml = config
|
||||
self.connected = False
|
||||
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
|
||||
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
|
||||
self.exposures: list[KNXExposeSensor | KNXExposeTime] = []
|
||||
self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {}
|
||||
self.entry = entry
|
||||
|
||||
self.project = KNXProject(hass=hass, entry=entry)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"loggers": ["xknx", "xknxproject"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==3.14.0",
|
||||
"xknx==3.13.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.12.30.151231"
|
||||
],
|
||||
|
||||
@@ -193,7 +193,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
|
||||
" for '%s' - %s"
|
||||
),
|
||||
group_address,
|
||||
replaced_exposure.name,
|
||||
replaced_exposure.device.name,
|
||||
)
|
||||
replaced_exposure.async_remove()
|
||||
exposure = create_knx_exposure(knx_module.hass, knx_module.xknx, call.data)
|
||||
@@ -201,7 +201,7 @@ async def service_exposure_register_modify(call: ServiceCall) -> None:
|
||||
_LOGGER.debug(
|
||||
"Service exposure_register registered exposure for '%s' - %s",
|
||||
group_address,
|
||||
exposure.name,
|
||||
exposure.device.name,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
},
|
||||
"conditions": {
|
||||
"is_off": {
|
||||
"description": "Tests if one or more lights are off.",
|
||||
"description": "Test if a light is off.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::condition_behavior_description%]",
|
||||
@@ -52,7 +52,7 @@
|
||||
"name": "If a light is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more lights are on.",
|
||||
"description": "Test if a light is on.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::light::common::condition_behavior_description%]",
|
||||
|
||||
@@ -7,7 +7,6 @@ from mill_local import OperationMode
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
@@ -112,16 +111,13 @@ class MillHeater(MillBaseEntity, ClimateEntity):
|
||||
super().__init__(coordinator, device)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature and optionally HVAC mode."""
|
||||
"""Set new target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
return
|
||||
await self.coordinator.mill_data_connection.set_heater_temp(
|
||||
self._id, float(temperature)
|
||||
)
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_handle_set_hvac_mode_service(hvac_mode)
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
@@ -129,11 +125,12 @@ class MillHeater(MillBaseEntity, ClimateEntity):
|
||||
await self.coordinator.mill_data_connection.heater_control(
|
||||
self._id, power_status=True
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.mill_data_connection.heater_control(
|
||||
self._id, power_status=False
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _update_attr(self, device: mill.Heater) -> None:
|
||||
@@ -192,26 +189,25 @@ class LocalMillHeater(CoordinatorEntity[MillDataUpdateCoordinator], ClimateEntit
|
||||
self._update_attr()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature and optionally HVAC mode."""
|
||||
"""Set new target temperature."""
|
||||
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None:
|
||||
return
|
||||
await self.coordinator.mill_data_connection.set_target_temperature(
|
||||
float(temperature)
|
||||
)
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None:
|
||||
await self.async_handle_set_hvac_mode_service(hvac_mode)
|
||||
else:
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
if hvac_mode == HVACMode.HEAT:
|
||||
await self.coordinator.mill_data_connection.set_operation_mode_control_individually()
|
||||
await self.coordinator.async_request_refresh()
|
||||
elif hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.mill_data_connection.set_operation_mode_off()
|
||||
await self.coordinator.async_request_refresh()
|
||||
elif hvac_mode == HVACMode.AUTO:
|
||||
await self.coordinator.mill_data_connection.set_operation_mode_weekly_program()
|
||||
await self.coordinator.async_request_refresh()
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.2"]
|
||||
"requirements": ["opower==0.16.1"]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["python-pooldose==0.8.2"]
|
||||
"requirements": ["python-pooldose==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ from .const import (
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
143
homeassistant/components/tibber/binary_sensor.py
Normal file
143
homeassistant/components/tibber/binary_sensor.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Support for Tibber binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from tibber.data_api import TibberDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, TibberConfigEntry
|
||||
from .coordinator import TibberDataAPICoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class TibberBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Describes Tibber binary sensor entity."""
|
||||
|
||||
is_on_fn: Callable[[str | None], bool | None]
|
||||
|
||||
|
||||
def _connector_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map connector status value to binary sensor state."""
|
||||
if value == "connected":
|
||||
return True
|
||||
if value == "disconnected":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _charging_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map charging status value to binary sensor state."""
|
||||
if value == "charging":
|
||||
return True
|
||||
if value == "idle":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
def _device_status_is_on(value: str | None) -> bool | None:
|
||||
"""Map device status value to binary sensor state."""
|
||||
if value == "on":
|
||||
return True
|
||||
if value == "off":
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
DATA_API_BINARY_SENSORS: tuple[TibberBinarySensorEntityDescription, ...] = (
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="connector.status",
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
is_on_fn={"connected": True, "disconnected": False}.get,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="charging.status",
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
is_on_fn={"charging": True, "idle": False}.get,
|
||||
),
|
||||
TibberBinarySensorEntityDescription(
|
||||
key="onOff",
|
||||
device_class=BinarySensorDeviceClass.POWER,
|
||||
is_on_fn={"on": True, "off": False}.get,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: TibberConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Tibber binary sensors."""
|
||||
coordinator = entry.runtime_data.data_api_coordinator
|
||||
assert coordinator is not None
|
||||
|
||||
entities: list[TibberDataAPIBinarySensor] = []
|
||||
api_binary_sensors = {sensor.key: sensor for sensor in DATA_API_BINARY_SENSORS}
|
||||
|
||||
for device in coordinator.data.values():
|
||||
for sensor in device.sensors:
|
||||
description: TibberBinarySensorEntityDescription | None = (
|
||||
api_binary_sensors.get(sensor.id)
|
||||
)
|
||||
if description is None:
|
||||
continue
|
||||
entities.append(TibberDataAPIBinarySensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class TibberDataAPIBinarySensor(
|
||||
CoordinatorEntity[TibberDataAPICoordinator], BinarySensorEntity
|
||||
):
|
||||
"""Representation of a Tibber Data API binary sensor."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
entity_description: TibberBinarySensorEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: TibberDataAPICoordinator,
|
||||
device: TibberDevice,
|
||||
entity_description: TibberBinarySensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the binary sensor."""
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._device_id: str = device.id
|
||||
self.entity_description = entity_description
|
||||
|
||||
self._attr_unique_id = f"{device.external_id}_{entity_description.key}"
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.external_id)},
|
||||
name=device.name,
|
||||
manufacturer=device.brand,
|
||||
model=device.model,
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and self._device_id in self.coordinator.sensors_by_device
|
||||
|
||||
@property
|
||||
def device(self) -> dict[str, tibber.data_api.Sensor]:
|
||||
return self.coordinator.sensors_by_device[self._device_id]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return the state of the binary sensor."""
|
||||
return self.entity_description.is_on_fn(self.device[self.entity_description.key])
|
||||
@@ -430,9 +430,6 @@ def _setup_data_api_sensors(
|
||||
for sensor in device.sensors:
|
||||
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
|
||||
if description is None:
|
||||
_LOGGER.debug(
|
||||
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
|
||||
)
|
||||
continue
|
||||
entities.append(TibberDataAPISensor(coordinator, device, description))
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==8.1.1", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==8.0.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -1011,7 +1011,7 @@ freebox-api==1.2.2
|
||||
freesms==0.2.0
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.1
|
||||
fressnapftracker==0.2.0
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1684,7 +1684,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.2
|
||||
opower==0.16.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2575,7 +2575,7 @@ python-overseerr==0.8.0
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.2
|
||||
python-pooldose==0.8.1
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
@@ -3081,7 +3081,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==8.1.1
|
||||
uiprotect==8.0.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -3219,7 +3219,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.4.1
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
xknx==3.13.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -890,7 +890,7 @@ forecast-solar==4.2.0
|
||||
freebox-api==1.2.2
|
||||
|
||||
# homeassistant.components.fressnapf_tracker
|
||||
fressnapftracker==0.2.1
|
||||
fressnapftracker==0.2.0
|
||||
|
||||
# homeassistant.components.fritz
|
||||
# homeassistant.components.fritzbox_callmonitor
|
||||
@@ -1458,7 +1458,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.2
|
||||
opower==0.16.1
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2165,7 +2165,7 @@ python-overseerr==0.8.0
|
||||
python-picnic-api2==1.3.1
|
||||
|
||||
# homeassistant.components.pooldose
|
||||
python-pooldose==0.8.2
|
||||
python-pooldose==0.8.1
|
||||
|
||||
# homeassistant.components.rabbitair
|
||||
python-rabbitair==0.0.8
|
||||
@@ -2575,7 +2575,7 @@ typedmonarchmoney==0.4.4
|
||||
uasiren==0.0.1
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==8.1.1
|
||||
uiprotect==8.0.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
@@ -2692,7 +2692,7 @@ wyoming==1.7.2
|
||||
xiaomi-ble==1.4.1
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.14.0
|
||||
xknx==3.13.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.8.2
|
||||
|
||||
@@ -24,12 +24,7 @@ def gather_info(arguments) -> Info:
|
||||
info = _gather_info(
|
||||
{
|
||||
"domain": {
|
||||
"prompt": (
|
||||
"""What is the domain?
|
||||
|
||||
Hint: The domain is a short name consisting of characters and underscores.
|
||||
This domain has to be unique, cannot be changed, and has to match the directory name of the integration."""
|
||||
),
|
||||
"prompt": "What is the domain?",
|
||||
"validators": [
|
||||
CHECK_EMPTY,
|
||||
[
|
||||
@@ -77,8 +72,13 @@ def gather_new_integration(determine_auth: bool) -> Info:
|
||||
},
|
||||
"codeowner": {
|
||||
"prompt": "What is your GitHub handle?",
|
||||
"validators": [CHECK_EMPTY],
|
||||
"converter": lambda value: value if value.startswith("@") else f"@{value}",
|
||||
"validators": [
|
||||
CHECK_EMPTY,
|
||||
[
|
||||
'GitHub handles need to start with an "@"',
|
||||
lambda value: value.startswith("@"),
|
||||
],
|
||||
],
|
||||
},
|
||||
"requirement": {
|
||||
"prompt": "What PyPI package and version do you depend on? Leave blank for none.",
|
||||
|
||||
@@ -172,90 +172,6 @@ class StateDescription(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class ConditionStateDescription(TypedDict):
|
||||
"""Test state and expected service call count."""
|
||||
|
||||
included: _StateDescription
|
||||
excluded: _StateDescription
|
||||
condition_true: bool
|
||||
state_valid: bool
|
||||
|
||||
|
||||
def parametrize_condition_states(
|
||||
*,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any] | None = None,
|
||||
target_states: list[str | None | tuple[str | None, dict]],
|
||||
other_states: list[str | None | tuple[str | None, dict]],
|
||||
additional_attributes: dict | None = None,
|
||||
) -> list[tuple[str, dict[str, Any], list[ConditionStateDescription]]]:
|
||||
"""Parametrize states and expected service call counts.
|
||||
|
||||
The target_states and other_states iterables are either iterables of
|
||||
states or iterables of (state, attributes) tuples.
|
||||
|
||||
Returns a list of tuples with (condition, condition options, list of states),
|
||||
where states is a list of ConditionStateDescription dicts.
|
||||
"""
|
||||
|
||||
additional_attributes = additional_attributes or {}
|
||||
condition_options = condition_options or {}
|
||||
|
||||
def state_with_attributes(
|
||||
state: str | None | tuple[str | None, dict],
|
||||
condition_true: bool,
|
||||
state_valid: bool,
|
||||
) -> ConditionStateDescription:
|
||||
"""Return (state, attributes) dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
"included": {
|
||||
"state": state,
|
||||
"attributes": additional_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state,
|
||||
"attributes": {},
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
"state_valid": state_valid,
|
||||
}
|
||||
return {
|
||||
"included": {
|
||||
"state": state[0],
|
||||
"attributes": state[1] | additional_attributes,
|
||||
},
|
||||
"excluded": {
|
||||
"state": state[0],
|
||||
"attributes": state[1],
|
||||
},
|
||||
"condition_true": condition_true,
|
||||
"state_valid": state_valid,
|
||||
}
|
||||
|
||||
return [
|
||||
(
|
||||
condition,
|
||||
condition_options,
|
||||
list(
|
||||
itertools.chain(
|
||||
(state_with_attributes(None, False, False),),
|
||||
(state_with_attributes(STATE_UNAVAILABLE, False, False),),
|
||||
(state_with_attributes(STATE_UNKNOWN, False, False),),
|
||||
(
|
||||
state_with_attributes(other_state, False, True)
|
||||
for other_state in other_states
|
||||
),
|
||||
(
|
||||
state_with_attributes(target_state, True, True)
|
||||
for target_state in target_states
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def parametrize_trigger_states(
|
||||
*,
|
||||
trigger: str,
|
||||
@@ -286,7 +202,7 @@ def parametrize_trigger_states(
|
||||
|
||||
def state_with_attributes(
|
||||
state: str | None | tuple[str | None, dict], count: int
|
||||
) -> StateDescription:
|
||||
) -> dict:
|
||||
"""Return (state, attributes) dict."""
|
||||
if isinstance(state, str) or state is None:
|
||||
return {
|
||||
|
||||
@@ -50,7 +50,7 @@ async def test_user_flow_success(
|
||||
# Submit SMS code
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -107,7 +107,7 @@ async def test_user_flow_request_sms_code_errors(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -142,7 +142,7 @@ async def test_user_flow_verify_phone_number_errors(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "999999"},
|
||||
{CONF_SMS_CODE: 999999},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -153,7 +153,7 @@ async def test_user_flow_verify_phone_number_errors(
|
||||
mock_auth_client.verify_phone_number.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
@@ -246,7 +246,7 @@ async def test_reauth_reconfigure_flow(
|
||||
# Submit SMS code
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -311,7 +311,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -358,7 +358,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "999999"},
|
||||
{CONF_SMS_CODE: 999999},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
@@ -369,7 +369,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
mock_auth_client.verify_phone_number.side_effect = None
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
@@ -436,7 +436,7 @@ async def test_reauth_reconfigure_flow_invalid_user_id(
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_SMS_CODE: "0123456"},
|
||||
{CONF_SMS_CODE: 123456},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Test light conditions."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -14,18 +13,24 @@ from homeassistant.const import (
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
parametrize_condition_states,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
INVALID_STATES = [
|
||||
{"state": STATE_UNAVAILABLE, "attributes": {}},
|
||||
{"state": STATE_UNKNOWN, "attributes": {}},
|
||||
{"state": None, "attributes": {}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
@@ -71,15 +76,15 @@ async def setup_automation_with_light_condition(
|
||||
)
|
||||
|
||||
|
||||
async def has_single_call_after_trigger(
|
||||
async def has_call_after_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> bool:
|
||||
"""Check if there is a single service call after the trigger event."""
|
||||
"""Check if there are service calls after the trigger event."""
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
num_calls = len(service_calls)
|
||||
has_calls = len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
return num_calls == 1
|
||||
return has_calls
|
||||
|
||||
|
||||
@pytest.fixture(name="enable_experimental_triggers_conditions")
|
||||
@@ -120,17 +125,17 @@ async def test_light_conditions_gated_by_labs_flag(
|
||||
parametrize_target_entities("light"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
("condition", "target_state", "other_state"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
(
|
||||
"light.is_on",
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
(
|
||||
"light.is_off",
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -143,15 +148,15 @@ async def test_light_state_condition_behavior_any(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
target_state: str,
|
||||
other_state: str,
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_lights) - {entity_id}
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
@@ -162,29 +167,38 @@ async def test_light_state_condition_behavior_any(
|
||||
)
|
||||
|
||||
# Set state for switches to ensure that they don't impact the condition
|
||||
for state in states:
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, state["included"])
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_single_call_after_trigger(hass, service_calls)
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
for eid in target_switches:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
await hass.async_block_till_done()
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to the condition state -> condition pass
|
||||
set_or_remove_state(hass, entity_id, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all remaining lights to the condition state -> condition pass
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set one light to the invalid state -> condition pass if there are
|
||||
# other lights in the condition state
|
||||
set_or_remove_state(hass, entity_id, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls) == bool(
|
||||
entities_in_target - 1
|
||||
)
|
||||
|
||||
# Check if changing other lights also passes the condition
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
await has_single_call_after_trigger(hass, service_calls)
|
||||
== state["condition_true"]
|
||||
)
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set all lights to invalid state -> condition fail
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, invalid_state)
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
|
||||
@@ -193,17 +207,17 @@ async def test_light_state_condition_behavior_any(
|
||||
parametrize_target_entities("light"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
("condition", "target_state", "other_state"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_on",
|
||||
target_states=[STATE_ON],
|
||||
other_states=[STATE_OFF],
|
||||
(
|
||||
"light.is_on",
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="light.is_off",
|
||||
target_states=[STATE_OFF],
|
||||
other_states=[STATE_ON],
|
||||
(
|
||||
"light.is_off",
|
||||
{"state": STATE_OFF, "attributes": {}},
|
||||
{"state": STATE_ON, "attributes": {}},
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -215,8 +229,8 @@ async def test_light_state_condition_behavior_all(
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
target_state: str,
|
||||
other_state: str,
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'all' behavior."""
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
@@ -227,7 +241,7 @@ async def test_light_state_condition_behavior_all(
|
||||
|
||||
# Set all lights, including the tested light, to the initial state
|
||||
for eid in target_lights:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
set_or_remove_state(hass, eid, other_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
@@ -237,22 +251,27 @@ async def test_light_state_condition_behavior_all(
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
# No lights on the condition state
|
||||
assert not await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
(not state["state_valid"])
|
||||
or (state["condition_true"] and entities_in_target == 1)
|
||||
)
|
||||
# Set one light to the condition state -> condition fail
|
||||
set_or_remove_state(hass, entity_id, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls) == (
|
||||
entities_in_target == 1
|
||||
)
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# Set all remaining lights to the condition state -> condition pass
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, target_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert await has_single_call_after_trigger(hass, service_calls) == (
|
||||
(not state["state_valid"]) or state["condition_true"]
|
||||
)
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set one light to the invalid state -> condition still pass
|
||||
set_or_remove_state(hass, entity_id, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
for invalid_state in INVALID_STATES:
|
||||
# Set all lights to unavailable -> condition passes
|
||||
for eid in other_entity_ids:
|
||||
set_or_remove_state(hass, eid, invalid_state)
|
||||
assert await has_call_after_trigger(hass, service_calls)
|
||||
|
||||
@@ -96,7 +96,7 @@ async def integration_fixture(
|
||||
"eve_energy_20ecn4101",
|
||||
"eve_energy_plug",
|
||||
"eve_energy_plug_patched",
|
||||
"eve_thermo_v4",
|
||||
"eve_thermo",
|
||||
"eve_thermo_v5",
|
||||
"eve_shutter",
|
||||
"eve_weather_sensor",
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"0/40/0": 17,
|
||||
"0/40/1": "Eve Systems",
|
||||
"0/40/2": 4874,
|
||||
"0/40/3": "Eve Thermo 20EBP1701",
|
||||
"0/40/3": "Eve Thermo",
|
||||
"0/40/4": 79,
|
||||
"0/40/5": "",
|
||||
"0/40/6": "**REDACTED**",
|
||||
@@ -391,7 +391,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing-entry]
|
||||
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -404,7 +404,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing',
|
||||
'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -426,20 +426,20 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing-state]
|
||||
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_local_temperature_remote_sensing-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Local temperature remote sensing',
|
||||
'friendly_name': 'Eve Thermo Local temperature remote sensing',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_local_temperature_remote_sensing',
|
||||
'entity_id': 'binary_sensor.eve_thermo_local_temperature_remote_sensing',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-entry]
|
||||
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -452,7 +452,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
|
||||
'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -474,13 +474,13 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[eve_thermo_v4][binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing-state]
|
||||
# name: test_binary_sensors[eve_thermo][binary_sensor.eve_thermo_outdoor_temperature_remote_sensing-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Outdoor temperature remote sensing',
|
||||
'friendly_name': 'Eve Thermo Outdoor temperature remote sensing',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.eve_thermo_20ebp1701_outdoor_temperature_remote_sensing',
|
||||
'entity_id': 'binary_sensor.eve_thermo_outdoor_temperature_remote_sensing',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -1418,7 +1418,7 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-entry]
|
||||
# name: test_buttons[eve_thermo][button.eve_thermo_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1431,7 +1431,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.eve_thermo_20ebp1701_identify',
|
||||
'entity_id': 'button.eve_thermo_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1453,14 +1453,14 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[eve_thermo_v4][button.eve_thermo_20ebp1701_identify-state]
|
||||
# name: test_buttons[eve_thermo][button.eve_thermo_identify-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'identify',
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Identify',
|
||||
'friendly_name': 'Eve Thermo Identify',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.eve_thermo_20ebp1701_identify',
|
||||
'entity_id': 'button.eve_thermo_identify',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_climates[eve_thermo_v4][climate.eve_thermo_20ebp1701-entry]
|
||||
# name: test_climates[eve_thermo][climate.eve_thermo-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -211,7 +211,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'climate',
|
||||
'entity_category': None,
|
||||
'entity_id': 'climate.eve_thermo_20ebp1701',
|
||||
'entity_id': 'climate.eve_thermo',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -233,11 +233,11 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_climates[eve_thermo_v4][climate.eve_thermo_20ebp1701-state]
|
||||
# name: test_climates[eve_thermo][climate.eve_thermo-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 21.0,
|
||||
'friendly_name': 'Eve Thermo 20EBP1701',
|
||||
'friendly_name': 'Eve Thermo',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
@@ -248,7 +248,7 @@
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.eve_thermo_20ebp1701',
|
||||
'entity_id': 'climate.eve_thermo',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -1037,7 +1037,7 @@
|
||||
'state': '3',
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[eve_thermo_v4][number.eve_thermo_20ebp1701_temperature_offset-entry]
|
||||
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1055,7 +1055,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.eve_thermo_20ebp1701_temperature_offset',
|
||||
'entity_id': 'number.eve_thermo_temperature_offset',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1077,11 +1077,11 @@
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_numbers[eve_thermo_v4][number.eve_thermo_20ebp1701_temperature_offset-state]
|
||||
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Temperature offset',
|
||||
'friendly_name': 'Eve Thermo Temperature offset',
|
||||
'max': 50,
|
||||
'min': -50,
|
||||
'mode': <NumberMode.BOX: 'box'>,
|
||||
@@ -1089,7 +1089,7 @@
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.eve_thermo_20ebp1701_temperature_offset',
|
||||
'entity_id': 'number.eve_thermo_temperature_offset',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -1400,7 +1400,7 @@
|
||||
'state': 'previous',
|
||||
})
|
||||
# ---
|
||||
# name: test_selects[eve_thermo_v4][select.eve_thermo_20ebp1701_temperature_display_mode-entry]
|
||||
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -1418,7 +1418,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.eve_thermo_20ebp1701_temperature_display_mode',
|
||||
'entity_id': 'select.eve_thermo_temperature_display_mode',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -1440,17 +1440,17 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_selects[eve_thermo_v4][select.eve_thermo_20ebp1701_temperature_display_mode-state]
|
||||
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Temperature display mode',
|
||||
'friendly_name': 'Eve Thermo Temperature display mode',
|
||||
'options': list([
|
||||
'Celsius',
|
||||
'Fahrenheit',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.eve_thermo_20ebp1701_temperature_display_mode',
|
||||
'entity_id': 'select.eve_thermo_temperature_display_mode',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -4904,7 +4904,7 @@
|
||||
'state': '100',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery-entry]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -4919,7 +4919,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_battery',
|
||||
'entity_id': 'sensor.eve_thermo_battery',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -4941,23 +4941,23 @@
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery-state]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Battery',
|
||||
'friendly_name': 'Eve Thermo Battery',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_battery',
|
||||
'entity_id': 'sensor.eve_thermo_battery',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '100',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery_voltage-entry]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -4972,7 +4972,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_battery_voltage',
|
||||
'entity_id': 'sensor.eve_thermo_battery_voltage',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -5000,23 +5000,23 @@
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_battery_voltage-state]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Battery voltage',
|
||||
'friendly_name': 'Eve Thermo Battery voltage',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_battery_voltage',
|
||||
'entity_id': 'sensor.eve_thermo_battery_voltage',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3.05',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_temperature-entry]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -5031,7 +5031,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_temperature',
|
||||
'entity_id': 'sensor.eve_thermo_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -5056,23 +5056,23 @@
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_temperature-state]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Temperature',
|
||||
'friendly_name': 'Eve Thermo Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_temperature',
|
||||
'entity_id': 'sensor.eve_thermo_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '21.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_valve_position-entry]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -5085,7 +5085,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_valve_position',
|
||||
'entity_id': 'sensor.eve_thermo_valve_position',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -5107,14 +5107,14 @@
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[eve_thermo_v4][sensor.eve_thermo_20ebp1701_valve_position-state]
|
||||
# name: test_sensors[eve_thermo][sensor.eve_thermo_valve_position-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Valve position',
|
||||
'friendly_name': 'Eve Thermo Valve position',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.eve_thermo_20ebp1701_valve_position',
|
||||
'entity_id': 'sensor.eve_thermo_valve_position',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -487,7 +487,7 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[eve_thermo_v4][switch.eve_thermo_20ebp1701_child_lock-entry]
|
||||
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -500,7 +500,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.eve_thermo_20ebp1701_child_lock',
|
||||
'entity_id': 'switch.eve_thermo_child_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -522,13 +522,13 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_switches[eve_thermo_v4][switch.eve_thermo_20ebp1701_child_lock-state]
|
||||
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Eve Thermo 20EBP1701 Child lock',
|
||||
'friendly_name': 'Eve Thermo Child lock',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.eve_thermo_20ebp1701_child_lock',
|
||||
'entity_id': 'switch.eve_thermo_child_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -201,7 +201,7 @@ async def test_battery_sensor_description(
|
||||
state = hass.states.get("sensor.smoke_sensor_battery_type") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"])
|
||||
@pytest.mark.parametrize("node_fixture", ["eve_thermo"])
|
||||
async def test_eve_thermo_sensor(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
@@ -209,26 +209,26 @@ async def test_eve_thermo_sensor(
|
||||
) -> None:
|
||||
"""Test Eve Thermo."""
|
||||
# Valve position
|
||||
state = hass.states.get("sensor.eve_thermo_20ebp1701_valve_position")
|
||||
state = hass.states.get("sensor.eve_thermo_valve_position")
|
||||
assert state
|
||||
assert state.state == "10"
|
||||
|
||||
set_node_attribute(matter_node, 1, 319486977, 319422488, 0)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
state = hass.states.get("sensor.eve_thermo_20ebp1701_valve_position")
|
||||
state = hass.states.get("sensor.eve_thermo_valve_position")
|
||||
assert state
|
||||
assert state.state == "0"
|
||||
|
||||
# LocalTemperature
|
||||
state = hass.states.get("sensor.eve_thermo_20ebp1701_temperature")
|
||||
state = hass.states.get("sensor.eve_thermo_temperature")
|
||||
assert state
|
||||
assert state.state == "21.0"
|
||||
|
||||
set_node_attribute(matter_node, 1, 513, 0, 1800)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
state = hass.states.get("sensor.eve_thermo_20ebp1701_temperature")
|
||||
state = hass.states.get("sensor.eve_thermo_temperature")
|
||||
assert state
|
||||
assert state.state == "18.0"
|
||||
|
||||
|
||||
@@ -116,32 +116,32 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non
|
||||
assert state.attributes["friendly_name"] == "Room AirConditioner Power"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"])
|
||||
@pytest.mark.parametrize("node_fixture", ["eve_thermo"])
|
||||
async def test_numeric_switch(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
matter_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test numeric switch entity is discovered and working using an Eve Thermo fixture ."""
|
||||
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
|
||||
state = hass.states.get("switch.eve_thermo_child_lock")
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
# name should be derived from description attribute
|
||||
assert state.attributes["friendly_name"] == "Eve Thermo 20EBP1701 Child lock"
|
||||
assert state.attributes["friendly_name"] == "Eve Thermo Child lock"
|
||||
# test attribute changes
|
||||
set_node_attribute(matter_node, 1, 516, 1, 1)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
|
||||
state = hass.states.get("switch.eve_thermo_child_lock")
|
||||
assert state.state == "on"
|
||||
set_node_attribute(matter_node, 1, 516, 1, 0)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
|
||||
state = hass.states.get("switch.eve_thermo_child_lock")
|
||||
assert state.state == "off"
|
||||
# test switch service
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_on",
|
||||
{"entity_id": "switch.eve_thermo_20ebp1701_child_lock"},
|
||||
{"entity_id": "switch.eve_thermo_child_lock"},
|
||||
blocking=True,
|
||||
)
|
||||
assert matter_client.write_attribute.call_count == 1
|
||||
@@ -156,7 +156,7 @@ async def test_numeric_switch(
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_off",
|
||||
{"entity_id": "switch.eve_thermo_20ebp1701_child_lock"},
|
||||
{"entity_id": "switch.eve_thermo_child_lock"},
|
||||
blocking=True,
|
||||
)
|
||||
assert matter_client.write_attribute.call_count == 2
|
||||
|
||||
@@ -1,572 +0,0 @@
|
||||
"""Tests for Mill climate."""
|
||||
|
||||
import contextlib
|
||||
from contextlib import nullcontext
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from mill import Heater
|
||||
from mill_local import OperationMode
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import mill
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_HVAC_MODE,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.mill.const import DOMAIN
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
HEATER_ID = "dev_id"
|
||||
HEATER_NAME = "heater_name"
|
||||
ENTITY_CLIMATE = f"climate.{HEATER_NAME}"
|
||||
|
||||
TEST_SET_TEMPERATURE = 25
|
||||
TEST_AMBIENT_TEMPERATURE = 20
|
||||
|
||||
NULL_EFFECT = nullcontext()
|
||||
|
||||
## MILL AND LOCAL MILL FIXTURES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_mill():
|
||||
"""Mock the mill.Mill object.
|
||||
|
||||
It is imported and initialized only in /homeassistant/components/mill/__init__.py
|
||||
"""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mill.Mill",
|
||||
autospec=True,
|
||||
) as mock_mill_class,
|
||||
):
|
||||
mill = mock_mill_class.return_value
|
||||
mill.connect.return_value = True
|
||||
mill.fetch_heater_and_sensor_data.return_value = {}
|
||||
mill.fetch_historic_energy_usage.return_value = {}
|
||||
yield mill
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_mill_local():
|
||||
"""Mock the mill_local.Mill object."""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.mill.MillLocal",
|
||||
autospec=True,
|
||||
) as mock_mill_local_class,
|
||||
):
|
||||
milllocal = mock_mill_local_class.return_value
|
||||
milllocal.url = "http://dummy.url"
|
||||
milllocal.name = HEATER_NAME
|
||||
milllocal.mac_address = "dead:beef"
|
||||
milllocal.version = "0x210927"
|
||||
milllocal.connect.return_value = {
|
||||
"name": milllocal.name,
|
||||
"mac_address": milllocal.mac_address,
|
||||
"version": milllocal.version,
|
||||
"operation_key": "",
|
||||
"status": "ok",
|
||||
}
|
||||
status = {
|
||||
"ambient_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"set_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"current_power": 0,
|
||||
"control_signal": 0,
|
||||
"raw_ambient_temperature": TEST_AMBIENT_TEMPERATURE,
|
||||
"operation_mode": OperationMode.OFF.value,
|
||||
}
|
||||
milllocal.fetch_heater_and_sensor_data.return_value = status
|
||||
milllocal._status = status
|
||||
yield milllocal
|
||||
|
||||
|
||||
## CLOUD HEATER INTEGRATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater(hass: HomeAssistant, mock_mill: MagicMock) -> Heater:
|
||||
"""Load Mill integration and creates one cloud heater."""
|
||||
|
||||
heater = Heater(
|
||||
name=HEATER_NAME,
|
||||
device_id=HEATER_ID,
|
||||
available=True,
|
||||
is_heating=False,
|
||||
power_status=False,
|
||||
current_temp=float(TEST_AMBIENT_TEMPERATURE),
|
||||
set_temp=float(TEST_AMBIENT_TEMPERATURE),
|
||||
)
|
||||
|
||||
devices = {HEATER_ID: heater}
|
||||
|
||||
mock_mill.fetch_heater_and_sensor_data.return_value = devices
|
||||
mock_mill.devices = devices
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
mill.CONF_USERNAME: "user",
|
||||
mill.CONF_PASSWORD: "pswd",
|
||||
mill.CONNECTION_TYPE: mill.CLOUD,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# We just need to load the climate component.
|
||||
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return heater
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater_set_temp(mock_mill: MagicMock, cloud_heater: MagicMock):
|
||||
"""Gets mock for the cloud heater `set_heater_temp` method."""
|
||||
return mock_mill.set_heater_temp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def cloud_heater_control(mock_mill: MagicMock, cloud_heater: MagicMock):
|
||||
"""Gets mock for the cloud heater `heater_control` method."""
|
||||
return mock_mill.heater_control
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def functional_cloud_heater(
|
||||
cloud_heater: MagicMock,
|
||||
cloud_heater_set_temp: MagicMock,
|
||||
cloud_heater_control: MagicMock,
|
||||
) -> Heater:
|
||||
"""Make sure the cloud heater is "functional".
|
||||
|
||||
This will create a pseudo-functional cloud heater,
|
||||
meaning that function calls will edit the original cloud heater
|
||||
in a similar way that the API would.
|
||||
"""
|
||||
|
||||
def calculate_heating():
|
||||
if (
|
||||
cloud_heater.power_status
|
||||
and cloud_heater.set_temp > cloud_heater.current_temp
|
||||
):
|
||||
cloud_heater.is_heating = True
|
||||
|
||||
def set_temperature(device_id: str, set_temp: float):
|
||||
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
|
||||
|
||||
cloud_heater.set_temp = set_temp
|
||||
|
||||
calculate_heating()
|
||||
|
||||
def heater_control(device_id: str, power_status: bool):
|
||||
assert device_id == HEATER_ID, "set_temperature called with wrong device_id"
|
||||
|
||||
# power_status gives the "do we want to heat, Y/N", while is_heating is based on temperature and internal state and whatnot.
|
||||
cloud_heater.power_status = power_status
|
||||
|
||||
calculate_heating()
|
||||
|
||||
cloud_heater_set_temp.side_effect = set_temperature
|
||||
cloud_heater_control.side_effect = heater_control
|
||||
|
||||
return cloud_heater
|
||||
|
||||
|
||||
## LOCAL HEATER INTEGRATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater(hass: HomeAssistant, mock_mill_local: MagicMock) -> dict:
|
||||
"""Local Mill Heater.
|
||||
|
||||
This returns a by-reference status dict
|
||||
with which this heater's information is organised and updated.
|
||||
"""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
mill.CONF_IP_ADDRESS: "192.168.1.59",
|
||||
mill.CONNECTION_TYPE: mill.LOCAL,
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
# We just need to load the climate component.
|
||||
with patch("homeassistant.components.mill.PLATFORMS", [Platform.CLIMATE]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_mill_local._status
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_target_temperature(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_target_temperature` method."""
|
||||
return mock_mill_local.set_target_temperature
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_mode_control_individually(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_operation_mode_control_individually` method."""
|
||||
return mock_mill_local.set_operation_mode_control_individually
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def local_heater_set_mode_off(
|
||||
mock_mill_local: MagicMock, local_heater: MagicMock
|
||||
):
|
||||
"""Gets mock for the local heater `set_operation_mode_off` method."""
|
||||
return mock_mill_local.set_operation_mode_off
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def functional_local_heater(
|
||||
mock_mill_local: MagicMock,
|
||||
local_heater_set_target_temperature: MagicMock,
|
||||
local_heater_set_mode_control_individually: MagicMock,
|
||||
local_heater_set_mode_off: MagicMock,
|
||||
local_heater: MagicMock,
|
||||
) -> None:
|
||||
"""Make sure the local heater is "functional".
|
||||
|
||||
This will create a pseudo-functional local heater,
|
||||
meaning that function calls will edit the original local heater
|
||||
in a similar way that the API would.
|
||||
"""
|
||||
|
||||
def set_temperature(target_temperature: float):
|
||||
local_heater["set_temperature"] = target_temperature
|
||||
|
||||
def set_operation_mode(operation_mode: OperationMode):
|
||||
local_heater["operation_mode"] = operation_mode.value
|
||||
|
||||
def mode_control_individually():
|
||||
set_operation_mode(OperationMode.CONTROL_INDIVIDUALLY)
|
||||
|
||||
def mode_off():
|
||||
set_operation_mode(OperationMode.OFF)
|
||||
|
||||
local_heater_set_target_temperature.side_effect = set_temperature
|
||||
local_heater_set_mode_control_individually.side_effect = mode_control_individually
|
||||
local_heater_set_mode_off.side_effect = mode_off
|
||||
|
||||
|
||||
### CLOUD
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"before_state",
|
||||
"before_attrs",
|
||||
"service_name",
|
||||
"service_params",
|
||||
"effect",
|
||||
"heater_control_calls",
|
||||
"heater_set_temp_calls",
|
||||
"after_state",
|
||||
"after_attrs",
|
||||
),
|
||||
[
|
||||
# set_hvac_mode
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=True)],
|
||||
[],
|
||||
HVACMode.HEAT,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=False)],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
[],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
# set_temperature (with hvac mode)
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=True)],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.HEAT,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[call(HEATER_ID, power_status=False)],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
# MillHeater will set the temperature before calling async_handle_set_hvac_mode,
|
||||
# meaning an invalid HVAC mode will raise only after the temperature is set.
|
||||
[],
|
||||
[call(HEATER_ID, float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
|
||||
# because the exception is raised before a refresh can be requested from the coordinator
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_cloud_heater(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
functional_cloud_heater: MagicMock,
|
||||
cloud_heater_control: MagicMock,
|
||||
cloud_heater_set_temp: MagicMock,
|
||||
before_state: HVACMode,
|
||||
before_attrs: dict,
|
||||
service_name: str,
|
||||
service_params: dict,
|
||||
effect: "contextlib.AbstractContextManager",
|
||||
heater_control_calls: list,
|
||||
heater_set_temp_calls: list,
|
||||
after_state: HVACMode,
|
||||
after_attrs: dict,
|
||||
) -> None:
|
||||
"""Tests setting HVAC mode (directly or through set_temperature) for a cloud heater."""
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == before_state
|
||||
for attr, value in before_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
with effect:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
service_name,
|
||||
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
cloud_heater_control.assert_has_calls(heater_control_calls)
|
||||
cloud_heater_set_temp.assert_has_calls(heater_set_temp_calls)
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == after_state
|
||||
for attr, value in after_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
|
||||
### LOCAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"before_state",
|
||||
"before_attrs",
|
||||
"service_name",
|
||||
"service_params",
|
||||
"effect",
|
||||
"heater_mode_set_individually_calls",
|
||||
"heater_mode_set_off_calls",
|
||||
"heater_set_target_temperature_calls",
|
||||
"after_state",
|
||||
"after_attrs",
|
||||
),
|
||||
[
|
||||
# set_hvac_mode
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call()],
|
||||
[],
|
||||
[],
|
||||
HVACMode.HEAT,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call()],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
HVACMode.OFF,
|
||||
{},
|
||||
),
|
||||
# set_temperature (with hvac mode)
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
NULL_EFFECT,
|
||||
[call()],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.HEAT,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[call()],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
NULL_EFFECT,
|
||||
[],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE},
|
||||
),
|
||||
(
|
||||
HVACMode.OFF,
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_TEMPERATURE: TEST_SET_TEMPERATURE, ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
pytest.raises(HomeAssistantError),
|
||||
# LocalMillHeater will set the temperature before calling async_handle_set_hvac_mode,
|
||||
# meaning an invalid HVAC mode will raise only after the temperature is set.
|
||||
[],
|
||||
[],
|
||||
[call(float(TEST_SET_TEMPERATURE))],
|
||||
HVACMode.OFF,
|
||||
# likewise, in this test, it hasn't had the chance to update its ambient temperature,
|
||||
# because the exception is raised before a refresh can be requested from the coordinator
|
||||
{ATTR_TEMPERATURE: TEST_AMBIENT_TEMPERATURE},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_local_heater(
|
||||
hass: HomeAssistant,
|
||||
functional_local_heater: MagicMock,
|
||||
local_heater_set_mode_control_individually: MagicMock,
|
||||
local_heater_set_mode_off: MagicMock,
|
||||
local_heater_set_target_temperature: MagicMock,
|
||||
before_state: HVACMode,
|
||||
before_attrs: dict,
|
||||
service_name: str,
|
||||
service_params: dict,
|
||||
effect: "contextlib.AbstractContextManager",
|
||||
heater_mode_set_individually_calls: list,
|
||||
heater_mode_set_off_calls: list,
|
||||
heater_set_target_temperature_calls: list,
|
||||
after_state: HVACMode,
|
||||
after_attrs: dict,
|
||||
) -> None:
|
||||
"""Tests setting HVAC mode (directly or through set_temperature) for a local heater."""
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == before_state
|
||||
for attr, value in before_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
|
||||
with effect:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
service_name,
|
||||
service_params | {ATTR_ENTITY_ID: ENTITY_CLIMATE},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
local_heater_set_mode_control_individually.assert_has_calls(
|
||||
heater_mode_set_individually_calls
|
||||
)
|
||||
local_heater_set_mode_off.assert_has_calls(heater_mode_set_off_calls)
|
||||
local_heater_set_target_temperature.assert_has_calls(
|
||||
heater_set_target_temperature_calls
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_CLIMATE)
|
||||
assert state is not None
|
||||
assert state.state == after_state
|
||||
for attr, value in after_attrs.items():
|
||||
assert state.attributes.get(attr) == value
|
||||
150
tests/components/tibber/test_binary_sensor.py
Normal file
150
tests/components/tibber/test_binary_sensor.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Tests for the Tibber binary sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import tibber
|
||||
|
||||
from homeassistant.components.recorder import Recorder
|
||||
from homeassistant.components.tibber.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
def create_tibber_device_with_binary_sensors(
|
||||
device_id: str = "device-id",
|
||||
external_id: str = "external-id",
|
||||
name: str = "Test Device",
|
||||
brand: str = "Tibber",
|
||||
model: str = "Gen1",
|
||||
connector_status: str | None = "connected",
|
||||
charging_status: str | None = "charging",
|
||||
device_status: str | None = "on",
|
||||
home_id: str = "home-id",
|
||||
) -> tibber.data_api.TibberDevice:
|
||||
"""Create a fake Tibber Data API device with binary sensor capabilities."""
|
||||
device_data = {
|
||||
"id": device_id,
|
||||
"externalId": external_id,
|
||||
"info": {
|
||||
"name": name,
|
||||
"brand": brand,
|
||||
"model": model,
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"id": "connector.status",
|
||||
"value": connector_status,
|
||||
"description": "Connector status",
|
||||
"unit": "",
|
||||
},
|
||||
{
|
||||
"id": "charging.status",
|
||||
"value": charging_status,
|
||||
"description": "Charging status",
|
||||
"unit": "",
|
||||
},
|
||||
{
|
||||
"id": "onOff",
|
||||
"value": device_status,
|
||||
"description": "Device status",
|
||||
"unit": "",
|
||||
},
|
||||
],
|
||||
}
|
||||
return tibber.data_api.TibberDevice(device_data, home_id=home_id)
|
||||
|
||||
|
||||
async def test_binary_sensors_are_created(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Ensure binary sensors are created from Data API devices."""
|
||||
device = create_tibber_device_with_binary_sensors()
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
connector_unique_id = "external-id_connector.status"
|
||||
connector_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, connector_unique_id
|
||||
)
|
||||
assert connector_entity_id is not None
|
||||
state = hass.states.get(connector_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
charging_unique_id = "external-id_charging.status"
|
||||
charging_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, charging_unique_id
|
||||
)
|
||||
assert charging_entity_id is not None
|
||||
state = hass.states.get(charging_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
device_unique_id = "external-id_onOff"
|
||||
device_entity_id = entity_registry.async_get_entity_id(
|
||||
"binary_sensor", DOMAIN, device_unique_id
|
||||
)
|
||||
assert device_entity_id is not None
|
||||
state = hass.states.get(device_entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_device_status_on(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test device status on state."""
|
||||
device = create_tibber_device_with_binary_sensors(device_status="on")
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unique_id = "external-id_onOff"
|
||||
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
|
||||
assert entity_id is not None
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_device_status_off(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
data_api_client_mock: AsyncMock,
|
||||
setup_credentials: None,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test device status off state."""
|
||||
device = create_tibber_device_with_binary_sensors(device_status="off")
|
||||
data_api_client_mock.get_all_devices = AsyncMock(return_value={"device-id": device})
|
||||
data_api_client_mock.update_devices = AsyncMock(return_value={"device-id": device})
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unique_id = "external-id_onOff"
|
||||
entity_id = entity_registry.async_get_entity_id("binary_sensor", DOMAIN, unique_id)
|
||||
assert entity_id is not None
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
Reference in New Issue
Block a user