Merge branch 'dev' into esphome_bronze

This commit is contained in:
J. Nick Koston 2025-04-21 08:44:50 -10:00 committed by GitHub
commit 3c4f540ce0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 2017 additions and 1328 deletions

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.11"]
"requirements": ["aioairzone-cloud==0.6.12"]
}

View File

@ -21,7 +21,7 @@
"entity": {
"binary_sensor": {
"off_grid_status": {
"name": "Off grid status"
"name": "Off-grid status"
},
"dc_1_short_circuit_error_status": {
"name": "DC 1 short circuit error status"

View File

@ -26,7 +26,7 @@
"sensor": {
"threshold": {
"state": {
"error": "Error",
"error": "[%key:common::state::error%]",
"green": "Green",
"yellow": "Yellow",
"red": "Red"

View File

@ -30,7 +30,7 @@
"available": "Available",
"charging": "[%key:common::state::charging%]",
"unavailable": "Unavailable",
"error": "Error",
"error": "[%key:common::state::error%]",
"offline": "Offline"
}
},
@ -41,7 +41,7 @@
"vehicle_detected": "Detected",
"ready": "Ready",
"no_power": "No power",
"vehicle_error": "Error"
"vehicle_error": "[%key:common::state::error%]"
}
},
"actual_v1": {

View File

@ -139,7 +139,7 @@
"state": {
"default": "Default",
"charging": "[%key:common::state::charging%]",
"error": "Error",
"error": "[%key:common::state::error%]",
"complete": "Complete",
"fully_charged": "Fully charged",
"finished_fully_charged": "Finished, fully charged",

View File

@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
from fnmatch import translate
from functools import lru_cache, partial
@ -66,13 +65,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import DHCPMatcher, async_get_dhcp
from .const import DOMAIN
from . import websocket_api
from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS
from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
HOSTNAME: Final = "hostname"
MAC_ADDRESS: Final = "macaddress"
IP_ADDRESS: Final = "ip"
REGISTERED_DEVICES: Final = "registered_devices"
SCAN_INTERVAL = timedelta(minutes=60)
@ -87,15 +85,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant(
)
@dataclass(slots=True)
class DhcpMatchers:
"""Prepared info from dhcp entries."""
registered_devices_domains: set[str]
no_oui_matchers: dict[str, list[DHCPMatcher]]
oui_matchers: dict[str, list[DHCPMatcher]]
def async_index_integration_matchers(
integration_matchers: list[DHCPMatcher],
) -> DhcpMatchers:
@ -133,36 +122,34 @@ def async_index_integration_matchers(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the dhcp component."""
watchers: list[WatcherBase] = []
address_data: dict[str, dict[str, str]] = {}
integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass))
dhcp_data = DHCPData(integration_matchers=integration_matchers)
hass.data[DATA_DHCP] = dhcp_data
websocket_api.async_setup(hass)
watchers: list[WatcherBase] = []
# For the passive classes we need to start listening
# for state changes and connect the dispatchers before
# everything else starts up or we will miss events
device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers)
device_watcher = DeviceTrackerWatcher(hass, dhcp_data)
device_watcher.async_start()
watchers.append(device_watcher)
device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(
hass, address_data, integration_matchers
)
device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data)
device_tracker_registered_watcher.async_start()
watchers.append(device_tracker_registered_watcher)
async def _async_initialize(event: Event) -> None:
await aiodhcpwatcher.async_init()
network_watcher = NetworkWatcher(hass, address_data, integration_matchers)
network_watcher = NetworkWatcher(hass, dhcp_data)
network_watcher.async_start()
watchers.append(network_watcher)
dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers)
dhcp_watcher = DHCPWatcher(hass, dhcp_data)
await dhcp_watcher.async_start()
watchers.append(dhcp_watcher)
rediscovery_watcher = RediscoveryWatcher(
hass, address_data, integration_matchers
)
rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data)
rediscovery_watcher.async_start()
watchers.append(rediscovery_watcher)
@ -180,18 +167,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
class WatcherBase:
"""Base class for dhcp and device tracker watching."""
def __init__(
self,
hass: HomeAssistant,
address_data: dict[str, dict[str, str]],
integration_matchers: DhcpMatchers,
) -> None:
def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None:
"""Initialize class."""
super().__init__()
self.hass = hass
self._integration_matchers = integration_matchers
self._address_data = address_data
self._callbacks = dhcp_data.callbacks
self._integration_matchers = dhcp_data.integration_matchers
self._address_data = dhcp_data.address_data
self._unsub: Callable[[], None] | None = None
@callback
@ -230,18 +212,18 @@ class WatcherBase:
mac_address = formatted_mac.replace(":", "")
compressed_ip_address = made_ip_address.compressed
data = self._address_data.get(mac_address)
current_data = self._address_data.get(mac_address)
if (
not force
and data
and data[IP_ADDRESS] == compressed_ip_address
and data[HOSTNAME].startswith(hostname)
and current_data
and current_data[IP_ADDRESS] == compressed_ip_address
and current_data[HOSTNAME].startswith(hostname)
):
# If the address data is the same no need
# to process it
return
data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname}
data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname}
self._address_data[mac_address] = data
lowercase_hostname = hostname.lower()
@ -287,9 +269,19 @@ class WatcherBase:
_LOGGER.debug("Matched %s against %s", data, matcher)
matched_domains.add(domain)
if not matched_domains:
return # avoid creating DiscoveryKey if there are no matches
if self._callbacks:
address_data = {mac_address: data}
for callback_ in self._callbacks:
callback_(address_data)
service_info: _DhcpServiceInfo | None = None
if not matched_domains:
return
service_info = _DhcpServiceInfo(
ip=ip_address,
hostname=lowercase_hostname,
macaddress=mac_address,
)
discovery_key = DiscoveryKey(
domain=DOMAIN,
key=mac_address,
@ -300,11 +292,7 @@ class WatcherBase:
self.hass,
domain,
{"source": config_entries.SOURCE_DHCP},
_DhcpServiceInfo(
ip=ip_address,
hostname=lowercase_hostname,
macaddress=mac_address,
),
service_info,
discovery_key=discovery_key,
)
@ -315,11 +303,10 @@ class NetworkWatcher(WatcherBase):
def __init__(
self,
hass: HomeAssistant,
address_data: dict[str, dict[str, str]],
integration_matchers: DhcpMatchers,
dhcp_data: DHCPData,
) -> None:
"""Initialize class."""
super().__init__(hass, address_data, integration_matchers)
super().__init__(hass, dhcp_data)
self._discover_hosts: DiscoverHosts | None = None
self._discover_task: asyncio.Task | None = None

View File

@ -1,3 +1,8 @@
"""Constants for the dhcp integration."""
from typing import Final
DOMAIN = "dhcp"
HOSTNAME: Final = "hostname"
MAC_ADDRESS: Final = "macaddress"
IP_ADDRESS: Final = "ip"

View File

@ -0,0 +1,37 @@
"""The dhcp integration."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from .models import DATA_DHCP, DHCPAddressData
@callback
def async_register_dhcp_callback_internal(
hass: HomeAssistant,
callback_: Callable[[dict[str, DHCPAddressData]], None],
) -> CALLBACK_TYPE:
"""Register a dhcp callback.
For internal use only.
This is not intended for use by integrations.
"""
callbacks = hass.data[DATA_DHCP].callbacks
callbacks.add(callback_)
return partial(callbacks.remove, callback_)
@callback
def async_get_address_data_internal(
hass: HomeAssistant,
) -> dict[str, DHCPAddressData]:
"""Get the address data.
For internal use only.
This is not intended for use by integrations.
"""
return hass.data[DATA_DHCP].address_data

View File

@ -0,0 +1,43 @@
"""The dhcp integration."""
from __future__ import annotations
from collections.abc import Callable
import dataclasses
from dataclasses import dataclass
from typing import TypedDict
from homeassistant.loader import DHCPMatcher
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
@dataclass(slots=True)
class DhcpMatchers:
"""Prepared info from dhcp entries."""
registered_devices_domains: set[str]
no_oui_matchers: dict[str, list[DHCPMatcher]]
oui_matchers: dict[str, list[DHCPMatcher]]
class DHCPAddressData(TypedDict):
"""Typed dict for DHCP address data."""
hostname: str
ip: str
@dataclasses.dataclass(slots=True)
class DHCPData:
"""Data for the dhcp component."""
integration_matchers: DhcpMatchers
callbacks: set[Callable[[dict[str, DHCPAddressData]], None]] = dataclasses.field(
default_factory=set
)
address_data: dict[str, DHCPAddressData] = dataclasses.field(default_factory=dict)
DATA_DHCP: HassKey[DHCPData] = HassKey(DOMAIN)

View File

@ -0,0 +1,63 @@
"""The dhcp integration websocket apis."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.json import json_bytes
from .const import HOSTNAME, IP_ADDRESS
from .helpers import (
async_get_address_data_internal,
async_register_dhcp_callback_internal,
)
from .models import DHCPAddressData
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the DHCP websocket API."""
websocket_api.async_register_command(hass, ws_subscribe_discovery)
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "dhcp/subscribe_discovery",
}
)
@websocket_api.async_response
async def ws_subscribe_discovery(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe discovery websocket command."""
ws_msg_id: int = msg["id"]
def _async_send(address_data: dict[str, DHCPAddressData]) -> None:
connection.send_message(
json_bytes(
websocket_api.event_message(
ws_msg_id,
{
"add": [
{
"mac_address": dr.format_mac(mac_address).upper(),
"hostname": data[HOSTNAME],
"ip_address": data[IP_ADDRESS],
}
for mac_address, data in address_data.items()
]
},
)
)
)
unsub = async_register_dhcp_callback_internal(hass, _async_send)
connection.subscriptions[ws_msg_id] = unsub
connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id)))
_async_send(async_get_address_data_internal(hass))

View File

@ -57,6 +57,7 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._host = entry_data[CONF_HOST]
self._port = entry_data[CONF_PORT]
self._password = entry_data[CONF_PASSWORD]
self._name = self._reauth_entry.title
self._device_name = entry_data.get(CONF_DEVICE_NAME)
self._name = self._reauth_entry.title
# Device without encryption allows fetching device info. We can then check
# if the device is no longer using a password. If we did try with a password,
@ -147,7 +148,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_reauth_confirm(
@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors,
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_reconfigure(
@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@property
def _name(self) -> str:
return self.__name or "ESPHome"
return self.__name or DEFAULT_NAME
@_name.setter
def _name(self, value: str) -> None:
self.__name = value
self.context["title_placeholders"] = {"name": self._name}
self.context["title_placeholders"] = {
"name": self._async_get_human_readable_name()
}
async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
"""Try to fetch device info and return any errors."""
@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
return await self._async_try_fetch_device_info()
return self.async_show_form(
step_id="discovery_confirm", description_placeholders={"name": self._name}
step_id="discovery_confirm",
description_placeholders={"name": self._async_get_human_readable_name()},
)
async def async_step_zeroconf(
@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Hostname is format: livingroom.local.
device_name = discovery_info.hostname.removesuffix(".local.")
self._name = discovery_info.properties.get("friendly_name", device_name)
self._device_name = device_name
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host
self._port = discovery_info.port
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
@ -306,7 +310,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
updates[CONF_HOST] = host
if port is not None:
updates[CONF_PORT] = port
self._abort_if_unique_id_configured(updates=updates)
self._abort_unique_id_configured_with_details(updates=updates)
@callback
def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None:
"""Abort if unique_id is already configured with details."""
assert self.unique_id is not None
if not (
conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id(
self.handler, self.unique_id
)
):
return
assert conflict_entry.unique_id is not None
if updates:
error = "already_configured_updates"
else:
error = "already_configured_detailed"
self._abort_if_unique_id_configured(
updates=updates,
error=error,
description_placeholders={
"title": conflict_entry.title,
"name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"),
"mac": format_mac(conflict_entry.unique_id),
},
)
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
@ -341,7 +370,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if already configured
await self.async_set_unique_id(mac_address)
self._abort_if_unique_id_configured(
self._abort_unique_id_configured_with_details(
updates={CONF_HOST: self._host, CONF_PORT: self._port}
)
@ -479,7 +508,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
data=self._reauth_entry.data | self._async_make_config_data(),
)
assert self._host is not None
self._abort_if_unique_id_configured(
self._abort_unique_id_configured_with_details(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
@ -510,7 +539,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if not (
unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id)
):
self._abort_if_unique_id_configured(
self._abort_unique_id_configured_with_details(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
@ -568,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="encryption_key",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors,
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
)
@callback
def _async_get_human_readable_name(self) -> str:
"""Return a human readable name for the entry."""
entry: ConfigEntry | None = None
if self.source == SOURCE_REAUTH:
entry = self._reauth_entry
elif self.source == SOURCE_RECONFIGURE:
entry = self._reconfig_entry
friendly_name = self._name
device_name = self._device_name
if (
device_name
and friendly_name in (DEFAULT_NAME, device_name)
and entry
and entry.title != friendly_name
):
friendly_name = entry.title
if not device_name or friendly_name == device_name:
return friendly_name
return f"{friendly_name} ({device_name})"
async def async_step_authenticate(
self, user_input: dict[str, Any] | None = None, error: str | None = None
) -> ConfigFlowResult:
@ -589,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="authenticate",
data_schema=vol.Schema({vol.Required("password"): str}),
description_placeholders={"name": self._name},
description_placeholders={"name": self._async_get_human_readable_name()},
errors=errors,
)
@ -623,9 +673,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error"
finally:
await cli.disconnect(force=True)
self._name = self._device_info.friendly_name or self._device_info.name
self._device_name = self._device_info.name
self._device_mac = format_mac(self._device_info.mac_address)
self._device_name = self._device_info.name
self._name = self._device_info.friendly_name or self._device_info.name
return None
async def fetch_device_info(self) -> str | None:
@ -640,7 +690,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
self._abort_if_unique_id_configured(
self._abort_unique_id_configured_with_details(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,

View File

@ -224,7 +224,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
if entity_info.name:
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
else:
# https://github.com/home-assistant/core/issues/132532
# If name is not set, ESPHome will use the sanitized friendly name
# as the name, however we want to use the original object_id
# as the entity_id before it is sanitized since the sanitizer
# is not utf-8 aware. In this case, its always going to be
# an empty string so we drop the object_id.
self.entity_id = f"{domain}.{device_info.name}"
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
@ -260,7 +269,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._static_info = static_info
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
self._attr_name = static_info.name
# https://github.com/home-assistant/core/issues/132532
# If the name is "", we need to set it to None since otherwise
# the friendly_name will be "{friendly_name} " with a trailing
# space. ESPHome uses protobuf under the hood, and an empty field
# gets a default value of "".
self._attr_name = static_info.name if static_info.name else None
if entity_category := static_info.entity_category:
self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category)
else:

View File

@ -2,6 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.",
"already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"mdns_missing_mac": "Missing MAC address in mDNS properties.",
@ -41,7 +43,7 @@
"data_description": {
"password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead."
},
"description": "Please enter the password you set in your ESPHome device YAML configuration for {name}."
"description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`."
},
"encryption_key": {
"data": {
@ -50,7 +52,7 @@
"data_description": {
"noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration."
},
"description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
"description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
},
"reauth_confirm": {
"data": {
@ -59,10 +61,10 @@
"data_description": {
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]"
},
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
"description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
},
"reauth_encryption_removed_confirm": {
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
"description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
},
"discovery_confirm": {
"description": "Do you want to add the device `{name}` to Home Assistant?",

View File

@ -184,7 +184,7 @@
"running": "Running",
"standby": "[%key:common::state::standby%]",
"bootloading": "Bootloading",
"error": "Error",
"error": "[%key:common::state::error%]",
"idle": "[%key:common::state::idle%]",
"ready": "Ready",
"sleeping": "Sleeping"

View File

@ -36,7 +36,7 @@
"name": "Inverter operation mode",
"state": {
"general": "General mode",
"off_grid": "Off grid mode",
"off_grid": "Off-grid mode",
"backup": "Backup mode",
"eco": "Eco mode",
"peak_shaving": "Peak shaving mode",

View File

@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
if not self.is_on or not kwargs:
await self.coordinator.turn_on(self._device)
if ATTR_BRIGHTNESS in kwargs:
brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0)
await self.coordinator.set_brightness(self._device, brightness)
@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
self._save_last_color_state()
await self.coordinator.set_scene(self._device, effect)
if not self.is_on or not kwargs:
await self.coordinator.turn_on(self._device)
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:

View File

@ -54,7 +54,7 @@ class HistoryStats:
self._period = (MIN_TIME_UTC, MIN_TIME_UTC)
self._state: HistoryStatsState = HistoryStatsState(None, None, self._period)
self._history_current_period: list[HistoryState] = []
self._previous_run_before_start = False
self._has_recorder_data = False
self._entity_states = set(entity_states)
self._duration = duration
self._start = start
@ -88,20 +88,20 @@ class HistoryStats:
if current_period_start_timestamp > now_timestamp:
# History cannot tell the future
self._history_current_period = []
self._previous_run_before_start = True
self._has_recorder_data = False
self._state = HistoryStatsState(None, None, self._period)
return self._state
#
# We avoid querying the database if the below did NOT happen:
#
# - The previous run happened before the start time
# - The start time changed
# - The period shrank in size
# - No previous run occurred (uninitialized)
# - The start time moved back in time
# - The end time moved back in time
# - The previous period ended before now
#
if (
not self._previous_run_before_start
and current_period_start_timestamp == previous_period_start_timestamp
self._has_recorder_data
and current_period_start_timestamp >= previous_period_start_timestamp
and (
current_period_end_timestamp == previous_period_end_timestamp
or (
@ -110,6 +110,12 @@ class HistoryStats:
)
)
):
start_changed = (
current_period_start_timestamp != previous_period_start_timestamp
)
if start_changed:
self._prune_history_cache(current_period_start_timestamp)
new_data = False
if event and (new_state := event.data["new_state"]) is not None:
if (
@ -121,7 +127,11 @@ class HistoryStats:
HistoryState(new_state.state, new_state.last_changed_timestamp)
)
new_data = True
if not new_data and current_period_end_timestamp < now_timestamp:
if (
not new_data
and current_period_end_timestamp < now_timestamp
and not start_changed
):
# If period has not changed and current time after the period end...
# Don't compute anything as the value cannot have changed
return self._state
@ -139,7 +149,7 @@ class HistoryStats:
HistoryState(new_state.state, new_state.last_changed_timestamp)
)
self._previous_run_before_start = False
self._has_recorder_data = True
seconds_matched, match_count = self._async_compute_seconds_and_changes(
now_timestamp,
@ -223,3 +233,18 @@ class HistoryStats:
# Save value in seconds
seconds_matched = elapsed
return seconds_matched, match_count
def _prune_history_cache(self, start_timestamp: float) -> None:
"""Remove unnecessary old data from the history state cache from previous runs.
Update the timestamp of the last record from before the start to the current start time.
"""
trim_count = 0
for i, history_state in enumerate(self._history_current_period):
if history_state.last_changed >= start_timestamp:
break
history_state.last_changed = start_timestamp
if i > 0:
trim_count += 1
if trim_count: # Don't slice if no data was removed
self._history_current_period = self._history_current_period[trim_count:]

View File

@ -1536,7 +1536,7 @@
"pause": "[%key:common::state::paused%]",
"actionrequired": "Action required",
"finished": "Finished",
"error": "Error",
"error": "[%key:common::state::error%]",
"aborting": "Aborting"
}
},
@ -1587,7 +1587,7 @@
"streaminglocal": "Streaming local",
"streamingcloud": "Streaming cloud",
"streaminglocal_and_cloud": "Streaming local and cloud",
"error": "Error"
"error": "[%key:common::state::error%]"
}
},
"last_selected_map": {

View File

@ -61,7 +61,7 @@ reload_config_entry:
required: false
example: 8955375327824e14ba89e4b29cc3ec9a
selector:
text:
config_entry:
save_persistent_states:

View File

@ -34,7 +34,7 @@
},
"select": {
"preferred_network_mode": {
"default": "mdi:transmission-tower"
"default": "mdi:antenna"
}
},
"switch": {

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling",
"loggers": ["huawei_lte_api.Session"],
"requirements": [
"huawei-lte-api==1.10.0",
"huawei-lte-api==1.11.0",
"stringcase==1.2.0",
"url-normalize==2.2.0"
],

View File

@ -181,7 +181,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
"cell_id": HuaweiSensorEntityDescription(
key="cell_id",
translation_key="cell_id",
icon="mdi:transmission-tower",
icon="mdi:antenna",
entity_category=EntityCategory.DIAGNOSTIC,
),
"cqi0": HuaweiSensorEntityDescription(
@ -230,6 +230,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
"enodeb_id": HuaweiSensorEntityDescription(
key="enodeb_id",
translation_key="enodeb_id",
icon="mdi:antenna",
entity_category=EntityCategory.DIAGNOSTIC,
),
"lac": HuaweiSensorEntityDescription(
@ -364,7 +365,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
"pci": HuaweiSensorEntityDescription(
key="pci",
translation_key="pci",
icon="mdi:transmission-tower",
icon="mdi:antenna",
entity_category=EntityCategory.DIAGNOSTIC,
),
"plmn": HuaweiSensorEntityDescription(

View File

@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
ERROR_KEY_LIST = [
"no_error",
ERROR_KEYS = [
"alarm_mower_in_motion",
"alarm_mower_lifted",
"alarm_mower_stopped",
@ -50,13 +49,11 @@ ERROR_KEY_LIST = [
"alarm_outside_geofence",
"angular_sensor_problem",
"battery_problem",
"battery_problem",
"battery_restriction_due_to_ambient_temperature",
"can_error",
"charging_current_too_high",
"charging_station_blocked",
"charging_system_problem",
"charging_system_problem",
"collision_sensor_defect",
"collision_sensor_error",
"collision_sensor_problem_front",
@ -67,24 +64,18 @@ ERROR_KEY_LIST = [
"connection_changed",
"connection_not_changed",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_problem",
"connectivity_settings_restored",
"cutting_drive_motor_1_defect",
"cutting_drive_motor_2_defect",
"cutting_drive_motor_3_defect",
"cutting_height_blocked",
"cutting_height_problem",
"cutting_height_problem_curr",
"cutting_height_problem_dir",
"cutting_height_problem_drive",
"cutting_height_problem",
"cutting_motor_problem",
"cutting_stopped_slope_too_steep",
"cutting_system_blocked",
"cutting_system_blocked",
"cutting_system_imbalance_warning",
"cutting_system_major_imbalance",
"destination_not_reachable",
@ -92,13 +83,9 @@ ERROR_KEY_LIST = [
"docking_sensor_defect",
"electronic_problem",
"empty_battery",
MowerStates.ERROR.lower(),
MowerStates.ERROR_AT_POWER_UP.lower(),
MowerStates.FATAL_ERROR.lower(),
"folding_cutting_deck_sensor_defect",
"folding_sensor_activated",
"geofence_problem",
"geofence_problem",
"gps_navigation_problem",
"guide_1_not_found",
"guide_2_not_found",
@ -116,7 +103,6 @@ ERROR_KEY_LIST = [
"lift_sensor_defect",
"lifted",
"limited_cutting_height_range",
"limited_cutting_height_range",
"loop_sensor_defect",
"loop_sensor_problem_front",
"loop_sensor_problem_left",
@ -129,6 +115,7 @@ ERROR_KEY_LIST = [
"no_accurate_position_from_satellites",
"no_confirmed_position",
"no_drive",
"no_error",
"no_loop_signal",
"no_power_in_charging_station",
"no_response_from_charger",
@ -139,9 +126,6 @@ ERROR_KEY_LIST = [
"safety_function_faulty",
"settings_restored",
"sim_card_locked",
"sim_card_locked",
"sim_card_locked",
"sim_card_locked",
"sim_card_not_found",
"sim_card_requires_pin",
"slipped_mower_has_slipped_situation_not_solved_with_moving_pattern",
@ -151,13 +135,6 @@ ERROR_KEY_LIST = [
"stuck_in_charging_station",
"switch_cord_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"temporary_battery_problem",
"tilt_sensor_problem",
"too_high_discharge_current",
"too_high_internal_current",
@ -189,11 +166,19 @@ ERROR_KEY_LIST = [
"zone_generator_problem",
]
ERROR_STATES = {
MowerStates.ERROR,
ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
MowerStates.ERROR,
MowerStates.FATAL_ERROR,
}
MowerStates.OFF,
MowerStates.STOPPED,
MowerStates.WAIT_POWER_UP,
MowerStates.WAIT_UPDATING,
]
ERROR_KEY_LIST = list(
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
)
RESTRICTED_REASONS: list = [
RestrictedReasons.ALL_WORK_AREAS_COMPLETED,
@ -292,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="cutting_blade_usage_time",
translation_key="cutting_blade_usage_time",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
@ -302,6 +288,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="downtime",
translation_key="downtime",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,
@ -386,6 +373,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="uptime",
translation_key="uptime",
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
entity_registry_enabled_default=False,

View File

@ -106,10 +106,10 @@
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
"cutting_height_blocked": "Cutting height blocked",
"cutting_height_problem": "Cutting height problem",
"cutting_height_problem_curr": "Cutting height problem, curr",
"cutting_height_problem_dir": "Cutting height problem, dir",
"cutting_height_problem_drive": "Cutting height problem, drive",
"cutting_height_problem": "Cutting height problem",
"cutting_motor_problem": "Cutting motor problem",
"cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep",
"cutting_system_blocked": "Cutting system blocked",
@ -120,8 +120,8 @@
"docking_sensor_defect": "Docking sensor defect",
"electronic_problem": "Electronic problem",
"empty_battery": "Empty battery",
"error": "Error",
"error_at_power_up": "Error at power up",
"error": "[%key:common::state::error%]",
"fatal_error": "Fatal error",
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
"folding_sensor_activated": "Folding sensor activated",
@ -159,6 +159,7 @@
"no_loop_signal": "No loop signal",
"no_power_in_charging_station": "No power in charging station",
"no_response_from_charger": "No response from charger",
"off": "[%key:common::state::off%]",
"outside_working_area": "Outside working area",
"poor_signal_quality": "Poor signal quality",
"reference_station_communication_problem": "Reference station communication problem",
@ -172,6 +173,7 @@
"slope_too_steep": "Slope too steep",
"sms_could_not_be_sent": "SMS could not be sent",
"stop_button_problem": "STOP button problem",
"stopped": "[%key:common::state::stopped%]",
"stuck_in_charging_station": "Stuck in charging station",
"switch_cord_problem": "Switch cord problem",
"temporary_battery_problem": "Temporary battery problem",
@ -187,6 +189,8 @@
"unexpected_cutting_height_adj": "Unexpected cutting height adjustment",
"unexpected_error": "Unexpected error",
"upside_down": "Upside down",
"wait_power_up": "Wait power up",
"wait_updating": "Wait updating",
"weak_gps_signal": "Weak GPS signal",
"wheel_drive_problem_left": "Left wheel drive problem",
"wheel_drive_problem_rear_left": "Rear left wheel drive problem",

View File

@ -56,7 +56,7 @@
"on": "[%key:common::state::on%]",
"warming": "Warming",
"cooling": "Cooling",
"error": "Error"
"error": "[%key:common::state::error%]"
}
}
}

View File

@ -4,7 +4,7 @@
"_": {
"name": "[%key:component::lawn_mower::title%]",
"state": {
"error": "Error",
"error": "[%key:common::state::error%]",
"paused": "[%key:common::state::paused%]",
"mowing": "Mowing",
"docked": "Docked",

View File

@ -88,7 +88,7 @@
"available": "Available",
"charging": "[%key:common::state::charging%]",
"connected": "[%key:common::state::connected%]",
"error": "Error",
"error": "[%key:common::state::error%]",
"locked": "[%key:common::state::locked%]",
"need_auth": "Waiting for authentication",
"paused": "[%key:common::state::paused%]",
@ -118,7 +118,7 @@
"ocpp": "OCPP",
"overtemperature": "Overtemperature",
"switching_phases": "Switching phases",
"1p_charging_disabled": "1p charging disabled"
"1p_charging_disabled": "1P charging disabled"
}
},
"breaker_current": {

View File

@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
"requirements": ["python-linkplay==0.2.3"],
"requirements": ["python-linkplay==0.2.4"],
"zeroconf": ["_linkplay._tcp.local."]
}

View File

@ -24,8 +24,10 @@ from .const import (
SERVICE_SET_LEVEL,
)
from .helpers import (
DATA_LOGGER,
LoggerDomainConfig,
LoggerSettings,
_clear_logger_overwrites, # noqa: F401
set_default_log_level,
set_log_levels,
)
@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
settings = LoggerSettings(hass, config)
domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings)
domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings)
logging.setLoggerClass(_get_logger_class(domain_config.overrides))
websocket_api.async_load_websocket_api(hass)

View File

@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass
from enum import StrEnum
from functools import lru_cache
import logging
from typing import Any, cast
from typing import Any
from homeassistant.const import EVENT_LOGGING_CHANGED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.hass_dict import HassKey
from .const import (
DOMAIN,
@ -28,6 +29,8 @@ from .const import (
STORAGE_VERSION,
)
DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN)
SAVE_DELAY = 15.0
# At startup, we want to save after a long delay to avoid
# saving while the system is still starting up. If the system
@ -39,12 +42,6 @@ SAVE_DELAY = 15.0
SAVE_DELAY_LONG = 180.0
@callback
def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig:
"""Return the domain config."""
return cast(LoggerDomainConfig, hass.data[DOMAIN])
@callback
def set_default_log_level(hass: HomeAssistant, level: int) -> None:
"""Set the default log level for components."""
@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None:
@callback
def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None:
"""Set the specified log levels."""
async_get_domain_config(hass).overrides.update(logpoints)
hass.data[DATA_LOGGER].overrides.update(logpoints)
for key, value in logpoints.items():
_set_log_level(logging.getLogger(key), value)
hass.bus.async_fire(EVENT_LOGGING_CHANGED)
@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int:
return min(level1, level2)
@callback
def _clear_logger_overwrites(hass: HomeAssistant) -> None:
"""Clear logger overwrites. Used for testing."""
hass.data[DATA_LOGGER].overrides.clear()
async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]:
"""Get loggers for an integration."""
loggers: set[str] = {f"homeassistant.components.{domain}"}

View File

@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations
from .const import LOGSEVERITY
from .helpers import (
DATA_LOGGER,
LoggerSetting,
LogPersistance,
LogSettingsType,
async_get_domain_config,
get_logger,
)
@ -68,7 +68,7 @@ async def handle_integration_log_level(
msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found"
)
return
await async_get_domain_config(hass).settings.async_update(
await hass.data[DATA_LOGGER].settings.async_update(
hass,
msg["integration"],
LoggerSetting(
@ -93,7 +93,7 @@ async def handle_module_log_level(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle setting integration log level."""
await async_get_domain_config(hass).settings.async_update(
await hass.data[DATA_LOGGER].settings.async_update(
hass,
msg["module"],
LoggerSetting(

View File

@ -270,7 +270,7 @@
"stopped": "[%key:common::state::stopped%]",
"running": "Running",
"paused": "[%key:common::state::paused%]",
"error": "Error",
"error": "[%key:common::state::error%]",
"seeking_charger": "Seeking charger",
"charging": "[%key:common::state::charging%]",
"docked": "Docked"

View File

@ -8,6 +8,6 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "bronze",
"requirements": ["pymiele==0.3.4"],
"requirements": ["pymiele==0.3.6"],
"single_config_entry": true
}

View File

@ -185,6 +185,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_config_entry_device(
hass: HomeAssistant,
config_entry: ConfigEntry,
device_entry: dr.DeviceEntry,
) -> bool:
"""Remove NUT config entry from a device."""
return not any(
identifier
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
and identifier[1] in config_entry.runtime_data.unique_id
)
async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@ -19,7 +19,6 @@ from homeassistant.const import (
CONF_PORT,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@ -56,7 +55,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema:
return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)})
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from _base_schema with values provided by the user.
@ -303,7 +302,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
info: dict[str, Any] = {}
description_placeholders: dict[str, str] = {}
try:
info = await validate_input(self.hass, config)
info = await validate_input(config)
except NUTLoginError:
errors[CONF_PASSWORD] = "invalid_auth"
except NUTError as ex:
@ -320,8 +319,6 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth."""
entry_id = self.context["entry_id"]
self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@ -330,17 +327,16 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle reauth input."""
errors: dict[str, str] = {}
existing_entry = self.reauth_entry
assert existing_entry
existing_data = existing_entry.data
reauth_entry = self._get_reauth_entry()
reauth_data = reauth_entry.data
description_placeholders: dict[str, str] = {
CONF_HOST: existing_data[CONF_HOST],
CONF_PORT: existing_data[CONF_PORT],
CONF_HOST: reauth_data[CONF_HOST],
CONF_PORT: reauth_data[CONF_PORT],
}
if user_input is not None:
new_config = {
**existing_data,
**reauth_data,
# Username/password are optional and some servers
# use ip based authentication and will fail if
# username/password are provided
@ -349,9 +345,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
}
_, errors, placeholders = await self._async_validate_or_error(new_config)
if not errors:
return self.async_update_reload_and_abort(
existing_entry, data=new_config
)
return self.async_update_reload_and_abort(reauth_entry, data=new_config)
description_placeholders.update(placeholders)
return self.async_show_form(

View File

@ -1,10 +1,5 @@
{
"entity": {
"button": {
"outlet_number_load_cycle": {
"default": "mdi:restart"
}
},
"sensor": {
"ambient_humidity_status": {
"default": "mdi:information-outline"

View File

@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
STATE_UNKNOWN,
EntityCategory,
UnitOfApparentPower,
UnitOfElectricCurrent,
@ -1120,9 +1119,9 @@ class NUTSensor(NUTBaseEntity, SensorEntity):
return status.get(self.entity_description.key)
def _format_display_state(status: dict[str, str]) -> str:
def _format_display_state(status: dict[str, str]) -> str | None:
"""Return UPS display state."""
try:
return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
except KeyError:
return STATE_UNKNOWN
return None

View File

@ -20,6 +20,9 @@
"title": "Choose the NUT server UPS to monitor",
"data": {
"alias": "NUT server UPS name"
},
"data_description": {
"alias": "The UPS name configured on the NUT server."
}
},
"reauth_confirm": {
@ -27,6 +30,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::nut::config::step::user::data_description::username%]",
"password": "[%key:component::nut::config::step::user::data_description::password%]"
}
},
"reconfigure": {
@ -48,6 +55,9 @@
"title": "[%key:component::nut::config::step::ups::title%]",
"data": {
"alias": "[%key:component::nut::config::step::ups::data::alias%]"
},
"data_description": {
"alias": "[%key:component::nut::config::step::ups::data_description::alias%]"
}
}
},

View File

@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
from pyoverkiz.exceptions import (
BadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyRequestsException,
)
@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
scenarios = await client.get_scenarios()
else:
scenarios = []
except (BadCredentialsException, NotSuchTokenException) as exception:
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsException as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception

View File

@ -13,6 +13,7 @@ from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
OverkizCommandParam.INTERNAL: HVACMode.AUTO,
}
OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = {
OverkizCommandParam.STANDBY: HVACAction.IDLE,
OverkizCommandParam.INCREASE: HVACAction.HEATING,
OverkizCommandParam.NONE: HVACAction.OFF,
}
HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()}
TEMPERATURE_SENSOR_DEVICE_INDEX = 2
@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode]
)
@property
def hvac_action(self) -> HVACAction:
"""Return the current running hvac operation ie. heating, idle, off."""
states = self.device.states
if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str:
return OVERKIZ_TO_HVAC_ACTION[state.value_as_str]
return HVACAction.OFF
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp."""

View File

@ -13,12 +13,12 @@ from pyoverkiz.exceptions import (
BadCredentialsException,
CozyTouchBadCredentialsException,
MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException,
TooManyAttemptsBannedException,
TooManyRequestsException,
UnknownUserException,
)
from pyoverkiz.models import OverkizServer
from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
import voluptuous as vol
@ -31,7 +31,6 @@ from homeassistant.const import (
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER
class DeveloperModeDisabled(HomeAssistantError):
"""Error to indicate Somfy Developer Mode is disabled."""
class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Overkiz (by Somfy)."""
VERSION = 1
_verify_ssl: bool = True
_api_type: APIType = APIType.CLOUD
_user: str | None = None
_server: str = DEFAULT_SERVER
@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
"""Validate user credentials."""
user_input[CONF_API_TYPE] = self._api_type
client = self._create_cloud_client(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
)
await client.login(register_event_listener=False)
# For Local API, we create and activate a local token
if self._api_type == APIType.LOCAL:
user_input[CONF_TOKEN] = await self._create_local_api_token(
cloud_client=client,
host=user_input[CONF_HOST],
user_input[CONF_VERIFY_SSL] = self._verify_ssl
session = async_create_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = OverkizClient(
username="",
password="",
token=user_input[CONF_TOKEN],
session=session,
server=generate_local_server(host=user_input[CONF_HOST]),
verify_ssl=user_input[CONF_VERIFY_SSL],
)
else: # APIType.CLOUD
session = async_create_clientsession(self.hass)
client = OverkizClient(
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
session=session,
)
await client.login(register_event_listener=False)
# Set main gateway id as unique id
if gateways := await client.get_gateways():
for gateway in gateways:
if is_overkiz_gateway(gateway.id):
gateway_id = gateway.id
await self.async_set_unique_id(gateway_id, raise_on_progress=False)
await self.async_set_unique_id(gateway.id, raise_on_progress=False)
break
return user_input
@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
self._user = user_input[CONF_USERNAME]
# inherit the server from previous step
user_input[CONF_HUB] = self._server
try:
await self.async_validate_input(user_input)
except TooManyRequestsException:
errors["base"] = "too_many_requests"
except BadCredentialsException as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
# If authentication with CozyTouch auth server is valid, but token is invalid
# for Overkiz API server, the hardware is not supported.
if user_input[CONF_HUB] in {
@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input:
self._host = user_input[CONF_HOST]
self._user = user_input[CONF_USERNAME]
# inherit the server from previous step
self._verify_ssl = user_input[CONF_VERIFY_SSL]
user_input[CONF_HUB] = self._server
try:
user_input = await self.async_validate_input(user_input)
except TooManyRequestsException:
errors["base"] = "too_many_requests"
except BadCredentialsException:
except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
):
errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception:
errors["base"] = "certificate_verify_failed"
@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException:
errors["base"] = "too_many_attempts"
except NotSuchTokenException:
errors["base"] = "no_such_token"
except DeveloperModeDisabled:
errors["base"] = "developer_mode_disabled"
except UnknownUserException:
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema(
{
vol.Required(CONF_HOST, default=self._host): str,
vol.Required(CONF_USERNAME, default=self._user): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool,
}
),
description_placeholders=description_placeholders,
@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth."""
# overkiz entries always have unique IDs
# Overkiz entries always have unique IDs
self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)}
self._user = entry_data[CONF_USERNAME]
self._server = entry_data[CONF_HUB]
self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD)
self._server = entry_data[CONF_HUB]
if self._api_type == APIType.LOCAL:
self._host = entry_data[CONF_HOST]
self._verify_ssl = entry_data[CONF_VERIFY_SSL]
else:
self._user = entry_data[CONF_USERNAME]
return await self.async_step_user(dict(entry_data))
def _create_cloud_client(
self, username: str, password: str, server: OverkizServer
) -> OverkizClient:
session = async_create_clientsession(self.hass)
return OverkizClient(
username=username, password=password, server=server, session=session
)
async def _create_local_api_token(
self, cloud_client: OverkizClient, host: str, verify_ssl: bool
) -> str:
"""Create local API token."""
# Create session on Somfy cloud server to generate an access token for local API
gateways = await cloud_client.get_gateways()
gateway_id = ""
for gateway in gateways:
# Overkiz can return multiple gateways, but we only can generate a token
# for the main gateway.
if is_overkiz_gateway(gateway.id):
gateway_id = gateway.id
developer_mode = await cloud_client.get_setup_option(
f"developerMode-{gateway_id}"
)
if developer_mode is None:
raise DeveloperModeDisabled
token = await cloud_client.generate_local_token(gateway_id)
await cloud_client.activate_local_token(
gateway_id=gateway_id, token=token, label="Home Assistant/local"
)
session = async_create_clientsession(self.hass, verify_ssl=verify_ssl)
# Local API
local_client = OverkizClient(
username="",
password="",
token=token,
session=session,
server=generate_local_server(host=host),
verify_ssl=verify_ssl,
)
await local_client.login()
return token

View File

@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""Fetch Overkiz data via event listener."""
try:
events = await self.client.fetch_events()
except BadCredentialsException as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsException as exception:
raise UpdateFailed("Too many concurrent requests.") from exception
@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try:
await self.client.login()
self.devices = await self._get_devices()
except BadCredentialsException as exception:
except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsException as exception:
raise UpdateFailed("Too many requests, try again later.") from exception

View File

@ -0,0 +1,20 @@
{
"entity": {
"climate": {
"overkiz": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "mdi:thermostat-auto",
"comfort-1": "mdi:thermometer",
"comfort-2": "mdi:thermometer-low",
"frost_protection": "mdi:snowflake",
"prog": "mdi:clock-outline",
"external": "mdi:remote"
}
}
}
}
}
}
}

