mirror of
https://github.com/home-assistant/core.git
synced 2025-08-04 21:25:13 +02:00
Merge branch 'dev' into aioautomower202541
This commit is contained in:
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -324,7 +324,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3.8.1
|
||||
uses: sigstore/cosign-installer@v3.8.2
|
||||
with:
|
||||
cosign-release: "v2.2.3"
|
||||
|
||||
|
@@ -363,6 +363,7 @@ homeassistant.components.no_ip.*
|
||||
homeassistant.components.nordpool.*
|
||||
homeassistant.components.notify.*
|
||||
homeassistant.components.notion.*
|
||||
homeassistant.components.ntfy.*
|
||||
homeassistant.components.number.*
|
||||
homeassistant.components.nut.*
|
||||
homeassistant.components.ohme.*
|
||||
|
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/nsw_fuel_station/ @nickw444
|
||||
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||
/tests/components/nsw_rural_fire_service_feed/ @exxamalte
|
||||
/homeassistant/components/ntfy/ @tr4nt0r
|
||||
/tests/components/ntfy/ @tr4nt0r
|
||||
/homeassistant/components/nuheat/ @tstabrawa
|
||||
/tests/components/nuheat/ @tstabrawa
|
||||
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import timeout
|
||||
from collections.abc import Mapping
|
||||
from http import HTTPStatus
|
||||
import json
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -260,10 +260,10 @@ async def async_enable_proactive_mode(
|
||||
def extra_significant_check(
|
||||
hass: HomeAssistant,
|
||||
old_state: str,
|
||||
old_attrs: dict[Any, Any] | MappingProxyType[Any, Any],
|
||||
old_attrs: Mapping[Any, Any],
|
||||
old_extra_arg: Any,
|
||||
new_state: str,
|
||||
new_attrs: dict[str, Any] | MappingProxyType[Any, Any],
|
||||
new_attrs: Mapping[Any, Any],
|
||||
new_extra_arg: Any,
|
||||
) -> bool:
|
||||
"""Check if the serialized data has changed."""
|
||||
|
@@ -3,12 +3,12 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"tracked_addons": "Addons",
|
||||
"tracked_addons": "Add-ons",
|
||||
"tracked_integrations": "Integrations",
|
||||
"tracked_custom_integrations": "Custom integrations"
|
||||
},
|
||||
"data_description": {
|
||||
"tracked_addons": "Select the addons you want to track",
|
||||
"tracked_addons": "Select the add-ons you want to track",
|
||||
"tracked_integrations": "Select the integrations you want to track",
|
||||
"tracked_custom_integrations": "Select the custom integrations you want to track"
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
@@ -175,7 +176,7 @@ class AnthropicOptionsFlow(OptionsFlow):
|
||||
|
||||
def anthropic_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
options: dict[str, Any] | MappingProxyType[str, Any],
|
||||
options: Mapping[str, Any],
|
||||
) -> dict:
|
||||
"""Return a schema for Anthropic completion options."""
|
||||
hass_apis: list[SelectOptionDict] = [
|
||||
|
@@ -265,21 +265,21 @@ async def _transform_stream(
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
tool_block = cast(ToolUseBlockParam, current_block)
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
tool_block["input"] = tool_args
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=tool_block["id"],
|
||||
tool_name=tool_block["name"],
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
LOGGER.debug("Thinking: %s", thinking_block["thinking"])
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
|
@@ -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"
|
||||
|
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from pyasuswrt import AsusWrtError
|
||||
@@ -363,7 +362,7 @@ class AsusWrtRouter:
|
||||
"""Add a function to call when router is closed."""
|
||||
self._on_close.append(func)
|
||||
|
||||
def update_options(self, new_options: MappingProxyType[str, Any]) -> bool:
|
||||
def update_options(self, new_options: Mapping[str, Any]) -> bool:
|
||||
"""Update router options."""
|
||||
req_reload = False
|
||||
for name, new_opt in new_options.items():
|
||||
|
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"]
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
api = await get_axis_api(self.hass, MappingProxyType(user_input))
|
||||
api = await get_axis_api(self.hass, user_input)
|
||||
|
||||
except AuthenticationRequired:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
@@ -1,7 +1,7 @@
|
||||
"""Axis network device abstraction."""
|
||||
|
||||
from asyncio import timeout
|
||||
from types import MappingProxyType
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import axis
|
||||
@@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect
|
||||
|
||||
async def get_axis_api(
|
||||
hass: HomeAssistant,
|
||||
config: MappingProxyType[str, Any],
|
||||
config: Mapping[str, Any],
|
||||
) -> axis.AxisDevice:
|
||||
"""Create a Axis device API."""
|
||||
session = get_async_client(hass, verify_ssl=False)
|
||||
|
@@ -3,11 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from azure.eventhub import EventData, EventDataBatch
|
||||
@@ -179,7 +178,7 @@ class AzureEventHub:
|
||||
await self.async_send(None)
|
||||
await self._queue.join()
|
||||
|
||||
def update_options(self, new_options: MappingProxyType[str, Any]) -> None:
|
||||
def update_options(self, new_options: Mapping[str, Any]) -> None:
|
||||
"""Update options."""
|
||||
self._send_interval = new_options[CONF_SEND_INTERVAL]
|
||||
|
||||
|
@@ -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",
|
||||
|
@@ -8,46 +8,18 @@ from typing import Final
|
||||
|
||||
from canary.api import Api
|
||||
from requests.exceptions import ConnectTimeout, HTTPError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_IMPORT
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT
|
||||
from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
|
||||
|
||||
_LOGGER: Final = logging.getLogger(__name__)
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30)
|
||||
|
||||
CONFIG_SCHEMA: Final = vol.Schema(
|
||||
vol.All(
|
||||
cv.deprecated(DOMAIN),
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(
|
||||
CONF_TIMEOUT, default=DEFAULT_TIMEOUT
|
||||
): cv.positive_int,
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
PLATFORMS: Final[list[Platform]] = [
|
||||
Platform.ALARM_CONTROL_PANEL,
|
||||
Platform.CAMERA,
|
||||
@@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Canary integration."""
|
||||
if hass.config_entries.async_entries(DOMAIN):
|
||||
return True
|
||||
|
||||
ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS
|
||||
if CAMERA_DOMAIN in config:
|
||||
camera_config = next(
|
||||
(item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN),
|
||||
None,
|
||||
)
|
||||
|
||||
if camera_config:
|
||||
ffmpeg_arguments = camera_config.get(
|
||||
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
|
||||
)
|
||||
|
||||
if DOMAIN in config:
|
||||
if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS:
|
||||
config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config[DOMAIN],
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
|
||||
"""Set up Canary from a config entry."""
|
||||
if not entry.options:
|
||||
|
@@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return CanaryOptionsFlowHandler()
|
||||
|
||||
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Handle a flow initiated by configuration file."""
|
||||
return await self.async_step_user(import_data)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/chacon_dio",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["dio_chacon_api"],
|
||||
"requirements": ["dio-chacon-wifi-api==1.2.1"]
|
||||
"requirements": ["dio-chacon-wifi-api==1.2.2"]
|
||||
}
|
||||
|
@@ -3,8 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from devolo_home_control_api.exceptions.gateway import GatewayOfflineError
|
||||
@@ -97,7 +97,7 @@ async def async_remove_config_entry_device(
|
||||
return True
|
||||
|
||||
|
||||
def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo:
|
||||
def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo:
|
||||
"""Configure mydevolo."""
|
||||
mydevolo = Mydevolo()
|
||||
mydevolo.user = conf[CONF_USERNAME]
|
||||
|
@@ -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))
|
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from types import MappingProxyType
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from dynalite_devices_lib.dynalite_devices import (
|
||||
@@ -50,7 +49,7 @@ class DynaliteBridge:
|
||||
LOGGER.debug("Setting up bridge - host %s", self.host)
|
||||
return await self.dynalite_devices.async_setup()
|
||||
|
||||
def reload_config(self, config: MappingProxyType[str, Any]) -> None:
|
||||
def reload_config(self, config: Mapping[str, Any]) -> None:
|
||||
"""Reconfigure a bridge when config changes."""
|
||||
LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config)
|
||||
self.dynalite_devices.configure(convert_config(config))
|
||||
|
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from dynalite_devices_lib import const as dyn_const
|
||||
@@ -138,9 +138,7 @@ def convert_template(config: dict[str, Any]) -> dict[str, Any]:
|
||||
return convert_with_map(config, my_map)
|
||||
|
||||
|
||||
def convert_config(
|
||||
config: dict[str, Any] | MappingProxyType[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
def convert_config(config: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Convert a config dict by replacing component consts with library consts."""
|
||||
my_map = {
|
||||
CONF_NAME: dyn_const.CONF_NAME,
|
||||
|
@@ -2,8 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from elevenlabs import AsyncElevenLabs
|
||||
@@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
|
||||
def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings:
|
||||
"""Return voice settings."""
|
||||
return VoiceSettings(
|
||||
stability=options.get(CONF_STABILITY, DEFAULT_STABILITY),
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from elkm1_lib.elements import Element
|
||||
@@ -235,7 +234,7 @@ def _async_find_matching_config_entry(
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool:
|
||||
"""Set up Elk-M1 Control from a config entry."""
|
||||
conf: MappingProxyType[str, Any] = entry.data
|
||||
conf = entry.data
|
||||
|
||||
host = hostname_from_url(entry.data[CONF_HOST])
|
||||
|
||||
|
@@ -293,9 +293,9 @@ async def ws_get_fossil_energy_consumption(
|
||||
if statistics_id not in statistic_ids:
|
||||
continue
|
||||
for period in stat:
|
||||
if period["change"] is None:
|
||||
if (change := period.get("change")) is None:
|
||||
continue
|
||||
result[period["start"]] += period["change"]
|
||||
result[period["start"]] += change
|
||||
|
||||
return {key: result[key] for key in sorted(result)}
|
||||
|
||||
|
@@ -6,6 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyenphase==1.25.5"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
@@ -1,31 +1,19 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: done
|
||||
status: exempt
|
||||
comment: only actions implemented are platform native ones.
|
||||
appropriate-polling:
|
||||
status: done
|
||||
comment: fixed 1 minute cycle based on Enphase Envoy device characteristics
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions
|
||||
docs-high-level-description:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy
|
||||
docs-installation-instructions:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#prerequisites
|
||||
docs-removal-instructions:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#removing-the-integration
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: no events used.
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
@@ -34,24 +22,14 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: todo
|
||||
comment: |
|
||||
needs to raise appropriate error when exception occurs.
|
||||
Pending https://github.com/pyenphase/pyenphase/pull/194
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#configuration
|
||||
docs-installation-parameters:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#required-manual-input
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates:
|
||||
status: done
|
||||
comment: pending https://github.com/home-assistant/core/pull/132373
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -60,22 +38,14 @@ rules:
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates
|
||||
docs-examples:
|
||||
status: todo
|
||||
comment: add blue-print examples, if any
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#supported-devices
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting:
|
||||
status: done
|
||||
comment: https://www.home-assistant.io/integrations/enphase_envoy#troubleshooting
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -86,7 +56,7 @@ rules:
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: no general issues or repair.py
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
|
@@ -336,6 +336,12 @@ class EsphomeAssistSatellite(
|
||||
"code": event.data["code"],
|
||||
"message": event.data["message"],
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
|
||||
assert event.data is not None
|
||||
if tts_output := event.data["tts_output"]:
|
||||
path = tts_output["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
|
||||
if self._tts_streaming_task is None:
|
||||
# No TTS
|
||||
|
@@ -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,
|
||||
)
|
||||
|
||||
@@ -612,10 +662,12 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return ERROR_REQUIRES_ENCRYPTION_KEY
|
||||
except InvalidEncryptionKeyAPIError as ex:
|
||||
if ex.received_name:
|
||||
device_name_changed = self._device_name != ex.received_name
|
||||
self._device_name = ex.received_name
|
||||
if ex.received_mac:
|
||||
self._device_mac = format_mac(ex.received_mac)
|
||||
self._name = ex.received_name
|
||||
if not self._name or device_name_changed:
|
||||
self._name = ex.received_name
|
||||
return ERROR_INVALID_ENCRYPTION_KEY
|
||||
except ResolveAPIError:
|
||||
return "resolve_error"
|
||||
@@ -623,9 +675,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 +692,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.",
|
||||
@@ -17,10 +19,11 @@
|
||||
"reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)."
|
||||
},
|
||||
"error": {
|
||||
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",
|
||||
"connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.",
|
||||
"resolve_error": "Unable to resolve the address of the ESPHome device. If this issue continues, consider setting a static IP address.",
|
||||
"connection_error": "Unable to connect to the ESPHome device. Make sure the device’s YAML configuration includes an `api` section.",
|
||||
"requires_encryption_key": "The ESPHome device requires an encryption key. Enter the key defined in the device’s YAML configuration under `api -> encryption -> key`.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_psk": "The transport encryption key is invalid. Please ensure it matches what you have in your configuration"
|
||||
"invalid_psk": "The encryption key is invalid. Make sure it matches the value in the device’s YAML configuration under `api -> encryption -> key`."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -41,7 +44,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 +53,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 +62,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?",
|
||||
|
@@ -72,7 +72,11 @@ async def get_hosts_list_if_supported(
|
||||
supports_hosts: bool = True
|
||||
fbx_devices: list[dict[str, Any]] = []
|
||||
try:
|
||||
fbx_devices = await fbx_api.lan.get_hosts_list() or []
|
||||
fbx_interfaces = await fbx_api.lan.get_interfaces() or []
|
||||
for interface in fbx_interfaces:
|
||||
fbx_devices.extend(
|
||||
await fbx_api.lan.get_hosts_list(interface["name"]) or []
|
||||
)
|
||||
except HttpRequestError as err:
|
||||
if (
|
||||
(matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err)))
|
||||
|
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, ValuesView
|
||||
from collections.abc import Callable, Mapping, ValuesView
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
import logging
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
from fritzconnection import FritzConnection
|
||||
@@ -187,7 +186,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
)
|
||||
|
||||
self._devices: dict[str, FritzDevice] = {}
|
||||
self._options: MappingProxyType[str, Any] | None = None
|
||||
self._options: Mapping[str, Any] | None = None
|
||||
self._unique_id: str | None = None
|
||||
self.connection: FritzConnection = None
|
||||
self.fritz_guest_wifi: FritzGuestWLAN = None
|
||||
@@ -213,9 +212,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
str, Callable[[FritzStatus, StateType], Any]
|
||||
] = {}
|
||||
|
||||
async def async_setup(
|
||||
self, options: MappingProxyType[str, Any] | None = None
|
||||
) -> None:
|
||||
async def async_setup(self, options: Mapping[str, Any] | None = None) -> None:
|
||||
"""Wrap up FritzboxTools class setup."""
|
||||
self._options = options
|
||||
await self.hass.async_add_executor_job(self.setup)
|
||||
|
@@ -53,8 +53,11 @@ MAX_TEMPERATURE = 28
|
||||
# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome)
|
||||
ON_API_TEMPERATURE = 127.0
|
||||
OFF_API_TEMPERATURE = 126.5
|
||||
ON_REPORT_SET_TEMPERATURE = 30.0
|
||||
OFF_REPORT_SET_TEMPERATURE = 0.0
|
||||
PRESET_API_HKR_STATE_MAPPING = {
|
||||
PRESET_COMFORT: "comfort",
|
||||
PRESET_BOOST: "on",
|
||||
PRESET_ECO: "eco",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -128,29 +131,28 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return self.data.actual_temperature # type: ignore [no-any-return]
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
def target_temperature(self) -> float | None:
|
||||
"""Return the temperature we try to reach."""
|
||||
if self.data.target_temperature == ON_API_TEMPERATURE:
|
||||
return ON_REPORT_SET_TEMPERATURE
|
||||
if self.data.target_temperature == OFF_API_TEMPERATURE:
|
||||
return OFF_REPORT_SET_TEMPERATURE
|
||||
if self.data.target_temperature in [ON_API_TEMPERATURE, OFF_API_TEMPERATURE]:
|
||||
return None
|
||||
return self.data.target_temperature # type: ignore [no-any-return]
|
||||
|
||||
async def async_set_hkr_state(self, hkr_state: str) -> None:
|
||||
"""Set the state of the climate."""
|
||||
await self.hass.async_add_executor_job(self.data.set_hkr_state, hkr_state, True)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperature."""
|
||||
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF:
|
||||
await self.async_set_hvac_mode(hvac_mode)
|
||||
if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF:
|
||||
await self.async_set_hkr_state("off")
|
||||
elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
|
||||
if target_temp == OFF_API_TEMPERATURE:
|
||||
target_temp = OFF_REPORT_SET_TEMPERATURE
|
||||
elif target_temp == ON_API_TEMPERATURE:
|
||||
target_temp = ON_REPORT_SET_TEMPERATURE
|
||||
await self.hass.async_add_executor_job(
|
||||
self.data.set_target_temperature, target_temp, True
|
||||
)
|
||||
await self.coordinator.async_refresh()
|
||||
else:
|
||||
return
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
@@ -159,10 +161,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
return HVACMode.HEAT
|
||||
if self.data.summer_active:
|
||||
return HVACMode.OFF
|
||||
if self.data.target_temperature in (
|
||||
OFF_REPORT_SET_TEMPERATURE,
|
||||
OFF_API_TEMPERATURE,
|
||||
):
|
||||
if self.data.target_temperature == OFF_API_TEMPERATURE:
|
||||
return HVACMode.OFF
|
||||
|
||||
return HVACMode.HEAT
|
||||
@@ -180,7 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
)
|
||||
return
|
||||
if hvac_mode is HVACMode.OFF:
|
||||
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
|
||||
await self.async_set_hkr_state("off")
|
||||
else:
|
||||
if value_scheduled_preset(self.data) == PRESET_ECO:
|
||||
target_temp = self.data.eco_temperature
|
||||
@@ -210,12 +209,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="change_preset_while_active_mode",
|
||||
)
|
||||
if preset_mode == PRESET_COMFORT:
|
||||
await self.async_set_temperature(temperature=self.data.comfort_temperature)
|
||||
elif preset_mode == PRESET_ECO:
|
||||
await self.async_set_temperature(temperature=self.data.eco_temperature)
|
||||
elif preset_mode == PRESET_BOOST:
|
||||
await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE)
|
||||
await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode])
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> ClimateExtraAttributes:
|
||||
|
@@ -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",
|
||||
|
119
homeassistant/components/google/quality_scale.yaml
Normal file
119
homeassistant/components/google/quality_scale.yaml
Normal file
@@ -0,0 +1,119 @@
|
||||
rules:
|
||||
# Bronze
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Some fields missing data_description in the option flow.
|
||||
brands: done
|
||||
dependency-transparency:
|
||||
status: todo
|
||||
comment: |
|
||||
This depends on the legacy (deprecated) oauth libraries for device
|
||||
auth (no longer recommended auth). Google publishes to pypi using
|
||||
an internal build system. We need to either revisit approach or
|
||||
revisit our stance on this.
|
||||
common-modules: done
|
||||
has-entity-name: done
|
||||
action-setup:
|
||||
status: todo
|
||||
comment: |
|
||||
Actions are current setup in `async_setup_entry` and need to be moved
|
||||
to `async_setup`.
|
||||
appropriate-polling: done
|
||||
test-before-configure: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration does not subscribe to events.
|
||||
unique-config-entry: done
|
||||
entity-unique-id: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
test-before-setup:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration does not test the connection in `async_setup_entry` but
|
||||
instead does this in the calendar platform only, which can be improved.
|
||||
docs-high-level-description: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
The config flow has 100% test coverage, however there are opportunities
|
||||
to increase functionality such as checking for the specific contents
|
||||
of a unique id assigned to a config entry.
|
||||
docs-actions: done
|
||||
runtime-data:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration stores config entry data in `hass.data` and should be
|
||||
updated to use `runtime_data`.
|
||||
|
||||
# Silver
|
||||
log-when-unavailable: done
|
||||
config-entry-unloading: done
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
The integration supports reauthentication, however the config flow test
|
||||
coverage can be improved on reauth corner cases.
|
||||
action-exceptions: done
|
||||
docs-installation-parameters: todo
|
||||
integration-owner: done
|
||||
parallel-updates: todo
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: One module needs an additional line of coverage to be above the bar
|
||||
docs-configuration-parameters: todo
|
||||
entity-unavailable: done
|
||||
|
||||
# Gold
|
||||
docs-examples: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Google calendar does not support discovery
|
||||
entity-device-class: todo
|
||||
entity-translations: todo
|
||||
docs-data-update: todo
|
||||
entity-disabled-by-default: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Google calendar does not support discovery
|
||||
exception-translations: todo
|
||||
devices: todo
|
||||
docs-supported-devices: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: Google calendar does not have any icons
|
||||
docs-known-limitations: todo
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Google calendar does not have devices
|
||||
docs-supported-functions: done
|
||||
repair-issues:
|
||||
status: todo
|
||||
comment: There are some warnings/deprecations that should be repair issues
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: There is nothing to configure in the configuration flow
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: The entities in google calendar do not support categories
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Google calendar does not have devices
|
||||
docs-troubleshooting: todo
|
||||
diagnostics: todo
|
||||
docs-use-cases: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency:
|
||||
status: done
|
||||
comment: |
|
||||
The main client `gcal_sync` library is async. The primary authentication
|
||||
used in config flow is handled by built in async OAuth code. The
|
||||
integration still supports legacy OAuth credentials setup in the
|
||||
configuration flow, which is no longer recommended or described in the
|
||||
documentation for new users. This legacy config flow uses oauth2client
|
||||
which is not natively async.
|
||||
strict-typing:
|
||||
status: todo
|
||||
comment: Dependency oauth2client does not confirm to PEP 561
|
||||
inject-websession: done
|
@@ -208,7 +208,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
options: dict[str, Any] | MappingProxyType[str, Any],
|
||||
options: Mapping[str, Any],
|
||||
genai_client: genai.Client,
|
||||
) -> dict:
|
||||
"""Return a schema for Google Generative AI completion options."""
|
||||
|
@@ -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:
|
||||
|
@@ -3,7 +3,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Create Group",
|
||||
"title": "Create group",
|
||||
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
|
||||
"menu_options": {
|
||||
"binary_sensor": "Binary sensor group",
|
||||
@@ -104,7 +104,7 @@
|
||||
"round_digits": "Round value to number of decimals",
|
||||
"device_class": "Device class",
|
||||
"state_class": "State class",
|
||||
"unit_of_measurement": "Unit of Measurement"
|
||||
"unit_of_measurement": "Unit of measurement"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -51,7 +51,6 @@ from homeassistant.helpers.hassio import (
|
||||
get_supervisor_ip as _get_supervisor_ip,
|
||||
is_hassio as _is_hassio,
|
||||
)
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.service_info.hassio import (
|
||||
HassioServiceInfo as _HassioServiceInfo,
|
||||
)
|
||||
@@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
SERVICE_ADDON_START = "addon_start"
|
||||
SERVICE_ADDON_STOP = "addon_stop"
|
||||
SERVICE_ADDON_RESTART = "addon_restart"
|
||||
SERVICE_ADDON_UPDATE = "addon_update"
|
||||
SERVICE_ADDON_STDIN = "addon_stdin"
|
||||
SERVICE_HOST_SHUTDOWN = "host_shutdown"
|
||||
SERVICE_HOST_REBOOT = "host_reboot"
|
||||
@@ -241,7 +239,6 @@ MAP_SERVICE_API = {
|
||||
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON),
|
||||
SERVICE_ADDON_STDIN: APIEndpointSettings(
|
||||
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
|
||||
),
|
||||
@@ -411,16 +408,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
|
||||
async def async_service_handler(service: ServiceCall) -> None:
|
||||
"""Handle service calls for Hass.io."""
|
||||
if service.service == SERVICE_ADDON_UPDATE:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"update_service_deprecated",
|
||||
breaks_in_ha_version="2025.5",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="update_service_deprecated",
|
||||
)
|
||||
api_endpoint = MAP_SERVICE_API[service.service]
|
||||
|
||||
data = service.data.copy()
|
||||
|
@@ -22,9 +22,6 @@
|
||||
"addon_stop": {
|
||||
"service": "mdi:stop"
|
||||
},
|
||||
"addon_update": {
|
||||
"service": "mdi:update"
|
||||
},
|
||||
"host_reboot": {
|
||||
"service": "mdi:restart"
|
||||
},
|
||||
|
@@ -30,14 +30,6 @@ addon_stop:
|
||||
selector:
|
||||
addon:
|
||||
|
||||
addon_update:
|
||||
fields:
|
||||
addon:
|
||||
required: true
|
||||
example: core_ssh
|
||||
selector:
|
||||
addon:
|
||||
|
||||
host_reboot:
|
||||
host_shutdown:
|
||||
backup_full:
|
||||
|
@@ -225,10 +225,6 @@
|
||||
"unsupported_virtualization_image": {
|
||||
"title": "Unsupported system - Incorrect OS image for virtualization",
|
||||
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
|
||||
},
|
||||
"update_service_deprecated": {
|
||||
"title": "Deprecated update add-on action",
|
||||
"description": "The update add-on action has been deprecated and will be removed in 2025.5. Please use the update entity and the respective action to update the add-on instead."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -313,16 +309,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"addon_update": {
|
||||
"name": "Update add-on",
|
||||
"description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.",
|
||||
"fields": {
|
||||
"addon": {
|
||||
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
|
||||
"description": "The add-on to update."
|
||||
}
|
||||
}
|
||||
},
|
||||
"host_reboot": {
|
||||
"name": "Reboot the host system",
|
||||
"description": "Reboots the host system."
|
||||
|
@@ -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:]
|
||||
|
@@ -47,8 +47,8 @@ from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes
|
||||
MAX_EXECUTIONS = 5
|
||||
MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour
|
||||
MAX_EXECUTIONS = 8
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
|
||||
|
||||
|
@@ -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:
|
||||
|
||||
|
@@ -1,5 +1,8 @@
|
||||
"""The Homee number platform."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from pyHomee.const import AttributeType
|
||||
from pyHomee.model import HomeeAttribute
|
||||
|
||||
@@ -8,7 +11,7 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import EntityCategory, UnitOfSpeed
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -18,69 +21,89 @@ from .entity import HomeeEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeeNumberEntityDescription(NumberEntityDescription):
|
||||
"""A class that describes Homee number entities."""
|
||||
|
||||
native_value_fn: Callable[[float], float] = lambda value: value
|
||||
set_native_value_fn: Callable[[float], float] = lambda value: value
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS = {
|
||||
AttributeType.DOWN_POSITION: NumberEntityDescription(
|
||||
AttributeType.DOWN_POSITION: HomeeNumberEntityDescription(
|
||||
key="down_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription(
|
||||
AttributeType.DOWN_SLAT_POSITION: HomeeNumberEntityDescription(
|
||||
key="down_slat_position",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.DOWN_TIME: NumberEntityDescription(
|
||||
AttributeType.DOWN_TIME: HomeeNumberEntityDescription(
|
||||
key="down_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription(
|
||||
AttributeType.ENDPOSITION_CONFIGURATION: HomeeNumberEntityDescription(
|
||||
key="endposition_configuration",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription(
|
||||
AttributeType.MOTION_ALARM_CANCELATION_DELAY: HomeeNumberEntityDescription(
|
||||
key="motion_alarm_cancelation_delay",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription(
|
||||
AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: HomeeNumberEntityDescription(
|
||||
key="open_window_detection_sensibility",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.POLLING_INTERVAL: NumberEntityDescription(
|
||||
AttributeType.POLLING_INTERVAL: HomeeNumberEntityDescription(
|
||||
key="polling_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription(
|
||||
AttributeType.SHUTTER_SLAT_TIME: HomeeNumberEntityDescription(
|
||||
key="shutter_slat_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription(
|
||||
AttributeType.SLAT_MAX_ANGLE: HomeeNumberEntityDescription(
|
||||
key="slat_max_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription(
|
||||
AttributeType.SLAT_MIN_ANGLE: HomeeNumberEntityDescription(
|
||||
key="slat_min_angle",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.SLAT_STEPS: NumberEntityDescription(
|
||||
AttributeType.SLAT_STEPS: HomeeNumberEntityDescription(
|
||||
key="slat_steps",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription(
|
||||
AttributeType.TEMPERATURE_OFFSET: HomeeNumberEntityDescription(
|
||||
key="temperature_offset",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.UP_TIME: NumberEntityDescription(
|
||||
AttributeType.UP_TIME: HomeeNumberEntityDescription(
|
||||
key="up_time",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription(
|
||||
AttributeType.WAKE_UP_INTERVAL: HomeeNumberEntityDescription(
|
||||
key="wake_up_interval",
|
||||
device_class=NumberDeviceClass.DURATION,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
AttributeType.WIND_MONITORING_STATE: HomeeNumberEntityDescription(
|
||||
key="wind_monitoring_state",
|
||||
device_class=NumberDeviceClass.WIND_SPEED,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_min_value=0,
|
||||
native_max_value=22.5,
|
||||
native_step=2.5,
|
||||
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
|
||||
native_value_fn=lambda value: value * 2.5,
|
||||
set_native_value_fn=lambda value: value / 2.5,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -102,20 +125,25 @@ async def async_setup_entry(
|
||||
class HomeeNumber(HomeeEntity, NumberEntity):
|
||||
"""Representation of a Homee number."""
|
||||
|
||||
entity_description: HomeeNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attribute: HomeeAttribute,
|
||||
entry: HomeeConfigEntry,
|
||||
description: NumberEntityDescription,
|
||||
description: HomeeNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Homee number entity."""
|
||||
super().__init__(attribute, entry)
|
||||
self.entity_description = description
|
||||
self._attr_translation_key = description.key
|
||||
self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
|
||||
self._attr_native_min_value = attribute.minimum
|
||||
self._attr_native_max_value = attribute.maximum
|
||||
self._attr_native_step = attribute.step_value
|
||||
self._attr_native_unit_of_measurement = (
|
||||
description.native_unit_of_measurement
|
||||
or HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
|
||||
)
|
||||
self._attr_native_min_value = description.native_min_value or attribute.minimum
|
||||
self._attr_native_max_value = description.native_max_value or attribute.maximum
|
||||
self._attr_native_step = description.native_step or attribute.step_value
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -123,10 +151,12 @@ class HomeeNumber(HomeeEntity, NumberEntity):
|
||||
return super().available and self._attribute.editable
|
||||
|
||||
@property
|
||||
def native_value(self) -> int:
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the native value of the number."""
|
||||
return int(self._attribute.current_value)
|
||||
return self.entity_description.native_value_fn(self._attribute.current_value)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the selected value."""
|
||||
await self.async_set_homee_value(value)
|
||||
await self.async_set_homee_value(
|
||||
self.entity_description.set_native_value_fn(value)
|
||||
)
|
||||
|
@@ -189,6 +189,9 @@
|
||||
},
|
||||
"wake_up_interval": {
|
||||
"name": "Wake-up interval"
|
||||
},
|
||||
"wind_monitoring_state": {
|
||||
"name": "Threshold for wind trigger"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
@@ -10,7 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==4.9.2",
|
||||
"fnv-hash-fast==1.4.0",
|
||||
"fnv-hash-fast==1.5.0",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
@@ -14,6 +14,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohomekit", "commentjson"],
|
||||
"requirements": ["aiohomekit==3.2.13"],
|
||||
"requirements": ["aiohomekit==3.2.14"],
|
||||
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
|
||||
}
|
||||
|
@@ -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(
|
||||
|
@@ -26,6 +26,10 @@
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::huawei_lte::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::huawei_lte::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
@@ -35,6 +39,12 @@
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.",
|
||||
"url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.",
|
||||
"username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).",
|
||||
"verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS."
|
||||
},
|
||||
"description": "Enter device access details.",
|
||||
"title": "Configure Huawei LTE"
|
||||
}
|
||||
@@ -48,6 +58,12 @@
|
||||
"recipient": "SMS notification recipients",
|
||||
"track_wired_clients": "Track wired network clients",
|
||||
"unauthenticated_mode": "Unauthenticated mode (change requires reload)"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Used to distinguish between notification services in case there are multiple Huawei LTE devices configured. Changes to this option value take effect after Home Assistant restart.",
|
||||
"recipient": "Comma separated list of default recipient SMS phone numbers for the notification service, used in case the notification sender does not specify any.",
|
||||
"track_wired_clients": "Whether the device tracker entities track also clients attached to the router's wired Ethernet network, in addition to wireless clients.",
|
||||
"unauthenticated_mode": "Whether to run in unauthenticated mode. Unauthenticated mode provides a limited set of features, but may help in case there are problems accessing the router's web interface from a browser while the integration is active. Changes to this option value take effect after integration reload."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -57,7 +57,7 @@
|
||||
"3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]",
|
||||
"4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]",
|
||||
"clock_wise": "Rotation clockwise",
|
||||
"counter_clock_wise": "Rotation counter-clockwise"
|
||||
"counter_clock_wise": "Rotation counterclockwise"
|
||||
},
|
||||
"trigger_type": {
|
||||
"remote_button_long_release": "\"{subtype}\" released after long press",
|
||||
@@ -96,7 +96,7 @@
|
||||
"event_type": {
|
||||
"state": {
|
||||
"clock_wise": "Clockwise",
|
||||
"counter_clock_wise": "Counter clockwise"
|
||||
"counter_clock_wise": "Counterclockwise"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -61,6 +61,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
self._zones_last_update: dict[str, set[str]] = {}
|
||||
self._areas_last_update: dict[str, set[int]] = {}
|
||||
|
||||
def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None:
|
||||
"""Add/remove devices and dynamic entities, when amount of devices changed."""
|
||||
self._async_add_remove_devices(data)
|
||||
for mower_id in data:
|
||||
if data[mower_id].capabilities.stay_out_zones:
|
||||
self._async_add_remove_stay_out_zones(data)
|
||||
if data[mower_id].capabilities.work_areas:
|
||||
self._async_add_remove_work_areas(data)
|
||||
|
||||
async def _async_update_data(self) -> MowerDictionary:
|
||||
"""Subscribe for websocket and poll data from the API."""
|
||||
if not self.ws_connected:
|
||||
@@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
||||
raise UpdateFailed(err) from err
|
||||
except AuthError as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
|
||||
self._async_add_remove_devices(data)
|
||||
for mower_id in data:
|
||||
if data[mower_id].capabilities.stay_out_zones:
|
||||
self._async_add_remove_stay_out_zones(data)
|
||||
for mower_id in data:
|
||||
if data[mower_id].capabilities.work_areas:
|
||||
self._async_add_remove_work_areas(data)
|
||||
self._async_add_remove_devices_and_entities(data)
|
||||
return data
|
||||
|
||||
@callback
|
||||
def callback(self, ws_data: MowerDictionary) -> None:
|
||||
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
|
||||
self.async_set_updated_data(ws_data)
|
||||
self._async_add_remove_devices_and_entities(ws_data)
|
||||
|
||||
async def client_listen(
|
||||
self,
|
||||
|
@@ -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",
|
||||
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -22,9 +23,6 @@ from homeassistant.helpers.dispatcher import (
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_INSTANCE_CLIENTS,
|
||||
CONF_ON_UNLOAD,
|
||||
CONF_ROOT_CLIENT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
HYPERION_RELEASES_URL,
|
||||
@@ -52,15 +50,15 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# The get_hyperion_unique_id method will create a per-entity unique id when given the
|
||||
# server id, an instance number and a name.
|
||||
|
||||
# hass.data format
|
||||
# ================
|
||||
#
|
||||
# hass.data[DOMAIN] = {
|
||||
# <config_entry.entry_id>: {
|
||||
# "ROOT_CLIENT": <Hyperion Client>,
|
||||
# "ON_UNLOAD": [<callable>, ...],
|
||||
# }
|
||||
# }
|
||||
type HyperionConfigEntry = ConfigEntry[HyperionData]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HyperionData:
|
||||
"""Hyperion runtime data."""
|
||||
|
||||
root_client: client.HyperionClient
|
||||
instance_clients: dict[int, client.HyperionClient]
|
||||
|
||||
|
||||
def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str:
|
||||
@@ -107,29 +105,29 @@ async def async_create_connect_hyperion_client(
|
||||
@callback
|
||||
def listen_for_instance_updates(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
add_func: Callable,
|
||||
remove_func: Callable,
|
||||
entry: HyperionConfigEntry,
|
||||
add_func: Callable[[int, str], None],
|
||||
remove_func: Callable[[int], None],
|
||||
) -> None:
|
||||
"""Listen for instance additions/removals."""
|
||||
|
||||
hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend(
|
||||
[
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
SIGNAL_INSTANCE_ADD.format(config_entry.entry_id),
|
||||
add_func,
|
||||
),
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id),
|
||||
remove_func,
|
||||
),
|
||||
]
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
SIGNAL_INSTANCE_ADD.format(entry.entry_id),
|
||||
add_func,
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
SIGNAL_INSTANCE_REMOVE.format(entry.entry_id),
|
||||
remove_func,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool:
|
||||
"""Set up Hyperion from a config entry."""
|
||||
host = entry.data[CONF_HOST]
|
||||
port = entry.data[CONF_PORT]
|
||||
@@ -185,12 +183,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
# We need 1 root client (to manage instances being removed/added) and then 1 client
|
||||
# per Hyperion server instance which is shared for all entities associated with
|
||||
# that instance.
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
CONF_ROOT_CLIENT: hyperion_client,
|
||||
CONF_INSTANCE_CLIENTS: {},
|
||||
CONF_ON_UNLOAD: [],
|
||||
}
|
||||
entry.runtime_data = HyperionData(
|
||||
root_client=hyperion_client,
|
||||
instance_clients={},
|
||||
)
|
||||
|
||||
async def async_instances_to_clients(response: dict[str, Any]) -> None:
|
||||
"""Convert instances to Hyperion clients."""
|
||||
@@ -203,7 +199,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
device_registry = dr.async_get(hass)
|
||||
running_instances: set[int] = set()
|
||||
stopped_instances: set[int] = set()
|
||||
existing_instances = hass.data[DOMAIN][entry.entry_id][CONF_INSTANCE_CLIENTS]
|
||||
existing_instances = entry.runtime_data.instance_clients
|
||||
server_id = cast(str, entry.unique_id)
|
||||
|
||||
# In practice, an instance can be in 3 states as seen by this function:
|
||||
@@ -270,39 +266,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
assert hyperion_client
|
||||
if hyperion_client.instances is not None:
|
||||
await async_instances_to_clients_raw(hyperion_client.instances)
|
||||
hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append(
|
||||
entry.add_update_listener(_async_entry_updated)
|
||||
)
|
||||
entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None:
|
||||
async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None:
|
||||
"""Handle entry updates."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
if unload_ok and config_entry.entry_id in hass.data[DOMAIN]:
|
||||
config_data = hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
for func in config_data[CONF_ON_UNLOAD]:
|
||||
func()
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
# Disconnect the shared instance clients.
|
||||
await asyncio.gather(
|
||||
*(
|
||||
config_data[CONF_INSTANCE_CLIENTS][
|
||||
instance_num
|
||||
].async_client_disconnect()
|
||||
for instance_num in config_data[CONF_INSTANCE_CLIENTS]
|
||||
inst.async_client_disconnect()
|
||||
for inst in entry.runtime_data.instance_clients.values()
|
||||
)
|
||||
)
|
||||
|
||||
# Disconnect the root client.
|
||||
root_client = config_data[CONF_ROOT_CLIENT]
|
||||
root_client = entry.runtime_data.root_client
|
||||
await root_client.async_client_disconnect()
|
||||
return unload_ok
|
||||
|
@@ -25,7 +25,6 @@ from homeassistant.components.camera import (
|
||||
Camera,
|
||||
async_get_still_stream,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
@@ -35,12 +34,12 @@ from homeassistant.helpers.dispatcher import (
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import (
|
||||
HyperionConfigEntry,
|
||||
get_hyperion_device_id,
|
||||
get_hyperion_unique_id,
|
||||
listen_for_instance_updates,
|
||||
)
|
||||
from .const import (
|
||||
CONF_INSTANCE_CLIENTS,
|
||||
DOMAIN,
|
||||
HYPERION_MANUFACTURER_NAME,
|
||||
HYPERION_MODEL_NAME,
|
||||
@@ -53,12 +52,11 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64,"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry: HyperionConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Hyperion platform from config entry."""
|
||||
entry_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
server_id = config_entry.unique_id
|
||||
server_id = entry.unique_id
|
||||
|
||||
def camera_unique_id(instance_num: int) -> str:
|
||||
"""Return the camera unique_id."""
|
||||
@@ -75,7 +73,7 @@ async def async_setup_entry(
|
||||
server_id,
|
||||
instance_num,
|
||||
instance_name,
|
||||
entry_data[CONF_INSTANCE_CLIENTS][instance_num],
|
||||
entry.runtime_data.instance_clients[instance_num],
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -91,7 +89,7 @@ async def async_setup_entry(
|
||||
),
|
||||
)
|
||||
|
||||
listen_for_instance_updates(hass, config_entry, instance_add, instance_remove)
|
||||
listen_for_instance_updates(hass, entry, instance_add, instance_remove)
|
||||
|
||||
|
||||
# A note on Hyperion streaming semantics:
|
||||
|
@@ -3,10 +3,7 @@
|
||||
CONF_AUTH_ID = "auth_id"
|
||||
CONF_CREATE_TOKEN = "create_token"
|
||||
CONF_INSTANCE = "instance"
|
||||
CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS"
|
||||
CONF_ON_UNLOAD = "ON_UNLOAD"
|
||||
CONF_PRIORITY = "priority"
|
||||
CONF_ROOT_CLIENT = "ROOT_CLIENT"
|
||||
CONF_EFFECT_HIDE_LIST = "effect_hide_list"
|
||||
CONF_EFFECT_SHOW_LIST = "effect_show_list"
|
||||
|
||||
|
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
import functools
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from hyperion import client, const
|
||||
@@ -18,7 +17,6 @@ from homeassistant.components.light import (
|
||||
LightEntity,
|
||||
LightEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
@@ -29,13 +27,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import (
|
||||
HyperionConfigEntry,
|
||||
get_hyperion_device_id,
|
||||
get_hyperion_unique_id,
|
||||
listen_for_instance_updates,
|
||||
)
|
||||
from .const import (
|
||||
CONF_EFFECT_HIDE_LIST,
|
||||
CONF_INSTANCE_CLIENTS,
|
||||
CONF_PRIORITY,
|
||||
DEFAULT_ORIGIN,
|
||||
DEFAULT_PRIORITY,
|
||||
@@ -75,28 +73,26 @@ ICON_EFFECT = "mdi:lava-lamp"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry: HyperionConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Hyperion platform from config entry."""
|
||||
|
||||
entry_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
server_id = config_entry.unique_id
|
||||
server_id = entry.unique_id
|
||||
|
||||
@callback
|
||||
def instance_add(instance_num: int, instance_name: str) -> None:
|
||||
"""Add entities for a new Hyperion instance."""
|
||||
assert server_id
|
||||
args = (
|
||||
server_id,
|
||||
instance_num,
|
||||
instance_name,
|
||||
config_entry.options,
|
||||
entry_data[CONF_INSTANCE_CLIENTS][instance_num],
|
||||
)
|
||||
async_add_entities(
|
||||
[
|
||||
HyperionLight(*args),
|
||||
HyperionLight(
|
||||
server_id,
|
||||
instance_num,
|
||||
instance_name,
|
||||
entry.options,
|
||||
entry.runtime_data.instance_clients[instance_num],
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -111,7 +107,7 @@ async def async_setup_entry(
|
||||
),
|
||||
)
|
||||
|
||||
listen_for_instance_updates(hass, config_entry, instance_add, instance_remove)
|
||||
listen_for_instance_updates(hass, entry, instance_add, instance_remove)
|
||||
|
||||
|
||||
class HyperionLight(LightEntity):
|
||||
@@ -129,7 +125,7 @@ class HyperionLight(LightEntity):
|
||||
server_id: str,
|
||||
instance_num: int,
|
||||
instance_name: str,
|
||||
options: MappingProxyType[str, Any],
|
||||
options: Mapping[str, Any],
|
||||
hyperion_client: client.HyperionClient,
|
||||
) -> None:
|
||||
"""Initialize the light."""
|
||||
|
@@ -19,7 +19,6 @@ from hyperion.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
@@ -29,12 +28,12 @@ from homeassistant.helpers.dispatcher import (
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import (
|
||||
HyperionConfigEntry,
|
||||
get_hyperion_device_id,
|
||||
get_hyperion_unique_id,
|
||||
listen_for_instance_updates,
|
||||
)
|
||||
from .const import (
|
||||
CONF_INSTANCE_CLIENTS,
|
||||
DOMAIN,
|
||||
HYPERION_MANUFACTURER_NAME,
|
||||
HYPERION_MODEL_NAME,
|
||||
@@ -62,12 +61,11 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str:
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry: HyperionConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Hyperion platform from config entry."""
|
||||
entry_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
server_id = config_entry.unique_id
|
||||
server_id = entry.unique_id
|
||||
|
||||
@callback
|
||||
def instance_add(instance_num: int, instance_name: str) -> None:
|
||||
@@ -78,7 +76,7 @@ async def async_setup_entry(
|
||||
server_id,
|
||||
instance_num,
|
||||
instance_name,
|
||||
entry_data[CONF_INSTANCE_CLIENTS][instance_num],
|
||||
entry.runtime_data.instance_clients[instance_num],
|
||||
PRIORITY_SENSOR_DESCRIPTION,
|
||||
)
|
||||
]
|
||||
@@ -98,7 +96,7 @@ async def async_setup_entry(
|
||||
),
|
||||
)
|
||||
|
||||
listen_for_instance_updates(hass, config_entry, instance_add, instance_remove)
|
||||
listen_for_instance_updates(hass, entry, instance_add, instance_remove)
|
||||
|
||||
|
||||
class HyperionSensor(SensorEntity):
|
||||
|
@@ -26,7 +26,6 @@ from hyperion.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -38,12 +37,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import (
|
||||
HyperionConfigEntry,
|
||||
get_hyperion_device_id,
|
||||
get_hyperion_unique_id,
|
||||
listen_for_instance_updates,
|
||||
)
|
||||
from .const import (
|
||||
CONF_INSTANCE_CLIENTS,
|
||||
DOMAIN,
|
||||
HYPERION_MANUFACTURER_NAME,
|
||||
HYPERION_MODEL_NAME,
|
||||
@@ -89,12 +88,11 @@ def _component_to_translation_key(component: str) -> str:
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
entry: HyperionConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a Hyperion platform from config entry."""
|
||||
entry_data = hass.data[DOMAIN][config_entry.entry_id]
|
||||
server_id = config_entry.unique_id
|
||||
server_id = entry.unique_id
|
||||
|
||||
@callback
|
||||
def instance_add(instance_num: int, instance_name: str) -> None:
|
||||
@@ -106,7 +104,7 @@ async def async_setup_entry(
|
||||
instance_num,
|
||||
instance_name,
|
||||
component,
|
||||
entry_data[CONF_INSTANCE_CLIENTS][instance_num],
|
||||
entry.runtime_data.instance_clients[instance_num],
|
||||
)
|
||||
for component in COMPONENT_SWITCHES
|
||||
)
|
||||
@@ -123,7 +121,7 @@ async def async_setup_entry(
|
||||
),
|
||||
)
|
||||
|
||||
listen_for_instance_updates(hass, config_entry, instance_add, instance_remove)
|
||||
listen_for_instance_updates(hass, entry, instance_add, instance_remove)
|
||||
|
||||
|
||||
class HyperionComponentSwitch(SwitchEntity):
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"description": "Select fireplace by serial number:"
|
||||
},
|
||||
"cloud_api": {
|
||||
"description": "Authenticate against IntelliFire Cloud",
|
||||
"description": "Authenticate against IntelliFire cloud",
|
||||
"data_description": {
|
||||
"username": "Your IntelliFire app username",
|
||||
"password": "Your IntelliFire app password"
|
||||
@@ -45,7 +45,7 @@
|
||||
"name": "Pilot flame error"
|
||||
},
|
||||
"flame_error": {
|
||||
"name": "Flame Error"
|
||||
"name": "Flame error"
|
||||
},
|
||||
"fan_delay_error": {
|
||||
"name": "Fan delay error"
|
||||
@@ -104,7 +104,7 @@
|
||||
"name": "Target temperature"
|
||||
},
|
||||
"fan_speed": {
|
||||
"name": "Fan Speed"
|
||||
"name": "Fan speed"
|
||||
},
|
||||
"timer_end_timestamp": {
|
||||
"name": "Timer end"
|
||||
|
@@ -22,6 +22,7 @@ from pynecil import (
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
@@ -83,7 +84,11 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
self.device_info = await self.device.get_device_info()
|
||||
|
||||
except CommunicationError as e:
|
||||
raise UpdateFailed("Cannot connect to device") from e
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={CONF_NAME: self.config_entry.title},
|
||||
) from e
|
||||
|
||||
self.v223_features = AwesomeVersion(self.device_info.build) >= V223
|
||||
|
||||
@@ -108,7 +113,11 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
|
||||
return await self.device.get_live_data()
|
||||
|
||||
except CommunicationError as e:
|
||||
raise UpdateFailed("Cannot connect to device") from e
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={CONF_NAME: self.config_entry.title},
|
||||
) from e
|
||||
|
||||
@property
|
||||
def has_tip(self) -> bool:
|
||||
@@ -187,4 +196,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
|
||||
try:
|
||||
return await self.github.latest_release()
|
||||
except UpdateException as e:
|
||||
raise UpdateFailed("Failed to check for latest IronOS update") from e
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_check_failed",
|
||||
) from e
|
||||
|
@@ -284,6 +284,12 @@
|
||||
},
|
||||
"submit_setting_failed": {
|
||||
"message": "Failed to submit setting to device, try again later"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to device {name}"
|
||||
},
|
||||
"update_check_failed": {
|
||||
"message": "Failed to check for latest IronOS update"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,11 +4,11 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError
|
||||
from pyecotrend_ista import PyEcotrendIsta
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import IstaConfigEntry, IstaCoordinator
|
||||
@@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool
|
||||
entry.data[CONF_PASSWORD],
|
||||
_LOGGER,
|
||||
)
|
||||
try:
|
||||
await hass.async_add_executor_job(ista.login)
|
||||
except ServerError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_exception",
|
||||
) from e
|
||||
except (LoginError, KeycloakError) as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_exception",
|
||||
translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]},
|
||||
) from e
|
||||
|
||||
coordinator = IstaCoordinator(hass, entry, ista)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
@@ -52,3 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> None:
|
||||
"""Handle removal of an entry."""
|
||||
statistic_ids = [f"{DOMAIN}:{name}" for name in entry.options.values()]
|
||||
get_instance(hass).async_clear_statistics(statistic_ids)
|
||||
|
@@ -100,8 +100,19 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
user_input[CONF_PASSWORD],
|
||||
_LOGGER,
|
||||
)
|
||||
|
||||
def get_consumption_units() -> set[str]:
|
||||
ista.login()
|
||||
consumption_units = ista.get_consumption_unit_details()[
|
||||
"consumptionUnits"
|
||||
]
|
||||
return {unit["id"] for unit in consumption_units}
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(ista.login)
|
||||
consumption_units = await self.hass.async_add_executor_job(
|
||||
get_consumption_units
|
||||
)
|
||||
|
||||
except ServerError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except (LoginError, KeycloakError):
|
||||
@@ -110,6 +121,8 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if reauth_entry.unique_id not in consumption_units:
|
||||
return self.async_abort(reason="unique_id_mismatch")
|
||||
return self.async_update_reload_and_abort(reauth_entry, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
|
@@ -11,7 +11,7 @@ from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerErr
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -25,6 +25,7 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
"""Ista EcoTrend data update coordinator."""
|
||||
|
||||
config_entry: IstaConfigEntry
|
||||
details: dict[str, Any]
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta
|
||||
@@ -38,22 +39,35 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
update_interval=timedelta(days=1),
|
||||
)
|
||||
self.ista = ista
|
||||
self.details: dict[str, Any] = {}
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the ista EcoTrend coordinator."""
|
||||
|
||||
try:
|
||||
self.details = await self.hass.async_add_executor_job(self.get_details)
|
||||
except ServerError as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_exception",
|
||||
) from e
|
||||
except (LoginError, KeycloakError) as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_exception",
|
||||
translation_placeholders={
|
||||
CONF_EMAIL: self.config_entry.data[CONF_EMAIL]
|
||||
},
|
||||
) from e
|
||||
|
||||
async def _async_update_data(self):
|
||||
"""Fetch ista EcoTrend data."""
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.ista.login)
|
||||
|
||||
if not self.details:
|
||||
self.details = await self.async_get_details()
|
||||
|
||||
return await self.hass.async_add_executor_job(self.get_consumption_data)
|
||||
|
||||
except ServerError as e:
|
||||
raise UpdateFailed(
|
||||
"Unable to connect and retrieve data from ista EcoTrend, try again later"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_exception",
|
||||
) from e
|
||||
except (LoginError, KeycloakError) as e:
|
||||
raise ConfigEntryAuthFailed(
|
||||
@@ -67,17 +81,17 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
def get_consumption_data(self) -> dict[str, Any]:
|
||||
"""Get raw json data for all consumption units."""
|
||||
|
||||
self.ista.login()
|
||||
return {
|
||||
consumption_unit: self.ista.get_consumption_data(consumption_unit)
|
||||
for consumption_unit in self.ista.get_uuids()
|
||||
}
|
||||
|
||||
async def async_get_details(self) -> dict[str, Any]:
|
||||
def get_details(self) -> dict[str, Any]:
|
||||
"""Retrieve details of consumption units."""
|
||||
|
||||
result = await self.hass.async_add_executor_job(
|
||||
self.ista.get_consumption_unit_details
|
||||
)
|
||||
self.ista.login()
|
||||
result = self.ista.get_consumption_unit_details()
|
||||
|
||||
return {
|
||||
consumption_unit: next(
|
||||
|
33
homeassistant/components/ista_ecotrend/diagnostics.py
Normal file
33
homeassistant/components/ista_ecotrend/diagnostics.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Diagnostics platform for ista EcoTrend integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import IstaConfigEntry
|
||||
|
||||
TO_REDACT = {
|
||||
"firstName",
|
||||
"lastName",
|
||||
"street",
|
||||
"houseNumber",
|
||||
"documentNumber",
|
||||
"postalCode",
|
||||
"city",
|
||||
"propertyNumber",
|
||||
"idAtCustomerUser",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: IstaConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
return {
|
||||
"details": async_redact_data(config_entry.runtime_data.details, TO_REDACT),
|
||||
"data": async_redact_data(config_entry.runtime_data.data, TO_REDACT),
|
||||
}
|
@@ -5,12 +5,8 @@ rules:
|
||||
comment: The integration registers no actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules:
|
||||
status: todo
|
||||
comment: Group the 3 different executor jobs as one executor job
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth,
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
@@ -47,7 +43,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: The integration is a web service, there are no discoverable devices.
|
||||
|
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
|
@@ -73,7 +73,7 @@ async def build_root_response(
|
||||
children = [
|
||||
await item_payload(hass, client, user_id, folder)
|
||||
for folder in folders["Items"]
|
||||
if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES
|
||||
if folder.get("CollectionType") in SUPPORTED_COLLECTION_TYPES
|
||||
]
|
||||
|
||||
return BrowseMedia(
|
||||
|
@@ -56,7 +56,7 @@
|
||||
"on": "[%key:common::state::on%]",
|
||||
"warming": "Warming",
|
||||
"cooling": "Cooling",
|
||||
"error": "Error"
|
||||
"error": "[%key:common::state::error%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
SETTINGS_UPDATE_INTERVAL = timedelta(hours=1)
|
||||
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -82,35 +82,32 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
"""Class to handle fetching data from the La Marzocco API centrally."""
|
||||
|
||||
async def _async_connect_websocket(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
if not self.device.websocket.connected:
|
||||
_LOGGER.debug("Init WebSocket in background task")
|
||||
|
||||
self.config_entry.async_create_background_task(
|
||||
hass=self.hass,
|
||||
target=self.device.connect_dashboard_websocket(
|
||||
update_callback=lambda _: self.async_set_updated_data(None)
|
||||
),
|
||||
name="lm_websocket_task",
|
||||
)
|
||||
|
||||
async def websocket_close(_: Any | None = None) -> None:
|
||||
if self.device.websocket.connected:
|
||||
await self.device.websocket.disconnect()
|
||||
|
||||
self.config_entry.async_on_unload(
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, websocket_close
|
||||
)
|
||||
)
|
||||
self.config_entry.async_on_unload(websocket_close)
|
||||
|
||||
async def _internal_async_update_data(self) -> None:
|
||||
"""Fetch data from API endpoint."""
|
||||
|
||||
if self.device.websocket.connected:
|
||||
return
|
||||
await self.device.get_dashboard()
|
||||
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
|
||||
await self._async_connect_websocket()
|
||||
|
||||
_LOGGER.debug("Init WebSocket in background task")
|
||||
|
||||
self.config_entry.async_create_background_task(
|
||||
hass=self.hass,
|
||||
target=self.device.connect_dashboard_websocket(
|
||||
update_callback=lambda _: self.async_set_updated_data(None)
|
||||
),
|
||||
name="lm_websocket_task",
|
||||
)
|
||||
|
||||
async def websocket_close(_: Any | None = None) -> None:
|
||||
if self.device.websocket.connected:
|
||||
await self.device.websocket.disconnect()
|
||||
|
||||
self.config_entry.async_on_unload(
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, websocket_close)
|
||||
)
|
||||
self.config_entry.async_on_unload(websocket_close)
|
||||
|
||||
|
||||
class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
|
||||
|
@@ -76,6 +76,9 @@
|
||||
"coffee_boiler_ready_time": {
|
||||
"default": "mdi:av-timer"
|
||||
},
|
||||
"last_cleaning_time": {
|
||||
"default": "mdi:spray-bottle"
|
||||
},
|
||||
"steam_boiler_ready_time": {
|
||||
"default": "mdi:av-timer"
|
||||
}
|
||||
|
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.0.0b1"]
|
||||
"requirements": ["pylamarzocco==2.0.0b3"]
|
||||
}
|
||||
|
@@ -7,6 +7,7 @@ from typing import cast
|
||||
|
||||
from pylamarzocco.const import ModelName, WidgetType
|
||||
from pylamarzocco.models import (
|
||||
BackFlush,
|
||||
BaseWidgetOutput,
|
||||
CoffeeBoiler,
|
||||
SteamBoilerLevel,
|
||||
@@ -84,6 +85,17 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI)
|
||||
),
|
||||
),
|
||||
LaMarzoccoSensorEntityDescription(
|
||||
key="last_cleaning_time",
|
||||
translation_key="last_cleaning_time",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
value_fn=(
|
||||
lambda config: cast(
|
||||
BackFlush, config[WidgetType.CM_BACK_FLUSH]
|
||||
).last_cleaning_start_time
|
||||
),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
@@ -146,6 +146,9 @@
|
||||
},
|
||||
"steam_boiler_ready_time": {
|
||||
"name": "Steam boiler ready time"
|
||||
},
|
||||
"last_cleaning_time": {
|
||||
"name": "Last cleaning time"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
@@ -24,12 +24,17 @@ from homeassistant.const import (
|
||||
CONF_IP_ADDRESS,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_RESOURCE,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -38,6 +43,7 @@ from .const import (
|
||||
CONF_DIM_MODE,
|
||||
CONF_DOMAIN_DATA,
|
||||
CONF_SK_NUM_TRIES,
|
||||
CONF_TARGET_VALUE_LOCKED,
|
||||
CONF_TRANSITION,
|
||||
CONNECTION,
|
||||
DEVICE_CONNECTIONS,
|
||||
@@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if config_entry.minor_version < 2:
|
||||
new_data[CONF_ACKNOWLEDGE] = False
|
||||
|
||||
if config_entry.version < 2:
|
||||
# update to 2.1 (fix transitions for lights and switches)
|
||||
new_entities_data = [*new_data[CONF_ENTITIES]]
|
||||
for entity in new_entities_data:
|
||||
@@ -164,8 +171,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0
|
||||
new_data[CONF_ENTITIES] = new_entities_data
|
||||
|
||||
if config_entry.version < 3:
|
||||
# update to 3.1 (remove resource parameter, add climate target lock value parameter)
|
||||
for entity in new_data[CONF_ENTITIES]:
|
||||
entity.pop(CONF_RESOURCE, None)
|
||||
|
||||
if entity[CONF_DOMAIN] == Platform.CLIMATE:
|
||||
entity[CONF_DOMAIN_DATA].setdefault(CONF_TARGET_VALUE_LOCKED, -1)
|
||||
|
||||
# migrate climate and scene unique ids
|
||||
await async_migrate_entities(hass, config_entry)
|
||||
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, data=new_data, minor_version=1, version=2
|
||||
config_entry, data=new_data, minor_version=1, version=3
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
@@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entities(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Migrate entity registry."""
|
||||
|
||||
@callback
|
||||
def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
|
||||
"""Update unique ID of entity entry."""
|
||||
# fix unique entity ids for climate and scene
|
||||
if "." in entity_entry.unique_id:
|
||||
if entity_entry.domain == Platform.CLIMATE:
|
||||
setpoint = entity_entry.unique_id.split(".")[-1]
|
||||
return {
|
||||
"new_unique_id": entity_entry.unique_id.rsplit("-", 1)[0]
|
||||
+ f"-{setpoint}"
|
||||
}
|
||||
if entity_entry.domain == Platform.SCENE:
|
||||
return {"new_unique_id": entity_entry.unique_id.replace(".", "")}
|
||||
return None
|
||||
|
||||
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Close connection to PCHK host represented by config_entry."""
|
||||
# forward unloading to platforms
|
||||
|
@@ -110,7 +110,7 @@ async def validate_connection(data: ConfigType) -> str | None:
|
||||
class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a LCN config flow."""
|
||||
|
||||
VERSION = 2
|
||||
VERSION = 3
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
|
@@ -3,18 +3,19 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import CONF_DOMAIN_DATA, DOMAIN
|
||||
from .helpers import (
|
||||
AddressType,
|
||||
DeviceConnectionType,
|
||||
InputType,
|
||||
generate_unique_id,
|
||||
get_device_connection,
|
||||
get_resource,
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +49,11 @@ class LcnEntity(Entity):
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique ID."""
|
||||
return generate_unique_id(
|
||||
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
|
||||
self.config_entry.entry_id,
|
||||
self.address,
|
||||
get_resource(
|
||||
self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA]
|
||||
).lower(),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
|
@@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||
CONF_ENTITIES,
|
||||
CONF_LIGHTS,
|
||||
CONF_NAME,
|
||||
CONF_RESOURCE,
|
||||
CONF_SENSORS,
|
||||
CONF_SWITCHES,
|
||||
)
|
||||
@@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CLIMATES,
|
||||
CONF_DOMAIN_DATA,
|
||||
CONF_HARDWARE_SERIAL,
|
||||
CONF_HARDWARE_TYPE,
|
||||
CONF_SCENES,
|
||||
@@ -79,9 +79,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
|
||||
if domain_name == "cover":
|
||||
return cast(str, domain_data["motor"])
|
||||
if domain_name == "climate":
|
||||
return f"{domain_data['source']}.{domain_data['setpoint']}"
|
||||
return cast(str, domain_data["setpoint"])
|
||||
if domain_name == "scene":
|
||||
return f"{domain_data['register']}.{domain_data['scene']}"
|
||||
return f"{domain_data['register']}{domain_data['scene']}"
|
||||
raise ValueError("Unknown domain")
|
||||
|
||||
|
||||
@@ -115,7 +115,9 @@ def purge_entity_registry(
|
||||
references_entry_data = set()
|
||||
for entity_data in imported_entry_data[CONF_ENTITIES]:
|
||||
entity_unique_id = generate_unique_id(
|
||||
entry_id, entity_data[CONF_ADDRESS], entity_data[CONF_RESOURCE]
|
||||
entry_id,
|
||||
entity_data[CONF_ADDRESS],
|
||||
get_resource(entity_data[CONF_DOMAIN], entity_data[CONF_DOMAIN_DATA]),
|
||||
)
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lcn",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"],
|
||||
"requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"]
|
||||
"requirements": ["pypck==0.8.5", "lcn-frontend==0.2.4"]
|
||||
}
|
||||
|
@@ -19,7 +19,6 @@ from homeassistant.const import (
|
||||
CONF_DOMAIN,
|
||||
CONF_ENTITIES,
|
||||
CONF_NAME,
|
||||
CONF_RESOURCE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
@@ -343,7 +342,6 @@ async def websocket_add_entity(
|
||||
entity_config = {
|
||||
CONF_ADDRESS: msg[CONF_ADDRESS],
|
||||
CONF_NAME: msg[CONF_NAME],
|
||||
CONF_RESOURCE: resource,
|
||||
CONF_DOMAIN: domain_name,
|
||||
CONF_DOMAIN_DATA: domain_data,
|
||||
}
|
||||
@@ -371,7 +369,15 @@ async def websocket_add_entity(
|
||||
vol.Required("entry_id"): cv.string,
|
||||
vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA,
|
||||
vol.Required(CONF_DOMAIN): cv.string,
|
||||
vol.Required(CONF_RESOURCE): cv.string,
|
||||
vol.Required(CONF_DOMAIN_DATA): vol.Any(
|
||||
DOMAIN_DATA_BINARY_SENSOR,
|
||||
DOMAIN_DATA_SENSOR,
|
||||
DOMAIN_DATA_SWITCH,
|
||||
DOMAIN_DATA_LIGHT,
|
||||
DOMAIN_DATA_CLIMATE,
|
||||
DOMAIN_DATA_COVER,
|
||||
DOMAIN_DATA_SCENE,
|
||||
),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@@ -390,7 +396,10 @@ async def websocket_delete_entity(
|
||||
if (
|
||||
tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS]
|
||||
and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN]
|
||||
and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE]
|
||||
and get_resource(
|
||||
entity_config[CONF_DOMAIN], entity_config[CONF_DOMAIN_DATA]
|
||||
)
|
||||
== get_resource(msg[CONF_DOMAIN], msg[CONF_DOMAIN_DATA])
|
||||
)
|
||||
),
|
||||
None,
|
||||
|
@@ -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."]
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -120,6 +121,8 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
|
||||
)
|
||||
|
||||
RETRY_POLL_MAXIMUM = 3
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
@@ -7,38 +7,19 @@ import mimetypes
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.camera import (
|
||||
PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA,
|
||||
Camera,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
AddEntitiesCallback,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH
|
||||
from .const import SERVICE_UPDATE_FILE_PATH
|
||||
from .util import check_file_path_access
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_FILE_PATH): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -67,57 +48,6 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Camera that works with local files."""
|
||||
file_path: str = config[CONF_FILE_PATH]
|
||||
file_path_slug = slugify(file_path)
|
||||
|
||||
if not await hass.async_add_executor_job(check_file_path_access, file_path):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"no_access_path_{file_path_slug}",
|
||||
breaks_in_ha_version="2025.5.0",
|
||||
is_fixable=False,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="no_access_path",
|
||||
translation_placeholders={
|
||||
"file_path": file_path_slug,
|
||||
},
|
||||
)
|
||||
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,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_yaml",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "Local file",
|
||||
},
|
||||
)
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_IMPORT},
|
||||
data=config,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LocalFile(Camera):
|
||||
"""Representation of a local file camera."""
|
||||
|
||||
|
@@ -50,18 +50,12 @@ DATA_SCHEMA_SETUP = vol.Schema(
|
||||
|
||||
CONFIG_FLOW = {
|
||||
"user": SchemaFlowFormStep(
|
||||
schema=DATA_SCHEMA_SETUP,
|
||||
validate_user_input=validate_options,
|
||||
),
|
||||
"import": SchemaFlowFormStep(
|
||||
schema=DATA_SCHEMA_SETUP,
|
||||
validate_user_input=validate_options,
|
||||
),
|
||||
schema=DATA_SCHEMA_SETUP, validate_user_input=validate_options
|
||||
)
|
||||
}
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(
|
||||
DATA_SCHEMA_OPTIONS,
|
||||
validate_user_input=validate_options,
|
||||
DATA_SCHEMA_OPTIONS, validate_user_input=validate_options
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -53,11 +53,5 @@
|
||||
"file_path_not_accessible": {
|
||||
"message": "Path {file_path} is not accessible"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"no_access_path": {
|
||||
"title": "Incorrect file path",
|
||||
"description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user