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", "documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"], "loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.11"] "requirements": ["aioairzone-cloud==0.6.12"]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,8 @@
"""Constants for the dhcp integration.""" """Constants for the dhcp integration."""
from typing import Final
DOMAIN = "dhcp" 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__) _LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA=" ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._host = entry_data[CONF_HOST] self._host = entry_data[CONF_HOST]
self._port = entry_data[CONF_PORT] self._port = entry_data[CONF_PORT]
self._password = entry_data[CONF_PASSWORD] self._password = entry_data[CONF_PASSWORD]
self._name = self._reauth_entry.title
self._device_name = entry_data.get(CONF_DEVICE_NAME) 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 # 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, # 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( return self.async_show_form(
step_id="reauth_encryption_removed_confirm", 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( async def async_step_reauth_confirm(
@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="reauth_confirm", step_id="reauth_confirm",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors, errors=errors,
description_placeholders={"name": self._name}, description_placeholders={"name": self._async_get_human_readable_name()},
) )
async def async_step_reconfigure( async def async_step_reconfigure(
@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@property @property
def _name(self) -> str: def _name(self) -> str:
return self.__name or "ESPHome" return self.__name or DEFAULT_NAME
@_name.setter @_name.setter
def _name(self, value: str) -> None: def _name(self, value: str) -> None:
self.__name = value 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: async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
"""Try to fetch device info and return any errors.""" """Try to fetch device info and return any errors."""
@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
return await self._async_try_fetch_device_info() return await self._async_try_fetch_device_info()
return self.async_show_form( 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( async def async_step_zeroconf(
@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Hostname is format: livingroom.local. # Hostname is format: livingroom.local.
device_name = discovery_info.hostname.removesuffix(".local.") device_name = discovery_info.hostname.removesuffix(".local.")
self._name = discovery_info.properties.get("friendly_name", device_name)
self._device_name = device_name self._device_name = device_name
self._name = discovery_info.properties.get("friendly_name", device_name)
self._host = discovery_info.host self._host = discovery_info.host
self._port = discovery_info.port self._port = discovery_info.port
self._noise_required = bool(discovery_info.properties.get("api_encryption")) self._noise_required = bool(discovery_info.properties.get("api_encryption"))
@ -306,7 +310,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
updates[CONF_HOST] = host updates[CONF_HOST] = host
if port is not None: if port is not None:
updates[CONF_PORT] = port 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( async def async_step_mqtt(
self, discovery_info: MqttServiceInfo self, discovery_info: MqttServiceInfo
@ -341,7 +370,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if already configured # Check if already configured
await self.async_set_unique_id(mac_address) 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} 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(), data=self._reauth_entry.data | self._async_make_config_data(),
) )
assert self._host is not None assert self._host is not None
self._abort_if_unique_id_configured( self._abort_unique_id_configured_with_details(
updates={ updates={
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: self._port, CONF_PORT: self._port,
@ -510,7 +539,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
if not ( if not (
unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id) 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={ updates={
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: self._port, CONF_PORT: self._port,
@ -568,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="encryption_key", step_id="encryption_key",
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}), data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
errors=errors, 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( async def async_step_authenticate(
self, user_input: dict[str, Any] | None = None, error: str | None = None self, user_input: dict[str, Any] | None = None, error: str | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -589,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="authenticate", step_id="authenticate",
data_schema=vol.Schema({vol.Required("password"): str}), data_schema=vol.Schema({vol.Required("password"): str}),
description_placeholders={"name": self._name}, description_placeholders={"name": self._async_get_human_readable_name()},
errors=errors, errors=errors,
) )
@ -623,9 +673,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error" return "connection_error"
finally: finally:
await cli.disconnect(force=True) 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_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 return None
async def fetch_device_info(self) -> str | 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) mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False) await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE): if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
self._abort_if_unique_id_configured( self._abort_unique_id_configured_with_details(
updates={ updates={
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: self._port, CONF_PORT: self._port,

View File

@ -224,7 +224,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} 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: async def async_added_to_hass(self) -> None:
"""Register callbacks.""" """Register callbacks."""
@ -260,7 +269,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._static_info = static_info self._static_info = static_info
self._attr_unique_id = build_unique_id(device_info.mac_address, 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_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: if entity_category := static_info.entity_category:
self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category) self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category)
else: else:

View File

@ -2,6 +2,8 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "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%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"mdns_missing_mac": "Missing MAC address in mDNS properties.", "mdns_missing_mac": "Missing MAC address in mDNS properties.",
@ -41,7 +43,7 @@
"data_description": { "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." "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": { "encryption_key": {
"data": { "data": {
@ -50,7 +52,7 @@
"data_description": { "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." "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": { "reauth_confirm": {
"data": { "data": {
@ -59,10 +61,10 @@
"data_description": { "data_description": {
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" "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": { "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": { "discovery_confirm": {
"description": "Do you want to add the device `{name}` to Home Assistant?", "description": "Do you want to add the device `{name}` to Home Assistant?",

View File

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

View File

@ -36,7 +36,7 @@
"name": "Inverter operation mode", "name": "Inverter operation mode",
"state": { "state": {
"general": "General mode", "general": "General mode",
"off_grid": "Off grid mode", "off_grid": "Off-grid mode",
"backup": "Backup mode", "backup": "Backup mode",
"eco": "Eco mode", "eco": "Eco mode",
"peak_shaving": "Peak shaving 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: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on.""" """Turn the device on."""
if not self.is_on or not kwargs:
await self.coordinator.turn_on(self._device)
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0) brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0)
await self.coordinator.set_brightness(self._device, brightness) await self.coordinator.set_brightness(self._device, brightness)
@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
self._save_last_color_state() self._save_last_color_state()
await self.coordinator.set_scene(self._device, effect) 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() self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: 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._period = (MIN_TIME_UTC, MIN_TIME_UTC)
self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period)
self._history_current_period: list[HistoryState] = [] self._history_current_period: list[HistoryState] = []
self._previous_run_before_start = False self._has_recorder_data = False
self._entity_states = set(entity_states) self._entity_states = set(entity_states)
self._duration = duration self._duration = duration
self._start = start self._start = start
@ -88,20 +88,20 @@ class HistoryStats:
if current_period_start_timestamp > now_timestamp: if current_period_start_timestamp > now_timestamp:
# History cannot tell the future # History cannot tell the future
self._history_current_period = [] self._history_current_period = []
self._previous_run_before_start = True self._has_recorder_data = False
self._state = HistoryStatsState(None, None, self._period) self._state = HistoryStatsState(None, None, self._period)
return self._state return self._state
# #
# We avoid querying the database if the below did NOT happen: # We avoid querying the database if the below did NOT happen:
# #
# - The previous run happened before the start time # - No previous run occurred (uninitialized)
# - The start time changed # - The start time moved back in time
# - The period shrank in size # - The end time moved back in time
# - The previous period ended before now # - The previous period ended before now
# #
if ( if (
not self._previous_run_before_start self._has_recorder_data
and current_period_start_timestamp == previous_period_start_timestamp and current_period_start_timestamp >= previous_period_start_timestamp
and ( and (
current_period_end_timestamp == previous_period_end_timestamp current_period_end_timestamp == previous_period_end_timestamp
or ( 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 new_data = False
if event and (new_state := event.data["new_state"]) is not None: if event and (new_state := event.data["new_state"]) is not None:
if ( if (
@ -121,7 +127,11 @@ class HistoryStats:
HistoryState(new_state.state, new_state.last_changed_timestamp) HistoryState(new_state.state, new_state.last_changed_timestamp)
) )
new_data = True 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... # If period has not changed and current time after the period end...
# Don't compute anything as the value cannot have changed # Don't compute anything as the value cannot have changed
return self._state return self._state
@ -139,7 +149,7 @@ class HistoryStats:
HistoryState(new_state.state, new_state.last_changed_timestamp) 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( seconds_matched, match_count = self._async_compute_seconds_and_changes(
now_timestamp, now_timestamp,
@ -223,3 +233,18 @@ class HistoryStats:
# Save value in seconds # Save value in seconds
seconds_matched = elapsed seconds_matched = elapsed
return seconds_matched, match_count 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%]", "pause": "[%key:common::state::paused%]",
"actionrequired": "Action required", "actionrequired": "Action required",
"finished": "Finished", "finished": "Finished",
"error": "Error", "error": "[%key:common::state::error%]",
"aborting": "Aborting" "aborting": "Aborting"
} }
}, },
@ -1587,7 +1587,7 @@
"streaminglocal": "Streaming local", "streaminglocal": "Streaming local",
"streamingcloud": "Streaming cloud", "streamingcloud": "Streaming cloud",
"streaminglocal_and_cloud": "Streaming local and cloud", "streaminglocal_and_cloud": "Streaming local and cloud",
"error": "Error" "error": "[%key:common::state::error%]"
} }
}, },
"last_selected_map": { "last_selected_map": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass
from enum import StrEnum from enum import StrEnum
from functools import lru_cache from functools import lru_cache
import logging import logging
from typing import Any, cast from typing import Any
from homeassistant.const import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_LOGGING_CHANGED
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.hass_dict import HassKey
from .const import ( from .const import (
DOMAIN, DOMAIN,
@ -28,6 +29,8 @@ from .const import (
STORAGE_VERSION, STORAGE_VERSION,
) )
DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN)
SAVE_DELAY = 15.0 SAVE_DELAY = 15.0
# At startup, we want to save after a long delay to avoid # At startup, we want to save after a long delay to avoid
# saving while the system is still starting up. If the system # saving while the system is still starting up. If the system
@ -39,12 +42,6 @@ SAVE_DELAY = 15.0
SAVE_DELAY_LONG = 180.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 @callback
def set_default_log_level(hass: HomeAssistant, level: int) -> None: def set_default_log_level(hass: HomeAssistant, level: int) -> None:
"""Set the default log level for components.""" """Set the default log level for components."""
@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None:
@callback @callback
def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None: def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None:
"""Set the specified log levels.""" """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(): for key, value in logpoints.items():
_set_log_level(logging.getLogger(key), value) _set_log_level(logging.getLogger(key), value)
hass.bus.async_fire(EVENT_LOGGING_CHANGED) hass.bus.async_fire(EVENT_LOGGING_CHANGED)
@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int:
return min(level1, level2) 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]: async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]:
"""Get loggers for an integration.""" """Get loggers for an integration."""
loggers: set[str] = {f"homeassistant.components.{domain}"} 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 .const import LOGSEVERITY
from .helpers import ( from .helpers import (
DATA_LOGGER,
LoggerSetting, LoggerSetting,
LogPersistance, LogPersistance,
LogSettingsType, LogSettingsType,
async_get_domain_config,
get_logger, get_logger,
) )
@ -68,7 +68,7 @@ async def handle_integration_log_level(
msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found" msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found"
) )
return return
await async_get_domain_config(hass).settings.async_update( await hass.data[DATA_LOGGER].settings.async_update(
hass, hass,
msg["integration"], msg["integration"],
LoggerSetting( LoggerSetting(
@ -93,7 +93,7 @@ async def handle_module_log_level(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Handle setting integration log level.""" """Handle setting integration log level."""
await async_get_domain_config(hass).settings.async_update( await hass.data[DATA_LOGGER].settings.async_update(
hass, hass,
msg["module"], msg["module"],
LoggerSetting( LoggerSetting(

View File

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

View File

@ -8,6 +8,6 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pymiele"], "loggers": ["pymiele"],
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["pymiele==0.3.4"], "requirements": ["pymiele==0.3.6"],
"single_config_entry": true "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) 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: async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None:
"""Handle options update.""" """Handle options update."""
await hass.config_entries.async_reload(entry.entry_id) await hass.config_entries.async_reload(entry.entry_id)

View File

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

View File

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

View File

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

View File

@ -20,6 +20,9 @@
"title": "Choose the NUT server UPS to monitor", "title": "Choose the NUT server UPS to monitor",
"data": { "data": {
"alias": "NUT server UPS name" "alias": "NUT server UPS name"
},
"data_description": {
"alias": "The UPS name configured on the NUT server."
} }
}, },
"reauth_confirm": { "reauth_confirm": {
@ -27,6 +30,10 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "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": { "reconfigure": {
@ -48,6 +55,9 @@
"title": "[%key:component::nut::config::step::ups::title%]", "title": "[%key:component::nut::config::step::ups::title%]",
"data": { "data": {
"alias": "[%key:component::nut::config::step::ups::data::alias%]" "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 ( from pyoverkiz.exceptions import (
BadCredentialsException, BadCredentialsException,
MaintenanceException, MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException, NotSuchTokenException,
TooManyRequestsException, TooManyRequestsException,
) )
@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
scenarios = await client.get_scenarios() scenarios = await client.get_scenarios()
else: else:
scenarios = [] scenarios = []
except (BadCredentialsException, NotSuchTokenException) as exception: except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
) as exception:
raise ConfigEntryAuthFailed("Invalid authentication") from exception raise ConfigEntryAuthFailed("Invalid authentication") from exception
except TooManyRequestsException as exception: except TooManyRequestsException as exception:
raise ConfigEntryNotReady("Too many requests, try again later") from exception raise ConfigEntryNotReady("Too many requests, try again later") from exception

View File

@ -13,6 +13,7 @@ from homeassistant.components.climate import (
PRESET_NONE, PRESET_NONE,
ClimateEntity, ClimateEntity,
ClimateEntityFeature, ClimateEntityFeature,
HVACAction,
HVACMode, HVACMode,
) )
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
OverkizCommandParam.INTERNAL: HVACMode.AUTO, 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()} HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()}
TEMPERATURE_SENSOR_DEVICE_INDEX = 2 TEMPERATURE_SENSOR_DEVICE_INDEX = 2
@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] 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 @property
def preset_mode(self) -> str | None: def preset_mode(self) -> str | None:
"""Return the current preset mode, e.g., home, away, temp.""" """Return the current preset mode, e.g., home, away, temp."""

View File

@ -13,12 +13,12 @@ from pyoverkiz.exceptions import (
BadCredentialsException, BadCredentialsException,
CozyTouchBadCredentialsException, CozyTouchBadCredentialsException,
MaintenanceException, MaintenanceException,
NotAuthenticatedException,
NotSuchTokenException, NotSuchTokenException,
TooManyAttemptsBannedException, TooManyAttemptsBannedException,
TooManyRequestsException, TooManyRequestsException,
UnknownUserException, UnknownUserException,
) )
from pyoverkiz.models import OverkizServer
from pyoverkiz.obfuscate import obfuscate_id from pyoverkiz.obfuscate import obfuscate_id
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
import voluptuous as vol import voluptuous as vol
@ -31,7 +31,6 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo 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 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): class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Overkiz (by Somfy).""" """Handle a config flow for Overkiz (by Somfy)."""
VERSION = 1 VERSION = 1
_verify_ssl: bool = True
_api_type: APIType = APIType.CLOUD _api_type: APIType = APIType.CLOUD
_user: str | None = None _user: str | None = None
_server: str = DEFAULT_SERVER _server: str = DEFAULT_SERVER
@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
"""Validate user credentials.""" """Validate user credentials."""
user_input[CONF_API_TYPE] = self._api_type 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: if self._api_type == APIType.LOCAL:
user_input[CONF_TOKEN] = await self._create_local_api_token( user_input[CONF_VERIFY_SSL] = self._verify_ssl
cloud_client=client, session = async_create_clientsession(
host=user_input[CONF_HOST], 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], 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 # Set main gateway id as unique id
if gateways := await client.get_gateways(): if gateways := await client.get_gateways():
for gateway in gateways: for gateway in gateways:
if is_overkiz_gateway(gateway.id): 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 return user_input
@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input: if user_input:
self._user = user_input[CONF_USERNAME] self._user = user_input[CONF_USERNAME]
# inherit the server from previous step
user_input[CONF_HUB] = self._server user_input[CONF_HUB] = self._server
try: try:
await self.async_validate_input(user_input) await self.async_validate_input(user_input)
except TooManyRequestsException: except TooManyRequestsException:
errors["base"] = "too_many_requests" 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 # If authentication with CozyTouch auth server is valid, but token is invalid
# for Overkiz API server, the hardware is not supported. # for Overkiz API server, the hardware is not supported.
if user_input[CONF_HUB] in { if user_input[CONF_HUB] in {
@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input: if user_input:
self._host = user_input[CONF_HOST] self._host = user_input[CONF_HOST]
self._user = user_input[CONF_USERNAME] self._verify_ssl = user_input[CONF_VERIFY_SSL]
# inherit the server from previous step
user_input[CONF_HUB] = self._server user_input[CONF_HUB] = self._server
try: try:
user_input = await self.async_validate_input(user_input) user_input = await self.async_validate_input(user_input)
except TooManyRequestsException: except TooManyRequestsException:
errors["base"] = "too_many_requests" errors["base"] = "too_many_requests"
except BadCredentialsException: except (
BadCredentialsException,
NotSuchTokenException,
NotAuthenticatedException,
):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
except ClientConnectorCertificateError as exception: except ClientConnectorCertificateError as exception:
errors["base"] = "certificate_verify_failed" errors["base"] = "certificate_verify_failed"
@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "server_in_maintenance" errors["base"] = "server_in_maintenance"
except TooManyAttemptsBannedException: except TooManyAttemptsBannedException:
errors["base"] = "too_many_attempts" errors["base"] = "too_many_attempts"
except NotSuchTokenException:
errors["base"] = "no_such_token"
except DeveloperModeDisabled:
errors["base"] = "developer_mode_disabled"
except UnknownUserException: except UnknownUserException:
# Somfy Protect accounts are not supported since they don't use # Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user. # the Overkiz API server. Login will return unknown user.
@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_HOST, default=self._host): str, vol.Required(CONF_HOST, default=self._host): str,
vol.Required(CONF_USERNAME, default=self._user): str, vol.Required(CONF_TOKEN): str,
vol.Required(CONF_PASSWORD): str, vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
} }
), ),
description_placeholders=description_placeholders, description_placeholders=description_placeholders,
@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
self, entry_data: Mapping[str, Any] self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reauth.""" """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.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._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD)
self._server = entry_data[CONF_HUB]
if self._api_type == APIType.LOCAL: if self._api_type == APIType.LOCAL:
self._host = entry_data[CONF_HOST] 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)) 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.""" """Fetch Overkiz data via event listener."""
try: try:
events = await self.client.fetch_events() events = await self.client.fetch_events()
except BadCredentialsException as exception: except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyConcurrentRequestsException as exception: except TooManyConcurrentRequestsException as exception:
raise UpdateFailed("Too many concurrent requests.") from exception raise UpdateFailed("Too many concurrent requests.") from exception
@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
try: try:
await self.client.login() await self.client.login()
self.devices = await self._get_devices() self.devices = await self._get_devices()
except BadCredentialsException as exception: except (BadCredentialsException, NotAuthenticatedException) as exception:
raise ConfigEntryAuthFailed("Invalid authentication.") from exception raise ConfigEntryAuthFailed("Invalid authentication.") from exception
except TooManyRequestsException as exception: except TooManyRequestsException as exception:
raise UpdateFailed("Too many requests, try again later.") from 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", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
"requirements": ["pyoverkiz==1.17.0"], "requirements": ["pyoverkiz==1.17.1"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_kizbox._tcp.local.", "type": "_kizbox._tcp.local.",

View File

@ -32,17 +32,15 @@
} }
}, },
"local": { "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": { "data": {
"host": "[%key:common::config_flow::data::host%]", "host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]", "token": "[%key:common::config_flow::data::api_token%]",
"password": "[%key:common::config_flow::data::password%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of your Overkiz hub.", "host": "The hostname or IP address of your Overkiz hub.",
"username": "The username of your cloud account (app).", "token": "Token generated by the app used to control your device.",
"password": "The password of your cloud account (app).",
"verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname." "verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
} }
} }
@ -73,8 +71,8 @@
"state": { "state": {
"auto": "[%key:common::state::auto%]", "auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]", "manual": "[%key:common::state::manual%]",
"comfort-1": "Comfort 1", "comfort-1": "Comfort -1°C",
"comfort-2": "Comfort 2", "comfort-2": "Comfort -2°C",
"drying": "Drying", "drying": "Drying",
"external": "External", "external": "External",
"freeze": "Freeze", "freeze": "Freeze",

View File

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

View File

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

View File

@ -84,8 +84,10 @@
"options": { "options": {
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::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%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::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_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",

View File

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

View File

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

View File

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

View File

@ -1,34 +1,10 @@
"""Support to control a Salda Smarty XP/XV ventilation unit.""" """Support to control a Salda Smarty XP/XV ventilation unit."""
import ipaddress from homeassistant.const import Platform
import logging 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 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 = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, 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: async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool:
"""Set up the Smarty environment from a config entry.""" """Set up the Smarty environment from a config entry."""

View File

@ -7,7 +7,7 @@ from pysmarty2 import Smarty
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 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 from .const import DOMAIN
@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST): str}), data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
errors=errors, 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.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DOMAIN from .const import DOMAIN
from .coordinator import SmartyCoordinator from .coordinator import SmartyCoordinator

View File

@ -20,20 +20,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "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": { "entity": {
"binary_sensor": { "binary_sensor": {
"alarm": { "alarm": {

View File

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

View File

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

View File

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

View File

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

View File

@ -24,14 +24,14 @@
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "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." "description": "The provided credentials have stopped working. Please update them."
} }
}, },
"error": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "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%]", "unknown": "[%key:common::config_flow::error::unknown%]",
"no_sites_found": "No sites found which the user can manage." "no_sites_found": "No sites found which the user can manage."
}, },
@ -46,31 +46,31 @@
"name": "Port {port_name} PoE" "name": "Port {port_name} PoE"
}, },
"wan_connect_ipv4": { "wan_connect_ipv4": {
"name": "Port {port_name} Internet Connected" "name": "Port {port_name} Internet connected"
}, },
"wan_connect_ipv6": { "wan_connect_ipv6": {
"name": "Port {port_name} Internet Connected (IPv6)" "name": "Port {port_name} Internet connected (IPv6)"
} }
}, },
"binary_sensor": { "binary_sensor": {
"wan_link": { "wan_link": {
"name": "Port {port_name} Internet Link" "name": "Port {port_name} Internet link"
}, },
"online_detection": { "online_detection": {
"name": "Port {port_name} Online Detection" "name": "Port {port_name} online detection"
}, },
"lan_status": { "lan_status": {
"name": "Port {port_name} LAN Status" "name": "Port {port_name} LAN status"
}, },
"poe_delivery": { "poe_delivery": {
"name": "Port {port_name} PoE Delivery" "name": "Port {port_name} PoE delivery"
} }
}, },
"sensor": { "sensor": {
"device_status": { "device_status": {
"name": "Device status", "name": "Device status",
"state": { "state": {
"error": "Error", "error": "[%key:common::state::error%]",
"disconnected": "[%key:common::state::disconnected%]", "disconnected": "[%key:common::state::disconnected%]",
"connected": "[%key:common::state::connected%]", "connected": "[%key:common::state::connected%]",
"pending": "Pending", "pending": "Pending",
@ -91,7 +91,7 @@
"services": { "services": {
"reconnect_client": { "reconnect_client": {
"name": "Reconnect wireless 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": { "fields": {
"mac": { "mac": {
"name": "MAC address", "name": "MAC address",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed
_GLOBAL_DEFAULT_TIMEOUT, _GLOBAL_DEFAULT_TIMEOUT,
) )
import threading import threading
from typing import Any, cast, overload from typing import TYPE_CHECKING, Any, cast, overload
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID 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.""" """Wrap value in list if it is not one."""
if value is None: if value is None:
return [] 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: def entity_id(value: Any) -> str:

View File

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

View File

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

2
requirements.txt generated
View File

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

14
requirements_all.txt generated
View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,11 @@ from homeassistant.components import device_automation, persistent_notification
from homeassistant.components.device_automation import ( # noqa: F401 from homeassistant.components.device_automation import ( # noqa: F401
_async_get_device_automation_capabilities as async_get_device_automation_capabilities, _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 import IntegrationConfigInfo, async_process_component_config
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import ( 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) 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( def import_and_test_deprecated_constant_enum(
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,
module: ModuleType, module: ModuleType,

View File

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

View File

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

View File

@ -29,7 +29,11 @@ from . import (
patch_bluetooth_time, 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: # If the adapter is in a stuck state the following errors are raised:
NEED_RESET_ERRORS = [ NEED_RESET_ERRORS = [
@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
) -> None: ) -> None:
"""Test we can recover the adapter at startup and we wait for Dbus to init.""" """Test we can recover the adapter at startup and we wait for Dbus to init."""
assert await async_setup_component(hass, "logger", {}) assert await async_setup_component(hass, "logger", {})
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog
"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,
): ):
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 async def stop(self, *args, **kwargs):
assert "Waiting for adapter to initialize" in caplog.text """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") @pytest.mark.usefixtures("one_adapter")

View File

@ -1,5 +1,7 @@
"""Test the DHCP discovery integration.""" """Test the DHCP discovery integration."""
from __future__ import annotations
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import datetime import datetime
import threading import threading
@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import (
SourceType, SourceType,
) )
from homeassistant.components.dhcp.const import DOMAIN from homeassistant.components.dhcp.const import DOMAIN
from homeassistant.components.dhcp.models import DHCPData
from homeassistant.const import ( from homeassistant.const import (
EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet(
integration_matchers: dhcp.DhcpMatchers, integration_matchers: dhcp.DhcpMatchers,
address_data: dict | None = None, address_data: dict | None = None,
) -> Callable[[Any], Awaitable[None]]: ) -> Callable[[Any], Awaitable[None]]:
"""Make a handler for a dhcp packet."""
if address_data is None: if address_data is None:
address_data = {} address_data = {}
dhcp_watcher = dhcp.DHCPWatcher( dhcp_watcher = dhcp.DHCPWatcher(
hass, hass,
address_data, DHCPData(integration_matchers, set(), address_data),
integration_matchers,
) )
with patch("aiodhcpwatcher.async_start"): with patch("aiodhcpwatcher.async_start"):
await dhcp_watcher.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( async def test_device_tracker_hostname_and_macaddress_exists_before_start(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> 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: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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: async def test_device_tracker_registered(hass: HomeAssistant) -> None:
"""Test matching based on hostname and macaddress when registered.""" """Test matching based on hostname and macaddress when registered."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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: async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None:
"""Test handle None hostname.""" """Test handle None hostname."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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.""" """Test matching based on hostname and macaddress after start."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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.""" """Test matching based on hostname and macaddress after start but not home."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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.""" """Test matching based on hostname and macaddress after start but not router."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
) )
device_tracker_watcher.async_start() 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.""" """Test matching based on hostname and macaddress after start but missing hostname."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address(
"""Test an invalid ip address.""" """Test an invalid ip address."""
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, hass,
{},
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}], [{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
) )
device_tracker_watcher.async_start() 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: 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() 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, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "irobot-*",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "irobot-*", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
return_value=[], return_value=[],
), ),
): ):
device_tracker_watcher = dhcp.NetworkWatcher( device_tracker_watcher = _make_network_watcher(
hass, hass,
{}, [
dhcp.async_index_integration_matchers( {
[ "domain": "mock-domain",
{ "hostname": "connect",
"domain": "mock-domain", "macaddress": "B8B7F1*",
"hostname": "connect", }
"macaddress": "B8B7F1*", ],
}
]
),
) )
device_tracker_watcher.async_start() device_tracker_watcher.async_start()
await hass.async_block_till_done() await hass.async_block_till_done()
@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover(
hass, integration_matchers, address_data hass, integration_matchers, address_data
) )
rediscovery_watcher = dhcp.RediscoveryWatcher( rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers hass, DHCPData(integration_matchers, set(), address_data)
) )
rediscovery_watcher.async_start() rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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 hass, integration_matchers, address_data
) )
rediscovery_watcher = dhcp.RediscoveryWatcher( rediscovery_watcher = dhcp.RediscoveryWatcher(
hass, address_data, integration_matchers hass, DHCPData(integration_matchers, set(), address_data)
) )
rediscovery_watcher.async_start() rediscovery_watcher.async_start()
with patch.object(hass.config_entries.flow, "async_init") as mock_init: 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, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN, DOMAIN,
) )
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -50,6 +51,17 @@ def mock_setup_entry():
yield 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") @pytest.mark.usefixtures("mock_zeroconf")
async def test_user_connection_works( async def test_user_connection_works(
hass: HomeAssistant, mock_client, mock_setup_entry: None 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}, data={CONF_HOST: "127.0.0.1", CONF_PORT: 80},
) )
assert result["type"] is FlowResultType.ABORT 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" 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["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm" 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 = await hass.config_entries.flow.async_configure(
discovery_result["flow_id"], discovery_result["flow_id"],
@ -173,7 +193,12 @@ async def test_user_sets_unique_id(
{CONF_HOST: "127.0.0.1", CONF_PORT: 6053}, {CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
) )
assert result["type"] is FlowResultType.ABORT 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") @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["type"] is FlowResultType.FORM
assert discovery_result["step_id"] == "discovery_confirm" assert discovery_result["step_id"] == "discovery_confirm"
assert discovery_result["description_placeholders"] == {
"name": "test8266",
}
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -287,6 +315,7 @@ async def test_user_with_password(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate" assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "password1"} 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["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate" assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = InvalidAuthAPIError 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["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate" assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "invalid_auth"} 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.""" """Test user step with key from dashboard that is incorrect."""
mock_client.device_info.side_effect = [ mock_client.device_info.side_effect = [
RequiresEncryptionAPIError, RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError, InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo( DeviceInfo(
uses_password=False, uses_password=False,
name="test", name="test",
@ -359,6 +390,7 @@ async def test_user_dashboard_has_wrong_key(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate" assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
mock_client.connect.side_effect = APIConnectionError mock_client.connect.side_effect = APIConnectionError
@ -562,6 +597,7 @@ async def test_login_connection_error(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate" assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {"name": "test"}
assert result["errors"] == {"base": "connection_error"} assert result["errors"] == {"base": "connection_error"}
@ -578,12 +614,18 @@ async def test_discovery_initiation(
port=6053, port=6053,
properties={ properties={
"mac": "1122334455aa", "mac": "1122334455aa",
"friendly_name": "The Test",
}, },
type="mock_type", type="mock_type",
) )
flow = await hass.config_entries.flow.async_init( flow = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info "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( result = await hass.config_entries.flow.async_configure(
flow["flow_id"], user_input={} flow["flow_id"], user_input={}
@ -645,7 +687,12 @@ async def test_discovery_already_configured(
) )
assert result["type"] is FlowResultType.ABORT 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( async def test_discovery_duplicate_data(
@ -667,6 +714,7 @@ async def test_discovery_duplicate_data(
) )
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm" assert result["step_id"] == "discovery_confirm"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} "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["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" 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["errors"] == {} assert result["errors"] == {}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert len(mock_client.connect.mock_calls) == 2 assert len(mock_client.connect.mock_calls) == 2
assert len(mock_client.device_info.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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info = AsyncMock( mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test") 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "ESPHome"}
mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError
result = await hass.config_entries.flow.async_configure( 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["errors"] == {"base": "invalid_psk"} assert result["errors"] == {"base": "invalid_psk"}
assert result["description_placeholders"] == {"name": "ESPHome"}
assert mock_client.noise_psk == INVALID_NOISE_PSK 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) result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
@pytest.mark.usefixtures("mock_zeroconf") @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["type"] is FlowResultType.FORM, result
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
mock_dashboard["configured"].append( mock_dashboard["configured"].append(
{ {
@ -1050,6 +1113,9 @@ async def test_reauth_confirm_invalid(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"] assert result["errors"]
assert result["errors"]["base"] == "invalid_psk" 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["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm" assert result["step_id"] == "reauth_confirm"
assert result["description_placeholders"] == {
"name": "Mock Title (test)",
}
assert result["errors"] assert result["errors"]
assert result["errors"]["base"] == "invalid_psk" assert result["errors"]["base"] == "invalid_psk"
@ -1125,6 +1194,9 @@ async def test_reauth_encryption_key_removed(
result = await entry.start_reauth_flow(hass) result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_encryption_removed_confirm" 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={} result["flow_id"], user_input={}
@ -1159,7 +1231,12 @@ async def test_discovery_dhcp_updates_host(
) )
assert result["type"] is FlowResultType.ABORT 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" 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["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 # Mac was wrong, should not update
assert entry.data[CONF_HOST] == "192.168.43.183" 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["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 # Mac was wrong, should not update
assert entry.data[CONF_HOST] == "192.168.43.183" 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["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 # Mac was missing, should not update
assert entry.data[CONF_HOST] == "192.168.43.183" 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["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm" assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append( 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["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm" assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
mock_dashboard["configured"].append( 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["type"] is FlowResultType.FORM
assert flow["step_id"] == "discovery_confirm" assert flow["step_id"] == "discovery_confirm"
assert flow["description_placeholders"] == {"name": "test8266"}
await dashboard.async_get_dashboard(hass).async_refresh() 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test8266"}
async def test_option_flow_allow_service_calls( 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["type"] is FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
assert result["description_placeholders"] == {"name": "test"}
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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 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") @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_with_existing_entry( async def test_reconfig_name_conflict_with_existing_entry(
hass: HomeAssistant, mock_client: APIClient 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["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") @pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")

View File

@ -17,6 +17,7 @@ from aioesphomeapi import (
) )
from homeassistant.const import ( from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_RESTORED, ATTR_RESTORED,
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_STOP,
STATE_OFF, STATE_OFF,
@ -503,3 +504,40 @@ async def test_esphome_device_without_friendly_name(
state = hass.states.get("binary_sensor.test_mybinary_sensor") state = hass.states.get("binary_sensor.test_mybinary_sensor")
assert state is not None assert state is not None
assert state.state == STATE_ON 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 .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( 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.async_block_till_done()
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
"set_level", ):
{"homeassistant.components.esphome": "DEBUG"}, assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
blocking=True,
)
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
device.mock_on_log_message( device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message") Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert "test_log_message" in caplog.text assert "test_log_message" in caplog.text
device.mock_on_log_message( device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message") Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert "test_error_log_message" in caplog.text assert "test_error_log_message" in caplog.text
caplog.set_level(logging.ERROR) caplog.set_level(logging.ERROR)
device.mock_on_log_message( device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert "test_debug_log_message" not in caplog.text assert "test_debug_log_message" not in caplog.text
caplog.set_level(logging.DEBUG) caplog.set_level(logging.DEBUG)
device.mock_on_log_message( device.mock_on_log_message(
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message") Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert "test_debug_log_message" in caplog.text assert "test_debug_log_message" in caplog.text
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
"set_level", ):
{"homeassistant.components.esphome": "WARNING"}, assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
blocking=True, async with async_call_logger_set_level(
) "homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN ):
await hass.services.async_call( assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
"logger", async with async_call_logger_set_level(
"set_level", "homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog
{"homeassistant.components.esphome": "ERROR"}, ):
blocking=True, assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
)
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 def test_esphome_device_service_calls_not_allowed( 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["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" assert entry.data[CONF_HOST] == "192.168.43.184"
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(new_info.mock_calls) == 2 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["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" assert entry.data[CONF_HOST] == "192.168.43.184"
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(new_info.mock_calls) == 2 assert len(new_info.mock_calls) == 2
@ -958,6 +961,7 @@ async def test_debug_logging(
[APIClient, list[EntityInfo], list[UserService], list[EntityState]], [APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockConfigEntry], Awaitable[MockConfigEntry],
], ],
caplog: pytest.LogCaptureFixture,
) -> None: ) -> None:
"""Test enabling and disabling debug logging.""" """Test enabling and disabling debug logging."""
assert await async_setup_component(hass, "logger", {"logger": {}}) assert await async_setup_component(hass, "logger", {"logger": {}})
@ -967,24 +971,16 @@ async def test_debug_logging(
user_service=[], user_service=[],
states=[], states=[],
) )
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
"set_level", ):
{"homeassistant.components.esphome": "DEBUG"}, mock_client.set_debug.assert_has_calls([call(True)])
blocking=True, mock_client.reset_mock()
)
await hass.async_block_till_done()
mock_client.set_debug.assert_has_calls([call(True)])
mock_client.reset_mock() async with async_call_logger_set_level(
await hass.services.async_call( "homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
"logger", ):
"set_level", mock_client.set_debug.assert_has_calls([call(False)])
{"homeassistant.components.esphome": "WARNING"},
blocking=True,
)
await hass.async_block_till_done()
mock_client.set_debug.assert_has_calls([call(False)])
async def test_esphome_device_with_dash_in_name_user_services( async def test_esphome_device_with_dash_in_name_user_services(

View File

@ -1,13 +1,24 @@
"""Test Govee light local.""" """Test Govee light local."""
from errno import EADDRINUSE, ENETDOWN 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 from govee_local_api import GoveeDevice
import pytest
from homeassistant.components.govee_light_local.const import DOMAIN 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.config_entries import ConfigEntryState
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES 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" assert light.state == "off"
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id}, {"entity_id": light.entity_id},
blocking=True, blocking=True,
) )
@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
# Turn off # Turn off
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_off", SERVICE_TURN_OFF,
{"entity_id": light.entity_id}, {"entity_id": light.entity_id},
blocking=True, 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) 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: async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
"""Test changing brightness.""" """Test changing brightness."""
mock_govee_api.devices = [ mock_govee_api.devices = [
@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light.state == "off" assert light.state == "off"
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness_pct": 50}, {"entity_id": light.entity_id, "brightness_pct": 50},
blocking=True, blocking=True,
) )
@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
assert light is not None assert light is not None
assert light.state == "on" assert light.state == "on"
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50) 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( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness": 255}, {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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) mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness": 255}, {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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) 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" assert light.state == "off"
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "rgb_color": [100, 255, 50]}, {"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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 assert light.attributes["color_mode"] == ColorMode.RGB
mock_govee_api.set_color.assert_awaited_with( 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( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "kelvin": 4400}, {"entity_id": light.entity_id, "kelvin": 4400},
blocking=True, blocking=True,
) )
@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
assert light.state == "off" assert light.state == "off"
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "sunrise"}, {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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) 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 # Set initial color
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "rgb_color": initial_color}, {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness": 255}, {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -439,15 +521,15 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes["brightness"] == 255 assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene # Activate scene
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "sunrise"}, {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -455,14 +537,14 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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) mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Deactivate scene # Deactivate scene
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "none"}, {"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -470,9 +552,9 @@ async def test_scene_restore_rgb(
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" assert light.state == "on"
assert light.attributes["effect"] is None assert light.attributes[ATTR_EFFECT] is None
assert light.attributes["rgb_color"] == initial_color assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes["brightness"] == 255 assert light.attributes[ATTR_BRIGHTNESS] == 255
async def test_scene_restore_temperature( async def test_scene_restore_temperature(
@ -505,8 +587,8 @@ async def test_scene_restore_temperature(
# Set initial color # Set initial color
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "color_temp_kelvin": initial_color}, {"entity_id": light.entity_id, "color_temp_kelvin": initial_color},
blocking=True, blocking=True,
) )
@ -520,9 +602,9 @@ async def test_scene_restore_temperature(
# Activate scene # Activate scene
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "sunrise"}, {"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -530,14 +612,14 @@ async def test_scene_restore_temperature(
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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") mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise")
# Deactivate scene # Deactivate scene
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "none"}, {"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -545,7 +627,7 @@ async def test_scene_restore_temperature(
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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 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 # Set initial color
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "rgb_color": initial_color}, {"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "brightness": 255}, {"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() 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") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" assert light.state == "on"
assert light.attributes["rgb_color"] == initial_color assert light.attributes[ATTR_RGB_COLOR] == initial_color
assert light.attributes["brightness"] == 255 assert light.attributes[ATTR_BRIGHTNESS] == 255
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True) mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
# Activate scene # Activate scene
await hass.services.async_call( await hass.services.async_call(
"light", LIGHT_DOMAIN,
"turn_on", SERVICE_TURN_ON,
{"entity_id": light.entity_id, "effect": "none"}, {"entity_id": light.entity_id, ATTR_EFFECT: "none"},
blocking=True, blocking=True,
) )
await hass.async_block_till_done() await hass.async_block_till_done()
light = hass.states.get("light.H615A") light = hass.states.get("light.H615A")
assert light is not None assert light is not None
assert light.state == "on" 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() 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" 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( async def test_does_not_work_into_the_future(
recorder_mock: Recorder, hass: HomeAssistant recorder_mock: Recorder, hass: HomeAssistant
) -> None: ) -> None:
@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards(
past_next_update = start_time + timedelta(minutes=30) past_next_update = start_time + timedelta(minutes=30)
with ( with (
patch(
"homeassistant.components.recorder.history.state_changes_during_period",
_fake_states,
),
freeze_time(past_next_update), freeze_time(past_next_update),
): ):
async_fire_time_changed(hass, 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" 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, # One minute has passed and the time has now rolled over into a new day, resetting the recorder window.
# and will see that the sensor is ON starting from midnight. # The sensor will be ON since midnight.
t3 = t2 + timedelta(minutes=1) t3 = t2 + timedelta(minutes=1)
with freeze_time(t3):
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),
):
# The sensor turns off around this time, before the sensor does its normal polled update. # The sensor turns off around this time, before the sensor does its normal polled update.
hass.states.async_set("binary_sensor.state", "off") hass.states.async_set("binary_sensor.state", "off")
await hass.async_block_till_done(wait_background_tasks=True) 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 import dt as dt_util
from homeassistant.util.ssl import server_context_intermediate, server_context_modern 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 from tests.typing import ClientSessionGenerator
@ -505,27 +505,21 @@ async def test_logging(
) )
) )
hass.states.async_set("logging.entity", "hello") hass.states.async_set("logging.entity", "hello")
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "aiohttp.access", "INFO", hass=hass, caplog=caplog
"set_level", ):
{"aiohttp.access": "info"}, client = await hass_client()
blocking=True, response = await client.get("/api/states/logging.entity")
) assert response.status == HTTPStatus.OK
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 assert "GET /api/states/logging.entity" in caplog.text
caplog.clear() caplog.clear()
await hass.services.async_call( async with async_call_logger_set_level(
"logger", "aiohttp.access", "WARNING", hass=hass, caplog=caplog
"set_level", ):
{"aiohttp.access": "warning"}, response = await client.get("/api/states/logging.entity")
blocking=True, assert response.status == HTTPStatus.OK
) assert "GET /api/states/logging.entity" not in caplog.text
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( async def test_register_static_paths(

View File

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

View File

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

View File

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

View File

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

View File

@ -18,10 +18,12 @@ from homeassistant.const import (
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr 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 .util import _get_mock_nutclient, async_init_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
async def test_config_entry_migrations(hass: HomeAssistant) -> None: 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) 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( async def test_config_not_ready(
hass: HomeAssistant, hass: HomeAssistant,
caplog: pytest.LogCaptureFixture, caplog: pytest.LogCaptureFixture,

View File

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

View File

@ -43,7 +43,7 @@ async def async_init_integration(
hass: HomeAssistant, hass: HomeAssistant,
ups_fixture: str | None = None, ups_fixture: str | None = None,
host: str = "mock", host: str = "mock",
port: str = "mock", port: int = 1234,
username: str = "mock", username: str = "mock",
password: str = "mock", password: str = "mock",
alias: str | None = None, alias: str | None = None,
@ -104,7 +104,6 @@ async def async_init_integration(
def _test_sensor_and_attributes( def _test_sensor_and_attributes(
hass: HomeAssistant, hass: HomeAssistant,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
model: str,
unique_id: str, unique_id: str,
device_id: str, device_id: str,
state_value: 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_HOST = "gateway-1234-5678-9123.local:8443"
TEST_HOST2 = "192.168.11.104:8443" TEST_HOST2 = "192.168.11.104:8443"
TEST_TOKEN = "1234123412341234"
MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)] MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)]
MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)] 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( async def test_form_local_happy_flow(
hass: HomeAssistant, mock_setup_entry: AsyncMock hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None: ) -> None:
"""Test we get the form.""" """Test local API configuration flow."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER} DOMAIN, context={"source": config_entries.SOURCE_USER}
) )
@ -179,21 +180,27 @@ async def test_form_local_happy_flow(
"pyoverkiz.client.OverkizClient", "pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True), login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), 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"], result["flow_id"],
{ {
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"host": "gateway-1234-5678-1234.local:8443", "host": "gateway-1234-5678-1234.local:8443",
"token": TEST_TOKEN,
"verify_ssl": True,
}, },
) )
await hass.async_block_till_done() 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 assert len(mock_setup_entry.mock_calls) == 1
@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud(
(MaintenanceException, "server_in_maintenance"), (MaintenanceException, "server_in_maintenance"),
(TooManyAttemptsBannedException, "too_many_attempts"), (TooManyAttemptsBannedException, "too_many_attempts"),
(UnknownUserException, "unsupported_hardware"), (UnknownUserException, "unsupported_hardware"),
(NotSuchTokenException, "no_such_token"), (NotSuchTokenException, "invalid_auth"),
(Exception, "unknown"), (Exception, "unknown"),
], ],
) )
@ -297,8 +304,7 @@ async def test_form_invalid_auth_local(
result["flow_id"], result["flow_id"],
{ {
"host": TEST_HOST, "host": TEST_HOST,
"username": TEST_EMAIL, "token": TEST_TOKEN,
"password": TEST_PASSWORD,
"verify_ssl": True, "verify_ssl": True,
}, },
) )
@ -309,52 +315,6 @@ async def test_form_invalid_auth_local(
assert result4["errors"] == {"base": error} 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( @pytest.mark.parametrize(
("side_effect", "error"), ("side_effect", "error"),
[ [
@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry(
async def test_local_abort_on_duplicate_entry( async def test_local_abort_on_duplicate_entry(
hass: HomeAssistant, mock_setup_entry: AsyncMock hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None: ) -> None:
"""Test we get the form.""" """Test local API configuration is aborted if gateway already exists."""
MockConfigEntry( MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id=TEST_GATEWAY_ID, unique_id=TEST_GATEWAY_ID,
version=2,
data={ data={
"host": TEST_HOST, "host": TEST_HOST,
"username": TEST_EMAIL, "token": TEST_TOKEN,
"password": TEST_PASSWORD, "verify_ssl": True,
"hub": TEST_SERVER, "hub": TEST_SERVER,
"api_type": "local",
}, },
).add_to_hass(hass) ).add_to_hass(hass)
@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry(
login=AsyncMock(return_value=True), login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
get_setup_option=AsyncMock(return_value=True), 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( result4 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
"host": TEST_HOST, "host": TEST_HOST,
"username": TEST_EMAIL, "token": TEST_TOKEN,
"password": TEST_PASSWORD,
"verify_ssl": True, "verify_ssl": True,
}, },
) )
@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None:
assert result2["reason"] == "reauth_wrong_account" assert result2["reason"] == "reauth_wrong_account"
async def test_local_reauth_success(hass: HomeAssistant) -> None: async def test_local_reauth_legacy(hass: HomeAssistant) -> None:
"""Test reauthentication flow.""" """Test legacy reauthentication flow with username/password."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id=TEST_GATEWAY_ID, unique_id=TEST_GATEWAY_ID,
version=2, version=2,
data={ data={
"host": TEST_HOST,
"username": TEST_EMAIL, "username": TEST_EMAIL,
"password": TEST_PASSWORD, "password": TEST_PASSWORD,
"verify_ssl": True,
"hub": TEST_SERVER, "hub": TEST_SERVER,
"host": TEST_HOST,
"api_type": "local", "api_type": "local",
}, },
) )
@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None:
"pyoverkiz.client.OverkizClient", "pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True), login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), 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( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={ {
"username": TEST_EMAIL, "host": TEST_HOST,
"password": TEST_PASSWORD2, "token": "new_token",
"verify_ssl": True,
}, },
) )
assert result3["type"] is FlowResultType.ABORT assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful" assert result3["reason"] == "reauth_successful"
assert mock_entry.data["username"] == TEST_EMAIL assert mock_entry.data["host"] == TEST_HOST
assert mock_entry.data["password"] == TEST_PASSWORD2 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: async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
"""Test reauthentication flow.""" """Test local reauth flow with wrong gateway account."""
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id=TEST_GATEWAY_ID2, unique_id=TEST_GATEWAY_ID2,
version=2, version=2,
data={ data={
"username": TEST_EMAIL,
"password": TEST_PASSWORD,
"hub": TEST_SERVER,
"host": TEST_HOST, "host": TEST_HOST,
"token": "old_token",
"verify_ssl": True,
"hub": TEST_SERVER,
"api_type": "local", "api_type": "local",
}, },
) )
@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
"pyoverkiz.client.OverkizClient", "pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True), login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), 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( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={ {
"username": TEST_EMAIL, "host": TEST_HOST,
"password": TEST_PASSWORD2, "token": "new_token",
"verify_ssl": True,
}, },
) )
@ -897,27 +903,27 @@ async def test_local_zeroconf_flow(
"pyoverkiz.client.OverkizClient", "pyoverkiz.client.OverkizClient",
login=AsyncMock(return_value=True), login=AsyncMock(return_value=True),
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE), 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( result4 = await hass.config_entries.flow.async_configure(
result["flow_id"], 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["type"] is FlowResultType.CREATE_ENTRY
assert result4["title"] == "gateway-1234-5678-9123.local:8443" 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 assert len(mock_setup_entry.mock_calls) == 1

View File

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

View File

@ -3,8 +3,8 @@
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from homeassistant.components.smarty.const import DOMAIN from homeassistant.components.smarty.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -114,52 +114,3 @@ async def test_existing_entry(
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured" 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 syrupy import SnapshotAssertion
from homeassistant.components.smarty import DOMAIN from homeassistant.components.smarty.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.setup import async_setup_component
from . import setup_integration from . import setup_integration
from tests.common import MockConfigEntry 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( async def test_device(
hass: HomeAssistant, hass: HomeAssistant,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,

View File

@ -463,3 +463,28 @@ HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak(
connectable=True, connectable=True,
tx_power=-127, 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_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'original_icon': 'mdi:battery-unknown', 'original_icon': 'mdi:battery-unknown',
'original_name': 'Off grid reserve', 'original_name': 'Off-grid reserve',
'platform': 'tesla_fleet', 'platform': 'tesla_fleet',
'previous_unique_id': None, 'previous_unique_id': None,
'supported_features': 0, 'supported_features': 0,
@ -101,7 +101,7 @@
StateSnapshot({ StateSnapshot({
'attributes': ReadOnlyDict({ 'attributes': ReadOnlyDict({
'device_class': 'battery', 'device_class': 'battery',
'friendly_name': 'Energy Site Off grid reserve', 'friendly_name': 'Energy Site Off-grid reserve',
'icon': 'mdi:battery-unknown', 'icon': 'mdi:battery-unknown',
'max': 100, 'max': 100,
'min': 0, 'min': 0,

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