View File

@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.17.0"],
"requirements": ["pyoverkiz==1.17.1"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",

View File

@ -32,17 +32,15 @@
}
},
"local": {
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.",
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"token": "[%key:common::config_flow::data::api_token%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"host": "The hostname or IP address of your Overkiz hub.",
"username": "The username of your cloud account (app).",
"password": "The password of your cloud account (app).",
"token": "Token generated by the app used to control your device.",
"verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
}
}
@ -73,8 +71,8 @@
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]",
"comfort-1": "Comfort 1",
"comfort-2": "Comfort 2",
"comfort-1": "Comfort -1°C",
"comfort-2": "Comfort -2°C",
"drying": "Drying",
"external": "External",
"freeze": "Freeze",

View File

@ -108,7 +108,7 @@
"name": "State",
"state": {
"charging": "[%key:common::state::charging%]",
"error": "Error",
"error": "[%key:common::state::error%]",
"fault": "Fault",
"invalid": "Invalid",
"no_ev_connected": "No EV connected",

View File

@ -37,7 +37,7 @@
"paused": "[%key:common::state::paused%]",
"finished": "Finished",
"stopped": "[%key:common::state::stopped%]",
"error": "Error",
"error": "[%key:common::state::error%]",
"attention": "Attention",
"ready": "Ready"
}

