mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Merge branch 'dev' into esphome_bronze
This commit is contained in:
commit
3c4f540ce0
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioairzone_cloud"],
|
||||
"requirements": ["aioairzone-cloud==0.6.11"]
|
||||
"requirements": ["aioairzone-cloud==0.6.12"]
|
||||
}
|
||||
|
@ -21,7 +21,7 @@
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"off_grid_status": {
|
||||
"name": "Off grid status"
|
||||
"name": "Off-grid status"
|
||||
},
|
||||
"dc_1_short_circuit_error_status": {
|
||||
"name": "DC 1 short circuit error status"
|
||||
|
@ -26,7 +26,7 @@
|
||||
"sensor": {
|
||||
"threshold": {
|
||||
"state": {
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"green": "Green",
|
||||
"yellow": "Yellow",
|
||||
"red": "Red"
|
||||
|
@ -30,7 +30,7 @@
|
||||
"available": "Available",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"unavailable": "Unavailable",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"offline": "Offline"
|
||||
}
|
||||
},
|
||||
@ -41,7 +41,7 @@
|
||||
"vehicle_detected": "Detected",
|
||||
"ready": "Ready",
|
||||
"no_power": "No power",
|
||||
"vehicle_error": "Error"
|
||||
"vehicle_error": "[%key:common::state::error%]"
|
||||
}
|
||||
},
|
||||
"actual_v1": {
|
||||
|
@ -139,7 +139,7 @@
|
||||
"state": {
|
||||
"default": "Default",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"complete": "Complete",
|
||||
"fully_charged": "Fully charged",
|
||||
"finished_fully_charged": "Finished, fully charged",
|
||||
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from fnmatch import translate
|
||||
from functools import lru_cache, partial
|
||||
@ -66,13 +65,12 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo as _DhcpServ
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import DHCPMatcher, async_get_dhcp
|
||||
|
||||
from .const import DOMAIN
|
||||
from . import websocket_api
|
||||
from .const import DOMAIN, HOSTNAME, IP_ADDRESS, MAC_ADDRESS
|
||||
from .models import DATA_DHCP, DHCPAddressData, DHCPData, DhcpMatchers
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
HOSTNAME: Final = "hostname"
|
||||
MAC_ADDRESS: Final = "macaddress"
|
||||
IP_ADDRESS: Final = "ip"
|
||||
REGISTERED_DEVICES: Final = "registered_devices"
|
||||
SCAN_INTERVAL = timedelta(minutes=60)
|
||||
|
||||
@ -87,15 +85,6 @@ _DEPRECATED_DhcpServiceInfo = DeprecatedConstant(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DhcpMatchers:
|
||||
"""Prepared info from dhcp entries."""
|
||||
|
||||
registered_devices_domains: set[str]
|
||||
no_oui_matchers: dict[str, list[DHCPMatcher]]
|
||||
oui_matchers: dict[str, list[DHCPMatcher]]
|
||||
|
||||
|
||||
def async_index_integration_matchers(
|
||||
integration_matchers: list[DHCPMatcher],
|
||||
) -> DhcpMatchers:
|
||||
@ -133,36 +122,34 @@ def async_index_integration_matchers(
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the dhcp component."""
|
||||
watchers: list[WatcherBase] = []
|
||||
address_data: dict[str, dict[str, str]] = {}
|
||||
integration_matchers = async_index_integration_matchers(await async_get_dhcp(hass))
|
||||
dhcp_data = DHCPData(integration_matchers=integration_matchers)
|
||||
hass.data[DATA_DHCP] = dhcp_data
|
||||
websocket_api.async_setup(hass)
|
||||
watchers: list[WatcherBase] = []
|
||||
# For the passive classes we need to start listening
|
||||
# for state changes and connect the dispatchers before
|
||||
# everything else starts up or we will miss events
|
||||
device_watcher = DeviceTrackerWatcher(hass, address_data, integration_matchers)
|
||||
device_watcher = DeviceTrackerWatcher(hass, dhcp_data)
|
||||
device_watcher.async_start()
|
||||
watchers.append(device_watcher)
|
||||
|
||||
device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(
|
||||
hass, address_data, integration_matchers
|
||||
)
|
||||
device_tracker_registered_watcher = DeviceTrackerRegisteredWatcher(hass, dhcp_data)
|
||||
device_tracker_registered_watcher.async_start()
|
||||
watchers.append(device_tracker_registered_watcher)
|
||||
|
||||
async def _async_initialize(event: Event) -> None:
|
||||
await aiodhcpwatcher.async_init()
|
||||
|
||||
network_watcher = NetworkWatcher(hass, address_data, integration_matchers)
|
||||
network_watcher = NetworkWatcher(hass, dhcp_data)
|
||||
network_watcher.async_start()
|
||||
watchers.append(network_watcher)
|
||||
|
||||
dhcp_watcher = DHCPWatcher(hass, address_data, integration_matchers)
|
||||
dhcp_watcher = DHCPWatcher(hass, dhcp_data)
|
||||
await dhcp_watcher.async_start()
|
||||
watchers.append(dhcp_watcher)
|
||||
|
||||
rediscovery_watcher = RediscoveryWatcher(
|
||||
hass, address_data, integration_matchers
|
||||
)
|
||||
rediscovery_watcher = RediscoveryWatcher(hass, dhcp_data)
|
||||
rediscovery_watcher.async_start()
|
||||
watchers.append(rediscovery_watcher)
|
||||
|
||||
@ -180,18 +167,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
class WatcherBase:
|
||||
"""Base class for dhcp and device tracker watching."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
address_data: dict[str, dict[str, str]],
|
||||
integration_matchers: DhcpMatchers,
|
||||
) -> None:
|
||||
def __init__(self, hass: HomeAssistant, dhcp_data: DHCPData) -> None:
|
||||
"""Initialize class."""
|
||||
super().__init__()
|
||||
|
||||
self.hass = hass
|
||||
self._integration_matchers = integration_matchers
|
||||
self._address_data = address_data
|
||||
self._callbacks = dhcp_data.callbacks
|
||||
self._integration_matchers = dhcp_data.integration_matchers
|
||||
self._address_data = dhcp_data.address_data
|
||||
self._unsub: Callable[[], None] | None = None
|
||||
|
||||
@callback
|
||||
@ -230,18 +212,18 @@ class WatcherBase:
|
||||
mac_address = formatted_mac.replace(":", "")
|
||||
compressed_ip_address = made_ip_address.compressed
|
||||
|
||||
data = self._address_data.get(mac_address)
|
||||
current_data = self._address_data.get(mac_address)
|
||||
if (
|
||||
not force
|
||||
and data
|
||||
and data[IP_ADDRESS] == compressed_ip_address
|
||||
and data[HOSTNAME].startswith(hostname)
|
||||
and current_data
|
||||
and current_data[IP_ADDRESS] == compressed_ip_address
|
||||
and current_data[HOSTNAME].startswith(hostname)
|
||||
):
|
||||
# If the address data is the same no need
|
||||
# to process it
|
||||
return
|
||||
|
||||
data = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname}
|
||||
data: DHCPAddressData = {IP_ADDRESS: compressed_ip_address, HOSTNAME: hostname}
|
||||
self._address_data[mac_address] = data
|
||||
|
||||
lowercase_hostname = hostname.lower()
|
||||
@ -287,9 +269,19 @@ class WatcherBase:
|
||||
_LOGGER.debug("Matched %s against %s", data, matcher)
|
||||
matched_domains.add(domain)
|
||||
|
||||
if not matched_domains:
|
||||
return # avoid creating DiscoveryKey if there are no matches
|
||||
if self._callbacks:
|
||||
address_data = {mac_address: data}
|
||||
for callback_ in self._callbacks:
|
||||
callback_(address_data)
|
||||
|
||||
service_info: _DhcpServiceInfo | None = None
|
||||
if not matched_domains:
|
||||
return
|
||||
service_info = _DhcpServiceInfo(
|
||||
ip=ip_address,
|
||||
hostname=lowercase_hostname,
|
||||
macaddress=mac_address,
|
||||
)
|
||||
discovery_key = DiscoveryKey(
|
||||
domain=DOMAIN,
|
||||
key=mac_address,
|
||||
@ -300,11 +292,7 @@ class WatcherBase:
|
||||
self.hass,
|
||||
domain,
|
||||
{"source": config_entries.SOURCE_DHCP},
|
||||
_DhcpServiceInfo(
|
||||
ip=ip_address,
|
||||
hostname=lowercase_hostname,
|
||||
macaddress=mac_address,
|
||||
),
|
||||
service_info,
|
||||
discovery_key=discovery_key,
|
||||
)
|
||||
|
||||
@ -315,11 +303,10 @@ class NetworkWatcher(WatcherBase):
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
address_data: dict[str, dict[str, str]],
|
||||
integration_matchers: DhcpMatchers,
|
||||
dhcp_data: DHCPData,
|
||||
) -> None:
|
||||
"""Initialize class."""
|
||||
super().__init__(hass, address_data, integration_matchers)
|
||||
super().__init__(hass, dhcp_data)
|
||||
self._discover_hosts: DiscoverHosts | None = None
|
||||
self._discover_task: asyncio.Task | None = None
|
||||
|
||||
|
@ -1,3 +1,8 @@
|
||||
"""Constants for the dhcp integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "dhcp"
|
||||
HOSTNAME: Final = "hostname"
|
||||
MAC_ADDRESS: Final = "macaddress"
|
||||
IP_ADDRESS: Final = "ip"
|
||||
|
37
homeassistant/components/dhcp/helpers.py
Normal file
37
homeassistant/components/dhcp/helpers.py
Normal 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
|
43
homeassistant/components/dhcp/models.py
Normal file
43
homeassistant/components/dhcp/models.py
Normal 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)
|
63
homeassistant/components/dhcp/websocket_api.py
Normal file
63
homeassistant/components/dhcp/websocket_api.py
Normal 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))
|
@ -57,6 +57,7 @@ ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
|
||||
DEFAULT_NAME = "ESPHome"
|
||||
|
||||
|
||||
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@ -117,8 +118,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._host = entry_data[CONF_HOST]
|
||||
self._port = entry_data[CONF_PORT]
|
||||
self._password = entry_data[CONF_PASSWORD]
|
||||
self._name = self._reauth_entry.title
|
||||
self._device_name = entry_data.get(CONF_DEVICE_NAME)
|
||||
self._name = self._reauth_entry.title
|
||||
|
||||
# Device without encryption allows fetching device info. We can then check
|
||||
# if the device is no longer using a password. If we did try with a password,
|
||||
@ -147,7 +148,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_encryption_removed_confirm",
|
||||
description_placeholders={"name": self._name},
|
||||
description_placeholders={"name": self._async_get_human_readable_name()},
|
||||
)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
@ -172,7 +173,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
|
||||
errors=errors,
|
||||
description_placeholders={"name": self._name},
|
||||
description_placeholders={"name": self._async_get_human_readable_name()},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
@ -189,12 +190,14 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@property
|
||||
def _name(self) -> str:
|
||||
return self.__name or "ESPHome"
|
||||
return self.__name or DEFAULT_NAME
|
||||
|
||||
@_name.setter
|
||||
def _name(self, value: str) -> None:
|
||||
self.__name = value
|
||||
self.context["title_placeholders"] = {"name": self._name}
|
||||
self.context["title_placeholders"] = {
|
||||
"name": self._async_get_human_readable_name()
|
||||
}
|
||||
|
||||
async def _async_try_fetch_device_info(self) -> ConfigFlowResult:
|
||||
"""Try to fetch device info and return any errors."""
|
||||
@ -254,7 +257,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
return await self._async_try_fetch_device_info()
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm", description_placeholders={"name": self._name}
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"name": self._async_get_human_readable_name()},
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
@ -274,8 +278,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
# Hostname is format: livingroom.local.
|
||||
device_name = discovery_info.hostname.removesuffix(".local.")
|
||||
|
||||
self._name = discovery_info.properties.get("friendly_name", device_name)
|
||||
self._device_name = device_name
|
||||
self._name = discovery_info.properties.get("friendly_name", device_name)
|
||||
self._host = discovery_info.host
|
||||
self._port = discovery_info.port
|
||||
self._noise_required = bool(discovery_info.properties.get("api_encryption"))
|
||||
@ -306,7 +310,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
updates[CONF_HOST] = host
|
||||
if port is not None:
|
||||
updates[CONF_PORT] = port
|
||||
self._abort_if_unique_id_configured(updates=updates)
|
||||
self._abort_unique_id_configured_with_details(updates=updates)
|
||||
|
||||
@callback
|
||||
def _abort_unique_id_configured_with_details(self, updates: dict[str, Any]) -> None:
|
||||
"""Abort if unique_id is already configured with details."""
|
||||
assert self.unique_id is not None
|
||||
if not (
|
||||
conflict_entry := self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||
self.handler, self.unique_id
|
||||
)
|
||||
):
|
||||
return
|
||||
assert conflict_entry.unique_id is not None
|
||||
if updates:
|
||||
error = "already_configured_updates"
|
||||
else:
|
||||
error = "already_configured_detailed"
|
||||
self._abort_if_unique_id_configured(
|
||||
updates=updates,
|
||||
error=error,
|
||||
description_placeholders={
|
||||
"title": conflict_entry.title,
|
||||
"name": conflict_entry.data.get(CONF_DEVICE_NAME, "unknown"),
|
||||
"mac": format_mac(conflict_entry.unique_id),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_mqtt(
|
||||
self, discovery_info: MqttServiceInfo
|
||||
@ -341,7 +370,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check if already configured
|
||||
await self.async_set_unique_id(mac_address)
|
||||
self._abort_if_unique_id_configured(
|
||||
self._abort_unique_id_configured_with_details(
|
||||
updates={CONF_HOST: self._host, CONF_PORT: self._port}
|
||||
)
|
||||
|
||||
@ -479,7 +508,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data=self._reauth_entry.data | self._async_make_config_data(),
|
||||
)
|
||||
assert self._host is not None
|
||||
self._abort_if_unique_id_configured(
|
||||
self._abort_unique_id_configured_with_details(
|
||||
updates={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: self._port,
|
||||
@ -510,7 +539,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if not (
|
||||
unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id)
|
||||
):
|
||||
self._abort_if_unique_id_configured(
|
||||
self._abort_unique_id_configured_with_details(
|
||||
updates={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: self._port,
|
||||
@ -568,9 +597,30 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="encryption_key",
|
||||
data_schema=vol.Schema({vol.Required(CONF_NOISE_PSK): str}),
|
||||
errors=errors,
|
||||
description_placeholders={"name": self._name},
|
||||
description_placeholders={"name": self._async_get_human_readable_name()},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_get_human_readable_name(self) -> str:
|
||||
"""Return a human readable name for the entry."""
|
||||
entry: ConfigEntry | None = None
|
||||
if self.source == SOURCE_REAUTH:
|
||||
entry = self._reauth_entry
|
||||
elif self.source == SOURCE_RECONFIGURE:
|
||||
entry = self._reconfig_entry
|
||||
friendly_name = self._name
|
||||
device_name = self._device_name
|
||||
if (
|
||||
device_name
|
||||
and friendly_name in (DEFAULT_NAME, device_name)
|
||||
and entry
|
||||
and entry.title != friendly_name
|
||||
):
|
||||
friendly_name = entry.title
|
||||
if not device_name or friendly_name == device_name:
|
||||
return friendly_name
|
||||
return f"{friendly_name} ({device_name})"
|
||||
|
||||
async def async_step_authenticate(
|
||||
self, user_input: dict[str, Any] | None = None, error: str | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@ -589,7 +639,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="authenticate",
|
||||
data_schema=vol.Schema({vol.Required("password"): str}),
|
||||
description_placeholders={"name": self._name},
|
||||
description_placeholders={"name": self._async_get_human_readable_name()},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@ -623,9 +673,9 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return "connection_error"
|
||||
finally:
|
||||
await cli.disconnect(force=True)
|
||||
self._name = self._device_info.friendly_name or self._device_info.name
|
||||
self._device_name = self._device_info.name
|
||||
self._device_mac = format_mac(self._device_info.mac_address)
|
||||
self._device_name = self._device_info.name
|
||||
self._name = self._device_info.friendly_name or self._device_info.name
|
||||
return None
|
||||
|
||||
async def fetch_device_info(self) -> str | None:
|
||||
@ -640,7 +690,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
mac_address = format_mac(self._device_info.mac_address)
|
||||
await self.async_set_unique_id(mac_address, raise_on_progress=False)
|
||||
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
|
||||
self._abort_if_unique_id_configured(
|
||||
self._abort_unique_id_configured_with_details(
|
||||
updates={
|
||||
CONF_HOST: self._host,
|
||||
CONF_PORT: self._port,
|
||||
|
@ -224,7 +224,16 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
|
||||
if entity_info.name:
|
||||
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
|
||||
else:
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If name is not set, ESPHome will use the sanitized friendly name
|
||||
# as the name, however we want to use the original object_id
|
||||
# as the entity_id before it is sanitized since the sanitizer
|
||||
# is not utf-8 aware. In this case, its always going to be
|
||||
# an empty string so we drop the object_id.
|
||||
self.entity_id = f"{domain}.{device_info.name}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@ -260,7 +269,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
|
||||
self._static_info = static_info
|
||||
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
|
||||
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
|
||||
self._attr_name = static_info.name
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If the name is "", we need to set it to None since otherwise
|
||||
# the friendly_name will be "{friendly_name} " with a trailing
|
||||
# space. ESPHome uses protobuf under the hood, and an empty field
|
||||
# gets a default value of "".
|
||||
self._attr_name = static_info.name if static_info.name else None
|
||||
if entity_category := static_info.entity_category:
|
||||
self._attr_entity_category = ENTITY_CATEGORIES.from_esphome(entity_category)
|
||||
else:
|
||||
|
@ -2,6 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.",
|
||||
"already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"mdns_missing_mac": "Missing MAC address in mDNS properties.",
|
||||
@ -41,7 +43,7 @@
|
||||
"data_description": {
|
||||
"password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead."
|
||||
},
|
||||
"description": "Please enter the password you set in your ESPHome device YAML configuration for {name}."
|
||||
"description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`."
|
||||
},
|
||||
"encryption_key": {
|
||||
"data": {
|
||||
@ -50,7 +52,7 @@
|
||||
"data_description": {
|
||||
"noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration."
|
||||
},
|
||||
"description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
|
||||
"description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
@ -59,10 +61,10 @@
|
||||
"data_description": {
|
||||
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]"
|
||||
},
|
||||
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
|
||||
"description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
|
||||
},
|
||||
"reauth_encryption_removed_confirm": {
|
||||
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
|
||||
"description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
|
||||
},
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to add the device `{name}` to Home Assistant?",
|
||||
|
@ -184,7 +184,7 @@
|
||||
"running": "Running",
|
||||
"standby": "[%key:common::state::standby%]",
|
||||
"bootloading": "Bootloading",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"ready": "Ready",
|
||||
"sleeping": "Sleeping"
|
||||
|
@ -36,7 +36,7 @@
|
||||
"name": "Inverter operation mode",
|
||||
"state": {
|
||||
"general": "General mode",
|
||||
"off_grid": "Off grid mode",
|
||||
"off_grid": "Off-grid mode",
|
||||
"backup": "Backup mode",
|
||||
"eco": "Eco mode",
|
||||
"peak_shaving": "Peak shaving mode",
|
||||
|
@ -157,9 +157,6 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the device on."""
|
||||
if not self.is_on or not kwargs:
|
||||
await self.coordinator.turn_on(self._device)
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0)
|
||||
await self.coordinator.set_brightness(self._device, brightness)
|
||||
@ -187,6 +184,9 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
|
||||
self._save_last_color_state()
|
||||
await self.coordinator.set_scene(self._device, effect)
|
||||
|
||||
if not self.is_on or not kwargs:
|
||||
await self.coordinator.turn_on(self._device)
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
|
@ -54,7 +54,7 @@ class HistoryStats:
|
||||
self._period = (MIN_TIME_UTC, MIN_TIME_UTC)
|
||||
self._state: HistoryStatsState = HistoryStatsState(None, None, self._period)
|
||||
self._history_current_period: list[HistoryState] = []
|
||||
self._previous_run_before_start = False
|
||||
self._has_recorder_data = False
|
||||
self._entity_states = set(entity_states)
|
||||
self._duration = duration
|
||||
self._start = start
|
||||
@ -88,20 +88,20 @@ class HistoryStats:
|
||||
if current_period_start_timestamp > now_timestamp:
|
||||
# History cannot tell the future
|
||||
self._history_current_period = []
|
||||
self._previous_run_before_start = True
|
||||
self._has_recorder_data = False
|
||||
self._state = HistoryStatsState(None, None, self._period)
|
||||
return self._state
|
||||
#
|
||||
# We avoid querying the database if the below did NOT happen:
|
||||
#
|
||||
# - The previous run happened before the start time
|
||||
# - The start time changed
|
||||
# - The period shrank in size
|
||||
# - No previous run occurred (uninitialized)
|
||||
# - The start time moved back in time
|
||||
# - The end time moved back in time
|
||||
# - The previous period ended before now
|
||||
#
|
||||
if (
|
||||
not self._previous_run_before_start
|
||||
and current_period_start_timestamp == previous_period_start_timestamp
|
||||
self._has_recorder_data
|
||||
and current_period_start_timestamp >= previous_period_start_timestamp
|
||||
and (
|
||||
current_period_end_timestamp == previous_period_end_timestamp
|
||||
or (
|
||||
@ -110,6 +110,12 @@ class HistoryStats:
|
||||
)
|
||||
)
|
||||
):
|
||||
start_changed = (
|
||||
current_period_start_timestamp != previous_period_start_timestamp
|
||||
)
|
||||
if start_changed:
|
||||
self._prune_history_cache(current_period_start_timestamp)
|
||||
|
||||
new_data = False
|
||||
if event and (new_state := event.data["new_state"]) is not None:
|
||||
if (
|
||||
@ -121,7 +127,11 @@ class HistoryStats:
|
||||
HistoryState(new_state.state, new_state.last_changed_timestamp)
|
||||
)
|
||||
new_data = True
|
||||
if not new_data and current_period_end_timestamp < now_timestamp:
|
||||
if (
|
||||
not new_data
|
||||
and current_period_end_timestamp < now_timestamp
|
||||
and not start_changed
|
||||
):
|
||||
# If period has not changed and current time after the period end...
|
||||
# Don't compute anything as the value cannot have changed
|
||||
return self._state
|
||||
@ -139,7 +149,7 @@ class HistoryStats:
|
||||
HistoryState(new_state.state, new_state.last_changed_timestamp)
|
||||
)
|
||||
|
||||
self._previous_run_before_start = False
|
||||
self._has_recorder_data = True
|
||||
|
||||
seconds_matched, match_count = self._async_compute_seconds_and_changes(
|
||||
now_timestamp,
|
||||
@ -223,3 +233,18 @@ class HistoryStats:
|
||||
# Save value in seconds
|
||||
seconds_matched = elapsed
|
||||
return seconds_matched, match_count
|
||||
|
||||
def _prune_history_cache(self, start_timestamp: float) -> None:
|
||||
"""Remove unnecessary old data from the history state cache from previous runs.
|
||||
|
||||
Update the timestamp of the last record from before the start to the current start time.
|
||||
"""
|
||||
trim_count = 0
|
||||
for i, history_state in enumerate(self._history_current_period):
|
||||
if history_state.last_changed >= start_timestamp:
|
||||
break
|
||||
history_state.last_changed = start_timestamp
|
||||
if i > 0:
|
||||
trim_count += 1
|
||||
if trim_count: # Don't slice if no data was removed
|
||||
self._history_current_period = self._history_current_period[trim_count:]
|
||||
|
@ -1536,7 +1536,7 @@
|
||||
"pause": "[%key:common::state::paused%]",
|
||||
"actionrequired": "Action required",
|
||||
"finished": "Finished",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"aborting": "Aborting"
|
||||
}
|
||||
},
|
||||
@ -1587,7 +1587,7 @@
|
||||
"streaminglocal": "Streaming local",
|
||||
"streamingcloud": "Streaming cloud",
|
||||
"streaminglocal_and_cloud": "Streaming local and cloud",
|
||||
"error": "Error"
|
||||
"error": "[%key:common::state::error%]"
|
||||
}
|
||||
},
|
||||
"last_selected_map": {
|
||||
|
@ -61,7 +61,7 @@ reload_config_entry:
|
||||
required: false
|
||||
example: 8955375327824e14ba89e4b29cc3ec9a
|
||||
selector:
|
||||
text:
|
||||
config_entry:
|
||||
|
||||
save_persistent_states:
|
||||
|
||||
|
@ -34,7 +34,7 @@
|
||||
},
|
||||
"select": {
|
||||
"preferred_network_mode": {
|
||||
"default": "mdi:transmission-tower"
|
||||
"default": "mdi:antenna"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["huawei_lte_api.Session"],
|
||||
"requirements": [
|
||||
"huawei-lte-api==1.10.0",
|
||||
"huawei-lte-api==1.11.0",
|
||||
"stringcase==1.2.0",
|
||||
"url-normalize==2.2.0"
|
||||
],
|
||||
|
@ -181,7 +181,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
"cell_id": HuaweiSensorEntityDescription(
|
||||
key="cell_id",
|
||||
translation_key="cell_id",
|
||||
icon="mdi:transmission-tower",
|
||||
icon="mdi:antenna",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"cqi0": HuaweiSensorEntityDescription(
|
||||
@ -230,6 +230,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
"enodeb_id": HuaweiSensorEntityDescription(
|
||||
key="enodeb_id",
|
||||
translation_key="enodeb_id",
|
||||
icon="mdi:antenna",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"lac": HuaweiSensorEntityDescription(
|
||||
@ -364,7 +365,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
|
||||
"pci": HuaweiSensorEntityDescription(
|
||||
key="pci",
|
||||
translation_key="pci",
|
||||
icon="mdi:transmission-tower",
|
||||
icon="mdi:antenna",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"plmn": HuaweiSensorEntityDescription(
|
||||
|
@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0
|
||||
|
||||
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
|
||||
|
||||
ERROR_KEY_LIST = [
|
||||
"no_error",
|
||||
ERROR_KEYS = [
|
||||
"alarm_mower_in_motion",
|
||||
"alarm_mower_lifted",
|
||||
"alarm_mower_stopped",
|
||||
@ -50,13 +49,11 @@ ERROR_KEY_LIST = [
|
||||
"alarm_outside_geofence",
|
||||
"angular_sensor_problem",
|
||||
"battery_problem",
|
||||
"battery_problem",
|
||||
"battery_restriction_due_to_ambient_temperature",
|
||||
"can_error",
|
||||
"charging_current_too_high",
|
||||
"charging_station_blocked",
|
||||
"charging_system_problem",
|
||||
"charging_system_problem",
|
||||
"collision_sensor_defect",
|
||||
"collision_sensor_error",
|
||||
"collision_sensor_problem_front",
|
||||
@ -67,24 +64,18 @@ ERROR_KEY_LIST = [
|
||||
"connection_changed",
|
||||
"connection_not_changed",
|
||||
"connectivity_problem",
|
||||
"connectivity_problem",
|
||||
"connectivity_problem",
|
||||
"connectivity_problem",
|
||||
"connectivity_problem",
|
||||
"connectivity_problem",
|
||||
"connectivity_settings_restored",
|
||||
"cutting_drive_motor_1_defect",
|
||||
"cutting_drive_motor_2_defect",
|
||||
"cutting_drive_motor_3_defect",
|
||||
"cutting_height_blocked",
|
||||
"cutting_height_problem",
|
||||
"cutting_height_problem_curr",
|
||||
"cutting_height_problem_dir",
|
||||
"cutting_height_problem_drive",
|
||||
"cutting_height_problem",
|
||||
"cutting_motor_problem",
|
||||
"cutting_stopped_slope_too_steep",
|
||||
"cutting_system_blocked",
|
||||
"cutting_system_blocked",
|
||||
"cutting_system_imbalance_warning",
|
||||
"cutting_system_major_imbalance",
|
||||
"destination_not_reachable",
|
||||
@ -92,13 +83,9 @@ ERROR_KEY_LIST = [
|
||||
"docking_sensor_defect",
|
||||
"electronic_problem",
|
||||
"empty_battery",
|
||||
MowerStates.ERROR.lower(),
|
||||
MowerStates.ERROR_AT_POWER_UP.lower(),
|
||||
MowerStates.FATAL_ERROR.lower(),
|
||||
"folding_cutting_deck_sensor_defect",
|
||||
"folding_sensor_activated",
|
||||
"geofence_problem",
|
||||
"geofence_problem",
|
||||
"gps_navigation_problem",
|
||||
"guide_1_not_found",
|
||||
"guide_2_not_found",
|
||||
@ -116,7 +103,6 @@ ERROR_KEY_LIST = [
|
||||
"lift_sensor_defect",
|
||||
"lifted",
|
||||
"limited_cutting_height_range",
|
||||
"limited_cutting_height_range",
|
||||
"loop_sensor_defect",
|
||||
"loop_sensor_problem_front",
|
||||
"loop_sensor_problem_left",
|
||||
@ -129,6 +115,7 @@ ERROR_KEY_LIST = [
|
||||
"no_accurate_position_from_satellites",
|
||||
"no_confirmed_position",
|
||||
"no_drive",
|
||||
"no_error",
|
||||
"no_loop_signal",
|
||||
"no_power_in_charging_station",
|
||||
"no_response_from_charger",
|
||||
@ -139,9 +126,6 @@ ERROR_KEY_LIST = [
|
||||
"safety_function_faulty",
|
||||
"settings_restored",
|
||||
"sim_card_locked",
|
||||
"sim_card_locked",
|
||||
"sim_card_locked",
|
||||
"sim_card_locked",
|
||||
"sim_card_not_found",
|
||||
"sim_card_requires_pin",
|
||||
"slipped_mower_has_slipped_situation_not_solved_with_moving_pattern",
|
||||
@ -151,13 +135,6 @@ ERROR_KEY_LIST = [
|
||||
"stuck_in_charging_station",
|
||||
"switch_cord_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"temporary_battery_problem",
|
||||
"tilt_sensor_problem",
|
||||
"too_high_discharge_current",
|
||||
"too_high_internal_current",
|
||||
@ -189,11 +166,19 @@ ERROR_KEY_LIST = [
|
||||
"zone_generator_problem",
|
||||
]
|
||||
|
||||
ERROR_STATES = {
|
||||
MowerStates.ERROR,
|
||||
ERROR_STATES = [
|
||||
MowerStates.ERROR_AT_POWER_UP,
|
||||
MowerStates.ERROR,
|
||||
MowerStates.FATAL_ERROR,
|
||||
}
|
||||
MowerStates.OFF,
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.WAIT_POWER_UP,
|
||||
MowerStates.WAIT_UPDATING,
|
||||
]
|
||||
|
||||
ERROR_KEY_LIST = list(
|
||||
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
|
||||
)
|
||||
|
||||
RESTRICTED_REASONS: list = [
|
||||
RestrictedReasons.ALL_WORK_AREAS_COMPLETED,
|
||||
@ -292,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
AutomowerSensorEntityDescription(
|
||||
key="cutting_blade_usage_time",
|
||||
translation_key="cutting_blade_usage_time",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
@ -302,6 +288,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
AutomowerSensorEntityDescription(
|
||||
key="downtime",
|
||||
translation_key="downtime",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
entity_registry_enabled_default=False,
|
||||
@ -386,6 +373,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
|
||||
AutomowerSensorEntityDescription(
|
||||
key="uptime",
|
||||
translation_key="uptime",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
entity_registry_enabled_default=False,
|
||||
|
@ -106,10 +106,10 @@
|
||||
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
|
||||
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
|
||||
"cutting_height_blocked": "Cutting height blocked",
|
||||
"cutting_height_problem": "Cutting height problem",
|
||||
"cutting_height_problem_curr": "Cutting height problem, curr",
|
||||
"cutting_height_problem_dir": "Cutting height problem, dir",
|
||||
"cutting_height_problem_drive": "Cutting height problem, drive",
|
||||
"cutting_height_problem": "Cutting height problem",
|
||||
"cutting_motor_problem": "Cutting motor problem",
|
||||
"cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep",
|
||||
"cutting_system_blocked": "Cutting system blocked",
|
||||
@ -120,8 +120,8 @@
|
||||
"docking_sensor_defect": "Docking sensor defect",
|
||||
"electronic_problem": "Electronic problem",
|
||||
"empty_battery": "Empty battery",
|
||||
"error": "Error",
|
||||
"error_at_power_up": "Error at power up",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"fatal_error": "Fatal error",
|
||||
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
|
||||
"folding_sensor_activated": "Folding sensor activated",
|
||||
@ -159,6 +159,7 @@
|
||||
"no_loop_signal": "No loop signal",
|
||||
"no_power_in_charging_station": "No power in charging station",
|
||||
"no_response_from_charger": "No response from charger",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"outside_working_area": "Outside working area",
|
||||
"poor_signal_quality": "Poor signal quality",
|
||||
"reference_station_communication_problem": "Reference station communication problem",
|
||||
@ -172,6 +173,7 @@
|
||||
"slope_too_steep": "Slope too steep",
|
||||
"sms_could_not_be_sent": "SMS could not be sent",
|
||||
"stop_button_problem": "STOP button problem",
|
||||
"stopped": "[%key:common::state::stopped%]",
|
||||
"stuck_in_charging_station": "Stuck in charging station",
|
||||
"switch_cord_problem": "Switch cord problem",
|
||||
"temporary_battery_problem": "Temporary battery problem",
|
||||
@ -187,6 +189,8 @@
|
||||
"unexpected_cutting_height_adj": "Unexpected cutting height adjustment",
|
||||
"unexpected_error": "Unexpected error",
|
||||
"upside_down": "Upside down",
|
||||
"wait_power_up": "Wait power up",
|
||||
"wait_updating": "Wait updating",
|
||||
"weak_gps_signal": "Weak GPS signal",
|
||||
"wheel_drive_problem_left": "Left wheel drive problem",
|
||||
"wheel_drive_problem_rear_left": "Rear left wheel drive problem",
|
||||
|
@ -56,7 +56,7 @@
|
||||
"on": "[%key:common::state::on%]",
|
||||
"warming": "Warming",
|
||||
"cooling": "Cooling",
|
||||
"error": "Error"
|
||||
"error": "[%key:common::state::error%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@
|
||||
"_": {
|
||||
"name": "[%key:component::lawn_mower::title%]",
|
||||
"state": {
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"mowing": "Mowing",
|
||||
"docked": "Docked",
|
||||
|
@ -88,7 +88,7 @@
|
||||
"available": "Available",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"locked": "[%key:common::state::locked%]",
|
||||
"need_auth": "Waiting for authentication",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
@ -118,7 +118,7 @@
|
||||
"ocpp": "OCPP",
|
||||
"overtemperature": "Overtemperature",
|
||||
"switching_phases": "Switching phases",
|
||||
"1p_charging_disabled": "1p charging disabled"
|
||||
"1p_charging_disabled": "1P charging disabled"
|
||||
}
|
||||
},
|
||||
"breaker_current": {
|
||||
|
@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["linkplay"],
|
||||
"requirements": ["python-linkplay==0.2.3"],
|
||||
"requirements": ["python-linkplay==0.2.4"],
|
||||
"zeroconf": ["_linkplay._tcp.local."]
|
||||
}
|
||||
|
@ -24,8 +24,10 @@ from .const import (
|
||||
SERVICE_SET_LEVEL,
|
||||
)
|
||||
from .helpers import (
|
||||
DATA_LOGGER,
|
||||
LoggerDomainConfig,
|
||||
LoggerSettings,
|
||||
_clear_logger_overwrites, # noqa: F401
|
||||
set_default_log_level,
|
||||
set_log_levels,
|
||||
)
|
||||
@ -54,7 +56,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
settings = LoggerSettings(hass, config)
|
||||
|
||||
domain_config = hass.data[DOMAIN] = LoggerDomainConfig({}, settings)
|
||||
domain_config = hass.data[DATA_LOGGER] = LoggerDomainConfig({}, settings)
|
||||
logging.setLoggerClass(_get_logger_class(domain_config.overrides))
|
||||
|
||||
websocket_api.async_load_websocket_api(hass)
|
||||
|
@ -9,13 +9,14 @@ from dataclasses import asdict, dataclass
|
||||
from enum import StrEnum
|
||||
from functools import lru_cache
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.const import EVENT_LOGGING_CHANGED
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import IntegrationNotFound, async_get_integration
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
@ -28,6 +29,8 @@ from .const import (
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
DATA_LOGGER: HassKey[LoggerDomainConfig] = HassKey(DOMAIN)
|
||||
|
||||
SAVE_DELAY = 15.0
|
||||
# At startup, we want to save after a long delay to avoid
|
||||
# saving while the system is still starting up. If the system
|
||||
@ -39,12 +42,6 @@ SAVE_DELAY = 15.0
|
||||
SAVE_DELAY_LONG = 180.0
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_domain_config(hass: HomeAssistant) -> LoggerDomainConfig:
|
||||
"""Return the domain config."""
|
||||
return cast(LoggerDomainConfig, hass.data[DOMAIN])
|
||||
|
||||
|
||||
@callback
|
||||
def set_default_log_level(hass: HomeAssistant, level: int) -> None:
|
||||
"""Set the default log level for components."""
|
||||
@ -55,7 +52,7 @@ def set_default_log_level(hass: HomeAssistant, level: int) -> None:
|
||||
@callback
|
||||
def set_log_levels(hass: HomeAssistant, logpoints: Mapping[str, int]) -> None:
|
||||
"""Set the specified log levels."""
|
||||
async_get_domain_config(hass).overrides.update(logpoints)
|
||||
hass.data[DATA_LOGGER].overrides.update(logpoints)
|
||||
for key, value in logpoints.items():
|
||||
_set_log_level(logging.getLogger(key), value)
|
||||
hass.bus.async_fire(EVENT_LOGGING_CHANGED)
|
||||
@ -78,6 +75,12 @@ def _chattiest_log_level(level1: int, level2: int) -> int:
|
||||
return min(level1, level2)
|
||||
|
||||
|
||||
@callback
|
||||
def _clear_logger_overwrites(hass: HomeAssistant) -> None:
|
||||
"""Clear logger overwrites. Used for testing."""
|
||||
hass.data[DATA_LOGGER].overrides.clear()
|
||||
|
||||
|
||||
async def get_integration_loggers(hass: HomeAssistant, domain: str) -> set[str]:
|
||||
"""Get loggers for an integration."""
|
||||
loggers: set[str] = {f"homeassistant.components.{domain}"}
|
||||
|
@ -12,10 +12,10 @@ from homeassistant.setup import async_get_loaded_integrations
|
||||
|
||||
from .const import LOGSEVERITY
|
||||
from .helpers import (
|
||||
DATA_LOGGER,
|
||||
LoggerSetting,
|
||||
LogPersistance,
|
||||
LogSettingsType,
|
||||
async_get_domain_config,
|
||||
get_logger,
|
||||
)
|
||||
|
||||
@ -68,7 +68,7 @@ async def handle_integration_log_level(
|
||||
msg["id"], websocket_api.ERR_NOT_FOUND, "Integration not found"
|
||||
)
|
||||
return
|
||||
await async_get_domain_config(hass).settings.async_update(
|
||||
await hass.data[DATA_LOGGER].settings.async_update(
|
||||
hass,
|
||||
msg["integration"],
|
||||
LoggerSetting(
|
||||
@ -93,7 +93,7 @@ async def handle_module_log_level(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle setting integration log level."""
|
||||
await async_get_domain_config(hass).settings.async_update(
|
||||
await hass.data[DATA_LOGGER].settings.async_update(
|
||||
hass,
|
||||
msg["module"],
|
||||
LoggerSetting(
|
||||
|
@ -270,7 +270,7 @@
|
||||
"stopped": "[%key:common::state::stopped%]",
|
||||
"running": "Running",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"seeking_charger": "Seeking charger",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"docked": "Docked"
|
||||
|
@ -8,6 +8,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pymiele"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pymiele==0.3.4"],
|
||||
"requirements": ["pymiele==0.3.6"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@ -185,6 +185,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Remove NUT config entry from a device."""
|
||||
return not any(
|
||||
identifier
|
||||
for identifier in device_entry.identifiers
|
||||
if identifier[0] == DOMAIN
|
||||
and identifier[1] in config_entry.runtime_data.unique_id
|
||||
)
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
@ -56,7 +55,7 @@ def _ups_schema(ups_list: dict[str, str]) -> vol.Schema:
|
||||
return vol.Schema({vol.Required(CONF_ALIAS): vol.In(ups_list)})
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
|
||||
async def validate_input(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from _base_schema with values provided by the user.
|
||||
@ -303,7 +302,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
info: dict[str, Any] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
try:
|
||||
info = await validate_input(self.hass, config)
|
||||
info = await validate_input(config)
|
||||
except NUTLoginError:
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
except NUTError as ex:
|
||||
@ -320,8 +319,6 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
entry_id = self.context["entry_id"]
|
||||
self.reauth_entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
@ -330,17 +327,16 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle reauth input."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
existing_entry = self.reauth_entry
|
||||
assert existing_entry
|
||||
existing_data = existing_entry.data
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
reauth_data = reauth_entry.data
|
||||
description_placeholders: dict[str, str] = {
|
||||
CONF_HOST: existing_data[CONF_HOST],
|
||||
CONF_PORT: existing_data[CONF_PORT],
|
||||
CONF_HOST: reauth_data[CONF_HOST],
|
||||
CONF_PORT: reauth_data[CONF_PORT],
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
new_config = {
|
||||
**existing_data,
|
||||
**reauth_data,
|
||||
# Username/password are optional and some servers
|
||||
# use ip based authentication and will fail if
|
||||
# username/password are provided
|
||||
@ -349,9 +345,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
_, errors, placeholders = await self._async_validate_or_error(new_config)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
existing_entry, data=new_config
|
||||
)
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=new_config)
|
||||
description_placeholders.update(placeholders)
|
||||
|
||||
return self.async_show_form(
|
||||
|
@ -1,10 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"outlet_number_load_cycle": {
|
||||
"default": "mdi:restart"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ambient_humidity_status": {
|
||||
"default": "mdi:information-outline"
|
||||
|
@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
UnitOfApparentPower,
|
||||
UnitOfElectricCurrent,
|
||||
@ -1120,9 +1119,9 @@ class NUTSensor(NUTBaseEntity, SensorEntity):
|
||||
return status.get(self.entity_description.key)
|
||||
|
||||
|
||||
def _format_display_state(status: dict[str, str]) -> str:
|
||||
def _format_display_state(status: dict[str, str]) -> str | None:
|
||||
"""Return UPS display state."""
|
||||
try:
|
||||
return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
|
||||
except KeyError:
|
||||
return STATE_UNKNOWN
|
||||
return None
|
||||
|
@ -20,6 +20,9 @@
|
||||
"title": "Choose the NUT server UPS to monitor",
|
||||
"data": {
|
||||
"alias": "NUT server UPS name"
|
||||
},
|
||||
"data_description": {
|
||||
"alias": "The UPS name configured on the NUT server."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@ -27,6 +30,10 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::nut::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::nut::config::step::user::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
@ -48,6 +55,9 @@
|
||||
"title": "[%key:component::nut::config::step::ups::title%]",
|
||||
"data": {
|
||||
"alias": "[%key:component::nut::config::step::ups::data::alias%]"
|
||||
},
|
||||
"data_description": {
|
||||
"alias": "[%key:component::nut::config::step::ups::data_description::alias%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -12,6 +12,7 @@ from pyoverkiz.enums import APIType, OverkizState, UIClass, UIWidget
|
||||
from pyoverkiz.exceptions import (
|
||||
BadCredentialsException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
NotSuchTokenException,
|
||||
TooManyRequestsException,
|
||||
)
|
||||
@ -92,7 +93,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry)
|
||||
scenarios = await client.get_scenarios()
|
||||
else:
|
||||
scenarios = []
|
||||
except (BadCredentialsException, NotSuchTokenException) as exception:
|
||||
except (
|
||||
BadCredentialsException,
|
||||
NotSuchTokenException,
|
||||
NotAuthenticatedException,
|
||||
) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication") from exception
|
||||
except TooManyRequestsException as exception:
|
||||
raise ConfigEntryNotReady("Too many requests, try again later") from exception
|
||||
|
@ -13,6 +13,7 @@ from homeassistant.components.climate import (
|
||||
PRESET_NONE,
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
@ -56,6 +57,12 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
|
||||
OverkizCommandParam.INTERNAL: HVACMode.AUTO,
|
||||
}
|
||||
|
||||
OVERKIZ_TO_HVAC_ACTION: dict[str, HVACAction] = {
|
||||
OverkizCommandParam.STANDBY: HVACAction.IDLE,
|
||||
OverkizCommandParam.INCREASE: HVACAction.HEATING,
|
||||
OverkizCommandParam.NONE: HVACAction.OFF,
|
||||
}
|
||||
|
||||
HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()}
|
||||
|
||||
TEMPERATURE_SENSOR_DEVICE_INDEX = 2
|
||||
@ -102,6 +109,14 @@ class AtlanticElectricalHeaterWithAdjustableTemperatureSetpoint(
|
||||
OverkizCommand.SET_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode]
|
||||
)
|
||||
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return the current running hvac operation ie. heating, idle, off."""
|
||||
states = self.device.states
|
||||
if (state := states[OverkizState.CORE_REGULATION_MODE]) and state.value_as_str:
|
||||
return OVERKIZ_TO_HVAC_ACTION[state.value_as_str]
|
||||
return HVACAction.OFF
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode, e.g., home, away, temp."""
|
||||
|
@ -13,12 +13,12 @@ from pyoverkiz.exceptions import (
|
||||
BadCredentialsException,
|
||||
CozyTouchBadCredentialsException,
|
||||
MaintenanceException,
|
||||
NotAuthenticatedException,
|
||||
NotSuchTokenException,
|
||||
TooManyAttemptsBannedException,
|
||||
TooManyRequestsException,
|
||||
UnknownUserException,
|
||||
)
|
||||
from pyoverkiz.models import OverkizServer
|
||||
from pyoverkiz.obfuscate import obfuscate_id
|
||||
from pyoverkiz.utils import generate_local_server, is_overkiz_gateway
|
||||
import voluptuous as vol
|
||||
@ -31,7 +31,6 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@ -39,15 +38,12 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from .const import CONF_API_TYPE, CONF_HUB, DEFAULT_SERVER, DOMAIN, LOGGER
|
||||
|
||||
|
||||
class DeveloperModeDisabled(HomeAssistantError):
|
||||
"""Error to indicate Somfy Developer Mode is disabled."""
|
||||
|
||||
|
||||
class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Overkiz (by Somfy)."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_verify_ssl: bool = True
|
||||
_api_type: APIType = APIType.CLOUD
|
||||
_user: str | None = None
|
||||
_server: str = DEFAULT_SERVER
|
||||
@ -57,27 +53,36 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Validate user credentials."""
|
||||
user_input[CONF_API_TYPE] = self._api_type
|
||||
|
||||
client = self._create_cloud_client(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
|
||||
)
|
||||
await client.login(register_event_listener=False)
|
||||
|
||||
# For Local API, we create and activate a local token
|
||||
if self._api_type == APIType.LOCAL:
|
||||
user_input[CONF_TOKEN] = await self._create_local_api_token(
|
||||
cloud_client=client,
|
||||
host=user_input[CONF_HOST],
|
||||
user_input[CONF_VERIFY_SSL] = self._verify_ssl
|
||||
session = async_create_clientsession(
|
||||
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
|
||||
)
|
||||
client = OverkizClient(
|
||||
username="",
|
||||
password="",
|
||||
token=user_input[CONF_TOKEN],
|
||||
session=session,
|
||||
server=generate_local_server(host=user_input[CONF_HOST]),
|
||||
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
else: # APIType.CLOUD
|
||||
session = async_create_clientsession(self.hass)
|
||||
client = OverkizClient(
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
server=SUPPORTED_SERVERS[user_input[CONF_HUB]],
|
||||
session=session,
|
||||
)
|
||||
|
||||
await client.login(register_event_listener=False)
|
||||
|
||||
# Set main gateway id as unique id
|
||||
if gateways := await client.get_gateways():
|
||||
for gateway in gateways:
|
||||
if is_overkiz_gateway(gateway.id):
|
||||
gateway_id = gateway.id
|
||||
await self.async_set_unique_id(gateway_id, raise_on_progress=False)
|
||||
await self.async_set_unique_id(gateway.id, raise_on_progress=False)
|
||||
break
|
||||
|
||||
return user_input
|
||||
|
||||
@ -141,15 +146,13 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
self._user = user_input[CONF_USERNAME]
|
||||
|
||||
# inherit the server from previous step
|
||||
user_input[CONF_HUB] = self._server
|
||||
|
||||
try:
|
||||
await self.async_validate_input(user_input)
|
||||
except TooManyRequestsException:
|
||||
errors["base"] = "too_many_requests"
|
||||
except BadCredentialsException as exception:
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
# If authentication with CozyTouch auth server is valid, but token is invalid
|
||||
# for Overkiz API server, the hardware is not supported.
|
||||
if user_input[CONF_HUB] in {
|
||||
@ -211,16 +214,18 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input:
|
||||
self._host = user_input[CONF_HOST]
|
||||
self._user = user_input[CONF_USERNAME]
|
||||
|
||||
# inherit the server from previous step
|
||||
self._verify_ssl = user_input[CONF_VERIFY_SSL]
|
||||
user_input[CONF_HUB] = self._server
|
||||
|
||||
try:
|
||||
user_input = await self.async_validate_input(user_input)
|
||||
except TooManyRequestsException:
|
||||
errors["base"] = "too_many_requests"
|
||||
except BadCredentialsException:
|
||||
except (
|
||||
BadCredentialsException,
|
||||
NotSuchTokenException,
|
||||
NotAuthenticatedException,
|
||||
):
|
||||
errors["base"] = "invalid_auth"
|
||||
except ClientConnectorCertificateError as exception:
|
||||
errors["base"] = "certificate_verify_failed"
|
||||
@ -232,10 +237,6 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "server_in_maintenance"
|
||||
except TooManyAttemptsBannedException:
|
||||
errors["base"] = "too_many_attempts"
|
||||
except NotSuchTokenException:
|
||||
errors["base"] = "no_such_token"
|
||||
except DeveloperModeDisabled:
|
||||
errors["base"] = "developer_mode_disabled"
|
||||
except UnknownUserException:
|
||||
# Somfy Protect accounts are not supported since they don't use
|
||||
# the Overkiz API server. Login will return unknown user.
|
||||
@ -264,9 +265,8 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=self._host): str,
|
||||
vol.Required(CONF_USERNAME, default=self._user): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(CONF_VERIFY_SSL, default=True): bool,
|
||||
vol.Required(CONF_TOKEN): str,
|
||||
vol.Required(CONF_VERIFY_SSL, default=self._verify_ssl): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders=description_placeholders,
|
||||
@ -320,64 +320,15 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reauth."""
|
||||
# overkiz entries always have unique IDs
|
||||
# Overkiz entries always have unique IDs
|
||||
self.context["title_placeholders"] = {"gateway_id": cast(str, self.unique_id)}
|
||||
|
||||
self._user = entry_data[CONF_USERNAME]
|
||||
self._server = entry_data[CONF_HUB]
|
||||
self._api_type = entry_data.get(CONF_API_TYPE, APIType.CLOUD)
|
||||
self._server = entry_data[CONF_HUB]
|
||||
|
||||
if self._api_type == APIType.LOCAL:
|
||||
self._host = entry_data[CONF_HOST]
|
||||
self._verify_ssl = entry_data[CONF_VERIFY_SSL]
|
||||
else:
|
||||
self._user = entry_data[CONF_USERNAME]
|
||||
|
||||
return await self.async_step_user(dict(entry_data))
|
||||
|
||||
def _create_cloud_client(
|
||||
self, username: str, password: str, server: OverkizServer
|
||||
) -> OverkizClient:
|
||||
session = async_create_clientsession(self.hass)
|
||||
return OverkizClient(
|
||||
username=username, password=password, server=server, session=session
|
||||
)
|
||||
|
||||
async def _create_local_api_token(
|
||||
self, cloud_client: OverkizClient, host: str, verify_ssl: bool
|
||||
) -> str:
|
||||
"""Create local API token."""
|
||||
# Create session on Somfy cloud server to generate an access token for local API
|
||||
gateways = await cloud_client.get_gateways()
|
||||
|
||||
gateway_id = ""
|
||||
for gateway in gateways:
|
||||
# Overkiz can return multiple gateways, but we only can generate a token
|
||||
# for the main gateway.
|
||||
if is_overkiz_gateway(gateway.id):
|
||||
gateway_id = gateway.id
|
||||
|
||||
developer_mode = await cloud_client.get_setup_option(
|
||||
f"developerMode-{gateway_id}"
|
||||
)
|
||||
|
||||
if developer_mode is None:
|
||||
raise DeveloperModeDisabled
|
||||
|
||||
token = await cloud_client.generate_local_token(gateway_id)
|
||||
await cloud_client.activate_local_token(
|
||||
gateway_id=gateway_id, token=token, label="Home Assistant/local"
|
||||
)
|
||||
|
||||
session = async_create_clientsession(self.hass, verify_ssl=verify_ssl)
|
||||
|
||||
# Local API
|
||||
local_client = OverkizClient(
|
||||
username="",
|
||||
password="",
|
||||
token=token,
|
||||
session=session,
|
||||
server=generate_local_server(host=host),
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
await local_client.login()
|
||||
|
||||
return token
|
||||
|
@ -79,7 +79,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
"""Fetch Overkiz data via event listener."""
|
||||
try:
|
||||
events = await self.client.fetch_events()
|
||||
except BadCredentialsException as exception:
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
|
||||
except TooManyConcurrentRequestsException as exception:
|
||||
raise UpdateFailed("Too many concurrent requests.") from exception
|
||||
@ -98,7 +98,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
try:
|
||||
await self.client.login()
|
||||
self.devices = await self._get_devices()
|
||||
except BadCredentialsException as exception:
|
||||
except (BadCredentialsException, NotAuthenticatedException) as exception:
|
||||
raise ConfigEntryAuthFailed("Invalid authentication.") from exception
|
||||
except TooManyRequestsException as exception:
|
||||
raise UpdateFailed("Too many requests, try again later.") from exception
|
||||
|
20
homeassistant/components/overkiz/icons.json
Normal file
20
homeassistant/components/overkiz/icons.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -13,7 +13,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.17.0"],
|
||||
"requirements": ["pyoverkiz==1.17.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
@ -32,17 +32,15 @@
|
||||
}
|
||||
},
|
||||
"local": {
|
||||
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\nAfter activation, enter your application credentials and change the host to include your Gateway PIN or enter the IP address of your gateway.",
|
||||
"description": "By activating the [Developer Mode of your TaHoma box](https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"token": "[%key:common::config_flow::data::api_token%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of your Overkiz hub.",
|
||||
"username": "The username of your cloud account (app).",
|
||||
"password": "The password of your cloud account (app).",
|
||||
"token": "Token generated by the app used to control your device.",
|
||||
"verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
|
||||
}
|
||||
}
|
||||
@ -73,8 +71,8 @@
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"manual": "[%key:common::state::manual%]",
|
||||
"comfort-1": "Comfort 1",
|
||||
"comfort-2": "Comfort 2",
|
||||
"comfort-1": "Comfort -1°C",
|
||||
"comfort-2": "Comfort -2°C",
|
||||
"drying": "Drying",
|
||||
"external": "External",
|
||||
"freeze": "Freeze",
|
||||
|
@ -108,7 +108,7 @@
|
||||
"name": "State",
|
||||
"state": {
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"fault": "Fault",
|
||||
"invalid": "Invalid",
|
||||
"no_ev_connected": "No EV connected",
|
||||
|
@ -37,7 +37,7 @@
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"finished": "Finished",
|
||||
"stopped": "[%key:common::state::stopped%]",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"attention": "Attention",
|
||||
"ready": "Ready"
|
||||
}
|
||||
|
@ -84,8 +84,10 @@
|
||||
"options": {
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"area": "[%key:component::sensor::entity_component::area::name%]",
|
||||
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
|
||||
"battery": "[%key:component::sensor::entity_component::battery::name%]",
|
||||
"blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
|
||||
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
|
||||
|
@ -232,7 +232,7 @@
|
||||
"charging_problem": "Charging problem",
|
||||
"paused": "[%key:common::state::paused%]",
|
||||
"spot_cleaning": "Spot cleaning",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"shutting_down": "Shutting down",
|
||||
"updating": "Updating",
|
||||
"docking": "Docking",
|
||||
|
@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/schlage",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["pyschlage==2024.11.0"]
|
||||
"requirements": ["pyschlage==2025.4.0"]
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"requirements": ["aioshelly==13.4.1"],
|
||||
"requirements": ["aioshelly==13.5.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
@ -1,34 +1,10 @@
|
||||
"""Support to control a Salda Smarty XP/XV ventilation unit."""
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME, Platform
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SmartyConfigEntry, SmartyCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
|
||||
vol.Optional(CONF_NAME, default="Smarty"): cv.string,
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
@ -38,54 +14,6 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
|
||||
"""Create a smarty system."""
|
||||
if config := hass_config.get(DOMAIN):
|
||||
hass.async_create_task(_async_import(hass, config))
|
||||
return True
|
||||
|
||||
|
||||
async def _async_import(hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Set up the smarty environment."""
|
||||
|
||||
if not hass.config_entries.async_entries(DOMAIN):
|
||||
# Start import flow
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
|
||||
)
|
||||
if result["type"] == FlowResultType.ABORT:
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
breaks_in_ha_version="2025.5.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Smarty",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_yaml_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.5.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Smarty",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SmartyConfigEntry) -> bool:
|
||||
"""Set up the Smarty environment from a config entry."""
|
||||
|
||||
|
@ -7,7 +7,7 @@ from pysmarty2 import Smarty
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@ -50,17 +50,3 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_import(
|
||||
self, import_config: dict[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by import."""
|
||||
error = await self.hass.async_add_executor_job(
|
||||
self._test_connection, import_config[CONF_HOST]
|
||||
)
|
||||
if not error:
|
||||
return self.async_create_entry(
|
||||
title=import_config[CONF_NAME],
|
||||
data={CONF_HOST: import_config[CONF_HOST]},
|
||||
)
|
||||
return self.async_abort(reason=error)
|
||||
|
@ -3,7 +3,7 @@
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .coordinator import SmartyCoordinator
|
||||
|
||||
|
||||
|
@ -20,20 +20,6 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_unknown": {
|
||||
"title": "YAML import failed with unknown error",
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
|
||||
},
|
||||
"deprecated_yaml_import_issue_auth_error": {
|
||||
"title": "YAML import failed due to an authentication error",
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
|
||||
},
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"title": "YAML import failed due to a connection error",
|
||||
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"alarm": {
|
||||
|
@ -199,7 +199,7 @@
|
||||
"name": "Charge limit"
|
||||
},
|
||||
"off_grid_vehicle_charging_reserve_percent": {
|
||||
"name": "Off grid reserve"
|
||||
"name": "Off-grid reserve"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
@ -38,7 +38,7 @@
|
||||
"connected": "Vehicle connected",
|
||||
"ready": "Ready to charge",
|
||||
"negotiating": "Negotiating connection",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"charging_finished": "Charging finished",
|
||||
"waiting_car": "Waiting for car",
|
||||
"charging_reduced": "Charging (reduced)",
|
||||
|
@ -363,7 +363,7 @@
|
||||
"name": "Charge limit"
|
||||
},
|
||||
"off_grid_vehicle_charging_reserve_percent": {
|
||||
"name": "Off grid reserve"
|
||||
"name": "Off-grid reserve"
|
||||
}
|
||||
},
|
||||
"cover": {
|
||||
@ -495,10 +495,10 @@
|
||||
"name": "Island status",
|
||||
"state": {
|
||||
"island_status_unknown": "Unknown",
|
||||
"on_grid": "On grid",
|
||||
"off_grid": "Off grid",
|
||||
"off_grid_intentional": "Off grid intentional",
|
||||
"off_grid_unintentional": "Off grid unintentional"
|
||||
"on_grid": "On-grid",
|
||||
"off_grid": "Off-grid",
|
||||
"off_grid_intentional": "Off-grid intentional",
|
||||
"off_grid_unintentional": "Off-grid unintentional"
|
||||
}
|
||||
},
|
||||
"load_power": {
|
||||
@ -662,7 +662,7 @@
|
||||
"message": "Departure time required to enable preconditioning"
|
||||
},
|
||||
"set_scheduled_departure_off_peak": {
|
||||
"message": "To enable scheduled departure, end off peak time is required."
|
||||
"message": "To enable scheduled departure, 'End off-peak time' is required."
|
||||
},
|
||||
"invalid_device": {
|
||||
"message": "Invalid device ID: {device_id}"
|
||||
@ -752,15 +752,15 @@
|
||||
},
|
||||
"end_off_peak_time": {
|
||||
"description": "Time to complete charging by.",
|
||||
"name": "End off peak time"
|
||||
"name": "End off-peak time"
|
||||
},
|
||||
"off_peak_charging_enabled": {
|
||||
"description": "Enable off peak charging.",
|
||||
"name": "Off peak charging enabled"
|
||||
"description": "Enable off-peak charging.",
|
||||
"name": "Off-peak charging enabled"
|
||||
},
|
||||
"off_peak_charging_weekdays_only": {
|
||||
"description": "Enable off peak charging on weekdays only.",
|
||||
"name": "Off peak charging weekdays only"
|
||||
"description": "Enable off-peak charging on weekdays only.",
|
||||
"name": "Off-peak charging weekdays only"
|
||||
},
|
||||
"preconditioning_enabled": {
|
||||
"description": "Enable preconditioning.",
|
||||
|
@ -217,7 +217,7 @@
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"scheduled": "Scheduled",
|
||||
"negotiating": "Negotiating",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"charging_finished": "Charging finished",
|
||||
"waiting_car": "Waiting car",
|
||||
"charging_reduced": "Charging reduced"
|
||||
@ -495,7 +495,7 @@
|
||||
"name": "Speed limit"
|
||||
},
|
||||
"off_grid_vehicle_charging_reserve_percent": {
|
||||
"name": "Off grid reserve"
|
||||
"name": "Off-grid reserve"
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
|
@ -24,14 +24,14 @@
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"title": "Update TP-Link Omada Credentials",
|
||||
"title": "Update TP-Link Omada credentials",
|
||||
"description": "The provided credentials have stopped working. Please update them."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unsupported_controller": "Omada Controller version not supported.",
|
||||
"unsupported_controller": "Omada controller version not supported.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"no_sites_found": "No sites found which the user can manage."
|
||||
},
|
||||
@ -46,31 +46,31 @@
|
||||
"name": "Port {port_name} PoE"
|
||||
},
|
||||
"wan_connect_ipv4": {
|
||||
"name": "Port {port_name} Internet Connected"
|
||||
"name": "Port {port_name} Internet connected"
|
||||
},
|
||||
"wan_connect_ipv6": {
|
||||
"name": "Port {port_name} Internet Connected (IPv6)"
|
||||
"name": "Port {port_name} Internet connected (IPv6)"
|
||||
}
|
||||
},
|
||||
"binary_sensor": {
|
||||
"wan_link": {
|
||||
"name": "Port {port_name} Internet Link"
|
||||
"name": "Port {port_name} Internet link"
|
||||
},
|
||||
"online_detection": {
|
||||
"name": "Port {port_name} Online Detection"
|
||||
"name": "Port {port_name} online detection"
|
||||
},
|
||||
"lan_status": {
|
||||
"name": "Port {port_name} LAN Status"
|
||||
"name": "Port {port_name} LAN status"
|
||||
},
|
||||
"poe_delivery": {
|
||||
"name": "Port {port_name} PoE Delivery"
|
||||
"name": "Port {port_name} PoE delivery"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"device_status": {
|
||||
"name": "Device status",
|
||||
"state": {
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"disconnected": "[%key:common::state::disconnected%]",
|
||||
"connected": "[%key:common::state::connected%]",
|
||||
"pending": "Pending",
|
||||
@ -91,7 +91,7 @@
|
||||
"services": {
|
||||
"reconnect_client": {
|
||||
"name": "Reconnect wireless client",
|
||||
"description": "Tries to get wireless client to reconnect to Omada Network.",
|
||||
"description": "Tries to get wireless client to reconnect to Omada network.",
|
||||
"fields": {
|
||||
"mac": {
|
||||
"name": "MAC address",
|
||||
|
@ -6,5 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/uptimerobot",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyuptimerobot"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyuptimerobot==22.2.0"]
|
||||
}
|
||||
|
@ -6,9 +6,7 @@ rules:
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: fix name and docstring
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
@ -41,9 +39,7 @@ rules:
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: recheck typos
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
@ -577,10 +577,10 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
async def _async_reset_meter(self, event):
|
||||
"""Reset the utility meter status."""
|
||||
|
||||
await self._program_reset()
|
||||
|
||||
await self.async_reset_meter(self._tariff_entity)
|
||||
|
||||
await self._program_reset()
|
||||
|
||||
async def async_reset_meter(self, entity_id):
|
||||
"""Reset meter."""
|
||||
if self._tariff_entity is not None and self._tariff_entity != entity_id:
|
||||
|
@ -23,7 +23,7 @@
|
||||
"state": {
|
||||
"cleaning": "Cleaning",
|
||||
"docked": "Docked",
|
||||
"error": "Error",
|
||||
"error": "[%key:common::state::error%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]",
|
||||
|
@ -3,7 +3,6 @@
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR]
|
||||
@ -36,7 +35,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) ->
|
||||
coordinator = entry.runtime_data
|
||||
await coordinator.api.logout()
|
||||
await coordinator.api.close()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
@ -1509,7 +1509,7 @@
|
||||
"name": "Software error",
|
||||
"state": {
|
||||
"nothing": "Good",
|
||||
"something": "Error"
|
||||
"something": "[%key:common::state::error%]"
|
||||
},
|
||||
"state_attributes": {
|
||||
"top_pcb_sensor_error": {
|
||||
|
@ -21,7 +21,7 @@ from socket import ( # type: ignore[attr-defined] # private, not in typeshed
|
||||
_GLOBAL_DEFAULT_TIMEOUT,
|
||||
)
|
||||
import threading
|
||||
from typing import Any, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, cast, overload
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
@ -355,7 +355,13 @@ def ensure_list[_T](value: _T | None) -> list[_T] | list[Any]:
|
||||
"""Wrap value in list if it is not one."""
|
||||
if value is None:
|
||||
return []
|
||||
return cast(list[_T], value) if isinstance(value, list) else [value]
|
||||
if isinstance(value, list):
|
||||
if TYPE_CHECKING:
|
||||
# https://github.com/home-assistant/core/pull/71960
|
||||
# cast with a type variable is still slow.
|
||||
return cast(list[_T], value)
|
||||
return value # type: ignore[unreachable]
|
||||
return [value]
|
||||
|
||||
|
||||
def entity_id(value: Any) -> str:
|
||||
|
@ -6,7 +6,7 @@ aiodns==3.2.0
|
||||
aiohasupervisor==0.3.1b1
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-fast-zlib==0.2.3
|
||||
aiohttp==3.11.16
|
||||
aiohttp==3.11.18
|
||||
aiohttp_cors==0.7.0
|
||||
aiousbwatcher==1.1.1
|
||||
aiozoneinfo==0.2.3
|
||||
|
@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools==77.0.3"]
|
||||
requires = ["setuptools==78.1.1"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
@ -28,7 +28,7 @@ dependencies = [
|
||||
# change behavior based on presence of supervisor. Deprecated with #127228
|
||||
# Lib can be removed with 2025.11
|
||||
"aiohasupervisor==0.3.1b1",
|
||||
"aiohttp==3.11.16",
|
||||
"aiohttp==3.11.18",
|
||||
"aiohttp_cors==0.7.0",
|
||||
"aiohttp-fast-zlib==0.2.3",
|
||||
"aiohttp-asyncmdnsresolver==0.1.1",
|
||||
|
2
requirements.txt
generated
2
requirements.txt
generated
@ -5,7 +5,7 @@
|
||||
# Home Assistant Core
|
||||
aiodns==3.2.0
|
||||
aiohasupervisor==0.3.1b1
|
||||
aiohttp==3.11.16
|
||||
aiohttp==3.11.18
|
||||
aiohttp_cors==0.7.0
|
||||
aiohttp-fast-zlib==0.2.3
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
|
14
requirements_all.txt
generated
14
requirements_all.txt
generated
@ -179,7 +179,7 @@ aioacaia==0.1.14
|
||||
aioairq==0.4.4
|
||||
|
||||
# homeassistant.components.airzone_cloud
|
||||
aioairzone-cloud==0.6.11
|
||||
aioairzone-cloud==0.6.12
|
||||
|
||||
# homeassistant.components.airzone
|
||||
aioairzone==1.0.0
|
||||
@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.4.1
|
||||
aioshelly==13.5.0
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@ -1172,7 +1172,7 @@ horimote==0.4.1
|
||||
httplib2==0.20.4
|
||||
|
||||
# homeassistant.components.huawei_lte
|
||||
huawei-lte-api==1.10.0
|
||||
huawei-lte-api==1.11.0
|
||||
|
||||
# homeassistant.components.huum
|
||||
huum==0.7.12
|
||||
@ -2134,7 +2134,7 @@ pymeteoclimatic==0.1.0
|
||||
pymicro-vad==1.0.1
|
||||
|
||||
# homeassistant.components.miele
|
||||
pymiele==0.3.4
|
||||
pymiele==0.3.6
|
||||
|
||||
# homeassistant.components.xiaomi_tv
|
||||
pymitv==1.4.3
|
||||
@ -2214,7 +2214,7 @@ pyotgw==2.2.2
|
||||
pyotp==2.8.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.17.0
|
||||
pyoverkiz==1.17.1
|
||||
|
||||
# homeassistant.components.onewire
|
||||
pyownet==0.10.0.post1
|
||||
@ -2289,7 +2289,7 @@ pysabnzbd==1.1.1
|
||||
pysaj==0.0.16
|
||||
|
||||
# homeassistant.components.schlage
|
||||
pyschlage==2024.11.0
|
||||
pyschlage==2025.4.0
|
||||
|
||||
# homeassistant.components.sensibo
|
||||
pysensibo==1.1.0
|
||||
@ -2436,7 +2436,7 @@ python-juicenet==1.1.0
|
||||
python-kasa[speedups]==0.10.2
|
||||
|
||||
# homeassistant.components.linkplay
|
||||
python-linkplay==0.2.3
|
||||
python-linkplay==0.2.4
|
||||
|
||||
# homeassistant.components.lirc
|
||||
# python-lirc==1.2.3
|
||||
|
14
requirements_test_all.txt
generated
14
requirements_test_all.txt
generated
@ -167,7 +167,7 @@ aioacaia==0.1.14
|
||||
aioairq==0.4.4
|
||||
|
||||
# homeassistant.components.airzone_cloud
|
||||
aioairzone-cloud==0.6.11
|
||||
aioairzone-cloud==0.6.12
|
||||
|
||||
# homeassistant.components.airzone
|
||||
aioairzone==1.0.0
|
||||
@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
|
||||
aiosenz==1.0.0
|
||||
|
||||
# homeassistant.components.shelly
|
||||
aioshelly==13.4.1
|
||||
aioshelly==13.5.0
|
||||
|
||||
# homeassistant.components.skybell
|
||||
aioskybell==22.7.0
|
||||
@ -999,7 +999,7 @@ homematicip==2.0.0
|
||||
httplib2==0.20.4
|
||||
|
||||
# homeassistant.components.huawei_lte
|
||||
huawei-lte-api==1.10.0
|
||||
huawei-lte-api==1.11.0
|
||||
|
||||
# homeassistant.components.huum
|
||||
huum==0.7.12
|
||||
@ -1746,7 +1746,7 @@ pymeteoclimatic==0.1.0
|
||||
pymicro-vad==1.0.1
|
||||
|
||||
# homeassistant.components.miele
|
||||
pymiele==0.3.4
|
||||
pymiele==0.3.6
|
||||
|
||||
# homeassistant.components.mochad
|
||||
pymochad==0.2.0
|
||||
@ -1811,7 +1811,7 @@ pyotgw==2.2.2
|
||||
pyotp==2.8.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.17.0
|
||||
pyoverkiz==1.17.1
|
||||
|
||||
# homeassistant.components.onewire
|
||||
pyownet==0.10.0.post1
|
||||
@ -1871,7 +1871,7 @@ pyrympro==0.0.9
|
||||
pysabnzbd==1.1.1
|
||||
|
||||
# homeassistant.components.schlage
|
||||
pyschlage==2024.11.0
|
||||
pyschlage==2025.4.0
|
||||
|
||||
# homeassistant.components.sensibo
|
||||
pysensibo==1.1.0
|
||||
@ -1976,7 +1976,7 @@ python-juicenet==1.1.0
|
||||
python-kasa[speedups]==0.10.2
|
||||
|
||||
# homeassistant.components.linkplay
|
||||
python-linkplay==0.2.3
|
||||
python-linkplay==0.2.4
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==7.0.0
|
||||
|
@ -2131,7 +2131,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"upcloud",
|
||||
"upnp",
|
||||
"uptime",
|
||||
"uptimerobot",
|
||||
"usb",
|
||||
"usgs_earthquakes_feed",
|
||||
"utility_meter",
|
||||
|
@ -191,7 +191,6 @@ EXCEPTIONS = {
|
||||
"enocean", # https://github.com/kipe/enocean/pull/142
|
||||
"imutils", # https://github.com/PyImageSearch/imutils/pull/292
|
||||
"iso4217", # Public domain
|
||||
"jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
|
||||
"kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6
|
||||
"ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7
|
||||
"maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48
|
||||
@ -205,6 +204,11 @@ EXCEPTIONS = {
|
||||
"repoze.lru",
|
||||
"sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14
|
||||
"tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5
|
||||
# ---
|
||||
# https://github.com/jaraco/skeleton/pull/170
|
||||
# https://github.com/jaraco/skeleton/pull/171
|
||||
"jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
|
||||
"setuptools", # MIT
|
||||
}
|
||||
|
||||
TODO = {
|
||||
|
@ -46,6 +46,11 @@ from homeassistant.components import device_automation, persistent_notification
|
||||
from homeassistant.components.device_automation import ( # noqa: F401
|
||||
_async_get_device_automation_capabilities as async_get_device_automation_capabilities,
|
||||
)
|
||||
from homeassistant.components.logger import (
|
||||
DOMAIN as LOGGER_DOMAIN,
|
||||
SERVICE_SET_LEVEL,
|
||||
_clear_logger_overwrites,
|
||||
)
|
||||
from homeassistant.config import IntegrationConfigInfo, async_process_component_config
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import (
|
||||
@ -1688,6 +1693,28 @@ def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) ->
|
||||
async_dispatcher_send(hass, SIGNAL_CLOUD_CONNECTION_STATE, state)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_call_logger_set_level(
|
||||
logger: str,
|
||||
level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "FATAL", "CRITICAL"],
|
||||
*,
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> AsyncGenerator[None]:
|
||||
"""Context manager to reset loggers after logger.set_level call."""
|
||||
assert LOGGER_DOMAIN in hass.data, "'logger' integration not setup"
|
||||
with caplog.at_level(logging.NOTSET, logger):
|
||||
await hass.services.async_call(
|
||||
LOGGER_DOMAIN,
|
||||
SERVICE_SET_LEVEL,
|
||||
{logger: level},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
_clear_logger_overwrites(hass)
|
||||
|
||||
|
||||
def import_and_test_deprecated_constant_enum(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
module: ModuleType,
|
||||
|
@ -120,7 +120,7 @@
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Off grid status',
|
||||
'original_name': 'Off-grid status',
|
||||
'platform': 'apsystems',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
@ -133,7 +133,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'problem',
|
||||
'friendly_name': 'Mock Title Off grid status',
|
||||
'friendly_name': 'Mock Title Off-grid status',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.mock_title_off_grid_status',
|
||||
|
@ -61,6 +61,7 @@ from . import (
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
MockModule,
|
||||
async_call_logger_set_level,
|
||||
async_fire_time_changed,
|
||||
load_fixture,
|
||||
mock_integration,
|
||||
@ -1144,54 +1145,45 @@ async def test_debug_logging(
|
||||
) -> None:
|
||||
"""Test debug logging."""
|
||||
assert await async_setup_component(hass, "logger", {"logger": {}})
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.bluetooth": "DEBUG"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog
|
||||
):
|
||||
address = "44:44:33:11:23:41"
|
||||
start_time_monotonic = 50.0
|
||||
|
||||
address = "44:44:33:11:23:41"
|
||||
start_time_monotonic = 50.0
|
||||
switchbot_device_poor_signal_hci0 = generate_ble_device(
|
||||
address, "wohand_poor_signal_hci0"
|
||||
)
|
||||
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
|
||||
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
|
||||
)
|
||||
inject_advertisement_with_time_and_source(
|
||||
hass,
|
||||
switchbot_device_poor_signal_hci0,
|
||||
switchbot_adv_poor_signal_hci0,
|
||||
start_time_monotonic,
|
||||
"hci0",
|
||||
)
|
||||
assert "wohand_poor_signal_hci0" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
switchbot_device_poor_signal_hci0 = generate_ble_device(
|
||||
address, "wohand_poor_signal_hci0"
|
||||
)
|
||||
switchbot_adv_poor_signal_hci0 = generate_advertisement_data(
|
||||
local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100
|
||||
)
|
||||
inject_advertisement_with_time_and_source(
|
||||
hass,
|
||||
switchbot_device_poor_signal_hci0,
|
||||
switchbot_adv_poor_signal_hci0,
|
||||
start_time_monotonic,
|
||||
"hci0",
|
||||
)
|
||||
assert "wohand_poor_signal_hci0" in caplog.text
|
||||
caplog.clear()
|
||||
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.bluetooth": "WARNING"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
switchbot_device_good_signal_hci0 = generate_ble_device(
|
||||
address, "wohand_good_signal_hci0"
|
||||
)
|
||||
switchbot_adv_good_signal_hci0 = generate_advertisement_data(
|
||||
local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33
|
||||
)
|
||||
inject_advertisement_with_time_and_source(
|
||||
hass,
|
||||
switchbot_device_good_signal_hci0,
|
||||
switchbot_adv_good_signal_hci0,
|
||||
start_time_monotonic,
|
||||
"hci0",
|
||||
)
|
||||
assert "wohand_good_signal_hci0" not in caplog.text
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.bluetooth", "WARNING", hass=hass, caplog=caplog
|
||||
):
|
||||
switchbot_device_good_signal_hci0 = generate_ble_device(
|
||||
address, "wohand_good_signal_hci0"
|
||||
)
|
||||
switchbot_adv_good_signal_hci0 = generate_advertisement_data(
|
||||
local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33
|
||||
)
|
||||
inject_advertisement_with_time_and_source(
|
||||
hass,
|
||||
switchbot_device_good_signal_hci0,
|
||||
switchbot_adv_good_signal_hci0,
|
||||
start_time_monotonic,
|
||||
"hci0",
|
||||
)
|
||||
assert "wohand_good_signal_hci0" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth", "macos_adapter")
|
||||
|
@ -29,7 +29,11 @@ from . import (
|
||||
patch_bluetooth_time,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
async_call_logger_set_level,
|
||||
async_fire_time_changed,
|
||||
)
|
||||
|
||||
# If the adapter is in a stuck state the following errors are raised:
|
||||
NEED_RESET_ERRORS = [
|
||||
@ -482,70 +486,67 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init(
|
||||
) -> None:
|
||||
"""Test we can recover the adapter at startup and we wait for Dbus to init."""
|
||||
assert await async_setup_component(hass, "logger", {})
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.bluetooth": "DEBUG"},
|
||||
blocking=True,
|
||||
)
|
||||
called_start = 0
|
||||
called_stop = 0
|
||||
_callback = None
|
||||
mock_discovered = []
|
||||
|
||||
class MockBleakScanner:
|
||||
async def start(self, *args, **kwargs):
|
||||
"""Mock Start."""
|
||||
nonlocal called_start
|
||||
called_start += 1
|
||||
if called_start == 1:
|
||||
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
|
||||
if called_start == 2:
|
||||
raise BleakError("org.bluez.Error.InProgress")
|
||||
if called_start == 3:
|
||||
raise BleakError("org.bluez.Error.InProgress")
|
||||
|
||||
async def stop(self, *args, **kwargs):
|
||||
"""Mock Start."""
|
||||
nonlocal called_stop
|
||||
called_stop += 1
|
||||
|
||||
@property
|
||||
def discovered_devices(self):
|
||||
"""Mock discovered_devices."""
|
||||
nonlocal mock_discovered
|
||||
return mock_discovered
|
||||
|
||||
def register_detection_callback(self, callback: AdvertisementDataCallback):
|
||||
"""Mock Register Detection Callback."""
|
||||
nonlocal _callback
|
||||
_callback = callback
|
||||
|
||||
scanner = MockBleakScanner()
|
||||
start_time_monotonic = time.monotonic()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"habluetooth.scanner.ADAPTER_INIT_TIME",
|
||||
0,
|
||||
),
|
||||
patch_bluetooth_time(
|
||||
start_time_monotonic,
|
||||
),
|
||||
patch(
|
||||
"habluetooth.scanner.OriginalBleakScanner",
|
||||
return_value=scanner,
|
||||
),
|
||||
patch(
|
||||
"habluetooth.util.recover_adapter", return_value=True
|
||||
) as mock_recover_adapter,
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.bluetooth", "DEBUG", hass=hass, caplog=caplog
|
||||
):
|
||||
await async_setup_with_one_adapter(hass)
|
||||
called_start = 0
|
||||
called_stop = 0
|
||||
_callback = None
|
||||
mock_discovered = []
|
||||
|
||||
assert called_start == 4
|
||||
class MockBleakScanner:
|
||||
async def start(self, *args, **kwargs):
|
||||
"""Mock Start."""
|
||||
nonlocal called_start
|
||||
called_start += 1
|
||||
if called_start == 1:
|
||||
raise BleakError("org.freedesktop.DBus.Error.UnknownObject")
|
||||
if called_start == 2:
|
||||
raise BleakError("org.bluez.Error.InProgress")
|
||||
if called_start == 3:
|
||||
raise BleakError("org.bluez.Error.InProgress")
|
||||
|
||||
assert len(mock_recover_adapter.mock_calls) == 1
|
||||
assert "Waiting for adapter to initialize" in caplog.text
|
||||
async def stop(self, *args, **kwargs):
|
||||
"""Mock Start."""
|
||||
nonlocal called_stop
|
||||
called_stop += 1
|
||||
|
||||
@property
|
||||
def discovered_devices(self):
|
||||
"""Mock discovered_devices."""
|
||||
nonlocal mock_discovered
|
||||
return mock_discovered
|
||||
|
||||
def register_detection_callback(self, callback: AdvertisementDataCallback):
|
||||
"""Mock Register Detection Callback."""
|
||||
nonlocal _callback
|
||||
_callback = callback
|
||||
|
||||
scanner = MockBleakScanner()
|
||||
start_time_monotonic = time.monotonic()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"habluetooth.scanner.ADAPTER_INIT_TIME",
|
||||
0,
|
||||
),
|
||||
patch_bluetooth_time(
|
||||
start_time_monotonic,
|
||||
),
|
||||
patch(
|
||||
"habluetooth.scanner.OriginalBleakScanner",
|
||||
return_value=scanner,
|
||||
),
|
||||
patch(
|
||||
"habluetooth.util.recover_adapter", return_value=True
|
||||
) as mock_recover_adapter,
|
||||
):
|
||||
await async_setup_with_one_adapter(hass)
|
||||
|
||||
assert called_start == 4
|
||||
|
||||
assert len(mock_recover_adapter.mock_calls) == 1
|
||||
assert "Waiting for adapter to initialize" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("one_adapter")
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Test the DHCP discovery integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
import datetime
|
||||
import threading
|
||||
@ -24,6 +26,7 @@ from homeassistant.components.device_tracker import (
|
||||
SourceType,
|
||||
)
|
||||
from homeassistant.components.dhcp.const import DOMAIN
|
||||
from homeassistant.components.dhcp.models import DHCPData
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
@ -147,12 +150,12 @@ async def _async_get_handle_dhcp_packet(
|
||||
integration_matchers: dhcp.DhcpMatchers,
|
||||
address_data: dict | None = None,
|
||||
) -> Callable[[Any], Awaitable[None]]:
|
||||
"""Make a handler for a dhcp packet."""
|
||||
if address_data is None:
|
||||
address_data = {}
|
||||
dhcp_watcher = dhcp.DHCPWatcher(
|
||||
hass,
|
||||
address_data,
|
||||
integration_matchers,
|
||||
DHCPData(integration_matchers, set(), address_data),
|
||||
)
|
||||
with patch("aiodhcpwatcher.async_start"):
|
||||
await dhcp_watcher.async_start()
|
||||
@ -666,6 +669,45 @@ async def test_setup_fails_with_broken_libpcap(
|
||||
)
|
||||
|
||||
|
||||
def _make_device_tracker_watcher(
|
||||
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
|
||||
) -> dhcp.DeviceTrackerWatcher:
|
||||
return dhcp.DeviceTrackerWatcher(
|
||||
hass,
|
||||
DHCPData(
|
||||
dhcp.async_index_integration_matchers(matchers),
|
||||
set(),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_device_tracker_registered_watcher(
|
||||
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
|
||||
) -> dhcp.DeviceTrackerRegisteredWatcher:
|
||||
return dhcp.DeviceTrackerRegisteredWatcher(
|
||||
hass,
|
||||
DHCPData(
|
||||
dhcp.async_index_integration_matchers(matchers),
|
||||
set(),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_network_watcher(
|
||||
hass: HomeAssistant, matchers: list[dhcp.DHCPMatcher]
|
||||
) -> dhcp.NetworkWatcher:
|
||||
return dhcp.NetworkWatcher(
|
||||
hass,
|
||||
DHCPData(
|
||||
dhcp.async_index_integration_matchers(matchers),
|
||||
set(),
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_device_tracker_hostname_and_macaddress_exists_before_start(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
@ -682,18 +724,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
|
||||
)
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -716,18 +755,15 @@ async def test_device_tracker_hostname_and_macaddress_exists_before_start(
|
||||
async def test_device_tracker_registered(hass: HomeAssistant) -> None:
|
||||
"""Test matching based on hostname and macaddress when registered."""
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_registered_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -756,18 +792,15 @@ async def test_device_tracker_registered(hass: HomeAssistant) -> None:
|
||||
async def test_device_tracker_registered_hostname_none(hass: HomeAssistant) -> None:
|
||||
"""Test handle None hostname."""
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerRegisteredWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -789,18 +822,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start(
|
||||
"""Test matching based on hostname and macaddress after start."""
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -837,18 +867,15 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_home(
|
||||
"""Test matching based on hostname and macaddress after start but not home."""
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -875,9 +902,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_not_router(
|
||||
"""Test matching based on hostname and macaddress after start but not router."""
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
@ -905,9 +931,8 @@ async def test_device_tracker_hostname_and_macaddress_after_start_hostname_missi
|
||||
"""Test matching based on hostname and macaddress after start but missing hostname."""
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
@ -934,9 +959,8 @@ async def test_device_tracker_invalid_ip_address(
|
||||
"""Test an invalid ip address."""
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
[{"domain": "mock-domain", "hostname": "connect", "macaddress": "B8B7F1*"}],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
@ -974,18 +998,15 @@ async def test_device_tracker_ignore_self_assigned_ips_before_start(
|
||||
)
|
||||
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
device_tracker_watcher = dhcp.DeviceTrackerWatcher(
|
||||
device_tracker_watcher = _make_device_tracker_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -1010,18 +1031,15 @@ async def test_aiodiscover_finds_new_hosts(hass: HomeAssistant) -> None:
|
||||
],
|
||||
),
|
||||
):
|
||||
device_tracker_watcher = dhcp.NetworkWatcher(
|
||||
device_tracker_watcher = _make_network_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -1073,18 +1091,15 @@ async def test_aiodiscover_does_not_call_again_on_shorter_hostname(
|
||||
],
|
||||
),
|
||||
):
|
||||
device_tracker_watcher = dhcp.NetworkWatcher(
|
||||
device_tracker_watcher = _make_network_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "irobot-*",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "irobot-*",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
@ -1123,19 +1138,17 @@ async def test_aiodiscover_finds_new_hosts_after_interval(hass: HomeAssistant) -
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
device_tracker_watcher = dhcp.NetworkWatcher(
|
||||
device_tracker_watcher = _make_network_watcher(
|
||||
hass,
|
||||
{},
|
||||
dhcp.async_index_integration_matchers(
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
]
|
||||
),
|
||||
[
|
||||
{
|
||||
"domain": "mock-domain",
|
||||
"hostname": "connect",
|
||||
"macaddress": "B8B7F1*",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
device_tracker_watcher.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@ -1235,7 +1248,7 @@ async def test_dhcp_rediscover(
|
||||
hass, integration_matchers, address_data
|
||||
)
|
||||
rediscovery_watcher = dhcp.RediscoveryWatcher(
|
||||
hass, address_data, integration_matchers
|
||||
hass, DHCPData(integration_matchers, set(), address_data)
|
||||
)
|
||||
rediscovery_watcher.async_start()
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
@ -1329,7 +1342,7 @@ async def test_dhcp_rediscover_no_match(
|
||||
hass, integration_matchers, address_data
|
||||
)
|
||||
rediscovery_watcher = dhcp.RediscoveryWatcher(
|
||||
hass, address_data, integration_matchers
|
||||
hass, DHCPData(integration_matchers, set(), address_data)
|
||||
)
|
||||
rediscovery_watcher.async_start()
|
||||
with patch.object(hass.config_entries.flow, "async_init") as mock_init:
|
||||
|
75
tests/components/dhcp/test_websocket_api.py
Normal file
75
tests/components/dhcp/test_websocket_api.py
Normal 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",
|
||||
}
|
||||
]
|
||||
}
|
@ -27,6 +27,7 @@ from homeassistant.components.esphome.const import (
|
||||
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
@ -50,6 +51,17 @@ def mock_setup_entry():
|
||||
yield
|
||||
|
||||
|
||||
def get_flow_context(hass: HomeAssistant, result: ConfigFlowResult) -> dict[str, Any]:
|
||||
"""Get the flow context from the result of async_init or async_configure."""
|
||||
flow = next(
|
||||
flow
|
||||
for flow in hass.config_entries.flow.async_progress()
|
||||
if flow["flow_id"] == result["flow_id"]
|
||||
)
|
||||
|
||||
return flow["context"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf")
|
||||
async def test_user_connection_works(
|
||||
hass: HomeAssistant, mock_client, mock_setup_entry: None
|
||||
@ -119,7 +131,12 @@ async def test_user_connection_updates_host(
|
||||
data={CONF_HOST: "127.0.0.1", CONF_PORT: 80},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
assert entry.data[CONF_HOST] == "127.0.0.1"
|
||||
|
||||
|
||||
@ -145,6 +162,9 @@ async def test_user_sets_unique_id(
|
||||
|
||||
assert discovery_result["type"] is FlowResultType.FORM
|
||||
assert discovery_result["step_id"] == "discovery_confirm"
|
||||
assert discovery_result["description_placeholders"] == {
|
||||
"name": "test8266",
|
||||
}
|
||||
|
||||
discovery_result = await hass.config_entries.flow.async_configure(
|
||||
discovery_result["flow_id"],
|
||||
@ -173,7 +193,12 @@ async def test_user_sets_unique_id(
|
||||
{CONF_HOST: "127.0.0.1", CONF_PORT: 6053},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "test",
|
||||
"name": "test",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf")
|
||||
@ -224,6 +249,9 @@ async def test_user_causes_zeroconf_to_abort(
|
||||
|
||||
assert discovery_result["type"] is FlowResultType.FORM
|
||||
assert discovery_result["step_id"] == "discovery_confirm"
|
||||
assert discovery_result["description_placeholders"] == {
|
||||
"name": "test8266",
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome",
|
||||
@ -287,6 +315,7 @@ async def test_user_with_password(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_PASSWORD: "password1"}
|
||||
@ -316,6 +345,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
mock_client.connect.side_effect = InvalidAuthAPIError
|
||||
|
||||
@ -325,6 +355,7 @@ async def test_user_invalid_password(hass: HomeAssistant, mock_client) -> None:
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
@ -338,7 +369,7 @@ async def test_user_dashboard_has_wrong_key(
|
||||
"""Test user step with key from dashboard that is incorrect."""
|
||||
mock_client.device_info.side_effect = [
|
||||
RequiresEncryptionAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
InvalidEncryptionKeyAPIError("Wrong key", "test"),
|
||||
DeviceInfo(
|
||||
uses_password=False,
|
||||
name="test",
|
||||
@ -359,6 +390,7 @@ async def test_user_dashboard_has_wrong_key(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
@ -467,6 +499,7 @@ async def test_user_discovers_name_and_gets_key_from_dashboard_fails(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
@ -522,6 +555,7 @@ async def test_user_discovers_name_and_dashboard_is_unavailable(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
@ -553,6 +587,7 @@ async def test_login_connection_error(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
mock_client.connect.side_effect = APIConnectionError
|
||||
|
||||
@ -562,6 +597,7 @@ async def test_login_connection_error(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "authenticate"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
assert result["errors"] == {"base": "connection_error"}
|
||||
|
||||
|
||||
@ -578,12 +614,18 @@ async def test_discovery_initiation(
|
||||
port=6053,
|
||||
properties={
|
||||
"mac": "1122334455aa",
|
||||
"friendly_name": "The Test",
|
||||
},
|
||||
type="mock_type",
|
||||
)
|
||||
flow = await hass.config_entries.flow.async_init(
|
||||
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info
|
||||
)
|
||||
assert get_flow_context(hass, flow) == {
|
||||
"source": config_entries.SOURCE_ZEROCONF,
|
||||
"title_placeholders": {"name": "The Test (test)"},
|
||||
"unique_id": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
flow["flow_id"], user_input={}
|
||||
@ -645,7 +687,12 @@ async def test_discovery_already_configured(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
|
||||
async def test_discovery_duplicate_data(
|
||||
@ -667,6 +714,7 @@ async def test_discovery_duplicate_data(
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "discovery_confirm"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF}
|
||||
@ -701,7 +749,12 @@ async def test_discovery_updates_unique_id(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
assert entry.unique_id == "11:22:33:44:55:aa"
|
||||
|
||||
@ -722,6 +775,7 @@ async def test_user_requires_psk(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["errors"] == {}
|
||||
assert result["description_placeholders"] == {"name": "ESPHome"}
|
||||
|
||||
assert len(mock_client.connect.mock_calls) == 2
|
||||
assert len(mock_client.device_info.mock_calls) == 2
|
||||
@ -744,6 +798,7 @@ async def test_encryption_key_valid_psk(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "ESPHome"}
|
||||
|
||||
mock_client.device_info = AsyncMock(
|
||||
return_value=DeviceInfo(uses_password=False, name="test")
|
||||
@ -779,6 +834,7 @@ async def test_encryption_key_invalid_psk(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "ESPHome"}
|
||||
|
||||
mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@ -788,6 +844,7 @@ async def test_encryption_key_invalid_psk(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["errors"] == {"base": "invalid_psk"}
|
||||
assert result["description_placeholders"] == {"name": "ESPHome"}
|
||||
assert mock_client.noise_psk == INVALID_NOISE_PSK
|
||||
|
||||
|
||||
@ -803,6 +860,9 @@ async def test_reauth_initiation(hass: HomeAssistant, mock_client) -> None:
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
"name": "Mock Title (test)",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf")
|
||||
@ -1005,6 +1065,9 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM, result
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
"name": "Mock Title (test)",
|
||||
}
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
@ -1050,6 +1113,9 @@ async def test_reauth_confirm_invalid(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
"name": "Mock Title (test)",
|
||||
}
|
||||
assert result["errors"]
|
||||
assert result["errors"]["base"] == "invalid_psk"
|
||||
|
||||
@ -1088,6 +1154,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
"name": "Mock Title (test)",
|
||||
}
|
||||
assert result["errors"]
|
||||
assert result["errors"]["base"] == "invalid_psk"
|
||||
|
||||
@ -1125,6 +1194,9 @@ async def test_reauth_encryption_key_removed(
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_encryption_removed_confirm"
|
||||
assert result["description_placeholders"] == {
|
||||
"name": "Mock Title (test)",
|
||||
}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
@ -1159,7 +1231,12 @@ async def test_discovery_dhcp_updates_host(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
assert entry.data[CONF_HOST] == "192.168.43.184"
|
||||
|
||||
@ -1188,7 +1265,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_detailed"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
# Mac was wrong, should not update
|
||||
assert entry.data[CONF_HOST] == "192.168.43.183"
|
||||
@ -1217,7 +1299,12 @@ async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_detailed"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
# Mac was wrong, should not update
|
||||
assert entry.data[CONF_HOST] == "192.168.43.183"
|
||||
@ -1246,7 +1333,12 @@ async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_detailed"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "unknown",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
|
||||
# Mac was missing, should not update
|
||||
assert entry.data[CONF_HOST] == "192.168.43.183"
|
||||
@ -1330,6 +1422,7 @@ async def test_zeroconf_encryption_key_via_dashboard(
|
||||
|
||||
assert flow["type"] is FlowResultType.FORM
|
||||
assert flow["step_id"] == "discovery_confirm"
|
||||
assert flow["description_placeholders"] == {"name": "test8266"}
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
@ -1397,6 +1490,7 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop(
|
||||
|
||||
assert flow["type"] is FlowResultType.FORM
|
||||
assert flow["step_id"] == "discovery_confirm"
|
||||
assert flow["description_placeholders"] == {"name": "test8266"}
|
||||
|
||||
mock_dashboard["configured"].append(
|
||||
{
|
||||
@ -1462,6 +1556,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
|
||||
|
||||
assert flow["type"] is FlowResultType.FORM
|
||||
assert flow["step_id"] == "discovery_confirm"
|
||||
assert flow["description_placeholders"] == {"name": "test8266"}
|
||||
|
||||
await dashboard.async_get_dashboard(hass).async_refresh()
|
||||
|
||||
@ -1473,6 +1568,7 @@ async def test_zeroconf_no_encryption_key_via_dashboard(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "test8266"}
|
||||
|
||||
|
||||
async def test_option_flow_allow_service_calls(
|
||||
@ -1585,6 +1681,7 @@ async def test_user_discovers_name_no_dashboard(
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "test"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
@ -1877,6 +1974,54 @@ async def test_reconfig_success_with_new_ip_same_name(
|
||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
|
||||
async def test_reconfig_success_noise_psk_changes(
|
||||
hass: HomeAssistant, mock_client: APIClient
|
||||
) -> None:
|
||||
"""Test reconfig initiation with new ip and new noise psk."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "127.0.0.1",
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "",
|
||||
CONF_DEVICE_NAME: "test",
|
||||
CONF_NOISE_PSK: VALID_NOISE_PSK,
|
||||
},
|
||||
unique_id="11:22:33:44:55:aa",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await entry.start_reconfigure_flow(hass)
|
||||
mock_client.device_info.side_effect = [
|
||||
RequiresEncryptionAPIError,
|
||||
InvalidEncryptionKeyAPIError("Wrong key", "test"),
|
||||
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
|
||||
]
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "encryption_key"
|
||||
assert result["description_placeholders"] == {"name": "Mock Title (test)"}
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert entry.data[CONF_HOST] == "127.0.0.1"
|
||||
assert entry.data[CONF_DEVICE_NAME] == "test"
|
||||
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
|
||||
async def test_reconfig_name_conflict_with_existing_entry(
|
||||
hass: HomeAssistant, mock_client: APIClient
|
||||
@ -1999,7 +2144,12 @@ async def test_reconfig_mac_used_by_other_entry(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "test4",
|
||||
"mac": "11:22:33:44:55:bb",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
|
||||
|
@ -17,6 +17,7 @@ from aioesphomeapi import (
|
||||
)
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_RESTORED,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_OFF,
|
||||
@ -503,3 +504,40 @@ async def test_esphome_device_without_friendly_name(
|
||||
state = hass.states.get("binary_sensor.test_mybinary_sensor")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
|
||||
async def test_entity_without_name_device_with_friendly_name(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
hass_storage: dict[str, Any],
|
||||
mock_esphome_device: Callable[
|
||||
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
|
||||
Awaitable[MockESPHomeDevice],
|
||||
],
|
||||
) -> None:
|
||||
"""Test name and entity_id for a device a friendly name and an entity without a name."""
|
||||
entity_info = [
|
||||
BinarySensorInfo(
|
||||
object_id="mybinary_sensor",
|
||||
key=1,
|
||||
name="",
|
||||
unique_id="my_binary_sensor",
|
||||
),
|
||||
]
|
||||
states = [
|
||||
BinarySensorState(key=1, state=True, missing_state=False),
|
||||
]
|
||||
user_service = []
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
user_service=user_service,
|
||||
states=states,
|
||||
device_info={"friendly_name": "The Best Mixer", "name": "mixer"},
|
||||
)
|
||||
state = hass.states.get("binary_sensor.mixer")
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
# Make sure we have set the name to `None` as otherwise
|
||||
# the friendly_name will be "The Best Mixer "
|
||||
assert state.attributes[ATTR_FRIENDLY_NAME] == "The Best Mixer"
|
||||
|
@ -49,7 +49,12 @@ from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import MockESPHomeDevice
|
||||
|
||||
from tests.common import MockConfigEntry, async_capture_events, async_mock_service
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
async_call_logger_set_level,
|
||||
async_capture_events,
|
||||
async_mock_service,
|
||||
)
|
||||
|
||||
|
||||
async def test_esphome_device_subscribe_logs(
|
||||
@ -83,62 +88,50 @@ async def test_esphome_device_subscribe_logs(
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "DEBUG"},
|
||||
blocking=True,
|
||||
)
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
|
||||
):
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_VERY_VERBOSE
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_log_message" in caplog.text
|
||||
caplog.set_level(logging.DEBUG)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_INFO, message=b"test_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_log_message" in caplog.text
|
||||
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_error_log_message" in caplog.text
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_ERROR, message=b"test_error_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_error_log_message" in caplog.text
|
||||
|
||||
caplog.set_level(logging.ERROR)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_debug_log_message" not in caplog.text
|
||||
caplog.set_level(logging.ERROR)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_debug_log_message" not in caplog.text
|
||||
|
||||
caplog.set_level(logging.DEBUG)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_debug_log_message" in caplog.text
|
||||
caplog.set_level(logging.DEBUG)
|
||||
device.mock_on_log_message(
|
||||
Mock(level=LogLevel.LOG_LEVEL_DEBUG, message=b"test_debug_log_message")
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert "test_debug_log_message" in caplog.text
|
||||
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "WARNING"},
|
||||
blocking=True,
|
||||
)
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "ERROR"},
|
||||
blocking=True,
|
||||
)
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "INFO"},
|
||||
blocking=True,
|
||||
)
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
|
||||
):
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_WARN
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "ERROR", hass=hass, caplog=caplog
|
||||
):
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_ERROR
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "INFO", hass=hass, caplog=caplog
|
||||
):
|
||||
assert device.current_log_level == LogLevel.LOG_LEVEL_CONFIG
|
||||
|
||||
|
||||
async def test_esphome_device_service_calls_not_allowed(
|
||||
@ -750,7 +743,12 @@ async def test_connection_aborted_wrong_device(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "test",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
assert entry.data[CONF_HOST] == "192.168.43.184"
|
||||
await hass.async_block_till_done()
|
||||
assert len(new_info.mock_calls) == 2
|
||||
@ -819,7 +817,12 @@ async def test_connection_aborted_wrong_device_same_name(
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert result["reason"] == "already_configured_updates"
|
||||
assert result["description_placeholders"] == {
|
||||
"title": "Mock Title",
|
||||
"name": "test",
|
||||
"mac": "11:22:33:44:55:aa",
|
||||
}
|
||||
assert entry.data[CONF_HOST] == "192.168.43.184"
|
||||
await hass.async_block_till_done()
|
||||
assert len(new_info.mock_calls) == 2
|
||||
@ -958,6 +961,7 @@ async def test_debug_logging(
|
||||
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
|
||||
Awaitable[MockConfigEntry],
|
||||
],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test enabling and disabling debug logging."""
|
||||
assert await async_setup_component(hass, "logger", {"logger": {}})
|
||||
@ -967,24 +971,16 @@ async def test_debug_logging(
|
||||
user_service=[],
|
||||
states=[],
|
||||
)
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "DEBUG"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
mock_client.set_debug.assert_has_calls([call(True)])
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "DEBUG", hass=hass, caplog=caplog
|
||||
):
|
||||
mock_client.set_debug.assert_has_calls([call(True)])
|
||||
mock_client.reset_mock()
|
||||
|
||||
mock_client.reset_mock()
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"homeassistant.components.esphome": "WARNING"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
mock_client.set_debug.assert_has_calls([call(False)])
|
||||
async with async_call_logger_set_level(
|
||||
"homeassistant.components.esphome", "WARNING", hass=hass, caplog=caplog
|
||||
):
|
||||
mock_client.set_debug.assert_has_calls([call(False)])
|
||||
|
||||
|
||||
async def test_esphome_device_with_dash_in_name_user_services(
|
||||
|
@ -1,13 +1,24 @@
|
||||
"""Test Govee light local."""
|
||||
|
||||
from errno import EADDRINUSE, ENETDOWN
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from govee_local_api import GoveeDevice
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.govee_light_local.const import DOMAIN
|
||||
from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_BRIGHTNESS_PCT,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import SERVICE_TURN_OFF, SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import DEFAULT_CAPABILITIES, SCENE_CAPABILITIES
|
||||
@ -197,8 +208,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
|
||||
assert light.state == "off"
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
@ -211,8 +222,8 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
|
||||
|
||||
# Turn off
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_off",
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{"entity_id": light.entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
@ -224,6 +235,77 @@ async def test_light_on_off(hass: HomeAssistant, mock_govee_api: MagicMock) -> N
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "value", "mock_call", "mock_call_args", "mock_call_kwargs"),
|
||||
[
|
||||
(
|
||||
ATTR_RGB_COLOR,
|
||||
[100, 255, 50],
|
||||
"set_color",
|
||||
[],
|
||||
{"temperature": None, "rgb": (100, 255, 50)},
|
||||
),
|
||||
(
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
4400,
|
||||
"set_color",
|
||||
[],
|
||||
{"temperature": 4400, "rgb": None},
|
||||
),
|
||||
(ATTR_EFFECT, "sunrise", "set_scene", ["sunrise"], {}),
|
||||
],
|
||||
)
|
||||
async def test_turn_on_call_order(
|
||||
hass: HomeAssistant,
|
||||
mock_govee_api: MagicMock,
|
||||
attribute: str,
|
||||
value: str | int | list[int],
|
||||
mock_call: str,
|
||||
mock_call_args: list[str],
|
||||
mock_call_kwargs: dict[str, any],
|
||||
) -> None:
|
||||
"""Test that turn_on is called after set_brightness/set_color/set_preset."""
|
||||
mock_govee_api.devices = [
|
||||
GoveeDevice(
|
||||
controller=mock_govee_api,
|
||||
ip="192.168.1.100",
|
||||
fingerprint="asdawdqwdqwd",
|
||||
sku="H615A",
|
||||
capabilities=SCENE_CAPABILITIES,
|
||||
)
|
||||
]
|
||||
|
||||
entry = MockConfigEntry(domain=DOMAIN)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "off"
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_BRIGHTNESS_PCT: 50, attribute: value},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_govee_api.assert_has_calls(
|
||||
[
|
||||
call.set_brightness(mock_govee_api.devices[0], 50),
|
||||
getattr(call, mock_call)(
|
||||
mock_govee_api.devices[0], *mock_call_args, **mock_call_kwargs
|
||||
),
|
||||
call.turn_on_off(mock_govee_api.devices[0], True),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
|
||||
"""Test changing brightness."""
|
||||
mock_govee_api.devices = [
|
||||
@ -249,8 +331,8 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
|
||||
assert light.state == "off"
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, "brightness_pct": 50},
|
||||
blocking=True,
|
||||
)
|
||||
@ -260,12 +342,12 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 50)
|
||||
assert light.attributes["brightness"] == 127
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 127
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "brightness": 255},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -273,13 +355,13 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["brightness"] == 255
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "brightness": 255},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -287,7 +369,7 @@ async def test_light_brightness(hass: HomeAssistant, mock_govee_api: MagicMock)
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["brightness"] == 255
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.set_brightness.assert_awaited_with(mock_govee_api.devices[0], 100)
|
||||
|
||||
|
||||
@ -316,9 +398,9 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
|
||||
assert light.state == "off"
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "rgb_color": [100, 255, 50]},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_RGB_COLOR: [100, 255, 50]},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -326,7 +408,7 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["rgb_color"] == (100, 255, 50)
|
||||
assert light.attributes[ATTR_RGB_COLOR] == (100, 255, 50)
|
||||
assert light.attributes["color_mode"] == ColorMode.RGB
|
||||
|
||||
mock_govee_api.set_color.assert_awaited_with(
|
||||
@ -334,8 +416,8 @@ async def test_light_color(hass: HomeAssistant, mock_govee_api: MagicMock) -> No
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, "kelvin": 4400},
|
||||
blocking=True,
|
||||
)
|
||||
@ -378,9 +460,9 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
|
||||
assert light.state == "off"
|
||||
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "sunrise"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -388,7 +470,7 @@ async def test_scene_on(hass: HomeAssistant, mock_govee_api: MagicMock) -> None:
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] == "sunrise"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
|
||||
@ -422,16 +504,16 @@ async def test_scene_restore_rgb(
|
||||
|
||||
# Set initial color
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "rgb_color": initial_color},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "brightness": 255},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -439,15 +521,15 @@ async def test_scene_restore_rgb(
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["rgb_color"] == initial_color
|
||||
assert light.attributes["brightness"] == 255
|
||||
assert light.attributes[ATTR_RGB_COLOR] == initial_color
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "sunrise"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -455,14 +537,14 @@ async def test_scene_restore_rgb(
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] == "sunrise"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Deactivate scene
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "none"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -470,9 +552,9 @@ async def test_scene_restore_rgb(
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] is None
|
||||
assert light.attributes["rgb_color"] == initial_color
|
||||
assert light.attributes["brightness"] == 255
|
||||
assert light.attributes[ATTR_EFFECT] is None
|
||||
assert light.attributes[ATTR_RGB_COLOR] == initial_color
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
|
||||
|
||||
async def test_scene_restore_temperature(
|
||||
@ -505,8 +587,8 @@ async def test_scene_restore_temperature(
|
||||
|
||||
# Set initial color
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, "color_temp_kelvin": initial_color},
|
||||
blocking=True,
|
||||
)
|
||||
@ -520,9 +602,9 @@ async def test_scene_restore_temperature(
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "sunrise"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "sunrise"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -530,14 +612,14 @@ async def test_scene_restore_temperature(
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] == "sunrise"
|
||||
assert light.attributes[ATTR_EFFECT] == "sunrise"
|
||||
mock_govee_api.set_scene.assert_awaited_with(mock_govee_api.devices[0], "sunrise")
|
||||
|
||||
# Deactivate scene
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "none"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -545,7 +627,7 @@ async def test_scene_restore_temperature(
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] is None
|
||||
assert light.attributes[ATTR_EFFECT] is None
|
||||
assert light.attributes["color_temp_kelvin"] == initial_color
|
||||
|
||||
|
||||
@ -577,16 +659,16 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non
|
||||
|
||||
# Set initial color
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "rgb_color": initial_color},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_RGB_COLOR: initial_color},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "brightness": 255},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_BRIGHTNESS: 255},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@ -594,21 +676,20 @@ async def test_scene_none(hass: HomeAssistant, mock_govee_api: MagicMock) -> Non
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["rgb_color"] == initial_color
|
||||
assert light.attributes["brightness"] == 255
|
||||
assert light.attributes[ATTR_RGB_COLOR] == initial_color
|
||||
assert light.attributes[ATTR_BRIGHTNESS] == 255
|
||||
mock_govee_api.turn_on_off.assert_awaited_with(mock_govee_api.devices[0], True)
|
||||
|
||||
# Activate scene
|
||||
await hass.services.async_call(
|
||||
"light",
|
||||
"turn_on",
|
||||
{"entity_id": light.entity_id, "effect": "none"},
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{"entity_id": light.entity_id, ATTR_EFFECT: "none"},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
light = hass.states.get("light.H615A")
|
||||
assert light is not None
|
||||
assert light.state == "on"
|
||||
assert light.attributes["effect"] is None
|
||||
assert light.attributes[ATTR_EFFECT] is None
|
||||
mock_govee_api.set_scene.assert_not_called()
|
||||
|
@ -969,6 +969,135 @@ async def test_async_start_from_history_and_switch_to_watching_state_changes_mul
|
||||
assert hass.states.get("sensor.sensor4").state == "87.5"
|
||||
|
||||
|
||||
async def test_start_from_history_then_watch_state_changes_sliding(
|
||||
recorder_mock: Recorder,
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test we startup from history and switch to watching state changes.
|
||||
|
||||
With a sliding window, history_stats does not requery the recorder.
|
||||
"""
|
||||
await hass.config.async_set_time_zone("UTC")
|
||||
utcnow = dt_util.utcnow()
|
||||
start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
time = start_time
|
||||
|
||||
def _fake_states(*args, **kwargs):
|
||||
return {
|
||||
"binary_sensor.state": [
|
||||
ha.State(
|
||||
"binary_sensor.state",
|
||||
"off",
|
||||
last_changed=start_time - timedelta(hours=1),
|
||||
last_updated=start_time - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.recorder.history.state_changes_during_period",
|
||||
_fake_states,
|
||||
),
|
||||
freeze_time(start_time),
|
||||
):
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"sensor",
|
||||
{
|
||||
"sensor": [
|
||||
{
|
||||
"platform": "history_stats",
|
||||
"entity_id": "binary_sensor.state",
|
||||
"name": f"sensor{i}",
|
||||
"state": "on",
|
||||
"end": "{{ utcnow() }}",
|
||||
"duration": {"hours": 1},
|
||||
"type": sensor_type,
|
||||
}
|
||||
for i, sensor_type in enumerate(["time", "ratio", "count"])
|
||||
]
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
for i in range(3):
|
||||
await async_update_entity(hass, f"sensor.sensor{i}")
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.sensor0").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor1").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor2").state == "0"
|
||||
|
||||
with freeze_time(time):
|
||||
hass.states.async_set("binary_sensor.state", "on")
|
||||
await hass.async_block_till_done()
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.sensor0").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor1").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor2").state == "1"
|
||||
|
||||
# After sensor has been on for 15 minutes, check state
|
||||
time += timedelta(minutes=15) # 00:15
|
||||
with freeze_time(time):
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.sensor0").state == "0.25"
|
||||
assert hass.states.get("sensor.sensor1").state == "25.0"
|
||||
assert hass.states.get("sensor.sensor2").state == "1"
|
||||
|
||||
with freeze_time(time):
|
||||
hass.states.async_set("binary_sensor.state", "off")
|
||||
await hass.async_block_till_done()
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
time += timedelta(minutes=30) # 00:45
|
||||
|
||||
with freeze_time(time):
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.sensor0").state == "0.25"
|
||||
assert hass.states.get("sensor.sensor1").state == "25.0"
|
||||
assert hass.states.get("sensor.sensor2").state == "1"
|
||||
|
||||
time += timedelta(minutes=20) # 01:05
|
||||
|
||||
with freeze_time(time):
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Sliding window will have started to erase the initial on period, so now it will only be on for 10 minutes
|
||||
assert hass.states.get("sensor.sensor0").state == "0.17"
|
||||
assert hass.states.get("sensor.sensor1").state == "16.7"
|
||||
assert hass.states.get("sensor.sensor2").state == "1"
|
||||
|
||||
time += timedelta(minutes=5) # 01:10
|
||||
|
||||
with freeze_time(time):
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Sliding window will continue to erase the initial on period, so now it will only be on for 5 minutes
|
||||
assert hass.states.get("sensor.sensor0").state == "0.08"
|
||||
assert hass.states.get("sensor.sensor1").state == "8.3"
|
||||
assert hass.states.get("sensor.sensor2").state == "1"
|
||||
|
||||
time += timedelta(minutes=10) # 01:20
|
||||
|
||||
with freeze_time(time):
|
||||
async_fire_time_changed(hass, time)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("sensor.sensor0").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor1").state == "0.0"
|
||||
assert hass.states.get("sensor.sensor2").state == "0"
|
||||
|
||||
|
||||
async def test_does_not_work_into_the_future(
|
||||
recorder_mock: Recorder, hass: HomeAssistant
|
||||
) -> None:
|
||||
@ -1366,10 +1495,6 @@ async def test_measure_from_end_going_backwards(
|
||||
|
||||
past_next_update = start_time + timedelta(minutes=30)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.recorder.history.state_changes_during_period",
|
||||
_fake_states,
|
||||
),
|
||||
freeze_time(past_next_update),
|
||||
):
|
||||
async_fire_time_changed(hass, past_next_update)
|
||||
@ -1526,29 +1651,10 @@ async def test_state_change_during_window_rollover(
|
||||
|
||||
assert hass.states.get("sensor.sensor1").state == "11.98"
|
||||
|
||||
# One minute has passed and the time has now rolled over into a new day, resetting the recorder window. The sensor will then query the database for updates,
|
||||
# and will see that the sensor is ON starting from midnight.
|
||||
# One minute has passed and the time has now rolled over into a new day, resetting the recorder window.
|
||||
# The sensor will be ON since midnight.
|
||||
t3 = t2 + timedelta(minutes=1)
|
||||
|
||||
def _fake_states_t3(*args, **kwargs):
|
||||
return {
|
||||
"binary_sensor.state": [
|
||||
ha.State(
|
||||
"binary_sensor.state",
|
||||
"on",
|
||||
last_changed=t3.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
last_updated=t3.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.recorder.history.state_changes_during_period",
|
||||
_fake_states_t3,
|
||||
),
|
||||
freeze_time(t3),
|
||||
):
|
||||
with freeze_time(t3):
|
||||
# The sensor turns off around this time, before the sensor does its normal polled update.
|
||||
hass.states.async_set("binary_sensor.state", "off")
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
@ -22,7 +22,7 @@ from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import async_call_logger_set_level, async_fire_time_changed
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@ -505,27 +505,21 @@ async def test_logging(
|
||||
)
|
||||
)
|
||||
hass.states.async_set("logging.entity", "hello")
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"aiohttp.access": "info"},
|
||||
blocking=True,
|
||||
)
|
||||
client = await hass_client()
|
||||
response = await client.get("/api/states/logging.entity")
|
||||
assert response.status == HTTPStatus.OK
|
||||
async with async_call_logger_set_level(
|
||||
"aiohttp.access", "INFO", hass=hass, caplog=caplog
|
||||
):
|
||||
client = await hass_client()
|
||||
response = await client.get("/api/states/logging.entity")
|
||||
assert response.status == HTTPStatus.OK
|
||||
|
||||
assert "GET /api/states/logging.entity" in caplog.text
|
||||
caplog.clear()
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"aiohttp.access": "warning"},
|
||||
blocking=True,
|
||||
)
|
||||
response = await client.get("/api/states/logging.entity")
|
||||
assert response.status == HTTPStatus.OK
|
||||
assert "GET /api/states/logging.entity" not in caplog.text
|
||||
assert "GET /api/states/logging.entity" in caplog.text
|
||||
caplog.clear()
|
||||
async with async_call_logger_set_level(
|
||||
"aiohttp.access", "WARNING", hass=hass, caplog=caplog
|
||||
):
|
||||
response = await client.get("/api/states/logging.entity")
|
||||
assert response.status == HTTPStatus.OK
|
||||
assert "GET /api/states/logging.entity" not in caplog.text
|
||||
|
||||
|
||||
async def test_register_static_paths(
|
||||
|
@ -65,7 +65,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.test_mower_1_cutting_blade_usage_time',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@ -120,7 +120,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.test_mower_1_downtime',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@ -171,7 +171,6 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'no_error',
|
||||
'alarm_mower_in_motion',
|
||||
'alarm_mower_lifted',
|
||||
'alarm_mower_stopped',
|
||||
@ -180,13 +179,11 @@
|
||||
'alarm_outside_geofence',
|
||||
'angular_sensor_problem',
|
||||
'battery_problem',
|
||||
'battery_problem',
|
||||
'battery_restriction_due_to_ambient_temperature',
|
||||
'can_error',
|
||||
'charging_current_too_high',
|
||||
'charging_station_blocked',
|
||||
'charging_system_problem',
|
||||
'charging_system_problem',
|
||||
'collision_sensor_defect',
|
||||
'collision_sensor_error',
|
||||
'collision_sensor_problem_front',
|
||||
@ -197,24 +194,18 @@
|
||||
'connection_changed',
|
||||
'connection_not_changed',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_settings_restored',
|
||||
'cutting_drive_motor_1_defect',
|
||||
'cutting_drive_motor_2_defect',
|
||||
'cutting_drive_motor_3_defect',
|
||||
'cutting_height_blocked',
|
||||
'cutting_height_problem',
|
||||
'cutting_height_problem_curr',
|
||||
'cutting_height_problem_dir',
|
||||
'cutting_height_problem_drive',
|
||||
'cutting_height_problem',
|
||||
'cutting_motor_problem',
|
||||
'cutting_stopped_slope_too_steep',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_imbalance_warning',
|
||||
'cutting_system_major_imbalance',
|
||||
'destination_not_reachable',
|
||||
@ -222,13 +213,9 @@
|
||||
'docking_sensor_defect',
|
||||
'electronic_problem',
|
||||
'empty_battery',
|
||||
'error',
|
||||
'error_at_power_up',
|
||||
'fatal_error',
|
||||
'folding_cutting_deck_sensor_defect',
|
||||
'folding_sensor_activated',
|
||||
'geofence_problem',
|
||||
'geofence_problem',
|
||||
'gps_navigation_problem',
|
||||
'guide_1_not_found',
|
||||
'guide_2_not_found',
|
||||
@ -246,7 +233,6 @@
|
||||
'lift_sensor_defect',
|
||||
'lifted',
|
||||
'limited_cutting_height_range',
|
||||
'limited_cutting_height_range',
|
||||
'loop_sensor_defect',
|
||||
'loop_sensor_problem_front',
|
||||
'loop_sensor_problem_left',
|
||||
@ -259,6 +245,7 @@
|
||||
'no_accurate_position_from_satellites',
|
||||
'no_confirmed_position',
|
||||
'no_drive',
|
||||
'no_error',
|
||||
'no_loop_signal',
|
||||
'no_power_in_charging_station',
|
||||
'no_response_from_charger',
|
||||
@ -269,9 +256,6 @@
|
||||
'safety_function_faulty',
|
||||
'settings_restored',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_not_found',
|
||||
'sim_card_requires_pin',
|
||||
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
|
||||
@ -281,13 +265,6 @@
|
||||
'stuck_in_charging_station',
|
||||
'switch_cord_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'tilt_sensor_problem',
|
||||
'too_high_discharge_current',
|
||||
'too_high_internal_current',
|
||||
@ -317,6 +294,13 @@
|
||||
'wrong_loop_signal',
|
||||
'wrong_pin_code',
|
||||
'zone_generator_problem',
|
||||
'error_at_power_up',
|
||||
'error',
|
||||
'fatal_error',
|
||||
'off',
|
||||
'stopped',
|
||||
'wait_power_up',
|
||||
'wait_updating',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@ -353,7 +337,6 @@
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'Test Mower 1 Error',
|
||||
'options': list([
|
||||
'no_error',
|
||||
'alarm_mower_in_motion',
|
||||
'alarm_mower_lifted',
|
||||
'alarm_mower_stopped',
|
||||
@ -362,13 +345,11 @@
|
||||
'alarm_outside_geofence',
|
||||
'angular_sensor_problem',
|
||||
'battery_problem',
|
||||
'battery_problem',
|
||||
'battery_restriction_due_to_ambient_temperature',
|
||||
'can_error',
|
||||
'charging_current_too_high',
|
||||
'charging_station_blocked',
|
||||
'charging_system_problem',
|
||||
'charging_system_problem',
|
||||
'collision_sensor_defect',
|
||||
'collision_sensor_error',
|
||||
'collision_sensor_problem_front',
|
||||
@ -379,24 +360,18 @@
|
||||
'connection_changed',
|
||||
'connection_not_changed',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_settings_restored',
|
||||
'cutting_drive_motor_1_defect',
|
||||
'cutting_drive_motor_2_defect',
|
||||
'cutting_drive_motor_3_defect',
|
||||
'cutting_height_blocked',
|
||||
'cutting_height_problem',
|
||||
'cutting_height_problem_curr',
|
||||
'cutting_height_problem_dir',
|
||||
'cutting_height_problem_drive',
|
||||
'cutting_height_problem',
|
||||
'cutting_motor_problem',
|
||||
'cutting_stopped_slope_too_steep',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_imbalance_warning',
|
||||
'cutting_system_major_imbalance',
|
||||
'destination_not_reachable',
|
||||
@ -404,13 +379,9 @@
|
||||
'docking_sensor_defect',
|
||||
'electronic_problem',
|
||||
'empty_battery',
|
||||
'error',
|
||||
'error_at_power_up',
|
||||
'fatal_error',
|
||||
'folding_cutting_deck_sensor_defect',
|
||||
'folding_sensor_activated',
|
||||
'geofence_problem',
|
||||
'geofence_problem',
|
||||
'gps_navigation_problem',
|
||||
'guide_1_not_found',
|
||||
'guide_2_not_found',
|
||||
@ -428,7 +399,6 @@
|
||||
'lift_sensor_defect',
|
||||
'lifted',
|
||||
'limited_cutting_height_range',
|
||||
'limited_cutting_height_range',
|
||||
'loop_sensor_defect',
|
||||
'loop_sensor_problem_front',
|
||||
'loop_sensor_problem_left',
|
||||
@ -441,6 +411,7 @@
|
||||
'no_accurate_position_from_satellites',
|
||||
'no_confirmed_position',
|
||||
'no_drive',
|
||||
'no_error',
|
||||
'no_loop_signal',
|
||||
'no_power_in_charging_station',
|
||||
'no_response_from_charger',
|
||||
@ -451,9 +422,6 @@
|
||||
'safety_function_faulty',
|
||||
'settings_restored',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_not_found',
|
||||
'sim_card_requires_pin',
|
||||
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
|
||||
@ -463,13 +431,6 @@
|
||||
'stuck_in_charging_station',
|
||||
'switch_cord_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'tilt_sensor_problem',
|
||||
'too_high_discharge_current',
|
||||
'too_high_internal_current',
|
||||
@ -499,6 +460,13 @@
|
||||
'wrong_loop_signal',
|
||||
'wrong_pin_code',
|
||||
'zone_generator_problem',
|
||||
'error_at_power_up',
|
||||
'error',
|
||||
'fatal_error',
|
||||
'off',
|
||||
'stopped',
|
||||
'wait_power_up',
|
||||
'wait_updating',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@ -1280,7 +1248,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.test_mower_1_uptime',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@ -1449,7 +1417,6 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'no_error',
|
||||
'alarm_mower_in_motion',
|
||||
'alarm_mower_lifted',
|
||||
'alarm_mower_stopped',
|
||||
@ -1458,13 +1425,11 @@
|
||||
'alarm_outside_geofence',
|
||||
'angular_sensor_problem',
|
||||
'battery_problem',
|
||||
'battery_problem',
|
||||
'battery_restriction_due_to_ambient_temperature',
|
||||
'can_error',
|
||||
'charging_current_too_high',
|
||||
'charging_station_blocked',
|
||||
'charging_system_problem',
|
||||
'charging_system_problem',
|
||||
'collision_sensor_defect',
|
||||
'collision_sensor_error',
|
||||
'collision_sensor_problem_front',
|
||||
@ -1475,24 +1440,18 @@
|
||||
'connection_changed',
|
||||
'connection_not_changed',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_settings_restored',
|
||||
'cutting_drive_motor_1_defect',
|
||||
'cutting_drive_motor_2_defect',
|
||||
'cutting_drive_motor_3_defect',
|
||||
'cutting_height_blocked',
|
||||
'cutting_height_problem',
|
||||
'cutting_height_problem_curr',
|
||||
'cutting_height_problem_dir',
|
||||
'cutting_height_problem_drive',
|
||||
'cutting_height_problem',
|
||||
'cutting_motor_problem',
|
||||
'cutting_stopped_slope_too_steep',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_imbalance_warning',
|
||||
'cutting_system_major_imbalance',
|
||||
'destination_not_reachable',
|
||||
@ -1500,13 +1459,9 @@
|
||||
'docking_sensor_defect',
|
||||
'electronic_problem',
|
||||
'empty_battery',
|
||||
'error',
|
||||
'error_at_power_up',
|
||||
'fatal_error',
|
||||
'folding_cutting_deck_sensor_defect',
|
||||
'folding_sensor_activated',
|
||||
'geofence_problem',
|
||||
'geofence_problem',
|
||||
'gps_navigation_problem',
|
||||
'guide_1_not_found',
|
||||
'guide_2_not_found',
|
||||
@ -1524,7 +1479,6 @@
|
||||
'lift_sensor_defect',
|
||||
'lifted',
|
||||
'limited_cutting_height_range',
|
||||
'limited_cutting_height_range',
|
||||
'loop_sensor_defect',
|
||||
'loop_sensor_problem_front',
|
||||
'loop_sensor_problem_left',
|
||||
@ -1537,6 +1491,7 @@
|
||||
'no_accurate_position_from_satellites',
|
||||
'no_confirmed_position',
|
||||
'no_drive',
|
||||
'no_error',
|
||||
'no_loop_signal',
|
||||
'no_power_in_charging_station',
|
||||
'no_response_from_charger',
|
||||
@ -1547,9 +1502,6 @@
|
||||
'safety_function_faulty',
|
||||
'settings_restored',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_not_found',
|
||||
'sim_card_requires_pin',
|
||||
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
|
||||
@ -1559,13 +1511,6 @@
|
||||
'stuck_in_charging_station',
|
||||
'switch_cord_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'tilt_sensor_problem',
|
||||
'too_high_discharge_current',
|
||||
'too_high_internal_current',
|
||||
@ -1595,6 +1540,13 @@
|
||||
'wrong_loop_signal',
|
||||
'wrong_pin_code',
|
||||
'zone_generator_problem',
|
||||
'error_at_power_up',
|
||||
'error',
|
||||
'fatal_error',
|
||||
'off',
|
||||
'stopped',
|
||||
'wait_power_up',
|
||||
'wait_updating',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@ -1631,7 +1583,6 @@
|
||||
'device_class': 'enum',
|
||||
'friendly_name': 'Test Mower 2 Error',
|
||||
'options': list([
|
||||
'no_error',
|
||||
'alarm_mower_in_motion',
|
||||
'alarm_mower_lifted',
|
||||
'alarm_mower_stopped',
|
||||
@ -1640,13 +1591,11 @@
|
||||
'alarm_outside_geofence',
|
||||
'angular_sensor_problem',
|
||||
'battery_problem',
|
||||
'battery_problem',
|
||||
'battery_restriction_due_to_ambient_temperature',
|
||||
'can_error',
|
||||
'charging_current_too_high',
|
||||
'charging_station_blocked',
|
||||
'charging_system_problem',
|
||||
'charging_system_problem',
|
||||
'collision_sensor_defect',
|
||||
'collision_sensor_error',
|
||||
'collision_sensor_problem_front',
|
||||
@ -1657,24 +1606,18 @@
|
||||
'connection_changed',
|
||||
'connection_not_changed',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_problem',
|
||||
'connectivity_settings_restored',
|
||||
'cutting_drive_motor_1_defect',
|
||||
'cutting_drive_motor_2_defect',
|
||||
'cutting_drive_motor_3_defect',
|
||||
'cutting_height_blocked',
|
||||
'cutting_height_problem',
|
||||
'cutting_height_problem_curr',
|
||||
'cutting_height_problem_dir',
|
||||
'cutting_height_problem_drive',
|
||||
'cutting_height_problem',
|
||||
'cutting_motor_problem',
|
||||
'cutting_stopped_slope_too_steep',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_blocked',
|
||||
'cutting_system_imbalance_warning',
|
||||
'cutting_system_major_imbalance',
|
||||
'destination_not_reachable',
|
||||
@ -1682,13 +1625,9 @@
|
||||
'docking_sensor_defect',
|
||||
'electronic_problem',
|
||||
'empty_battery',
|
||||
'error',
|
||||
'error_at_power_up',
|
||||
'fatal_error',
|
||||
'folding_cutting_deck_sensor_defect',
|
||||
'folding_sensor_activated',
|
||||
'geofence_problem',
|
||||
'geofence_problem',
|
||||
'gps_navigation_problem',
|
||||
'guide_1_not_found',
|
||||
'guide_2_not_found',
|
||||
@ -1706,7 +1645,6 @@
|
||||
'lift_sensor_defect',
|
||||
'lifted',
|
||||
'limited_cutting_height_range',
|
||||
'limited_cutting_height_range',
|
||||
'loop_sensor_defect',
|
||||
'loop_sensor_problem_front',
|
||||
'loop_sensor_problem_left',
|
||||
@ -1719,6 +1657,7 @@
|
||||
'no_accurate_position_from_satellites',
|
||||
'no_confirmed_position',
|
||||
'no_drive',
|
||||
'no_error',
|
||||
'no_loop_signal',
|
||||
'no_power_in_charging_station',
|
||||
'no_response_from_charger',
|
||||
@ -1729,9 +1668,6 @@
|
||||
'safety_function_faulty',
|
||||
'settings_restored',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_locked',
|
||||
'sim_card_not_found',
|
||||
'sim_card_requires_pin',
|
||||
'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern',
|
||||
@ -1741,13 +1677,6 @@
|
||||
'stuck_in_charging_station',
|
||||
'switch_cord_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'temporary_battery_problem',
|
||||
'tilt_sensor_problem',
|
||||
'too_high_discharge_current',
|
||||
'too_high_internal_current',
|
||||
@ -1777,6 +1706,13 @@
|
||||
'wrong_loop_signal',
|
||||
'wrong_pin_code',
|
||||
'zone_generator_problem',
|
||||
'error_at_power_up',
|
||||
'error',
|
||||
'fatal_error',
|
||||
'off',
|
||||
'stopped',
|
||||
'wait_power_up',
|
||||
'wait_updating',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import async_call_logger_set_level, async_fire_time_changed
|
||||
|
||||
HASS_NS = "unused.homeassistant"
|
||||
COMPONENTS_NS = f"{HASS_NS}.components"
|
||||
@ -73,28 +73,27 @@ async def test_log_filtering(
|
||||
msg_test(filter_logger, True, "format string shouldfilter%s", "not")
|
||||
|
||||
# Filtering should work even if log level is modified
|
||||
await hass.services.async_call(
|
||||
"logger",
|
||||
"set_level",
|
||||
{"test.filter": "warning"},
|
||||
blocking=True,
|
||||
)
|
||||
assert filter_logger.getEffectiveLevel() == logging.WARNING
|
||||
msg_test(
|
||||
filter_logger,
|
||||
False,
|
||||
"this line containing shouldfilterall should still be filtered",
|
||||
)
|
||||
async with async_call_logger_set_level(
|
||||
"test.filter", "WARNING", hass=hass, caplog=caplog
|
||||
):
|
||||
assert filter_logger.getEffectiveLevel() == logging.WARNING
|
||||
msg_test(
|
||||
filter_logger,
|
||||
False,
|
||||
"this line containing shouldfilterall should still be filtered",
|
||||
)
|
||||
|
||||
# Filtering should be scoped to a service
|
||||
msg_test(
|
||||
filter_logger, True, "this line containing otherfilterer should not be filtered"
|
||||
)
|
||||
msg_test(
|
||||
logging.getLogger("test.other_filter"),
|
||||
False,
|
||||
"this line containing otherfilterer SHOULD be filtered",
|
||||
)
|
||||
# Filtering should be scoped to a service
|
||||
msg_test(
|
||||
filter_logger,
|
||||
True,
|
||||
"this line containing otherfilterer should not be filtered",
|
||||
)
|
||||
msg_test(
|
||||
logging.getLogger("test.other_filter"),
|
||||
False,
|
||||
"this line containing otherfilterer SHOULD be filtered",
|
||||
)
|
||||
|
||||
|
||||
async def test_setting_level(hass: HomeAssistant) -> None:
|
||||
|
@ -4,7 +4,7 @@ import logging
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant import loader
|
||||
from homeassistant.components.logger.helpers import async_get_domain_config
|
||||
from homeassistant.components.logger.helpers import DATA_LOGGER
|
||||
from homeassistant.components.websocket_api import TYPE_RESULT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
@ -76,7 +76,7 @@ async def test_integration_log_level(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.DEBUG
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ async def test_custom_integration_log_level(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.hue": logging.DEBUG,
|
||||
"custom_components.hue": logging.DEBUG,
|
||||
"some_other_logger": logging.DEBUG,
|
||||
@ -182,7 +182,7 @@ async def test_module_log_level(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.DEBUG,
|
||||
"homeassistant.components.other_component": logging.WARNING,
|
||||
}
|
||||
@ -199,7 +199,7 @@ async def test_module_log_level_override(
|
||||
{"logger": {"logs": {"homeassistant.components.websocket_api": "warning"}}},
|
||||
)
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.WARNING
|
||||
}
|
||||
|
||||
@ -218,7 +218,7 @@ async def test_module_log_level_override(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.ERROR
|
||||
}
|
||||
|
||||
@ -237,7 +237,7 @@ async def test_module_log_level_override(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.DEBUG
|
||||
}
|
||||
|
||||
@ -256,6 +256,6 @@ async def test_module_log_level_override(
|
||||
assert msg["type"] == TYPE_RESULT
|
||||
assert msg["success"]
|
||||
|
||||
assert async_get_domain_config(hass).overrides == {
|
||||
assert hass.data[DATA_LOGGER].overrides == {
|
||||
"homeassistant.components.websocket_api": logging.NOTSET
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
|
||||
from aionut import NUTError, NUTLoginError
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.nut.config_flow import PASSWORD_NOT_CHANGED
|
||||
from homeassistant.components.nut.const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
@ -86,7 +86,6 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_form_user_one_alias(hass: HomeAssistant) -> None:
|
||||
"""Test we can configure a device with one alias."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@ -131,8 +130,6 @@ async def test_form_user_one_alias(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_form_user_multiple_aliases(hass: HomeAssistant) -> None:
|
||||
"""Test we can configure device with multiple aliases."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "2.2.2.2", CONF_PORT: 123, CONF_RESOURCES: ["battery.charge"]},
|
||||
@ -202,7 +199,6 @@ async def test_form_user_one_alias_with_ignored_entry(hass: HomeAssistant) -> No
|
||||
)
|
||||
ignored_entry.add_to_hass(hass)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
@ -18,10 +18,12 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .util import _get_mock_nutclient, async_init_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def test_config_entry_migrations(hass: HomeAssistant) -> None:
|
||||
@ -84,6 +86,78 @@ async def test_async_setup_entry(hass: HomeAssistant) -> None:
|
||||
assert not hass.data.get(DOMAIN)
|
||||
|
||||
|
||||
async def test_remove_device_valid(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that we cannot remove a device that still exists."""
|
||||
assert await async_setup_component(hass, "config", {})
|
||||
|
||||
mock_serial_number = "A00000000000"
|
||||
config_entry = await async_init_integration(
|
||||
hass,
|
||||
username="someuser",
|
||||
password="somepassword",
|
||||
list_vars={"ups.serial": mock_serial_number},
|
||||
list_ups={"ups1": "UPS 1"},
|
||||
list_commands_return_value=[],
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
assert device_registry is not None
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_serial_number)}
|
||||
)
|
||||
|
||||
assert device_entry is not None
|
||||
assert device_entry.serial_number == mock_serial_number
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
response = await client.remove_device(device_entry.id, config_entry.entry_id)
|
||||
assert not response["success"]
|
||||
|
||||
|
||||
async def test_remove_device_stale(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test that we can remove a device that no longer exists."""
|
||||
assert await async_setup_component(hass, "config", {})
|
||||
|
||||
mock_serial_number = "A00000000000"
|
||||
config_entry = await async_init_integration(
|
||||
hass,
|
||||
username="someuser",
|
||||
password="somepassword",
|
||||
list_vars={"ups.serial": mock_serial_number},
|
||||
list_ups={"ups1": "UPS 1"},
|
||||
list_commands_return_value=[],
|
||||
)
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
assert device_registry is not None
|
||||
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "remove-device-id")},
|
||||
)
|
||||
|
||||
assert device_entry is not None
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
response = await client.remove_device(device_entry.id, config_entry.entry_id)
|
||||
assert response["success"]
|
||||
|
||||
# Verify that device entry is removed
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "remove-device-id")}
|
||||
)
|
||||
assert device_entry is None
|
||||
|
||||
|
||||
async def test_config_not_ready(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
|
@ -125,7 +125,6 @@ async def test_pdu_devices_with_unique_ids(
|
||||
_test_sensor_and_attributes(
|
||||
hass,
|
||||
entity_registry,
|
||||
model,
|
||||
unique_id=f"{unique_id_base}_input.voltage",
|
||||
device_id="sensor.ups1_input_voltage",
|
||||
state_value="122.91",
|
||||
@ -140,7 +139,6 @@ async def test_pdu_devices_with_unique_ids(
|
||||
_test_sensor_and_attributes(
|
||||
hass,
|
||||
entity_registry,
|
||||
model,
|
||||
unique_id=f"{unique_id_base}_ambient.humidity.status",
|
||||
device_id="sensor.ups1_ambient_humidity_status",
|
||||
state_value="good",
|
||||
@ -153,7 +151,6 @@ async def test_pdu_devices_with_unique_ids(
|
||||
_test_sensor_and_attributes(
|
||||
hass,
|
||||
entity_registry,
|
||||
model,
|
||||
unique_id=f"{unique_id_base}_ambient.temperature.status",
|
||||
device_id="sensor.ups1_ambient_temperature_status",
|
||||
state_value="good",
|
||||
@ -334,7 +331,6 @@ async def test_pdu_dynamic_outlets(
|
||||
_test_sensor_and_attributes(
|
||||
hass,
|
||||
entity_registry,
|
||||
model,
|
||||
unique_id=f"{unique_id_base}_outlet.1.current",
|
||||
device_id="sensor.ups1_outlet_a1_current",
|
||||
state_value="0",
|
||||
@ -348,7 +344,6 @@ async def test_pdu_dynamic_outlets(
|
||||
_test_sensor_and_attributes(
|
||||
hass,
|
||||
entity_registry,
|
||||
model,
|
||||
unique_id=f"{unique_id_base}_outlet.24.current",
|
||||
device_id="sensor.ups1_outlet_a24_current",
|
||||
state_value="0.19",
|
||||
|
@ -43,7 +43,7 @@ async def async_init_integration(
|
||||
hass: HomeAssistant,
|
||||
ups_fixture: str | None = None,
|
||||
host: str = "mock",
|
||||
port: str = "mock",
|
||||
port: int = 1234,
|
||||
username: str = "mock",
|
||||
password: str = "mock",
|
||||
alias: str | None = None,
|
||||
@ -104,7 +104,6 @@ async def async_init_integration(
|
||||
def _test_sensor_and_attributes(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
model: str,
|
||||
unique_id: str,
|
||||
device_id: str,
|
||||
state_value: str,
|
||||
|
@ -40,6 +40,7 @@ TEST_GATEWAY_ID3 = "SOMFY_PROTECT-v0NT53occUBPyuJRzx59kalW1hFfzimN"
|
||||
|
||||
TEST_HOST = "gateway-1234-5678-9123.local:8443"
|
||||
TEST_HOST2 = "192.168.11.104:8443"
|
||||
TEST_TOKEN = "1234123412341234"
|
||||
|
||||
MOCK_GATEWAY_RESPONSE = [Mock(id=TEST_GATEWAY_ID)]
|
||||
MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID3), Mock(id=TEST_GATEWAY_ID2)]
|
||||
@ -152,7 +153,7 @@ async def test_form_only_cloud_supported(
|
||||
async def test_form_local_happy_flow(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
"""Test local API configuration flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
@ -179,21 +180,27 @@ async def test_form_local_happy_flow(
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=True),
|
||||
generate_local_token=AsyncMock(return_value="1234123412341234"),
|
||||
activate_local_token=AsyncMock(return_value=True),
|
||||
):
|
||||
await hass.config_entries.flow.async_configure(
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"host": "gateway-1234-5678-1234.local:8443",
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result4["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result4["title"] == "gateway-1234-5678-1234.local:8443"
|
||||
assert result4["data"] == {
|
||||
"host": "gateway-1234-5678-1234.local:8443",
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": True,
|
||||
"hub": TEST_SERVER,
|
||||
"api_type": "local",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
@ -262,7 +269,7 @@ async def test_form_invalid_auth_cloud(
|
||||
(MaintenanceException, "server_in_maintenance"),
|
||||
(TooManyAttemptsBannedException, "too_many_attempts"),
|
||||
(UnknownUserException, "unsupported_hardware"),
|
||||
(NotSuchTokenException, "no_such_token"),
|
||||
(NotSuchTokenException, "invalid_auth"),
|
||||
(Exception, "unknown"),
|
||||
],
|
||||
)
|
||||
@ -297,8 +304,7 @@ async def test_form_invalid_auth_local(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": TEST_HOST,
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
@ -309,52 +315,6 @@ async def test_form_invalid_auth_local(
|
||||
assert result4["errors"] == {"base": error}
|
||||
|
||||
|
||||
async def test_form_local_developer_mode_disabled(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"hub": TEST_SERVER},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["step_id"] == "local_or_cloud"
|
||||
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_type": "local"},
|
||||
)
|
||||
|
||||
assert result3["type"] is FlowResultType.FORM
|
||||
assert result3["step_id"] == "local"
|
||||
|
||||
with patch.multiple(
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=None),
|
||||
):
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"host": "gateway-1234-5678-1234.local:8443",
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result4["type"] is FlowResultType.FORM
|
||||
assert result4["errors"] == {"base": "developer_mode_disabled"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
@ -444,16 +404,18 @@ async def test_cloud_abort_on_duplicate_entry(
|
||||
async def test_local_abort_on_duplicate_entry(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
"""Test local API configuration is aborted if gateway already exists."""
|
||||
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_GATEWAY_ID,
|
||||
version=2,
|
||||
data={
|
||||
"host": TEST_HOST,
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": True,
|
||||
"hub": TEST_SERVER,
|
||||
"api_type": "local",
|
||||
},
|
||||
).add_to_hass(hass)
|
||||
|
||||
@ -484,15 +446,12 @@ async def test_local_abort_on_duplicate_entry(
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=True),
|
||||
generate_local_token=AsyncMock(return_value="1234123412341234"),
|
||||
activate_local_token=AsyncMock(return_value=True),
|
||||
):
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": TEST_HOST,
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
@ -639,18 +598,18 @@ async def test_cloud_reauth_wrong_account(hass: HomeAssistant) -> None:
|
||||
assert result2["reason"] == "reauth_wrong_account"
|
||||
|
||||
|
||||
async def test_local_reauth_success(hass: HomeAssistant) -> None:
|
||||
"""Test reauthentication flow."""
|
||||
|
||||
async def test_local_reauth_legacy(hass: HomeAssistant) -> None:
|
||||
"""Test legacy reauthentication flow with username/password."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_GATEWAY_ID,
|
||||
version=2,
|
||||
data={
|
||||
"host": TEST_HOST,
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"verify_ssl": True,
|
||||
"hub": TEST_SERVER,
|
||||
"host": TEST_HOST,
|
||||
"api_type": "local",
|
||||
},
|
||||
)
|
||||
@ -672,36 +631,85 @@ async def test_local_reauth_success(hass: HomeAssistant) -> None:
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=True),
|
||||
generate_local_token=AsyncMock(return_value="1234123412341234"),
|
||||
activate_local_token=AsyncMock(return_value=True),
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD2,
|
||||
{
|
||||
"host": TEST_HOST,
|
||||
"token": "new_token",
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
assert mock_entry.data["username"] == TEST_EMAIL
|
||||
assert mock_entry.data["password"] == TEST_PASSWORD2
|
||||
assert mock_entry.data["host"] == TEST_HOST
|
||||
assert mock_entry.data["token"] == "new_token"
|
||||
assert mock_entry.data["verify_ssl"] is True
|
||||
|
||||
|
||||
async def test_local_reauth_success(hass: HomeAssistant) -> None:
|
||||
"""Test modern local reauth flow."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_GATEWAY_ID,
|
||||
version=2,
|
||||
data={
|
||||
"host": TEST_HOST,
|
||||
"token": "old_token",
|
||||
"verify_ssl": True,
|
||||
"hub": TEST_SERVER,
|
||||
"api_type": "local",
|
||||
},
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
result = await mock_entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "local_or_cloud"
|
||||
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"api_type": "local"},
|
||||
)
|
||||
|
||||
assert result2["step_id"] == "local"
|
||||
|
||||
with patch.multiple(
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": TEST_HOST,
|
||||
"token": "new_token",
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
assert mock_entry.data["host"] == TEST_HOST
|
||||
assert mock_entry.data["token"] == "new_token"
|
||||
assert mock_entry.data["verify_ssl"] is True
|
||||
assert "username" not in mock_entry.data
|
||||
assert "password" not in mock_entry.data
|
||||
|
||||
|
||||
async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
|
||||
"""Test reauthentication flow."""
|
||||
"""Test local reauth flow with wrong gateway account."""
|
||||
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=TEST_GATEWAY_ID2,
|
||||
version=2,
|
||||
data={
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"hub": TEST_SERVER,
|
||||
"host": TEST_HOST,
|
||||
"token": "old_token",
|
||||
"verify_ssl": True,
|
||||
"hub": TEST_SERVER,
|
||||
"api_type": "local",
|
||||
},
|
||||
)
|
||||
@ -722,15 +730,13 @@ async def test_local_reauth_wrong_account(hass: HomeAssistant) -> None:
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=True),
|
||||
generate_local_token=AsyncMock(return_value="1234123412341234"),
|
||||
activate_local_token=AsyncMock(return_value=True),
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD2,
|
||||
{
|
||||
"host": TEST_HOST,
|
||||
"token": "new_token",
|
||||
"verify_ssl": True,
|
||||
},
|
||||
)
|
||||
|
||||
@ -897,27 +903,27 @@ async def test_local_zeroconf_flow(
|
||||
"pyoverkiz.client.OverkizClient",
|
||||
login=AsyncMock(return_value=True),
|
||||
get_gateways=AsyncMock(return_value=MOCK_GATEWAY_RESPONSE),
|
||||
get_setup_option=AsyncMock(return_value=True),
|
||||
generate_local_token=AsyncMock(return_value="1234123412341234"),
|
||||
activate_local_token=AsyncMock(return_value=True),
|
||||
):
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{"username": TEST_EMAIL, "password": TEST_PASSWORD, "verify_ssl": False},
|
||||
{
|
||||
"host": "gateway-1234-5678-9123.local:8443",
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result4["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result4["title"] == "gateway-1234-5678-9123.local:8443"
|
||||
assert result4["data"] == {
|
||||
"username": TEST_EMAIL,
|
||||
"password": TEST_PASSWORD,
|
||||
"hub": TEST_SERVER,
|
||||
"host": "gateway-1234-5678-9123.local:8443",
|
||||
"api_type": "local",
|
||||
"token": "1234123412341234",
|
||||
"verify_ssl": False,
|
||||
}
|
||||
|
||||
# Verify no username/password in data
|
||||
assert result4["data"] == {
|
||||
"host": "gateway-1234-5678-9123.local:8443",
|
||||
"token": TEST_TOKEN,
|
||||
"verify_ssl": False,
|
||||
"hub": TEST_SERVER,
|
||||
"api_type": "local",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
|
@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.smarty import DOMAIN
|
||||
from homeassistant.components.smarty.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
@ -3,8 +3,8 @@
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant.components.smarty.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@ -114,52 +114,3 @@ async def test_existing_entry(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_import_flow(
|
||||
hass: HomeAssistant, mock_smarty: AsyncMock, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test the import flow."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "Smarty"
|
||||
assert result["data"] == {CONF_HOST: "192.168.0.2"}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_import_cannot_connect(
|
||||
hass: HomeAssistant, mock_smarty: AsyncMock
|
||||
) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
|
||||
mock_smarty.update.return_value = False
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_import_unknown_error(
|
||||
hass: HomeAssistant, mock_smarty: AsyncMock
|
||||
) -> None:
|
||||
"""Test we handle unknown error."""
|
||||
|
||||
mock_smarty.update.side_effect = Exception
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data={CONF_HOST: "192.168.0.2", CONF_NAME: "Smarty"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "unknown"
|
||||
|
@ -4,68 +4,15 @@ from unittest.mock import AsyncMock
|
||||
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.smarty import DOMAIN
|
||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.components.smarty.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_import_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_smarty: AsyncMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test import flow."""
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues
|
||||
|
||||
|
||||
async def test_import_flow_already_exists(
|
||||
hass: HomeAssistant,
|
||||
mock_smarty: AsyncMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test import flow when entry already exists."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
|
||||
assert (HOMEASSISTANT_DOMAIN, "deprecated_yaml_smarty") in issue_registry.issues
|
||||
|
||||
|
||||
async def test_import_flow_error(
|
||||
hass: HomeAssistant,
|
||||
mock_smarty: AsyncMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
mock_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test import flow when error occurs."""
|
||||
mock_smarty.update.return_value = False
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_HOST: "192.168.0.2", CONF_NAME: "smarty"}}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(hass.config_entries.async_entries(DOMAIN)) == 0
|
||||
assert (
|
||||
DOMAIN,
|
||||
"deprecated_yaml_import_issue_cannot_connect",
|
||||
) in issue_registry.issues
|
||||
|
||||
|
||||
async def test_device(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
|
@ -463,3 +463,28 @@ HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||
connectable=True,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
|
||||
WOSTRIP_SERVICE_INFO = BluetoothServiceInfoBleak(
|
||||
name="WoStrip",
|
||||
address="AA:BB:CC:DD:EE:FF",
|
||||
manufacturer_data={
|
||||
2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00'
|
||||
},
|
||||
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"},
|
||||
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
rssi=-60,
|
||||
source="local",
|
||||
advertisement=generate_advertisement_data(
|
||||
local_name="WoStrip",
|
||||
manufacturer_data={
|
||||
2409: b'\x84\xf7\x03\xb3?\xde\x04\xe4"\x0c\x00\x00\x00\x00\x00\x00'
|
||||
},
|
||||
service_data={"00000d00-0000-1000-8000-00805f9b34fb": b"r\x00d"},
|
||||
service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"],
|
||||
),
|
||||
device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoStrip"),
|
||||
time=0,
|
||||
connectable=True,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
139
tests/components/switchbot/test_light.py
Normal file
139
tests/components/switchbot/test_light.py
Normal 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)
|
@ -88,7 +88,7 @@
|
||||
}),
|
||||
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
|
||||
'original_icon': 'mdi:battery-unknown',
|
||||
'original_name': 'Off grid reserve',
|
||||
'original_name': 'Off-grid reserve',
|
||||
'platform': 'tesla_fleet',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
@ -101,7 +101,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'battery',
|
||||
'friendly_name': 'Energy Site Off grid reserve',
|
||||
'friendly_name': 'Energy Site Off-grid reserve',
|
||||
'icon': 'mdi:battery-unknown',
|
||||
'max': 100,
|
||||
'min': 0,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user