View File

@ -84,8 +84,10 @@
"options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"area": "[%key:component::sensor::entity_component::area::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",

View File

@ -232,7 +232,7 @@
"charging_problem": "Charging problem",
"paused": "[%key:common::state::paused%]",
"spot_cleaning": "Spot cleaning",
"error": "Error",
"error": "[%key:common::state::error%]",
"shutting_down": "Shutting down",
"updating": "Updating",
"docking": "Docking",

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/schlage",
"iot_class": "cloud_polling",
"requirements": ["pyschlage==2024.11.0"]
"requirements": ["pyschlage==2025.4.0"]
}

View File

@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
"requirements": ["aioshelly==13.4.1"],
"requirements": ["aioshelly==13.5.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",

View File

@ -1,34 +1,10 @@
"""Support to control a Salda Smarty XP/XV ventilation unit."""
import ipaddress
import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import SmartyConfigEntry, SmartyCoordinator
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
vol.Optional(CONF_NAME, default="Smarty"): cv.string,
}
)
},
extra=vol.ALLOW_EXTRA,
)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@ -38,54 +14,6 @@ PLATFORMS = [
]
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Create a smarty system."""
if config := hass_config.get(DOMAIN):
hass.async_create_task(_async_import(hass, config))
return True
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
"""Set up the smarty environment."""
if not hass.config_entries.async_entries(DOMAIN):
# Start import flow
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
if result["type"] == FlowResultType.ABORT:
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Smarty",
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Smarty",
},
)
async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool:
"""Set up the Smarty environment from a config entry."""

View File

@ -7,7 +7,7 @@ from pysmarty2 import Smarty
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.const import CONF_HOST
from .const import DOMAIN
@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors,
)
async def async_step_import(
self, import_config: dict[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by import."""
error = await self.hass.async_add_executor_job(
self._test_connection, import_config[CONF_HOST]
)
if not error:
return self.async_create_entry(
title=import_config[CONF_NAME],
data={CONF_HOST: import_config[CONF_HOST]},
)
return self.async_abort(reason=error)

View File

@ -3,7 +3,7 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN
from .const import DOMAIN
from .coordinator import SmartyCoordinator

View File

@ -20,20 +20,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"issues": {
"deprecated_yaml_import_issue_unknown": {
"title": "YAML import failed with unknown error",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_auth_error": {
"title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "YAML import failed due to a connection error",
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
}
},
"entity": {
"binary_sensor": {
"alarm": {

View File

@ -199,7 +199,7 @@
"name": "Charge limit"
},
"off_grid_vehicle_charging_reserve_percent": {
"name": "Off grid reserve"
"name": "Off-grid reserve"
}
},
"select": {

View File

@ -38,7 +38,7 @@
"connected": "Vehicle connected",
"ready": "Ready to charge",
"negotiating": "Negotiating connection",
"error": "Error",
"error": "[%key:common::state::error%]",
"charging_finished": "Charging finished",
"waiting_car": "Waiting for car",
"charging_reduced": "Charging (reduced)",

View File

@ -363,7 +363,7 @@
"name": "Charge limit"
},
"off_grid_vehicle_charging_reserve_percent": {
"name": "Off grid reserve"
"name": "Off-grid reserve"
}
},
"cover": {
@ -495,10 +495,10 @@
"name": "Island status",
"state": {
"island_status_unknown": "Unknown",
"on_grid": "On grid",
"off_grid": "Off grid",
"off_grid_intentional": "Off grid intentional",
"off_grid_unintentional": "Off grid unintentional"
"on_grid": "On-grid",
"off_grid": "Off-grid",
"off_grid_intentional": "Off-grid intentional",
"off_grid_unintentional": "Off-grid unintentional"
}
},
"load_power": {
@ -662,7 +662,7 @@
"message": "Departure time required to enable preconditioning"
},
"set_scheduled_departure_off_peak": {
"message": "To enable scheduled departure, end off peak time is required."
"message": "To enable scheduled departure, 'End off-peak time' is required."
},
"invalid_device": {
"message": "Invalid device ID: {device_id}"
@ -752,15 +752,15 @@
},
"end_off_peak_time": {
"description": "Time to complete charging by.",
"name": "End off peak time"
"name": "End off-peak time"
},
"off_peak_charging_enabled": {
"description": "Enable off peak charging.",
"name": "Off peak charging enabled"
"description": "Enable off-peak charging.",
"name": "Off-peak charging enabled"
},
"off_peak_charging_weekdays_only": {
"description": "Enable off peak charging on weekdays only.",
"name": "Off peak charging weekdays only"
"description": "Enable off-peak charging on weekdays only.",
"name": "Off-peak charging weekdays only"
},
"preconditioning_enabled": {
"description": "Enable preconditioning.",

View File

@ -217,7 +217,7 @@
"connected": "[%key:common::state::connected%]",
"scheduled": "Scheduled",
"negotiating": "Negotiating",
"error": "Error",
"error": "[%key:common::state::error%]",
"charging_finished": "Charging finished",
"waiting_car": "Waiting car",
"charging_reduced": "Charging reduced"
@ -495,7 +495,7 @@
"name": "Speed limit"
},
"off_grid_vehicle_charging_reserve_percent": {
"name": "Off grid reserve"
"name": "Off-grid reserve"
}
},
"update": {

View File

@ -24,14 +24,14 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"title": "Update TP-Link Omada Credentials",
"title": "Update TP-Link Omada credentials",
"description": "The provided credentials have stopped working. Please update them."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unsupported_controller": "Omada Controller version not supported.",
"unsupported_controller": "Omada controller version not supported.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_sites_found": "No sites found which the user can manage."
},
@ -46,31 +46,31 @@
"name": "Port {port_name} PoE"
},
"wan_connect_ipv4": {
"name": "Port {port_name} Internet Connected"
"name": "Port {port_name} Internet connected"
},
"wan_connect_ipv6": {
"name": "Port {port_name} Internet Connected (IPv6)"
"name": "Port {port_name} Internet connected (IPv6)"
}
},
"binary_sensor": {
"wan_link": {
"name": "Port {port_name} Internet Link"
"name": "Port {port_name} Internet link"
},
"online_detection": {
"name": "Port {port_name} Online Detection"
"name": "Port {port_name} online detection"
},
"lan_status": {
"name": "Port {port_name} LAN Status"
"name": "Port {port_name} LAN status"
},
"poe_delivery": {
"name": "Port {port_name} PoE Delivery"
"name": "Port {port_name} PoE delivery"
}
},
"sensor": {
"device_status": {
"name": "Device status",
"state": {
"error": "Error",
"error": "[%key:common::state::error%]",
"disconnected": "[%key:common::state::disconnected%]",
"connected": "[%key:common::state::connected%]",
"pending": "Pending",
@ -91,7 +91,7 @@
"services": {
"reconnect_client": {
"name": "Reconnect wireless client",
"description": "Tries to get wireless client to reconnect to Omada Network.",
"description": "Tries to get wireless client to reconnect to Omada network.",
"fields": {
"mac": {
"name": "MAC address",

View File

@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/uptimerobot",
"iot_class": "cloud_polling",
"loggers": ["pyuptimerobot"],
"quality_scale": "bronze",
"requirements": ["pyuptimerobot==22.2.0"]
}

View File

@ -6,9 +6,7 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: fix name and docstring
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
@ -41,9 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
comment: recheck typos
test-coverage: done
# Gold
devices: done

View File

@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor):
async def _async_reset_meter(self, event):
"""Reset the utility meter status."""
await self._program_reset()
await self.async_reset_meter(self._tariff_entity)
await self._program_reset()
async def async_reset_meter(self, entity_id):
"""Reset meter."""
if self._tariff_entity is not None and self._tariff_entity != entity_id:

View File

@ -23,7 +23,7 @@
"state": {
"cleaning": "Cleaning",
"docked": "Docked",
"error": "Error",
"error": "[%key:common::state::error%]",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",

View File

@ -3,7 +3,6 @@
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR]
@ -36,7 +35,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) ->
coordinator = entry.runtime_data
await coordinator.api.logout()
await coordinator.api.close()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@ -1509,7 +1509,7 @@
"name": "Software error",
"state": {
"nothing": "Good",
"something": "Error"
"something": "[%key:common::state::error%]"
},
"state_attributes": {
"top_pcb_sensor_error": {

View File

@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed
_GLOBAL_DEFAULT_TIMEOUT,
)
import threading
from typing import Any, cast, overload
from typing import TYPE_CHECKING, Any, cast, overload
from urllib.parse import urlparse
from uuid import UUID
@ -355,7 +355,13 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]:
"""Wrap value in list if it is not one."""
if value is None:
return []
return cast(list[_T], value) if isinstance(value, list) else [value]
if isinstance(value, list):
if TYPE_CHECKING:
# https://github.com/home-assistant/core/pull/71960
# cast with a type variable is still slow.
return cast(list[_T], value)
return value # type: ignore[unreachable]
return [value]
def entity_id(value: Any) -> str:

View File

@ -6,7 +6,7 @@ aiodns==3.2.0
aiohasupervisor==0.3.1b1
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.2.3
aiohttp==3.11.16
aiohttp==3.11.18
aiohttp_cors==0.7.0
aiousbwatcher==1.1.1
aiozoneinfo==0.2.3

View File

@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools==77.0.3"]
requires = ["setuptools==78.1.1"]
build-backend = "setuptools.build_meta"
[project]
@ -28,7 +28,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
"aiohasupervisor==0.3.1b1",
"aiohttp==3.11.16",
"aiohttp==3.11.18",
"aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.2.3",
"aiohttp-asyncmdnsresolver==0.1.1",

2
requirements.txt generated
View File

@ -5,7 +5,7 @@
# Home Assistant Core
aiodns==3.2.0
aiohasupervisor==0.3.1b1
aiohttp==3.11.16
aiohttp==3.11.18
aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.2.3
aiohttp-asyncmdnsresolver==0.1.1

14
requirements_all.txt generated
View File

@ -179,7 +179,7 @@ aioacaia==0.1.14
aioairq==0.4.4
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.11
aioairzone-cloud==0.6.12
# homeassistant.components.airzone
aioairzone==1.0.0
@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.4.1
aioshelly==13.5.0
# homeassistant.components.skybell
aioskybell==22.7.0
@ -1172,7 +1172,7 @@ horimote==0.4.1
httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.10.0
huawei-lte-api==1.11.0
# homeassistant.components.huum
huum==0.7.12
@ -2134,7 +2134,7 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.3.4
pymiele==0.3.6
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@ -2214,7 +2214,7 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
pyoverkiz==1.17.0
pyoverkiz==1.17.1
# homeassistant.components.onewire
pyownet==0.10.0.post1
@ -2289,7 +2289,7 @@ pysabnzbd==1.1.1
pysaj==0.0.16
# homeassistant.components.schlage
pyschlage==2024.11.0
pyschlage==2025.4.0
# homeassistant.components.sensibo
pysensibo==1.1.0
@ -2436,7 +2436,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.2.3
python-linkplay==0.2.4
# homeassistant.components.lirc
# python-lirc==1.2.3

View File

@ -167,7 +167,7 @@ aioacaia==0.1.14
aioairq==0.4.4
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.11
aioairzone-cloud==0.6.12
# homeassistant.components.airzone
aioairzone==1.0.0
@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
aioshelly==13.4.1
aioshelly==13.5.0
# homeassistant.components.skybell
aioskybell==22.7.0
@ -999,7 +999,7 @@ homematicip==2.0.0
httplib2==0.20.4
# homeassistant.components.huawei_lte
huawei-lte-api==1.10.0
huawei-lte-api==1.11.0
# homeassistant.components.huum
huum==0.7.12
@ -1746,7 +1746,7 @@ pymeteoclimatic==0.1.0
pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.3.4
pymiele==0.3.6
# homeassistant.components.mochad
pymochad==0.2.0
@ -1811,7 +1811,7 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
pyoverkiz==1.17.0
pyoverkiz==1.17.1
# homeassistant.components.onewire
pyownet==0.10.0.post1
@ -1871,7 +1871,7 @@ pyrympro==0.0.9
pysabnzbd==1.1.1
# homeassistant.components.schlage
pyschlage==2024.11.0
pyschlage==2025.4.0
# homeassistant.components.sensibo
pysensibo==1.1.0
@ -1976,7 +1976,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
python-linkplay==0.2.3
python-linkplay==0.2.4
# homeassistant.components.matter
python-matter-server==7.0.0

View File

@ -2131,7 +2131,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"upcloud",
"upnp",
"uptime",
"uptimerobot",
"usb",
"usgs_earthquakes_feed",
"utility_meter",

View File

@ -191,7 +191,6 @@ EXCEPTIONS = {
"enocean", # https://github.com/kipe/enocean/pull/142
"imutils", # https://github.com/PyImageSearch/imutils/pull/292
"iso4217", # Public domain
"jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
"kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6
"ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7
"maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48
@ -205,6 +204,11 @@ EXCEPTIONS = {
"repoze.lru",
"sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14
"tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5
# ---
# https://github.com/jaraco/skeleton/pull/170
# https://github.com/jaraco/skeleton/pull/171
"jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
"setuptools", # MIT
}
TODO = {

View File

@ -46,6 +46,11 @@ from homeassistant.components import device_automation, persistent_notification
from homeassistant.components.device_automation import ( # noqa: F401
_async_get_device_automation_capabilities as async_get_device_automation_capabilities,
)
from homeassistant.components.logger import (
DOMAIN as LOGGER_DOMAIN,
SERVICE_SET_LEVEL,
_clear_logger_overwrites,
)
from homeassistant.config import IntegrationConfigInfo, async_process_component_config
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
@ -1688,6 +1693,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) ->
async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state)
@asynccontextmanager
async def async_call_logger_set_level(
logger: str,
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"],
*,
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,
) -> AsyncGenerator[None]:
"""Context manager to reset loggers after logger.set_level call."""
assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup"
with caplog.at_level(logging.NOTSET, logger):
await hass.services.async_call(
LOGGER_DOMAIN,
SERVICE_SET_LEVEL,
{logger: level},
blocking=True,
)
await hass.async_block_till_done()
yield
_clear_logger_overwrites(hass)
def import_and_test_deprecated_constant_enum(
caplog: pytest.LogCaptureFixture,
module: ModuleType,

View File

@ -120,7 +120,7 @@
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Off grid status',
'original_name': 'Off-grid status',
'platform': 'apsystems',
'previous_unique_id': None,
'supported_features': 0,
@ -133,7 +133,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Mock Title Off grid status',
'friendly_name': 'Mock Title Off-grid status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.mock_title_off_grid_status',

View File

@ -61,6 +61,7 @@ from . import (
from tests.common import (
MockConfigEntry,
MockModule,
async_call_logger_set_level,
async_fire_time_changed,
load_fixture,
mock_integration,
@ -1144,54 +1145,45 @@ async def test_debug_logging(
) -> None:
"""Test debug logging."""
assert await async_setup_component(hass, "logger", {"logger": {}})
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.bluetooth": "DEBUG"},
blocking=True,
)
await hass.async_block_till_done()
async with async_call_logger_set_level(
"homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog
):
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
address = "44:44:33:11:23:41"
start_time_monotonic = 50.0
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
hass,
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
"hci0",
)
assert "wohand_poor_signal_hci0" in caplog.text
caplog.clear()
switchbot_device_poor_signal_hci0 = generate_ble_device(
address, "wohand_poor_signal_hci0"
)
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
)
inject_advertisement_with_time_and_source(
hass,
switchbot_device_poor_signal_hci0,
switchbot_adv_poor_signal_hci0,
start_time_monotonic,
"hci0",
)
assert "wohand_poor_signal_hci0" in caplog.text
caplog.clear()
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.bluetooth": "WARNING"},
blocking=True,
)
switchbot_device_good_signal_hci0 = generate_ble_device(
address, "wohand_good_signal_hci0"
)
switchbot_adv_good_signal_hci0 = generate_advertisement_data(
local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33
)
inject_advertisement_with_time_and_source(
hass,
switchbot_device_good_signal_hci0,
switchbot_adv_good_signal_hci0,
start_time_monotonic,
"hci0",
)
assert "wohand_good_signal_hci0" not in caplog.text
async with async_call_logger_set_level(
"homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog
):
switchbot_device_good_signal_hci0 = generate_ble_device(
address, "wohand_good_signal_hci0"
)
switchbot_adv_good_signal_hci0 = generate_advertisement_data(
local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33
)
inject_advertisement_with_time_and_source(
hass,
switchbot_device_good_signal_hci0,
switchbot_adv_good_signal_hci0,
start_time_monotonic,
"hci0",
)
assert "wohand_good_signal_hci0" not in caplog.text
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")

View File

@ -29,7 +29,11 @@ from . import (
patch_bluetooth_time,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import (
MockConfigEntry,
async_call_logger_set_level,
async_fire_time_changed,
)
# If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [
@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
) -> None:
"""Test we can recover the adapter at startup and we wait for Dbus to init."""
assert await async_setup_component(hass, "logger", {})
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.bluetooth": "DEBUG"},
blocking=True,
)
called_start = 0
called_stop = 0
_callback = None
mock_discovered = []
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(self, callback: AdvertisementDataCallback):
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
async with async_call_logger_set_level(
"homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog
):
await async_setup_with_one_adapter(hass)
called_start = 0
called_stop = 0
_callback = None
mock_discovered = []
assert called_start == 4
class MockBleakScanner:
async def start(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_start
called_start += 1
if called_start == 1:
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
if called_start == 2:
raise BleakError("org.bluez.Error.InProgress")
if called_start == 3:
raise BleakError("org.bluez.Error.InProgress")
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
async def stop(self, *args, **kwargs):
"""Mock Start."""
nonlocal called_stop
called_stop += 1
@property
def discovered_devices(self):
"""Mock discovered_devices."""
nonlocal mock_discovered
return mock_discovered
def register_detection_callback(self, callback: AdvertisementDataCallback):
"""Mock Register Detection Callback."""
nonlocal _callback
_callback = callback
scanner = MockBleakScanner()
start_time_monotonic = time.monotonic()
with (
patch(
"habluetooth.scanner.ADAPTER_INIT_TIME",
0,
),
patch_bluetooth_time(
start_time_monotonic,
),
patch(
"habluetooth.scanner.OriginalBleakScanner",
return_value=scanner,
),
patch(
"habluetooth.util.recover_adapter", return_value=True
) as mock_recover_adapter,
):
await async_setup_with_one_adapter(hass)
assert called_start == 4
assert len(mock_recover_adapter.mock_calls) == 1
assert "Waiting for adapter to initialize" in caplog.text
@pytest.mark.usefixtures("one_adapter")

View File

@ -1,5 +1,7 @@
"""Test the DHCP discovery integration."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
import datetime
import threading
@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import (
SourceType,
)
from homeassistant.components.dhcp.const import DOMAIN
from homeassistant.components.dhcp.models import DHCPData
from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet(
integration_matchers: dhcp.DhcpMatchers,
address_data: dict | None = None,
) -> Callable[[Any], Awaitable[None]]:
"""Make a handler for a dhcp packet."""
if address_data is None:
address_data = {}
dhcp_watcher = dhcp.DHCPWatcher(
hass,
address_data,
integration_matchers,
DHCPData(integration_matchers, set(), address_data),
)
with patch("aiodhcpwatcher.async_start"):
await dhcp_watcher.async_start()
@ -666,6 +669,45 @@ async def test_setup_fails_with_broken_libpcap(
)
def _make_device_tracker_watcher(
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
) -> dhcp.DeviceTrackerWatcher:
return dhcp.DeviceTrackerWatcher(
hass,
DHCPData(
dhcp.async_index_integration_matchers(matchers),
set(),
{},
),
)
def _make_device_tracker_registered_watcher(
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
) -> dhcp.DeviceTrackerRegisteredWatcher:
return dhcp.DeviceTrackerRegisteredWatcher(
hass,
DHCPData(
dhcp.async_index_integration_matchers(matchers),
set(),
{},
),
)
def _make_network_watcher(
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
) -> dhcp.NetworkWatcher:
return dhcp.NetworkWatcher(
hass,
DHCPData(
dhcp.async_index_integration_matchers(matchers),
set(),
{},
),
)
async def test_device_tracker_hostname_and_macaddress_exists_before_start(
hass: HomeAssistant,
) -> None:
@ -682,18 +724,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
)
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -716,18 +755,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
async def test_device_tracker_registered(hass: HomeAssistant) -> None:
"""Test matching based on hostname and macaddress when registered."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher(
device_tracker_watcher = _make_device_tracker_registered_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -756,18 +792,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None:
async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None:
"""Test handle None hostname."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -789,18 +822,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start(
"""Test matching based on hostname and macaddress after start."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -837,18 +867,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home(
"""Test matching based on hostname and macaddress after start but not home."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -875,9 +902,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router(
"""Test matching based on hostname and macaddress after start but not router."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
@ -905,9 +931,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi
"""Test matching based on hostname and macaddress after start but missing hostname."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address(
"""Test an invalid ip address."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
)
device_tracker_watcher.async_start()
@ -974,18 +998,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start(
)
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
device_tracker_watcher = _make_device_tracker_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -1010,18 +1031,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None:
],
),
):
device_tracker_watcher = dhcp.NetworkWatcher(
device_tracker_watcher = _make_network_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -1073,18 +1091,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(
],
),
):
device_tracker_watcher = dhcp.NetworkWatcher(
device_tracker_watcher = _make_network_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "irobot-*",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "irobot-*",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
return_value=[],
),
):
device_tracker_watcher = dhcp.NetworkWatcher(
device_tracker_watcher = _make_network_watcher(
hass,
{},
dhcp.async_index_integration_matchers(
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
]
),
[
{
"domain": "mock-domain",
"hostname": "connect",
"macaddress": "B8B7F1*",
}
],
)
device_tracker_watcher.async_start()
await hass.async_block_till_done()
@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover(
hass, integration_matchers, address_data
)
rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers
hass, DHCPData(integration_matchers, set(), address_data)
)
rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
@ -1329,7 +1342,7 @@ async def test_dhcp_rediscover_no_match(
hass, integration_matchers, address_data
)
rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers
hass, DHCPData(integration_matchers, set(), address_data)
)
rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init:

View File

@ -0,0 +1,75 @@
"""The tests for the dhcp WebSocket API."""
import asyncio
from collections.abc import Callable
from unittest.mock import patch
import aiodhcpwatcher
from homeassistant.components.dhcp import DOMAIN
from homeassistant.core import EVENT_HOMEASSISTANT_STARTED, HomeAssistant
from homeassistant.setup import async_setup_component
from tests.typing import WebSocketGenerator
async def test_subscribe_discovery(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test dhcp subscribe_discovery."""
saved_callback: Callable[[aiodhcpwatcher.DHCPRequest], None] | None = None
async def mock_start(
callback: Callable[[aiodhcpwatcher.DHCPRequest], None],
) -> None:
"""Mock start."""
nonlocal saved_callback
saved_callback = callback
with (
patch("homeassistant.components.dhcp.aiodhcpwatcher.async_start", mock_start),
patch("homeassistant.components.dhcp.DiscoverHosts"),
):
await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.2", "happy", "44:44:33:11:23:12"))
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "dhcp/subscribe_discovery",
}
)
async with asyncio.timeout(1):
response = await client.receive_json()
assert response["success"]
async with asyncio.timeout(1):
response = await client.receive_json()
assert response["event"] == {
"add": [
{
"hostname": "happy",
"ip_address": "4.3.2.2",
"mac_address": "44:44:33:11:23:12",
}
]
}
saved_callback(aiodhcpwatcher.DHCPRequest("4.3.2.1", "sad", "44:44:33:11:23:13"))
async with asyncio.timeout(1):
response = await client.receive_json()
assert response["event"] == {
"add": [
{
"hostname": "sad",
"ip_address": "4.3.2.1",
"mac_address": "44:44:33:11:23:13",
}
]
}

View File

@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import (
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -50,6 +51,17 @@ def mock_setup_entry():
yield
def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]:
"""Get the flow context from the result of async_init or async_configure."""
flow = next(
flow
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == result["flow_id"]
)
return flow["context"]
@pytest.mark.usefixtures("mock_zeroconf")
async def test_user_connection_works(
hass: HomeAssistant, mock_client, mock_setup_entry: None
@ -119,7 +131,12 @@ async def test_user_connection_updates_host(
data={CONF_HOST: "127.0.0.1", CONF_PORT: 80},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
assert entry.data[CONF_HOST] == "127.0.0.1"
@ -145,6 +162,9 @@ async def test_user_sets_unique_id(
assert discovery_result["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm"
assert discovery_result["description_placeholders"] == {
"name": "test8266",
}
discovery_result = await hass.config_entries.flow.async_configure(
discovery_result["flow_id"],
@ -173,7 +193,12 @@ async def test_user_sets_unique_id(
{CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "test",
"name": "test",
"mac": "11:22:33:44:55:aa",
}
@pytest.mark.usefixtures("mock_zeroconf")
@ -224,6 +249,9 @@ async def test_user_causes_zeroconf_to_abort(
assert discovery_result["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm"
assert discovery_result["description_placeholders"] == {
"name": "test8266",
}
result = await hass.config_entries.flow.async_init(
"esphome",
@ -287,6 +315,7 @@ async def test_user_with_password(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "password1"}
@ -316,6 +345,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = InvalidAuthAPIError
@ -325,6 +355,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "invalid_auth"}
@ -338,7 +369,7 @@ async def test_user_dashboard_has_wrong_key(
"""Test user step with key from dashboard that is incorrect."""
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(
uses_password=False,
name="test",
@ -359,6 +390,7 @@ async def test_user_dashboard_has_wrong_key(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -467,6 +499,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -522,6 +555,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -553,6 +587,7 @@ async def test_login_connection_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = APIConnectionError
@ -562,6 +597,7 @@ async def test_login_connection_error(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "connection_error"}
@ -578,12 +614,18 @@ async def test_discovery_initiation(
port=6053,
properties={
"mac": "1122334455aa",
"friendly_name": "The Test",
},
type="mock_type",
)
flow = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info
)
assert get_flow_context(hass, flow) == {
"source": config_entries.SOURCE_ZEROCONF,
"title_placeholders": {"name": "The Test (test)"},
"unique_id": "11:22:33:44:55:aa",
}
result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={}
@ -645,7 +687,12 @@ async def test_discovery_already_configured(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
async def test_discovery_duplicate_data(
@ -667,6 +714,7 @@ async def test_discovery_duplicate_data(
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_init(
"esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF}
@ -701,7 +749,12 @@ async def test_discovery_updates_unique_id(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
assert entry.unique_id == "11:22:33:44:55:aa"
@ -722,6 +775,7 @@ async def test_user_requires_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["errors"] == {}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert len(mock_client.connect.mock_calls) == 2
assert len(mock_client.device_info.mock_calls) == 2
@ -744,6 +798,7 @@ async def test_encryption_key_valid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
@ -779,6 +834,7 @@ async def test_encryption_key_invalid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError
result = await hass.config_entries.flow.async_configure(
@ -788,6 +844,7 @@ async def test_encryption_key_invalid_psk(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["errors"] == {"base": "invalid_psk"}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert mock_client.noise_psk == INVALID_NOISE_PSK
@ -803,6 +860,9 @@ async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None:
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
@pytest.mark.usefixtures("mock_zeroconf")
@ -1005,6 +1065,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
assert result["type"] is FlowResultType.FORM, result
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
mock_dashboard["configured"].append(
{
@ -1050,6 +1113,9 @@ async def test_reauth_confirm_invalid(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"]
assert result["errors"]["base"] == "invalid_psk"
@ -1088,6 +1154,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"]
assert result["errors"]["base"] == "invalid_psk"
@ -1125,6 +1194,9 @@ async def test_reauth_encryption_key_removed(
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_encryption_removed_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
@ -1159,7 +1231,12 @@ async def test_discovery_dhcp_updates_host(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
assert entry.data[CONF_HOST] == "192.168.43.184"
@ -1188,7 +1265,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_detailed"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
# Mac was wrong, should not update
assert entry.data[CONF_HOST] == "192.168.43.183"
@ -1217,7 +1299,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_detailed"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
# Mac was wrong, should not update
assert entry.data[CONF_HOST] == "192.168.43.183"
@ -1246,7 +1333,12 @@ async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_detailed"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "unknown",
"mac": "11:22:33:44:55:aa",
}
# Mac was missing, should not update
assert entry.data[CONF_HOST] == "192.168.43.183"
@ -1330,6 +1422,7 @@ async def test_zeroconf_encryption_key_via_dashboard(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append(
{
@ -1397,6 +1490,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append(
{
@ -1462,6 +1556,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert flow["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
await dashboard.async_get_dashboard(hass).async_refresh()
@ -1473,6 +1568,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test8266"}
async def test_option_flow_allow_service_calls(
@ -1585,6 +1681,7 @@ async def test_user_discovers_name_no_dashboard(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1877,6 +1974,54 @@ async def test_reconfig_success_with_new_ip_same_name(
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_noise_psk_changes(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and new noise psk."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
CONF_NOISE_PSK: VALID_NOISE_PSK,
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.side_effect = [
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "test"
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_with_existing_entry(
hass: HomeAssistant, mock_client: APIClient
@ -1999,7 +2144,12 @@ async def test_reconfig_mac_used_by_other_entry(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "test4",
"mac": "11:22:33:44:55:bb",
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")

View File

@ -17,6 +17,7 @@ from aioesphomeapi import (
)
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_RESTORED,
EVENT_HOMEASSISTANT_STOP,
STATE_OFF,
@ -503,3 +504,40 @@ async def test_esphome_device_without_friendly_name(
state = hass.states.get("binary_sensor.test_mybinary_sensor")
assert state is not None
assert state.state == STATE_ON
async def test_entity_without_name_device_with_friendly_name(
hass: HomeAssistant,
mock_client: APIClient,
hass_storage: dict[str, Any],
mock_esphome_device: Callable[
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockESPHomeDevice],
],
) -> None:
"""Test name and entity_id for a device a friendly name and an entity without a name."""
entity_info = [
BinarySensorInfo(
object_id="mybinary_sensor",
key=1,
name="",
unique_id="my_binary_sensor",
),
]
states = [
BinarySensorState(key=1, state=True, missing_state=False),
]
user_service = []
await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=user_service,
states=states,
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
)
state = hass.states.get("binary_sensor.mixer")
assert state is not None
assert state.state == STATE_ON
# Make sure we have set the name to `None` as otherwise
# the friendly_name will be "The Best Mixer "
assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer"

View File

@ -49,7 +49,12 @@ from homeassistant.setup import async_setup_component
from .conftest import MockESPHomeDevice
from tests.common import MockConfigEntry, async_capture_events, async_mock_service
from tests.common import (
MockConfigEntry,
async_call_logger_set_level,
async_capture_events,
async_mock_service,
)
async def test_esphome_device_subscribe_logs(
@ -83,62 +88,50 @@ async def test_esphome_device_subscribe_logs(
)
await hass.async_block_till_done()
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "DEBUG"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
async with async_call_logger_set_level(
"homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
):
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
)
await hass.async_block_till_done()
assert "test_log_message" in caplog.text
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
)
await hass.async_block_till_done()
assert "test_log_message" in caplog.text
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
)
await hass.async_block_till_done()
assert "test_error_log_message" in caplog.text
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
)
await hass.async_block_till_done()
assert "test_error_log_message" in caplog.text
caplog.set_level(logging.ERROR)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" not in caplog.text
caplog.set_level(logging.ERROR)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" not in caplog.text
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" in caplog.text
caplog.set_level(logging.DEBUG)
device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
)
await hass.async_block_till_done()
assert "test_debug_log_message" in caplog.text
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "WARNING"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "ERROR"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "INFO"},
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
async with async_call_logger_set_level(
"homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
):
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
async with async_call_logger_set_level(
"homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog
):
assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
async with async_call_logger_set_level(
"homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog
):
assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
async def test_esphome_device_service_calls_not_allowed(
@ -750,7 +743,12 @@ async def test_connection_aborted_wrong_device(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "test",
"mac": "11:22:33:44:55:aa",
}
assert entry.data[CONF_HOST] == "192.168.43.184"
await hass.async_block_till_done()
assert len(new_info.mock_calls) == 2
@ -819,7 +817,12 @@ async def test_connection_aborted_wrong_device_same_name(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["reason"] == "already_configured_updates"
assert result["description_placeholders"] == {
"title": "Mock Title",
"name": "test",
"mac": "11:22:33:44:55:aa",
}
assert entry.data[CONF_HOST] == "192.168.43.184"
await hass.async_block_till_done()
assert len(new_info.mock_calls) == 2
@ -958,6 +961,7 @@ async def test_debug_logging(
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockConfigEntry],
],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test enabling and disabling debug logging."""
assert await async_setup_component(hass, "logger", {"logger": {}})
@ -967,24 +971,16 @@ async def test_debug_logging(
user_service=[],
states=[],
)
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "DEBUG"},
blocking=True,
)
await hass.async_block_till_done()
mock_client.set_debug.assert_has_calls([call(True)])
async with async_call_logger_set_level(
"homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
):
mock_client.set_debug.assert_has_calls([call(True)])
mock_client.reset_mock()
mock_client.reset_mock()
await hass.services.async_call(
"logger",
"set_level",
{"homeassistant.components.esphome": "WARNING"},
blocking=True,
)
await hass.async_block_till_done()
mock_client.set_debug.assert_has_calls([call(False)])
async with async_call_logger_set_level(
"homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
):
mock_client.set_debug.assert_has_calls([call(False)])
async def test_esphome_device_with_dash_in_name_user_services(

View File

@ -1,13 +1,24 @@
"""Test Govee light local."""
from errno import EADDRINUSE, ENETDOWN
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, call, patch
from govee_local_api import GoveeDevice
import pytest
from homeassistant.components.govee_light_local.const import DOMAIN
from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_BRIGHTNESS_PCT,
ATTR_COLOR_TEMP_KELVIN,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ATTR_SUPPORTED_COLOR_MODES,
DOMAIN as LIGHT_DOMAIN,
ColorMode,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES
@ -197,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
assert light.state == "off"
await hass.services.async_call(
"light",
"turn_on",
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id},
blocking=True,
)
@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
# Turn off
await hass.services.async_call(
"light",
"turn_off",
LIGHT_DOMAIN,
SERVICE_TURN_OFF,
{"entity_id": light.entity_id},
blocking=True,
)
@ -224,6 +235,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False)
@pytest.mark.parametrize(
("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"),
[
(
ATTR_RGB_COLOR,
[100, 255, 50],
"set_color",
[],
{"temperature": None, "rgb": (100, 255, 50)},
),
(
ATTR_COLOR_TEMP_KELVIN,
4400,
"set_color",
[],
{"temperature": 4400, "rgb": None},
),
(ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}),
],
)
async def test_turn_on_call_order(
hass: HomeAssistant,
mock_govee_api: MagicMock,
attribute: str,
value: str | int | list[int],
mock_call: str,
mock_call_args: list[str],
mock_call_kwargs: dict[str, any],
) -> None:
"""Test that turn_on is called after set_brightness/set_color/set_preset."""
mock_govee_api.devices = [
GoveeDevice(
controller=mock_govee_api,
ip="192.168.1.100",
fingerprint="asdawdqwdqwd",
sku="H615A",
capabilities=SCENE_CAPABILITIES,
)
]
entry = MockConfigEntry(domain=DOMAIN)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "off"
await hass.services.async_call(
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value},
blocking=True,
)
await hass.async_block_till_done()
mock_govee_api.assert_has_calls(
[
call.set_brightness(mock_govee_api.devices[0], 50),
getattr(call, mock_call)(
mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs
),
call.turn_on_off(mock_govee_api.devices[0], True),
]
)
async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
"""Test changing brightness."""
mock_govee_api.devices = [
@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light.state == "off"
await hass.services.async_call(
"light",
"turn_on",
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness_pct": 50},
blocking=True,
)
@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light is not None
assert light.state == "on"
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50)
assert light.attributes["brightness"] == 127
assert light.attributes[ATTR_BRIGHTNESS] == 127
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True,
)
await hass.async_block_till_done()
@ -273,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["brightness"] == 255
assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True,
)
await hass.async_block_till_done()
@ -287,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["brightness"] == 255
assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
@ -316,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
assert light.state == "off"
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "rgb_color": [100, 255, 50]},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]},
blocking=True,
)
await hass.async_block_till_done()
@ -326,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["rgb_color"] == (100, 255, 50)
assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50)
assert light.attributes["color_mode"] == ColorMode.RGB
mock_govee_api.set_color.assert_awaited_with(
@ -334,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
)
await hass.services.async_call(
"light",
"turn_on",
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, "kelvin": 4400},
blocking=True,
)
@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
assert light.state == "off"
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
@ -388,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
assert light.attributes[ATTR_EFFECT] == "sunrise"
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
@ -422,16 +504,16 @@ async def test_scene_restore_rgb(
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "rgb_color": initial_color},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
blocking=True,
)
await hass.async_block_till_done()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True,
)
await hass.async_block_till_done()
@ -439,15 +521,15 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
@ -455,14 +537,14 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
assert light.attributes[ATTR_EFFECT] == "sunrise"
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Deactivate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True,
)
await hass.async_block_till_done()
@ -470,9 +552,9 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
assert light.attributes[ATTR_EFFECT] is None
assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes[ATTR_BRIGHTNESS] == 255
async def test_scene_restore_temperature(
@ -505,8 +587,8 @@ async def test_scene_restore_temperature(
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, "color_temp_kelvin": initial_color},
blocking=True,
)
@ -520,9 +602,9 @@ async def test_scene_restore_temperature(
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "sunrise"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True,
)
await hass.async_block_till_done()
@ -530,14 +612,14 @@ async def test_scene_restore_temperature(
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] == "sunrise"
assert light.attributes[ATTR_EFFECT] == "sunrise"
mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise")
# Deactivate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True,
)
await hass.async_block_till_done()
@ -545,7 +627,7 @@ async def test_scene_restore_temperature(
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
assert light.attributes[ATTR_EFFECT] is None
assert light.attributes["color_temp_kelvin"] == initial_color
@ -577,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non
# Set initial color
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "rgb_color": initial_color},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
blocking=True,
)
await hass.async_block_till_done()
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "brightness": 255},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True,
)
await hass.async_block_till_done()
@ -594,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color
assert light.attributes["brightness"] == 255
assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene
await hass.services.async_call(
"light",
"turn_on",
{"entity_id": light.entity_id, "effect": "none"},
LIGHT_DOMAIN,
SERVICE_TURN_ON,
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True,
)
await hass.async_block_till_done()
light = hass.states.get("light.H615A")
assert light is not None
assert light.state == "on"
assert light.attributes["effect"] is None
assert light.attributes[ATTR_EFFECT] is None
mock_govee_api.set_scene.assert_not_called()

View File

@ -969,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul
assert hass.states.get("sensor.sensor4").state == "87.5"
async def test_start_from_history_then_watch_state_changes_sliding(
recorder_mock: Recorder,
hass: HomeAssistant,
) -> None:
"""Test we startup from history and switch to watching state changes.
With a sliding window, history_stats does not requery the recorder.
"""
await hass.config.async_set_time_zone("UTC")
utcnow = dt_util.utcnow()
start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0)
time = start_time
def _fake_states(*args, **kwargs):
return {
"binary_sensor.state": [
ha.State(
"binary_sensor.state",
"off",
last_changed=start_time - timedelta(hours=1),
last_updated=start_time - timedelta(hours=1),
),
]
}
with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states,
),
freeze_time(start_time),
):
await async_setup_component(
hass,
"sensor",
{
"sensor": [
{
"platform": "history_stats",
"entity_id": "binary_sensor.state",
"name": f"sensor{i}",
"state": "on",
"end": "{{ utcnow() }}",
"duration": {"hours": 1},
"type": sensor_type,
}
for i, sensor_type in enumerate(["time", "ratio", "count"])
]
},
)
await hass.async_block_till_done()
for i in range(3):
await async_update_entity(hass, f"sensor.sensor{i}")
await hass.async_block_till_done()
assert hass.states.get("sensor.sensor0").state == "0.0"
assert hass.states.get("sensor.sensor1").state == "0.0"
assert hass.states.get("sensor.sensor2").state == "0"
with freeze_time(time):
hass.states.async_set("binary_sensor.state", "on")
await hass.async_block_till_done()
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
assert hass.states.get("sensor.sensor0").state == "0.0"
assert hass.states.get("sensor.sensor1").state == "0.0"
assert hass.states.get("sensor.sensor2").state == "1"
# After sensor has been on for 15 minutes, check state
time += timedelta(minutes=15) # 00:15
with freeze_time(time):
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
assert hass.states.get("sensor.sensor0").state == "0.25"
assert hass.states.get("sensor.sensor1").state == "25.0"
assert hass.states.get("sensor.sensor2").state == "1"
with freeze_time(time):
hass.states.async_set("binary_sensor.state", "off")
await hass.async_block_till_done()
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
time += timedelta(minutes=30) # 00:45
with freeze_time(time):
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
assert hass.states.get("sensor.sensor0").state == "0.25"
assert hass.states.get("sensor.sensor1").state == "25.0"
assert hass.states.get("sensor.sensor2").state == "1"
time += timedelta(minutes=20) # 01:05
with freeze_time(time):
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
# Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes
assert hass.states.get("sensor.sensor0").state == "0.17"
assert hass.states.get("sensor.sensor1").state == "16.7"
assert hass.states.get("sensor.sensor2").state == "1"
time += timedelta(minutes=5) # 01:10
with freeze_time(time):
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
# Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes
assert hass.states.get("sensor.sensor0").state == "0.08"
assert hass.states.get("sensor.sensor1").state == "8.3"
assert hass.states.get("sensor.sensor2").state == "1"
time += timedelta(minutes=10) # 01:20
with freeze_time(time):
async_fire_time_changed(hass, time)
await hass.async_block_till_done()
assert hass.states.get("sensor.sensor0").state == "0.0"
assert hass.states.get("sensor.sensor1").state == "0.0"
assert hass.states.get("sensor.sensor2").state == "0"
async def test_does_not_work_into_the_future(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:
@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards(
past_next_update = start_time + timedelta(minutes=30)
with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states,
),
freeze_time(past_next_update),
):
async_fire_time_changed(hass, past_next_update)
@ -1526,29 +1651,10 @@ async def test_state_change_during_window_rollover(
assert hass.states.get("sensor.sensor1").state == "11.98"
# One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates,
# and will see that the sensor is ON starting from midnight.
# One minute has passed and the time has now rolled over into a new day, resetting the recorder window.
# The sensor will be ON since midnight.
t3 = t2 + timedelta(minutes=1)
def _fake_states_t3(*args, **kwargs):
return {
"binary_sensor.state": [
ha.State(
"binary_sensor.state",
"on",
last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0),
last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0),
),
]
}
with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states_t3,
),
freeze_time(t3),
):
with freeze_time(t3):
# The sensor turns off around this time, before the sensor does its normal polled update.
hass.states.async_set("binary_sensor.state", "off")
await hass.async_block_till_done(wait_background_tasks=True)

View File

@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
from tests.common import async_fire_time_changed
from tests.common import async_call_logger_set_level, async_fire_time_changed
from tests.typing import ClientSessionGenerator
@ -505,27 +505,21 @@ async def test_logging(
)
)
hass.states.async_set("logging.entity", "hello")
await hass.services.async_call(
"logger",
"set_level",
{"aiohttp.access": "info"},
blocking=True,
)
client = await hass_client()
response = await client.get("/api/states/logging.entity")
assert response.status == HTTPStatus.OK
async with async_call_logger_set_level(
"aiohttp.access", "INFO", hass=hass, caplog=caplog
):
client = await hass_client()
response = await client.get("/api/states/logging.entity")
assert response.status == HTTPStatus.OK
assert "GET /api/states/logging.entity" in caplog.text
caplog.clear()
await hass.services.async_call(
"logger",
"set_level",
{"aiohttp.access": "warning"},
blocking=True,
)
response = await client.get("/api/states/logging.entity")
assert response.status == HTTPStatus.OK
assert "GET /api/states/logging.entity" not in caplog.text
assert "GET /api/states/logging.entity" in caplog.text
caplog.clear()
async with async_call_logger_set_level(
"aiohttp.access", "WARNING", hass=hass, caplog=caplog
):
response = await client.get("/api/states/logging.entity")
assert response.status == HTTPStatus.OK
assert "GET /api/states/logging.entity" not in caplog.text
async def test_register_static_paths(

View File

@ -65,7 +65,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time',
'has_entity_name': True,
'hidden_by': None,
@ -120,7 +120,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_mower_1_downtime',
'has_entity_name': True,
'hidden_by': None,
@ -171,7 +171,6 @@
'area_id': None,
'capabilities': dict({
'options': list([
'no_error',
'alarm_mower_in_motion',
'alarm_mower_lifted',
'alarm_mower_stopped',
@ -180,13 +179,11 @@
'alarm_outside_geofence',
'angular_sensor_problem',
'battery_problem',
'battery_problem',
'battery_restriction_due_to_ambient_temperature',
'can_error',
'charging_current_too_high',
'charging_station_blocked',
'charging_system_problem',
'charging_system_problem',
'collision_sensor_defect',
'collision_sensor_error',
'collision_sensor_problem_front',
@ -197,24 +194,18 @@
'connection_changed',
'connection_not_changed',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_settings_restored',
'cutting_drive_motor_1_defect',
'cutting_drive_motor_2_defect',
'cutting_drive_motor_3_defect',
'cutting_height_blocked',
'cutting_height_problem',
'cutting_height_problem_curr',
'cutting_height_problem_dir',
'cutting_height_problem_drive',
'cutting_height_problem',
'cutting_motor_problem',
'cutting_stopped_slope_too_steep',
'cutting_system_blocked',
'cutting_system_blocked',
'cutting_system_imbalance_warning',
'cutting_system_major_imbalance',
'destination_not_reachable',
@ -222,13 +213,9 @@
'docking_sensor_defect',
'electronic_problem',
'empty_battery',
'error',
'error_at_power_up',
'fatal_error',
'folding_cutting_deck_sensor_defect',
'folding_sensor_activated',
'geofence_problem',
'geofence_problem',
'gps_navigation_problem',
'guide_1_not_found',
'guide_2_not_found',
@ -246,7 +233,6 @@
'lift_sensor_defect',
'lifted',
'limited_cutting_height_range',
'limited_cutting_height_range',
'loop_sensor_defect',
'loop_sensor_problem_front',
'loop_sensor_problem_left',
@ -259,6 +245,7 @@
'no_accurate_position_from_satellites',
'no_confirmed_position',
'no_drive',
'no_error',
'no_loop_signal',
'no_power_in_charging_station',
'no_response_from_charger',
@ -269,9 +256,6 @@
'safety_function_faulty',
'settings_restored',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_not_found',
'sim_card_requires_pin',
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
@ -281,13 +265,6 @@
'stuck_in_charging_station',
'switch_cord_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'tilt_sensor_problem',
'too_high_discharge_current',
'too_high_internal_current',
@ -317,6 +294,13 @@
'wrong_loop_signal',
'wrong_pin_code',
'zone_generator_problem',
'error_at_power_up',
'error',
'fatal_error',
'off',
'stopped',
'wait_power_up',
'wait_updating',
]),
}),
'config_entry_id': <ANY>,
@ -353,7 +337,6 @@
'device_class': 'enum',
'friendly_name': 'Test Mower 1 Error',
'options': list([
'no_error',
'alarm_mower_in_motion',
'alarm_mower_lifted',
'alarm_mower_stopped',
@ -362,13 +345,11 @@
'alarm_outside_geofence',
'angular_sensor_problem',
'battery_problem',
'battery_problem',
'battery_restriction_due_to_ambient_temperature',
'can_error',
'charging_current_too_high',
'charging_station_blocked',
'charging_system_problem',
'charging_system_problem',
'collision_sensor_defect',
'collision_sensor_error',
'collision_sensor_problem_front',
@ -379,24 +360,18 @@
'connection_changed',
'connection_not_changed',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_settings_restored',
'cutting_drive_motor_1_defect',
'cutting_drive_motor_2_defect',
'cutting_drive_motor_3_defect',
'cutting_height_blocked',
'cutting_height_problem',
'cutting_height_problem_curr',
'cutting_height_problem_dir',
'cutting_height_problem_drive',
'cutting_height_problem',
'cutting_motor_problem',
'cutting_stopped_slope_too_steep',
'cutting_system_blocked',
'cutting_system_blocked',
'cutting_system_imbalance_warning',
'cutting_system_major_imbalance',
'destination_not_reachable',
@ -404,13 +379,9 @@
'docking_sensor_defect',
'electronic_problem',
'empty_battery',
'error',
'error_at_power_up',
'fatal_error',
'folding_cutting_deck_sensor_defect',
'folding_sensor_activated',
'geofence_problem',
'geofence_problem',
'gps_navigation_problem',
'guide_1_not_found',
'guide_2_not_found',
@ -428,7 +399,6 @@
'lift_sensor_defect',
'lifted',
'limited_cutting_height_range',
'limited_cutting_height_range',
'loop_sensor_defect',
'loop_sensor_problem_front',
'loop_sensor_problem_left',
@ -441,6 +411,7 @@
'no_accurate_position_from_satellites',
'no_confirmed_position',
'no_drive',
'no_error',
'no_loop_signal',
'no_power_in_charging_station',
'no_response_from_charger',
@ -451,9 +422,6 @@
'safety_function_faulty',
'settings_restored',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_not_found',
'sim_card_requires_pin',
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
@ -463,13 +431,6 @@
'stuck_in_charging_station',
'switch_cord_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'tilt_sensor_problem',
'too_high_discharge_current',
'too_high_internal_current',
@ -499,6 +460,13 @@
'wrong_loop_signal',
'wrong_pin_code',
'zone_generator_problem',
'error_at_power_up',
'error',
'fatal_error',
'off',
'stopped',
'wait_power_up',
'wait_updating',
]),
}),
'context': <ANY>,
@ -1280,7 +1248,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.test_mower_1_uptime',
'has_entity_name': True,
'hidden_by': None,
@ -1449,7 +1417,6 @@
'area_id': None,
'capabilities': dict({
'options': list([
'no_error',
'alarm_mower_in_motion',
'alarm_mower_lifted',
'alarm_mower_stopped',
@ -1458,13 +1425,11 @@
'alarm_outside_geofence',
'angular_sensor_problem',
'battery_problem',
'battery_problem',
'battery_restriction_due_to_ambient_temperature',
'can_error',
'charging_current_too_high',
'charging_station_blocked',
'charging_system_problem',
'charging_system_problem',
'collision_sensor_defect',
'collision_sensor_error',
'collision_sensor_problem_front',
@ -1475,24 +1440,18 @@
'connection_changed',
'connection_not_changed',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_settings_restored',
'cutting_drive_motor_1_defect',
'cutting_drive_motor_2_defect',
'cutting_drive_motor_3_defect',
'cutting_height_blocked',
'cutting_height_problem',
'cutting_height_problem_curr',
'cutting_height_problem_dir',
'cutting_height_problem_drive',
'cutting_height_problem',
'cutting_motor_problem',
'cutting_stopped_slope_too_steep',
'cutting_system_blocked',
'cutting_system_blocked',
'cutting_system_imbalance_warning',
'cutting_system_major_imbalance',
'destination_not_reachable',
@ -1500,13 +1459,9 @@
'docking_sensor_defect',
'electronic_problem',
'empty_battery',
'error',
'error_at_power_up',
'fatal_error',
'folding_cutting_deck_sensor_defect',
'folding_sensor_activated',
'geofence_problem',
'geofence_problem',
'gps_navigation_problem',
'guide_1_not_found',
'guide_2_not_found',
@ -1524,7 +1479,6 @@
'lift_sensor_defect',
'lifted',
'limited_cutting_height_range',
'limited_cutting_height_range',
'loop_sensor_defect',
'loop_sensor_problem_front',
'loop_sensor_problem_left',
@ -1537,6 +1491,7 @@
'no_accurate_position_from_satellites',
'no_confirmed_position',
'no_drive',
'no_error',
'no_loop_signal',
'no_power_in_charging_station',
'no_response_from_charger',
@ -1547,9 +1502,6 @@
'safety_function_faulty',
'settings_restored',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_not_found',
'sim_card_requires_pin',
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
@ -1559,13 +1511,6 @@
'stuck_in_charging_station',
'switch_cord_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'tilt_sensor_problem',
'too_high_discharge_current',
'too_high_internal_current',
@ -1595,6 +1540,13 @@
'wrong_loop_signal',
'wrong_pin_code',
'zone_generator_problem',
'error_at_power_up',
'error',
'fatal_error',
'off',
'stopped',
'wait_power_up',
'wait_updating',
]),
}),
'config_entry_id': <ANY>,
@ -1631,7 +1583,6 @@
'device_class': 'enum',
'friendly_name': 'Test Mower 2 Error',
'options': list([
'no_error',
'alarm_mower_in_motion',
'alarm_mower_lifted',
'alarm_mower_stopped',
@ -1640,13 +1591,11 @@
'alarm_outside_geofence',
'angular_sensor_problem',
'battery_problem',
'battery_problem',
'battery_restriction_due_to_ambient_temperature',
'can_error',
'charging_current_too_high',
'charging_station_blocked',
'charging_system_problem',
'charging_system_problem',
'collision_sensor_defect',
'collision_sensor_error',
'collision_sensor_problem_front',
@ -1657,24 +1606,18 @@
'connection_changed',
'connection_not_changed',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_problem',
'connectivity_settings_restored',
'cutting_drive_motor_1_defect',
'cutting_drive_motor_2_defect',
'cutting_drive_motor_3_defect',
'cutting_height_blocked',
'cutting_height_problem',
'cutting_height_problem_curr',
'cutting_height_problem_dir',
'cutting_height_problem_drive',
'cutting_height_problem',
'cutting_motor_problem',
'cutting_stopped_slope_too_steep',
'cutting_system_blocked',
'cutting_system_blocked',
'cutting_system_imbalance_warning',
'cutting_system_major_imbalance',
'destination_not_reachable',
@ -1682,13 +1625,9 @@
'docking_sensor_defect',
'electronic_problem',
'empty_battery',
'error',
'error_at_power_up',
'fatal_error',
'folding_cutting_deck_sensor_defect',
'folding_sensor_activated',
'geofence_problem',
'geofence_problem',
'gps_navigation_problem',
'guide_1_not_found',
'guide_2_not_found',
@ -1706,7 +1645,6 @@
'lift_sensor_defect',
'lifted',
'limited_cutting_height_range',
'limited_cutting_height_range',
'loop_sensor_defect',
'loop_sensor_problem_front',
'loop_sensor_problem_left',
@ -1719,6 +1657,7 @@
'no_accurate_position_from_satellites',
'no_confirmed_position',
'no_drive',
'no_error',
'no_loop_signal',
'no_power_in_charging_station',
'no_response_from_charger',
@ -1729,9 +1668,6 @@
'safety_function_faulty',
'settings_restored',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_locked',
'sim_card_not_found',
'sim_card_requires_pin',
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
@ -1741,13 +1677,6 @@
'stuck_in_charging_station',
'switch_cord_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'temporary_battery_problem',
'tilt_sensor_problem',
'too_high_discharge_current',
'too_high_internal_current',
@ -1777,6 +1706,13 @@
'wrong_loop_signal',
'wrong_pin_code',
'zone_generator_problem',
'error_at_power_up',
'error',
'fatal_error',
'off',
'stopped',
'wait_power_up',
'wait_updating',
]),
}),
'context': <ANY>,

View File

@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.common import async_fire_time_changed
from tests.common import async_call_logger_set_level, async_fire_time_changed
HASS_NS = "unused.homeassistant"
COMPONENTS_NS = f"{HASS_NS}.components"
@ -73,28 +73,27 @@ async def test_log_filtering(
msg_test(filter_logger, True, "format string shouldfilter%s", "not")
# Filtering should work even if log level is modified
await hass.services.async_call(
"logger",
"set_level",
{"test.filter": "warning"},
blocking=True,
)
assert filter_logger.getEffectiveLevel() == logging.WARNING
msg_test(
filter_logger,
False,
"this line containing shouldfilterall should still be filtered",
)
async with async_call_logger_set_level(
"test.filter", "WARNING", hass=hass, caplog=caplog
):
assert filter_logger.getEffectiveLevel() == logging.WARNING
msg_test(
filter_logger,
False,
"this line containing shouldfilterall should still be filtered",
)
# Filtering should be scoped to a service
msg_test(
filter_logger, True, "this line containing otherfilterer should not be filtered"
)
msg_test(
logging.getLogger("test.other_filter"),
False,
"this line containing otherfilterer SHOULD be filtered",
)
# Filtering should be scoped to a service
msg_test(
filter_logger,
True,
"this line containing otherfilterer should not be filtered",
)
msg_test(
logging.getLogger("test.other_filter"),
False,
"this line containing otherfilterer SHOULD be filtered",
)
async def test_setting_level(hass: HomeAssistant) -> None:

View File

@ -4,7 +4,7 @@ import logging
from unittest.mock import patch
from homeassistant import loader
from homeassistant.components.logger.helpers import async_get_domain_config
from homeassistant.components.logger.helpers import DATA_LOGGER
from homeassistant.components.websocket_api import TYPE_RESULT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@ -76,7 +76,7 @@ async def test_integration_log_level(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.DEBUG
}
@ -126,7 +126,7 @@ async def test_custom_integration_log_level(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.hue": logging.DEBUG,
"custom_components.hue": logging.DEBUG,
"some_other_logger": logging.DEBUG,
@ -182,7 +182,7 @@ async def test_module_log_level(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.DEBUG,
"homeassistant.components.other_component": logging.WARNING,
}
@ -199,7 +199,7 @@ async def test_module_log_level_override(
{"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}},
)
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.WARNING
}
@ -218,7 +218,7 @@ async def test_module_log_level_override(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.ERROR
}
@ -237,7 +237,7 @@ async def test_module_log_level_override(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.DEBUG
}
@ -256,6 +256,6 @@ async def test_module_log_level_override(
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert async_get_domain_config(hass).overrides == {
assert hass.data[DATA_LOGGER].overrides == {
"homeassistant.components.websocket_api": logging.NOTSET
}

View File

@ -5,7 +5,7 @@ from unittest.mock import patch
from aionut import NUTError, NUTLoginError
from homeassistant import config_entries, setup
from homeassistant import config_entries
from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED
from homeassistant.components.nut.const import DOMAIN
from homeassistant.const import (
@ -86,7 +86,6 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None:
async def test_form_user_one_alias(hass: HomeAssistant) -> None:
"""Test we can configure a device with one alias."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -131,8 +130,6 @@ async def test_form_user_one_alias(hass: HomeAssistant) -> None:
async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None:
"""Test we can configure device with multiple aliases."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]},
@ -202,7 +199,6 @@ async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> No
)
ignored_entry.add_to_hass(hass)
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)

View File

@ -18,10 +18,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.setup import async_setup_component
from .util import _get_mock_nutclient, async_init_integration
from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
async def test_config_entry_migrations(hass: HomeAssistant) -> None:
@ -84,6 +86,78 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None:
assert not hass.data.get(DOMAIN)
async def test_remove_device_valid(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that we cannot remove a device that still exists."""
assert await async_setup_component(hass, "config", {})
mock_serial_number = "A00000000000"
config_entry = await async_init_integration(
hass,
username="someuser",
password="somepassword",
list_vars={"ups.serial": mock_serial_number},
list_ups={"ups1": "UPS 1"},
list_commands_return_value=[],
)
device_registry = dr.async_get(hass)
assert device_registry is not None
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, mock_serial_number)}
)
assert device_entry is not None
assert device_entry.serial_number == mock_serial_number
client = await hass_ws_client(hass)
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert not response["success"]
async def test_remove_device_stale(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test that we can remove a device that no longer exists."""
assert await async_setup_component(hass, "config", {})
mock_serial_number = "A00000000000"
config_entry = await async_init_integration(
hass,
username="someuser",
password="somepassword",
list_vars={"ups.serial": mock_serial_number},
list_ups={"ups1": "UPS 1"},
list_commands_return_value=[],
)
device_registry = dr.async_get(hass)
assert device_registry is not None
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, "remove-device-id")},
)
assert device_entry is not None
client = await hass_ws_client(hass)
response = await client.remove_device(device_entry.id, config_entry.entry_id)
assert response["success"]
# Verify that device entry is removed
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "remove-device-id")}
)
assert device_entry is None
async def test_config_not_ready(
hass: HomeAssistant,
caplog: pytest.LogCaptureFixture,

View File

@ -125,7 +125,6 @@ async def test_pdu_devices_with_unique_ids(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_input.voltage",
device_id="sensor.ups1_input_voltage",
state_value="122.91",
@ -140,7 +139,6 @@ async def test_pdu_devices_with_unique_ids(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_ambient.humidity.status",
device_id="sensor.ups1_ambient_humidity_status",
state_value="good",
@ -153,7 +151,6 @@ async def test_pdu_devices_with_unique_ids(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_ambient.temperature.status",
device_id="sensor.ups1_ambient_temperature_status",
state_value="good",
@ -334,7 +331,6 @@ async def test_pdu_dynamic_outlets(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_outlet.1.current",
device_id="sensor.ups1_outlet_a1_current",
state_value="0",
@ -348,7 +344,6 @@ async def test_pdu_dynamic_outlets(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_outlet.24.current",
device_id="sensor.ups1_outlet_a24_current",
state_value="0.19",

View File

@ -43,7 +43,7 @@ async def async_init_integration(
hass: HomeAssistant,
ups_fixture: str | None = None,
host: str = "mock",
port: str = "mock",
port: int = 1234,
username: str = "mock",
password: str = "mock",
alias: str | None = None,
@ -104,7 +104,6 @@ async def async_init_integration(
def _test_sensor_and_attributes(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
model: str,
unique_id: str,
device_id: str,
state_value: str,

View File

@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN"
TEST_HOST = "gateway-1234-5678-9123.local:8443"
TEST_HOST2 = "192.168.11.104:8443"
TEST_TOKEN = "1234123412341234"
MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)]
MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)]
@ -152,7 +153,7 @@ async def test_form_only_cloud_supported(
async def test_form_local_happy_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form."""
"""Test local API configuration flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -179,21 +180,27 @@ async def test_form_local_happy_flow(
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True),
generate_local_token=AsyncMock(return_value="1234123412341234"),
activate_local_token=AsyncMock(return_value=True),
):
await hass.config_entries.flow.async_configure(
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"host": "gateway-1234-5678-1234.local:8443",
"token": TEST_TOKEN,
"verify_ssl": True,
},
)
await hass.async_block_till_done()
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "gateway-1234-5678-1234.local:8443"
assert result4["data"] == {
"host": "gateway-1234-5678-1234.local:8443",
"token": TEST_TOKEN,
"verify_ssl": True,
"hub": TEST_SERVER,
"api_type": "local",
}
assert len(mock_setup_entry.mock_calls) == 1
@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud(
(MaintenanceException, "server_in_maintenance"),
(TooManyAttemptsBannedException, "too_many_attempts"),
(UnknownUserException, "unsupported_hardware"),
(NotSuchTokenException, "no_such_token"),
(NotSuchTokenException, "invalid_auth"),
(Exception, "unknown"),
],
)
@ -297,8 +304,7 @@ async def test_form_invalid_auth_local(
result["flow_id"],
{
"host": TEST_HOST,
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"token": TEST_TOKEN,
"verify_ssl": True,
},
)
@ -309,52 +315,6 @@ async def test_form_invalid_auth_local(
assert result4["errors"] == {"base": error}
async def test_form_local_developer_mode_disabled(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"hub": TEST_SERVER},
)
assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "local_or_cloud"
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"api_type": "local"},
)
assert result3["type"] is FlowResultType.FORM
assert result3["step_id"] == "local"
with patch.multiple(
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=None),
):
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"host": "gateway-1234-5678-1234.local:8443",
"verify_ssl": True,
},
)
assert result4["type"] is FlowResultType.FORM
assert result4["errors"] == {"base": "developer_mode_disabled"}
@pytest.mark.parametrize(
("side_effect", "error"),
[
@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry(
async def test_local_abort_on_duplicate_entry(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form."""
"""Test local API configuration is aborted if gateway already exists."""
MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_ID,
version=2,
data={
"host": TEST_HOST,
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"token": TEST_TOKEN,
"verify_ssl": True,
"hub": TEST_SERVER,
"api_type": "local",
},
).add_to_hass(hass)
@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry(
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True),
generate_local_token=AsyncMock(return_value="1234123412341234"),
activate_local_token=AsyncMock(return_value=True),
):
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": TEST_HOST,
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"token": TEST_TOKEN,
"verify_ssl": True,
},
)
@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None:
assert result2["reason"] == "reauth_wrong_account"
async def test_local_reauth_success(hass: HomeAssistant) -> None:
"""Test reauthentication flow."""
async def test_local_reauth_legacy(hass: HomeAssistant) -> None:
"""Test legacy reauthentication flow with username/password."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_ID,
version=2,
data={
"host": TEST_HOST,
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"verify_ssl": True,
"hub": TEST_SERVER,
"host": TEST_HOST,
"api_type": "local",
},
)
@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None:
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True),
generate_local_token=AsyncMock(return_value="1234123412341234"),
activate_local_token=AsyncMock(return_value=True),
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
"username": TEST_EMAIL,
"password": TEST_PASSWORD2,
{
"host": TEST_HOST,
"token": "new_token",
"verify_ssl": True,
},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert mock_entry.data["username"] == TEST_EMAIL
assert mock_entry.data["password"] == TEST_PASSWORD2
assert mock_entry.data["host"] == TEST_HOST
assert mock_entry.data["token"] == "new_token"
assert mock_entry.data["verify_ssl"] is True
async def test_local_reauth_success(hass: HomeAssistant) -> None:
"""Test modern local reauth flow."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_ID,
version=2,
data={
"host": TEST_HOST,
"token": "old_token",
"verify_ssl": True,
"hub": TEST_SERVER,
"api_type": "local",
},
)
mock_entry.add_to_hass(hass)
result = await mock_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "local_or_cloud"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"api_type": "local"},
)
assert result2["step_id"] == "local"
with patch.multiple(
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"host": TEST_HOST,
"token": "new_token",
"verify_ssl": True,
},
)
assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful"
assert mock_entry.data["host"] == TEST_HOST
assert mock_entry.data["token"] == "new_token"
assert mock_entry.data["verify_ssl"] is True
assert "username" not in mock_entry.data
assert "password" not in mock_entry.data
async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
"""Test reauthentication flow."""
"""Test local reauth flow with wrong gateway account."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=TEST_GATEWAY_ID2,
version=2,
data={
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"hub": TEST_SERVER,
"host": TEST_HOST,
"token": "old_token",
"verify_ssl": True,
"hub": TEST_SERVER,
"api_type": "local",
},
)
@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True),
generate_local_token=AsyncMock(return_value="1234123412341234"),
activate_local_token=AsyncMock(return_value=True),
):
result3 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
"username": TEST_EMAIL,
"password": TEST_PASSWORD2,
{
"host": TEST_HOST,
"token": "new_token",
"verify_ssl": True,
},
)
@ -897,27 +903,27 @@ async def test_local_zeroconf_flow(
"pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True),
generate_local_token=AsyncMock(return_value="1234123412341234"),
activate_local_token=AsyncMock(return_value=True),
):
result4 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False},
{
"host": "gateway-1234-5678-9123.local:8443",
"token": TEST_TOKEN,
"verify_ssl": False,
},
)
assert result4["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "gateway-1234-5678-9123.local:8443"
assert result4["data"] == {
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"hub": TEST_SERVER,
"host": "gateway-1234-5678-9123.local:8443",
"api_type": "local",
"token": "1234123412341234",
"verify_ssl": False,
}
# Verify no username/password in data
assert result4["data"] == {
"host": "gateway-1234-5678-9123.local:8443",
"token": TEST_TOKEN,
"verify_ssl": False,
"hub": TEST_SERVER,
"api_type": "local",
}
assert len(mock_setup_entry.mock_calls) == 1

View File

@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.smarty import DOMAIN
from homeassistant.components.smarty.const import DOMAIN
from homeassistant.const import CONF_HOST
from tests.common import MockConfigEntry

View File

@ -3,8 +3,8 @@
from unittest.mock import AsyncMock
from homeassistant.components.smarty.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -114,52 +114,3 @@ async def test_existing_entry(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_import_flow(
hass: HomeAssistant, mock_smarty: AsyncMock, mock_setup_entry: AsyncMock
) -> None:
"""Test the import flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Smarty"
assert result["data"] == {CONF_HOST: "192.168.0.2"}
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_cannot_connect(
hass: HomeAssistant, mock_smarty: AsyncMock
) -> None:
"""Test we handle cannot connect error."""
mock_smarty.update.return_value = False
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_import_unknown_error(
hass: HomeAssistant, mock_smarty: AsyncMock
) -> None:
"""Test we handle unknown error."""
mock_smarty.update.side_effect = Exception
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"

View File

@ -4,68 +4,15 @@ from unittest.mock import AsyncMock
from syrupy import SnapshotAssertion
from homeassistant.components.smarty import DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.components.smarty.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry
async def test_import_flow(
hass: HomeAssistant,
mock_smarty: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test import flow."""
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues
async def test_import_flow_already_exists(
hass: HomeAssistant,
mock_smarty: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test import flow when entry already exists."""
mock_config_entry.add_to_hass(hass)
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues
async def test_import_flow_error(
hass: HomeAssistant,
mock_smarty: AsyncMock,
issue_registry: ir.IssueRegistry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test import flow when error occurs."""
mock_smarty.update.return_value = False
assert await async_setup_component(
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
assert (
DOMAIN,
"deprecated_yaml_import_issue_cannot_connect",
) in issue_registry.issues
async def test_device(
hass: HomeAssistant,
snapshot: SnapshotAssertion,

View File

@ -463,3 +463,28 @@ HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True,
tx_power=-127,
)
WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak(
name="WoStrip",
address="AA:BB:CC:DD:EE:FF",
manufacturer_data={
2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00'
},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
rssi=-60,
source="local",
advertisement=generate_advertisement_data(
local_name="WoStrip",
manufacturer_data={
2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00'
},
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"},
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
),
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"),
time=0,
connectable=True,
tx_power=-127,
)

View File

@ -0,0 +1,139 @@
"""Test the switchbot lights."""
from collections.abc import Callable
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from switchbot import ColorMode as switchbotColorMode
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
ATTR_RGB_COLOR,
DOMAIN as LIGHT_DOMAIN,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
)
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from . import WOSTRIP_SERVICE_INFO
from tests.common import MockConfigEntry
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.parametrize(
(
"service",
"service_data",
"mock_method",
"expected_args",
"color_modes",
"color_mode",
),
[
(
SERVICE_TURN_OFF,
{},
"turn_off",
(),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{},
"turn_on",
(),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_BRIGHTNESS: 128},
"set_brightness",
(round(128 / 255 * 100),),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_RGB_COLOR: (255, 0, 0)},
"set_rgb",
(round(255 / 255 * 100), 255, 0, 0),
{switchbotColorMode.RGB},
switchbotColorMode.RGB,
),
(
SERVICE_TURN_ON,
{ATTR_COLOR_TEMP_KELVIN: 4000},
"set_color_temp",
(100, 4000),
{switchbotColorMode.COLOR_TEMP},
switchbotColorMode.COLOR_TEMP,
),
],
)
async def test_light_strip_services(
hass: HomeAssistant,
mock_entry_factory: Callable[[str], MockConfigEntry],
service: str,
service_data: dict,
mock_method: str,
expected_args: Any,
color_modes: set | None,
color_mode: switchbotColorMode | None,
) -> None:
"""Test all SwitchBot light strip services with proper parameters."""
inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO)
entry = mock_entry_factory(sensor_type="light_strip")
entry.add_to_hass(hass)
entity_id = "light.test_name"
with (
patch("switchbot.SwitchbotLightStrip.color_modes", new=color_modes),
patch("switchbot.SwitchbotLightStrip.color_mode", new=color_mode),
patch(
"switchbot.SwitchbotLightStrip.turn_on",
new=AsyncMock(return_value=True),
) as mock_turn_on,
patch(
"switchbot.SwitchbotLightStrip.turn_off",
new=AsyncMock(return_value=True),
) as mock_turn_off,
patch(
"switchbot.SwitchbotLightStrip.set_brightness",
new=AsyncMock(return_value=True),
) as mock_set_brightness,
patch(
"switchbot.SwitchbotLightStrip.set_rgb",
new=AsyncMock(return_value=True),
) as mock_set_rgb,
patch(
"switchbot.SwitchbotLightStrip.set_color_temp",
new=AsyncMock(return_value=True),
) as mock_set_color_temp,
patch("switchbot.SwitchbotLightStrip.update", new=AsyncMock(return_value=None)),
):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.services.async_call(
LIGHT_DOMAIN,
service,
{**service_data, ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_map = {
"turn_off": mock_turn_off,
"turn_on": mock_turn_on,
"set_brightness": mock_set_brightness,
"set_rgb": mock_set_rgb,
"set_color_temp": mock_set_color_temp,
}
mock_instance = mock_map[mock_method]
mock_instance.assert_awaited_once_with(*expected_args)

View File

@ -88,7 +88,7 @@
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'original_icon': 'mdi:battery-unknown',
'original_name': 'Off grid reserve',
'original_name': 'Off-grid reserve',
'platform': 'tesla_fleet',
'previous_unique_id': None,
'supported_features': 0,
@ -101,7 +101,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Energy Site Off grid reserve',
'friendly_name': 'Energy Site Off-grid reserve',
'icon': 'mdi:battery-unknown',
'max': 100,
'min': 0,

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