Merge branch 'dev' into aioautomower202541

This commit is contained in:
Thomas55555
2025-04-23 21:17:50 +02:00
committed by GitHub
350 changed files with 7679 additions and 3857 deletions

View File

@@ -324,7 +324,7 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1 uses: sigstore/cosign-installer@v3.8.2
with: with:
cosign-release: "v2.2.3" cosign-release: "v2.2.3"

View File

@@ -363,6 +363,7 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.* homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.ntfy.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
homeassistant.components.ohme.* homeassistant.components.ohme.*

2
CODEOWNERS generated
View File

@@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor
/tests/components/nsw_fuel_station/ @nickw444 /tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
/tests/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 /homeassistant/components/nuheat/ @tstabrawa
/tests/components/nuheat/ @tstabrawa /tests/components/nuheat/ @tstabrawa
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree /homeassistant/components/nuki/ @pschmitt @pvizeli @pree

View File

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

View File

@@ -3,10 +3,10 @@
from __future__ import annotations from __future__ import annotations
from asyncio import timeout from asyncio import timeout
from collections.abc import Mapping
from http import HTTPStatus from http import HTTPStatus
import json import json
import logging import logging
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4 from uuid import uuid4
@@ -260,10 +260,10 @@ async def async_enable_proactive_mode(
def extra_significant_check( def extra_significant_check(
hass: HomeAssistant, hass: HomeAssistant,
old_state: str, old_state: str,
old_attrs: dict[Any, Any] | MappingProxyType[Any, Any], old_attrs: Mapping[Any, Any],
old_extra_arg: Any, old_extra_arg: Any,
new_state: str, new_state: str,
new_attrs: dict[str, Any] | MappingProxyType[Any, Any], new_attrs: Mapping[Any, Any],
new_extra_arg: Any, new_extra_arg: Any,
) -> bool: ) -> bool:
"""Check if the serialized data has changed.""" """Check if the serialized data has changed."""

View File

@@ -3,12 +3,12 @@
"step": { "step": {
"user": { "user": {
"data": { "data": {
"tracked_addons": "Addons", "tracked_addons": "Add-ons",
"tracked_integrations": "Integrations", "tracked_integrations": "Integrations",
"tracked_custom_integrations": "Custom integrations" "tracked_custom_integrations": "Custom integrations"
}, },
"data_description": { "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_integrations": "Select the integrations you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track" "tracked_custom_integrations": "Select the custom integrations you want to track"
} }

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from functools import partial from functools import partial
import logging import logging
from types import MappingProxyType from types import MappingProxyType
@@ -175,7 +176,7 @@ class AnthropicOptionsFlow(OptionsFlow):
def anthropic_config_option_schema( def anthropic_config_option_schema(
hass: HomeAssistant, hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any], options: Mapping[str, Any],
) -> dict: ) -> dict:
"""Return a schema for Anthropic completion options.""" """Return a schema for Anthropic completion options."""
hass_apis: list[SelectOptionDict] = [ hass_apis: list[SelectOptionDict] = [

View File

@@ -265,21 +265,21 @@ async def _transform_stream(
if current_block is None: if current_block is None:
raise ValueError("Unexpected stop event without a current block") raise ValueError("Unexpected stop event without a current block")
if current_block["type"] == "tool_use": 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_args = json.loads(current_tool_args) if current_tool_args else {}
tool_block["input"] = tool_args current_block["input"] = tool_args
yield { yield {
"tool_calls": [ "tool_calls": [
llm.ToolInput( llm.ToolInput(
id=tool_block["id"], id=current_block["id"],
tool_name=tool_block["name"], tool_name=current_block["name"],
tool_args=tool_args, tool_args=tool_args,
) )
] ]
} }
elif current_block["type"] == "thinking": elif current_block["type"] == "thinking":
thinking_block = cast(ThinkingBlockParam, current_block) # thinking block
LOGGER.debug("Thinking: %s", thinking_block["thinking"]) LOGGER.debug("Thinking: %s", current_block["thinking"])
if current_message is None: if current_message is None:
raise ValueError("Unexpected stop event without a current message") raise ValueError("Unexpected stop event without a current message")

View File

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

View File

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

View File

@@ -2,10 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from pyasuswrt import AsusWrtError from pyasuswrt import AsusWrtError
@@ -363,7 +362,7 @@ class AsusWrtRouter:
"""Add a function to call when router is closed.""" """Add a function to call when router is closed."""
self._on_close.append(func) 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.""" """Update router options."""
req_reload = False req_reload = False
for name, new_opt in new_options.items(): for name, new_opt in new_options.items():

View File

@@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august", "documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"], "loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"] "requirements": ["yalexs==8.10.0", "yalexs-ble==2.6.0"]
} }

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from ipaddress import ip_address from ipaddress import ip_address
from types import MappingProxyType
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
@@ -88,7 +87,7 @@ class AxisFlowHandler(ConfigFlow, domain=AXIS_DOMAIN):
if user_input is not None: if user_input is not None:
try: try:
api = await get_axis_api(self.hass, MappingProxyType(user_input)) api = await get_axis_api(self.hass, user_input)
except AuthenticationRequired: except AuthenticationRequired:
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"

View File

@@ -1,7 +1,7 @@
"""Axis network device abstraction.""" """Axis network device abstraction."""
from asyncio import timeout from asyncio import timeout
from types import MappingProxyType from collections.abc import Mapping
from typing import Any from typing import Any
import axis import axis
@@ -23,7 +23,7 @@ from ..errors import AuthenticationRequired, CannotConnect
async def get_axis_api( async def get_axis_api(
hass: HomeAssistant, hass: HomeAssistant,
config: MappingProxyType[str, Any], config: Mapping[str, Any],
) -> axis.AxisDevice: ) -> axis.AxisDevice:
"""Create a Axis device API.""" """Create a Axis device API."""
session = get_async_client(hass, verify_ssl=False) session = get_async_client(hass, verify_ssl=False)

View File

@@ -3,11 +3,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable, Mapping
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from azure.eventhub import EventData, EventDataBatch from azure.eventhub import EventData, EventDataBatch
@@ -179,7 +178,7 @@ class AzureEventHub:
await self.async_send(None) await self.async_send(None)
await self._queue.join() 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.""" """Update options."""
self._send_interval = new_options[CONF_SEND_INTERVAL] self._send_interval = new_options[CONF_SEND_INTERVAL]

View File

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

View File

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

View File

@@ -8,46 +8,18 @@ from typing import Final
from canary.api import Api from canary.api import Api
from requests.exceptions import ConnectTimeout, HTTPError 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.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT
CONF_FFMPEG_ARGUMENTS,
DEFAULT_FFMPEG_ARGUMENTS,
DEFAULT_TIMEOUT,
DOMAIN,
)
from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
_LOGGER: Final = logging.getLogger(__name__) _LOGGER: Final = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30) 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]] = [ PLATFORMS: Final[list[Platform]] = [
Platform.ALARM_CONTROL_PANEL, Platform.ALARM_CONTROL_PANEL,
Platform.CAMERA, 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: async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
"""Set up Canary from a config entry.""" """Set up Canary from a config entry."""
if not entry.options: if not entry.options:

View File

@@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler.""" """Get the options flow for this handler."""
return CanaryOptionsFlowHandler() 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( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/chacon_dio", "documentation": "https://www.home-assistant.io/integrations/chacon_dio",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["dio_chacon_api"], "loggers": ["dio_chacon_api"],
"requirements": ["dio-chacon-wifi-api==1.2.1"] "requirements": ["dio-chacon-wifi-api==1.2.2"]
} }

View File

@@ -3,8 +3,8 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Mapping
from functools import partial from functools import partial
from types import MappingProxyType
from typing import Any from typing import Any
from devolo_home_control_api.exceptions.gateway import GatewayOfflineError from devolo_home_control_api.exceptions.gateway import GatewayOfflineError
@@ -97,7 +97,7 @@ async def async_remove_config_entry_device(
return True return True
def configure_mydevolo(conf: dict[str, Any] | MappingProxyType[str, Any]) -> Mydevolo: def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo:
"""Configure mydevolo.""" """Configure mydevolo."""
mydevolo = Mydevolo() mydevolo = Mydevolo()
mydevolo.user = conf[CONF_USERNAME] mydevolo.user = conf[CONF_USERNAME]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from types import MappingProxyType
from typing import Any from typing import Any
from dynalite_devices_lib.dynalite_devices import ( from dynalite_devices_lib.dynalite_devices import (
@@ -50,7 +49,7 @@ class DynaliteBridge:
LOGGER.debug("Setting up bridge - host %s", self.host) LOGGER.debug("Setting up bridge - host %s", self.host)
return await self.dynalite_devices.async_setup() 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.""" """Reconfigure a bridge when config changes."""
LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config)
self.dynalite_devices.configure(convert_config(config)) self.dynalite_devices.configure(convert_config(config))

View File

@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from types import MappingProxyType from collections.abc import Mapping
from typing import Any from typing import Any
from dynalite_devices_lib import const as dyn_const 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) return convert_with_map(config, my_map)
def convert_config( def convert_config(config: Mapping[str, Any]) -> dict[str, Any]:
config: dict[str, Any] | MappingProxyType[str, Any],
) -> dict[str, Any]:
"""Convert a config dict by replacing component consts with library consts.""" """Convert a config dict by replacing component consts with library consts."""
my_map = { my_map = {
CONF_NAME: dyn_const.CONF_NAME, CONF_NAME: dyn_const.CONF_NAME,

View File

@@ -2,8 +2,8 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from elevenlabs import AsyncElevenLabs from elevenlabs import AsyncElevenLabs
@@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0 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 voice settings."""
return VoiceSettings( return VoiceSettings(
stability=options.get(CONF_STABILITY, DEFAULT_STABILITY), stability=options.get(CONF_STABILITY, DEFAULT_STABILITY),

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re import re
from types import MappingProxyType
from typing import Any from typing import Any
from elkm1_lib.elements import Element 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: async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> bool:
"""Set up Elk-M1 Control from a config entry.""" """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]) host = hostname_from_url(entry.data[CONF_HOST])

View File

@@ -293,9 +293,9 @@ async def ws_get_fossil_energy_consumption(
if statistics_id not in statistic_ids: if statistics_id not in statistic_ids:
continue continue
for period in stat: for period in stat:
if period["change"] is None: if (change := period.get("change")) is None:
continue continue
result[period["start"]] += period["change"] result[period["start"]] += change
return {key: result[key] for key in sorted(result)} return {key: result[key] for key in sorted(result)}

View File

@@ -6,6 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"quality_scale": "bronze",
"requirements": ["pyenphase==1.25.5"], "requirements": ["pyenphase==1.25.5"],
"zeroconf": [ "zeroconf": [
{ {

View File

@@ -1,31 +1,19 @@
rules: rules:
# Bronze # Bronze
action-setup: action-setup:
status: done status: exempt
comment: only actions implemented are platform native ones. comment: only actions implemented are platform native ones.
appropriate-polling: appropriate-polling: done
status: done
comment: fixed 1 minute cycle based on Enphase Envoy device characteristics
brands: done brands: done
common-modules: done common-modules: done
config-flow-test-coverage: done config-flow-test-coverage: done
config-flow: done config-flow: done
dependency-transparency: done dependency-transparency: done
docs-actions: docs-actions: done
status: done docs-high-level-description: done
comment: https://www.home-assistant.io/integrations/enphase_envoy/#actions docs-installation-instructions: done
docs-high-level-description: docs-removal-instructions: done
status: done entity-event-setup: 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.
entity-unique-id: done entity-unique-id: done
has-entity-name: done has-entity-name: done
runtime-data: done runtime-data: done
@@ -34,24 +22,14 @@ rules:
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: action-exceptions: done
status: todo
comment: |
needs to raise appropriate error when exception occurs.
Pending https://github.com/pyenphase/pyenphase/pull/194
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: docs-configuration-parameters: done
status: done docs-installation-parameters: 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
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: parallel-updates: done
status: done
comment: pending https://github.com/home-assistant/core/pull/132373
reauthentication-flow: done reauthentication-flow: done
test-coverage: done test-coverage: done
@@ -60,22 +38,14 @@ rules:
diagnostics: done diagnostics: done
discovery-update-info: done discovery-update-info: done
discovery: done discovery: done
docs-data-update: docs-data-update: done
status: done docs-examples: done
comment: https://www.home-assistant.io/integrations/enphase_envoy#data-updates docs-known-limitations: done
docs-examples: docs-supported-devices: done
status: todo docs-supported-functions: done
comment: add blue-print examples, if any docs-troubleshooting: done
docs-known-limitations: todo docs-use-cases: done
docs-supported-devices: dynamic-devices: done
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
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
@@ -86,7 +56,7 @@ rules:
repair-issues: repair-issues:
status: exempt status: exempt
comment: no general issues or repair.py comment: no general issues or repair.py
stale-devices: todo stale-devices: done
# Platinum # Platinum
async-dependency: done async-dependency: done

View File

@@ -336,6 +336,12 @@ class EsphomeAssistSatellite(
"code": event.data["code"], "code": event.data["code"],
"message": event.data["message"], "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: elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
if self._tts_streaming_task is None: if self._tts_streaming_task is None:
# No TTS # No TTS

View File

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

View File

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

View File

@@ -2,6 +2,8 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_configured_detailed": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`.",
"already_configured_updates": "A device `{name}`, with MAC address `{mac}` is already configured as `{title}`; the existing configuration will be updated with the validated data.",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"mdns_missing_mac": "Missing MAC address in mDNS properties.", "mdns_missing_mac": "Missing MAC address in mDNS properties.",
@@ -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}`)." "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": { "error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", "resolve_error": "Unable to resolve the address of the ESPHome device. If this issue continues, consider setting a static IP address.",
"connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", "connection_error": "Unable to connect to the ESPHome device. Make sure the devices YAML configuration includes an `api` section.",
"requires_encryption_key": "The ESPHome device requires an encryption key. Enter the key defined in the devices YAML configuration under `api -> encryption -> key`.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "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 devices YAML configuration under `api -> encryption -> key`."
}, },
"step": { "step": {
"user": { "user": {
@@ -41,7 +44,7 @@
"data_description": { "data_description": {
"password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead." "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead."
}, },
"description": "Please enter the password you set in your ESPHome device YAML configuration for {name}." "description": "Please enter the password you set in your ESPHome device YAML configuration for `{name}`."
}, },
"encryption_key": { "encryption_key": {
"data": { "data": {
@@ -50,7 +53,7 @@
"data_description": { "data_description": {
"noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration." "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration."
}, },
"description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." "description": "Please enter the encryption key for `{name}`. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
}, },
"reauth_confirm": { "reauth_confirm": {
"data": { "data": {
@@ -59,10 +62,10 @@
"data_description": { "data_description": {
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]" "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]"
}, },
"description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration." "description": "The ESPHome device `{name}` enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
}, },
"reauth_encryption_removed_confirm": { "reauth_encryption_removed_confirm": {
"description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections." "description": "The ESPHome device `{name}` disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
}, },
"discovery_confirm": { "discovery_confirm": {
"description": "Do you want to add the device `{name}` to Home Assistant?", "description": "Do you want to add the device `{name}` to Home Assistant?",

View File

@@ -72,7 +72,11 @@ async def get_hosts_list_if_supported(
supports_hosts: bool = True supports_hosts: bool = True
fbx_devices: list[dict[str, Any]] = [] fbx_devices: list[dict[str, Any]] = []
try: 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: except HttpRequestError as err:
if ( if (
(matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err))) (matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err)))

View File

@@ -2,13 +2,12 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, ValuesView from collections.abc import Callable, Mapping, ValuesView
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import partial from functools import partial
import logging import logging
import re import re
from types import MappingProxyType
from typing import Any, TypedDict, cast from typing import Any, TypedDict, cast
from fritzconnection import FritzConnection from fritzconnection import FritzConnection
@@ -187,7 +186,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
) )
self._devices: dict[str, FritzDevice] = {} 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._unique_id: str | None = None
self.connection: FritzConnection = None self.connection: FritzConnection = None
self.fritz_guest_wifi: FritzGuestWLAN = None self.fritz_guest_wifi: FritzGuestWLAN = None
@@ -213,9 +212,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
str, Callable[[FritzStatus, StateType], Any] str, Callable[[FritzStatus, StateType], Any]
] = {} ] = {}
async def async_setup( async def async_setup(self, options: Mapping[str, Any] | None = None) -> None:
self, options: MappingProxyType[str, Any] | None = None
) -> None:
"""Wrap up FritzboxTools class setup.""" """Wrap up FritzboxTools class setup."""
self._options = options self._options = options
await self.hass.async_add_executor_job(self.setup) await self.hass.async_add_executor_job(self.setup)

View File

@@ -53,8 +53,11 @@ MAX_TEMPERATURE = 28
# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) # special temperatures for on/off in Fritz!Box API (modified by pyfritzhome)
ON_API_TEMPERATURE = 127.0 ON_API_TEMPERATURE = 127.0
OFF_API_TEMPERATURE = 126.5 OFF_API_TEMPERATURE = 126.5
ON_REPORT_SET_TEMPERATURE = 30.0 PRESET_API_HKR_STATE_MAPPING = {
OFF_REPORT_SET_TEMPERATURE = 0.0 PRESET_COMFORT: "comfort",
PRESET_BOOST: "on",
PRESET_ECO: "eco",
}
async def async_setup_entry( async def async_setup_entry(
@@ -128,29 +131,28 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return self.data.actual_temperature # type: ignore [no-any-return] return self.data.actual_temperature # type: ignore [no-any-return]
@property @property
def target_temperature(self) -> float: def target_temperature(self) -> float | None:
"""Return the temperature we try to reach.""" """Return the temperature we try to reach."""
if self.data.target_temperature == ON_API_TEMPERATURE: if self.data.target_temperature in [ON_API_TEMPERATURE, OFF_API_TEMPERATURE]:
return ON_REPORT_SET_TEMPERATURE return None
if self.data.target_temperature == OFF_API_TEMPERATURE:
return OFF_REPORT_SET_TEMPERATURE
return self.data.target_temperature # type: ignore [no-any-return] 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: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature.""" """Set new target temperature."""
if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF: if kwargs.get(ATTR_HVAC_MODE) is HVACMode.OFF:
await self.async_set_hvac_mode(hvac_mode) await self.async_set_hkr_state("off")
elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None: 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( await self.hass.async_add_executor_job(
self.data.set_target_temperature, target_temp, True self.data.set_target_temperature, target_temp, True
) )
await self.coordinator.async_refresh()
else: else:
return return
await self.coordinator.async_refresh()
@property @property
def hvac_mode(self) -> HVACMode: def hvac_mode(self) -> HVACMode:
@@ -159,10 +161,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return HVACMode.HEAT return HVACMode.HEAT
if self.data.summer_active: if self.data.summer_active:
return HVACMode.OFF return HVACMode.OFF
if self.data.target_temperature in ( if self.data.target_temperature == OFF_API_TEMPERATURE:
OFF_REPORT_SET_TEMPERATURE,
OFF_API_TEMPERATURE,
):
return HVACMode.OFF return HVACMode.OFF
return HVACMode.HEAT return HVACMode.HEAT
@@ -180,7 +179,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
) )
return return
if hvac_mode is HVACMode.OFF: if hvac_mode is HVACMode.OFF:
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) await self.async_set_hkr_state("off")
else: else:
if value_scheduled_preset(self.data) == PRESET_ECO: if value_scheduled_preset(self.data) == PRESET_ECO:
target_temp = self.data.eco_temperature target_temp = self.data.eco_temperature
@@ -210,12 +209,7 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="change_preset_while_active_mode", translation_key="change_preset_while_active_mode",
) )
if preset_mode == PRESET_COMFORT: await self.async_set_hkr_state(PRESET_API_HKR_STATE_MAPPING[preset_mode])
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)
@property @property
def extra_state_attributes(self) -> ClimateExtraAttributes: def extra_state_attributes(self) -> ClimateExtraAttributes:

View File

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

View File

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

View File

@@ -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

View File

@@ -208,7 +208,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
async def google_generative_ai_config_option_schema( async def google_generative_ai_config_option_schema(
hass: HomeAssistant, hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any], options: Mapping[str, Any],
genai_client: genai.Client, genai_client: genai.Client,
) -> dict: ) -> dict:
"""Return a schema for Google Generative AI completion options.""" """Return a schema for Google Generative AI completion options."""

View File

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

View File

@@ -3,7 +3,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Create Group", "title": "Create group",
"description": "Groups allow you to create a new entity that represents multiple entities of the same type.", "description": "Groups allow you to create a new entity that represents multiple entities of the same type.",
"menu_options": { "menu_options": {
"binary_sensor": "Binary sensor group", "binary_sensor": "Binary sensor group",
@@ -104,7 +104,7 @@
"round_digits": "Round value to number of decimals", "round_digits": "Round value to number of decimals",
"device_class": "Device class", "device_class": "Device class",
"state_class": "State class", "state_class": "State class",
"unit_of_measurement": "Unit of Measurement" "unit_of_measurement": "Unit of measurement"
} }
}, },
"switch": { "switch": {

View File

@@ -51,7 +51,6 @@ from homeassistant.helpers.hassio import (
get_supervisor_ip as _get_supervisor_ip, get_supervisor_ip as _get_supervisor_ip,
is_hassio as _is_hassio, is_hassio as _is_hassio,
) )
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.hassio import ( from homeassistant.helpers.service_info.hassio import (
HassioServiceInfo as _HassioServiceInfo, HassioServiceInfo as _HassioServiceInfo,
) )
@@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
SERVICE_ADDON_START = "addon_start" SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop" SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart" SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_UPDATE = "addon_update"
SERVICE_ADDON_STDIN = "addon_stdin" SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown" SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot" SERVICE_HOST_REBOOT = "host_reboot"
@@ -241,7 +239,6 @@ MAP_SERVICE_API = {
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON), SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON), SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON), SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings( SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN "/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: async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io.""" """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] api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy() data = service.data.copy()

View File

@@ -22,9 +22,6 @@
"addon_stop": { "addon_stop": {
"service": "mdi:stop" "service": "mdi:stop"
}, },
"addon_update": {
"service": "mdi:update"
},
"host_reboot": { "host_reboot": {
"service": "mdi:restart" "service": "mdi:restart"
}, },

View File

@@ -30,14 +30,6 @@ addon_stop:
selector: selector:
addon: addon:
addon_update:
fields:
addon:
required: true
example: core_ssh
selector:
addon:
host_reboot: host_reboot:
host_shutdown: host_shutdown:
backup_full: backup_full:

View File

@@ -225,10 +225,6 @@
"unsupported_virtualization_image": { "unsupported_virtualization_image": {
"title": "Unsupported system - Incorrect OS image for virtualization", "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." "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": { "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": { "host_reboot": {
"name": "Reboot the host system", "name": "Reboot the host system",
"description": "Reboots the host system." "description": "Reboots the host system."

View File

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

View File

@@ -47,8 +47,8 @@ from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes MAX_EXECUTIONS_TIME_WINDOW = 60 * 60 # 1 hour
MAX_EXECUTIONS = 5 MAX_EXECUTIONS = 8
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator] type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
"""The Homee number platform.""" """The Homee number platform."""
from collections.abc import Callable
from dataclasses import dataclass
from pyHomee.const import AttributeType from pyHomee.const import AttributeType
from pyHomee.model import HomeeAttribute from pyHomee.model import HomeeAttribute
@@ -8,7 +11,7 @@ from homeassistant.components.number import (
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
) )
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory, UnitOfSpeed
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -18,69 +21,89 @@ from .entity import HomeeEntity
PARALLEL_UPDATES = 0 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 = { NUMBER_DESCRIPTIONS = {
AttributeType.DOWN_POSITION: NumberEntityDescription( AttributeType.DOWN_POSITION: HomeeNumberEntityDescription(
key="down_position", key="down_position",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription( AttributeType.DOWN_SLAT_POSITION: HomeeNumberEntityDescription(
key="down_slat_position", key="down_slat_position",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.DOWN_TIME: NumberEntityDescription( AttributeType.DOWN_TIME: HomeeNumberEntityDescription(
key="down_time", key="down_time",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription( AttributeType.ENDPOSITION_CONFIGURATION: HomeeNumberEntityDescription(
key="endposition_configuration", key="endposition_configuration",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription( AttributeType.MOTION_ALARM_CANCELATION_DELAY: HomeeNumberEntityDescription(
key="motion_alarm_cancelation_delay", key="motion_alarm_cancelation_delay",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription( AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: HomeeNumberEntityDescription(
key="open_window_detection_sensibility", key="open_window_detection_sensibility",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.POLLING_INTERVAL: NumberEntityDescription( AttributeType.POLLING_INTERVAL: HomeeNumberEntityDescription(
key="polling_interval", key="polling_interval",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription( AttributeType.SHUTTER_SLAT_TIME: HomeeNumberEntityDescription(
key="shutter_slat_time", key="shutter_slat_time",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription( AttributeType.SLAT_MAX_ANGLE: HomeeNumberEntityDescription(
key="slat_max_angle", key="slat_max_angle",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription( AttributeType.SLAT_MIN_ANGLE: HomeeNumberEntityDescription(
key="slat_min_angle", key="slat_min_angle",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.SLAT_STEPS: NumberEntityDescription( AttributeType.SLAT_STEPS: HomeeNumberEntityDescription(
key="slat_steps", key="slat_steps",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription( AttributeType.TEMPERATURE_OFFSET: HomeeNumberEntityDescription(
key="temperature_offset", key="temperature_offset",
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.UP_TIME: NumberEntityDescription( AttributeType.UP_TIME: HomeeNumberEntityDescription(
key="up_time", key="up_time",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
), ),
AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription( AttributeType.WAKE_UP_INTERVAL: HomeeNumberEntityDescription(
key="wake_up_interval", key="wake_up_interval",
device_class=NumberDeviceClass.DURATION, device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG, 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): class HomeeNumber(HomeeEntity, NumberEntity):
"""Representation of a Homee number.""" """Representation of a Homee number."""
entity_description: HomeeNumberEntityDescription
def __init__( def __init__(
self, self,
attribute: HomeeAttribute, attribute: HomeeAttribute,
entry: HomeeConfigEntry, entry: HomeeConfigEntry,
description: NumberEntityDescription, description: HomeeNumberEntityDescription,
) -> None: ) -> None:
"""Initialize a Homee number entity.""" """Initialize a Homee number entity."""
super().__init__(attribute, entry) super().__init__(attribute, entry)
self.entity_description = description self.entity_description = description
self._attr_translation_key = description.key self._attr_translation_key = description.key
self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit] self._attr_native_unit_of_measurement = (
self._attr_native_min_value = attribute.minimum description.native_unit_of_measurement
self._attr_native_max_value = attribute.maximum or HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
self._attr_native_step = attribute.step_value )
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 @property
def available(self) -> bool: def available(self) -> bool:
@@ -123,10 +151,12 @@ class HomeeNumber(HomeeEntity, NumberEntity):
return super().available and self._attribute.editable return super().available and self._attribute.editable
@property @property
def native_value(self) -> int: def native_value(self) -> float | None:
"""Return the native value of the number.""" """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: async def async_set_native_value(self, value: float) -> None:
"""Set the selected value.""" """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)
)

View File

@@ -189,6 +189,9 @@
}, },
"wake_up_interval": { "wake_up_interval": {
"name": "Wake-up interval" "name": "Wake-up interval"
},
"wind_monitoring_state": {
"name": "Threshold for wind trigger"
} }
}, },
"select": { "select": {

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"], "loggers": ["pyhap"],
"requirements": [ "requirements": [
"HAP-python==4.9.2", "HAP-python==4.9.2",
"fnv-hash-fast==1.4.0", "fnv-hash-fast==1.5.0",
"PyQRCode==1.2.1", "PyQRCode==1.2.1",
"base36==0.1.1" "base36==0.1.1"
], ],

View File

@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller", "documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"], "loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.2.13"], "requirements": ["aiohomekit==3.2.14"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
} }

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,10 @@
"data": { "data": {
"password": "[%key:common::config_flow::data::password%]", "password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]" "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": { "user": {
@@ -35,6 +39,12 @@
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" "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.", "description": "Enter device access details.",
"title": "Configure Huawei LTE" "title": "Configure Huawei LTE"
} }
@@ -48,6 +58,12 @@
"recipient": "SMS notification recipients", "recipient": "SMS notification recipients",
"track_wired_clients": "Track wired network clients", "track_wired_clients": "Track wired network clients",
"unauthenticated_mode": "Unauthenticated mode (change requires reload)" "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."
} }
} }
} }

View File

@@ -57,7 +57,7 @@
"3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]", "3": "[%key:component::hue::device_automation::trigger_subtype::button_3%]",
"4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]", "4": "[%key:component::hue::device_automation::trigger_subtype::button_4%]",
"clock_wise": "Rotation clockwise", "clock_wise": "Rotation clockwise",
"counter_clock_wise": "Rotation counter-clockwise" "counter_clock_wise": "Rotation counterclockwise"
}, },
"trigger_type": { "trigger_type": {
"remote_button_long_release": "\"{subtype}\" released after long press", "remote_button_long_release": "\"{subtype}\" released after long press",
@@ -96,7 +96,7 @@
"event_type": { "event_type": {
"state": { "state": {
"clock_wise": "Clockwise", "clock_wise": "Clockwise",
"counter_clock_wise": "Counter clockwise" "counter_clock_wise": "Counterclockwise"
} }
} }
} }

View File

@@ -61,6 +61,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self._zones_last_update: dict[str, set[str]] = {} self._zones_last_update: dict[str, set[str]] = {}
self._areas_last_update: dict[str, set[int]] = {} 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: async def _async_update_data(self) -> MowerDictionary:
"""Subscribe for websocket and poll data from the API.""" """Subscribe for websocket and poll data from the API."""
if not self.ws_connected: if not self.ws_connected:
@@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
raise UpdateFailed(err) from err raise UpdateFailed(err) from err
except AuthError as err: except AuthError as err:
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
self._async_add_remove_devices_and_entities(data)
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)
return data return data
@callback @callback
def callback(self, ws_data: MowerDictionary) -> None: def callback(self, ws_data: MowerDictionary) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator.""" """Process websocket callbacks and write them to the DataUpdateCoordinator."""
self.async_set_updated_data(ws_data) self.async_set_updated_data(ws_data)
self._async_add_remove_devices_and_entities(ws_data)
async def client_listen( async def client_listen(
self, self,

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass
import logging import logging
from typing import Any, cast from typing import Any, cast
@@ -22,9 +23,6 @@ from homeassistant.helpers.dispatcher import (
) )
from .const import ( from .const import (
CONF_INSTANCE_CLIENTS,
CONF_ON_UNLOAD,
CONF_ROOT_CLIENT,
DEFAULT_NAME, DEFAULT_NAME,
DOMAIN, DOMAIN,
HYPERION_RELEASES_URL, 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 # The get_hyperion_unique_id method will create a per-entity unique id when given the
# server id, an instance number and a name. # server id, an instance number and a name.
# hass.data format type HyperionConfigEntry = ConfigEntry[HyperionData]
# ================
#
# hass.data[DOMAIN] = { @dataclass
# <config_entry.entry_id>: { class HyperionData:
# "ROOT_CLIENT": <Hyperion Client>, """Hyperion runtime data."""
# "ON_UNLOAD": [<callable>, ...],
# } root_client: client.HyperionClient
# } instance_clients: dict[int, client.HyperionClient]
def get_hyperion_unique_id(server_id: str, instance: int, name: str) -> str: 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 @callback
def listen_for_instance_updates( def listen_for_instance_updates(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, entry: HyperionConfigEntry,
add_func: Callable, add_func: Callable[[int, str], None],
remove_func: Callable, remove_func: Callable[[int], None],
) -> None: ) -> None:
"""Listen for instance additions/removals.""" """Listen for instance additions/removals."""
hass.data[DOMAIN][config_entry.entry_id][CONF_ON_UNLOAD].extend( entry.async_on_unload(
[
async_dispatcher_connect( async_dispatcher_connect(
hass, hass,
SIGNAL_INSTANCE_ADD.format(config_entry.entry_id), SIGNAL_INSTANCE_ADD.format(entry.entry_id),
add_func, add_func,
), )
)
entry.async_on_unload(
async_dispatcher_connect( async_dispatcher_connect(
hass, hass,
SIGNAL_INSTANCE_REMOVE.format(config_entry.entry_id), SIGNAL_INSTANCE_REMOVE.format(entry.entry_id),
remove_func, 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.""" """Set up Hyperion from a config entry."""
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
port = entry.data[CONF_PORT] 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 # 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 # per Hyperion server instance which is shared for all entities associated with
# that instance. # that instance.
hass.data.setdefault(DOMAIN, {}) entry.runtime_data = HyperionData(
hass.data[DOMAIN][entry.entry_id] = { root_client=hyperion_client,
CONF_ROOT_CLIENT: hyperion_client, instance_clients={},
CONF_INSTANCE_CLIENTS: {}, )
CONF_ON_UNLOAD: [],
}
async def async_instances_to_clients(response: dict[str, Any]) -> None: async def async_instances_to_clients(response: dict[str, Any]) -> None:
"""Convert instances to Hyperion clients.""" """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) device_registry = dr.async_get(hass)
running_instances: set[int] = set() running_instances: set[int] = set()
stopped_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) server_id = cast(str, entry.unique_id)
# In practice, an instance can be in 3 states as seen by this function: # 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 assert hyperion_client
if hyperion_client.instances is not None: if hyperion_client.instances is not None:
await async_instances_to_clients_raw(hyperion_client.instances) await async_instances_to_clients_raw(hyperion_client.instances)
hass.data[DOMAIN][entry.entry_id][CONF_ON_UNLOAD].append( entry.async_on_unload(entry.add_update_listener(_async_entry_updated))
entry.add_update_listener(_async_entry_updated)
)
return True 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.""" """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 a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms( unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
config_entry, PLATFORMS if unload_ok:
)
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()
# Disconnect the shared instance clients. # Disconnect the shared instance clients.
await asyncio.gather( await asyncio.gather(
*( *(
config_data[CONF_INSTANCE_CLIENTS][ inst.async_client_disconnect()
instance_num for inst in entry.runtime_data.instance_clients.values()
].async_client_disconnect()
for instance_num in config_data[CONF_INSTANCE_CLIENTS]
) )
) )
# Disconnect the root client. # Disconnect the root client.
root_client = config_data[CONF_ROOT_CLIENT] root_client = entry.runtime_data.root_client
await root_client.async_client_disconnect() await root_client.async_client_disconnect()
return unload_ok return unload_ok

View File

@@ -25,7 +25,6 @@ from homeassistant.components.camera import (
Camera, Camera,
async_get_still_stream, async_get_still_stream,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
@@ -35,12 +34,12 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ( from . import (
HyperionConfigEntry,
get_hyperion_device_id, get_hyperion_device_id,
get_hyperion_unique_id, get_hyperion_unique_id,
listen_for_instance_updates, listen_for_instance_updates,
) )
from .const import ( from .const import (
CONF_INSTANCE_CLIENTS,
DOMAIN, DOMAIN,
HYPERION_MANUFACTURER_NAME, HYPERION_MANUFACTURER_NAME,
HYPERION_MODEL_NAME, HYPERION_MODEL_NAME,
@@ -53,12 +52,11 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64,"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, entry: HyperionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up a Hyperion platform from config entry.""" """Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = entry.unique_id
server_id = config_entry.unique_id
def camera_unique_id(instance_num: int) -> str: def camera_unique_id(instance_num: int) -> str:
"""Return the camera unique_id.""" """Return the camera unique_id."""
@@ -75,7 +73,7 @@ async def async_setup_entry(
server_id, server_id,
instance_num, instance_num,
instance_name, 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: # A note on Hyperion streaming semantics:

View File

@@ -3,10 +3,7 @@
CONF_AUTH_ID = "auth_id" CONF_AUTH_ID = "auth_id"
CONF_CREATE_TOKEN = "create_token" CONF_CREATE_TOKEN = "create_token"
CONF_INSTANCE = "instance" CONF_INSTANCE = "instance"
CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS"
CONF_ON_UNLOAD = "ON_UNLOAD"
CONF_PRIORITY = "priority" CONF_PRIORITY = "priority"
CONF_ROOT_CLIENT = "ROOT_CLIENT"
CONF_EFFECT_HIDE_LIST = "effect_hide_list" CONF_EFFECT_HIDE_LIST = "effect_hide_list"
CONF_EFFECT_SHOW_LIST = "effect_show_list" CONF_EFFECT_SHOW_LIST = "effect_show_list"

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
import functools import functools
import logging import logging
from types import MappingProxyType
from typing import Any from typing import Any
from hyperion import client, const from hyperion import client, const
@@ -18,7 +17,6 @@ from homeassistant.components.light import (
LightEntity, LightEntity,
LightEntityFeature, LightEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import ( 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 homeassistant.util import color as color_util
from . import ( from . import (
HyperionConfigEntry,
get_hyperion_device_id, get_hyperion_device_id,
get_hyperion_unique_id, get_hyperion_unique_id,
listen_for_instance_updates, listen_for_instance_updates,
) )
from .const import ( from .const import (
CONF_EFFECT_HIDE_LIST, CONF_EFFECT_HIDE_LIST,
CONF_INSTANCE_CLIENTS,
CONF_PRIORITY, CONF_PRIORITY,
DEFAULT_ORIGIN, DEFAULT_ORIGIN,
DEFAULT_PRIORITY, DEFAULT_PRIORITY,
@@ -75,28 +73,26 @@ ICON_EFFECT = "mdi:lava-lamp"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, entry: HyperionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up a Hyperion platform from config entry.""" """Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = entry.unique_id
server_id = config_entry.unique_id
@callback @callback
def instance_add(instance_num: int, instance_name: str) -> None: def instance_add(instance_num: int, instance_name: str) -> None:
"""Add entities for a new Hyperion instance.""" """Add entities for a new Hyperion instance."""
assert server_id assert server_id
args = ( async_add_entities(
[
HyperionLight(
server_id, server_id,
instance_num, instance_num,
instance_name, instance_name,
config_entry.options, entry.options,
entry_data[CONF_INSTANCE_CLIENTS][instance_num], entry.runtime_data.instance_clients[instance_num],
) ),
async_add_entities(
[
HyperionLight(*args),
] ]
) )
@@ -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): class HyperionLight(LightEntity):
@@ -129,7 +125,7 @@ class HyperionLight(LightEntity):
server_id: str, server_id: str,
instance_num: int, instance_num: int,
instance_name: str, instance_name: str,
options: MappingProxyType[str, Any], options: Mapping[str, Any],
hyperion_client: client.HyperionClient, hyperion_client: client.HyperionClient,
) -> None: ) -> None:
"""Initialize the light.""" """Initialize the light."""

View File

@@ -19,7 +19,6 @@ from hyperion.const import (
) )
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
@@ -29,12 +28,12 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ( from . import (
HyperionConfigEntry,
get_hyperion_device_id, get_hyperion_device_id,
get_hyperion_unique_id, get_hyperion_unique_id,
listen_for_instance_updates, listen_for_instance_updates,
) )
from .const import ( from .const import (
CONF_INSTANCE_CLIENTS,
DOMAIN, DOMAIN,
HYPERION_MANUFACTURER_NAME, HYPERION_MANUFACTURER_NAME,
HYPERION_MODEL_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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, entry: HyperionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up a Hyperion platform from config entry.""" """Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = entry.unique_id
server_id = config_entry.unique_id
@callback @callback
def instance_add(instance_num: int, instance_name: str) -> None: def instance_add(instance_num: int, instance_name: str) -> None:
@@ -78,7 +76,7 @@ async def async_setup_entry(
server_id, server_id,
instance_num, instance_num,
instance_name, instance_name,
entry_data[CONF_INSTANCE_CLIENTS][instance_num], entry.runtime_data.instance_clients[instance_num],
PRIORITY_SENSOR_DESCRIPTION, 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): class HyperionSensor(SensorEntity):

View File

@@ -26,7 +26,6 @@ from hyperion.const import (
) )
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@@ -38,12 +37,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify from homeassistant.util import slugify
from . import ( from . import (
HyperionConfigEntry,
get_hyperion_device_id, get_hyperion_device_id,
get_hyperion_unique_id, get_hyperion_unique_id,
listen_for_instance_updates, listen_for_instance_updates,
) )
from .const import ( from .const import (
CONF_INSTANCE_CLIENTS,
DOMAIN, DOMAIN,
HYPERION_MANUFACTURER_NAME, HYPERION_MANUFACTURER_NAME,
HYPERION_MODEL_NAME, HYPERION_MODEL_NAME,
@@ -89,12 +88,11 @@ def _component_to_translation_key(component: str) -> str:
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, entry: HyperionConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up a Hyperion platform from config entry.""" """Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id] server_id = entry.unique_id
server_id = config_entry.unique_id
@callback @callback
def instance_add(instance_num: int, instance_name: str) -> None: def instance_add(instance_num: int, instance_name: str) -> None:
@@ -106,7 +104,7 @@ async def async_setup_entry(
instance_num, instance_num,
instance_name, instance_name,
component, component,
entry_data[CONF_INSTANCE_CLIENTS][instance_num], entry.runtime_data.instance_clients[instance_num],
) )
for component in COMPONENT_SWITCHES 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): class HyperionComponentSwitch(SwitchEntity):

View File

@@ -7,7 +7,7 @@
"description": "Select fireplace by serial number:" "description": "Select fireplace by serial number:"
}, },
"cloud_api": { "cloud_api": {
"description": "Authenticate against IntelliFire Cloud", "description": "Authenticate against IntelliFire cloud",
"data_description": { "data_description": {
"username": "Your IntelliFire app username", "username": "Your IntelliFire app username",
"password": "Your IntelliFire app password" "password": "Your IntelliFire app password"
@@ -45,7 +45,7 @@
"name": "Pilot flame error" "name": "Pilot flame error"
}, },
"flame_error": { "flame_error": {
"name": "Flame Error" "name": "Flame error"
}, },
"fan_delay_error": { "fan_delay_error": {
"name": "Fan delay error" "name": "Fan delay error"
@@ -104,7 +104,7 @@
"name": "Target temperature" "name": "Target temperature"
}, },
"fan_speed": { "fan_speed": {
"name": "Fan Speed" "name": "Fan speed"
}, },
"timer_end_timestamp": { "timer_end_timestamp": {
"name": "Timer end" "name": "Timer end"

View File

@@ -22,6 +22,7 @@ from pynecil import (
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.debounce import Debouncer
@@ -83,7 +84,11 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
self.device_info = await self.device.get_device_info() self.device_info = await self.device.get_device_info()
except CommunicationError as e: 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 self.v223_features = AwesomeVersion(self.device_info.build) >= V223
@@ -108,7 +113,11 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
return await self.device.get_live_data() return await self.device.get_live_data()
except CommunicationError as e: 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 @property
def has_tip(self) -> bool: def has_tip(self) -> bool:
@@ -187,4 +196,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
try: try:
return await self.github.latest_release() return await self.github.latest_release()
except UpdateException as e: 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

View File

@@ -284,6 +284,12 @@
}, },
"submit_setting_failed": { "submit_setting_failed": {
"message": "Failed to submit setting to device, try again later" "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"
} }
} }
} }

View File

@@ -4,11 +4,11 @@ from __future__ import annotations
import logging 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.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN from .const import DOMAIN
from .coordinator import IstaConfigEntry, IstaCoordinator from .coordinator import IstaConfigEntry, IstaCoordinator
@@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool
entry.data[CONF_PASSWORD], entry.data[CONF_PASSWORD],
_LOGGER, _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) coordinator = IstaCoordinator(hass, entry, ista)
await coordinator.async_config_entry_first_refresh() 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: async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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)

View File

@@ -100,8 +100,19 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_PASSWORD], user_input[CONF_PASSWORD],
_LOGGER, _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: try:
await self.hass.async_add_executor_job(ista.login) consumption_units = await self.hass.async_add_executor_job(
get_consumption_units
)
except ServerError: except ServerError:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except (LoginError, KeycloakError): except (LoginError, KeycloakError):
@@ -110,6 +121,8 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: 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_update_reload_and_abort(reauth_entry, data=user_input)
return self.async_show_form( return self.async_show_form(

View File

@@ -11,7 +11,7 @@ from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerErr
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@@ -25,6 +25,7 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Ista EcoTrend data update coordinator.""" """Ista EcoTrend data update coordinator."""
config_entry: IstaConfigEntry config_entry: IstaConfigEntry
details: dict[str, Any]
def __init__( def __init__(
self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta self, hass: HomeAssistant, config_entry: IstaConfigEntry, ista: PyEcotrendIsta
@@ -38,22 +39,35 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
update_interval=timedelta(days=1), update_interval=timedelta(days=1),
) )
self.ista = ista 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): async def _async_update_data(self):
"""Fetch ista EcoTrend data.""" """Fetch ista EcoTrend data."""
try: 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) return await self.hass.async_add_executor_job(self.get_consumption_data)
except ServerError as e: except ServerError as e:
raise UpdateFailed( raise UpdateFailed(
"Unable to connect and retrieve data from ista EcoTrend, try again later" translation_domain=DOMAIN,
translation_key="connection_exception",
) from e ) from e
except (LoginError, KeycloakError) as e: except (LoginError, KeycloakError) as e:
raise ConfigEntryAuthFailed( raise ConfigEntryAuthFailed(
@@ -67,17 +81,17 @@ class IstaCoordinator(DataUpdateCoordinator[dict[str, Any]]):
def get_consumption_data(self) -> dict[str, Any]: def get_consumption_data(self) -> dict[str, Any]:
"""Get raw json data for all consumption units.""" """Get raw json data for all consumption units."""
self.ista.login()
return { return {
consumption_unit: self.ista.get_consumption_data(consumption_unit) consumption_unit: self.ista.get_consumption_data(consumption_unit)
for consumption_unit in self.ista.get_uuids() 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.""" """Retrieve details of consumption units."""
result = await self.hass.async_add_executor_job( self.ista.login()
self.ista.get_consumption_unit_details result = self.ista.get_consumption_unit_details()
)
return { return {
consumption_unit: next( consumption_unit: next(

View 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),
}

View File

@@ -5,12 +5,8 @@ rules:
comment: The integration registers no actions. comment: The integration registers no actions.
appropriate-polling: done appropriate-polling: done
brands: done brands: done
common-modules: common-modules: done
status: todo config-flow-test-coverage: done
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,
config-flow: done config-flow: done
dependency-transparency: done dependency-transparency: done
docs-actions: docs-actions:
@@ -47,7 +43,7 @@ rules:
# Gold # Gold
devices: done devices: done
diagnostics: todo diagnostics: done
discovery-update-info: discovery-update-info:
status: exempt status: exempt
comment: The integration is a web service, there are no discoverable devices. comment: The integration is a web service, there are no discoverable devices.

View File

@@ -2,7 +2,8 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"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": { "error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

@@ -73,7 +73,7 @@ async def build_root_response(
children = [ children = [
await item_payload(hass, client, user_id, folder) await item_payload(hass, client, user_id, folder)
for folder in folders["Items"] for folder in folders["Items"]
if folder["CollectionType"] in SUPPORTED_COLLECTION_TYPES if folder.get("CollectionType") in SUPPORTED_COLLECTION_TYPES
] ]
return BrowseMedia( return BrowseMedia(

View File

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

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30) SCAN_INTERVAL = timedelta(seconds=15)
SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) SETTINGS_UPDATE_INTERVAL = timedelta(hours=1)
SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -82,9 +82,14 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator): class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally.""" """Class to handle fetching data from the La Marzocco API centrally."""
async def _async_connect_websocket(self) -> None: async def _internal_async_update_data(self) -> None:
"""Set up the coordinator.""" """Fetch data from API endpoint."""
if not self.device.websocket.connected:
if self.device.websocket.connected:
return
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
_LOGGER.debug("Init WebSocket in background task") _LOGGER.debug("Init WebSocket in background task")
self.config_entry.async_create_background_task( self.config_entry.async_create_background_task(
@@ -100,18 +105,10 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
await self.device.websocket.disconnect() await self.device.websocket.disconnect()
self.config_entry.async_on_unload( self.config_entry.async_on_unload(
self.hass.bus.async_listen_once( self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, websocket_close)
EVENT_HOMEASSISTANT_STOP, websocket_close
)
) )
self.config_entry.async_on_unload(websocket_close) self.config_entry.async_on_unload(websocket_close)
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.device.get_dashboard()
_LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
await self._async_connect_websocket()
class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator): class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Coordinator for La Marzocco settings.""" """Coordinator for La Marzocco settings."""

View File

@@ -76,6 +76,9 @@
"coffee_boiler_ready_time": { "coffee_boiler_ready_time": {
"default": "mdi:av-timer" "default": "mdi:av-timer"
}, },
"last_cleaning_time": {
"default": "mdi:spray-bottle"
},
"steam_boiler_ready_time": { "steam_boiler_ready_time": {
"default": "mdi:av-timer" "default": "mdi:av-timer"
} }

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["pylamarzocco"], "loggers": ["pylamarzocco"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pylamarzocco==2.0.0b1"] "requirements": ["pylamarzocco==2.0.0b3"]
} }

View File

@@ -7,6 +7,7 @@ from typing import cast
from pylamarzocco.const import ModelName, WidgetType from pylamarzocco.const import ModelName, WidgetType
from pylamarzocco.models import ( from pylamarzocco.models import (
BackFlush,
BaseWidgetOutput, BaseWidgetOutput,
CoffeeBoiler, CoffeeBoiler,
SteamBoilerLevel, SteamBoilerLevel,
@@ -84,6 +85,17 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI) 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,
),
) )

View File

@@ -146,6 +146,9 @@
}, },
"steam_boiler_ready_time": { "steam_boiler_ready_time": {
"name": "Steam boiler ready time" "name": "Steam boiler ready time"
},
"last_cleaning_time": {
"name": "Last cleaning time"
} }
}, },
"switch": { "switch": {

View File

@@ -24,12 +24,17 @@ from homeassistant.const import (
CONF_IP_ADDRESS, CONF_IP_ADDRESS,
CONF_PASSWORD, CONF_PASSWORD,
CONF_PORT, CONF_PORT,
CONF_RESOURCE,
CONF_USERNAME, CONF_USERNAME,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady 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 homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
@@ -38,6 +43,7 @@ from .const import (
CONF_DIM_MODE, CONF_DIM_MODE,
CONF_DOMAIN_DATA, CONF_DOMAIN_DATA,
CONF_SK_NUM_TRIES, CONF_SK_NUM_TRIES,
CONF_TARGET_VALUE_LOCKED,
CONF_TRANSITION, CONF_TRANSITION,
CONNECTION, CONNECTION,
DEVICE_CONNECTIONS, DEVICE_CONNECTIONS,
@@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if config_entry.minor_version < 2: if config_entry.minor_version < 2:
new_data[CONF_ACKNOWLEDGE] = False new_data[CONF_ACKNOWLEDGE] = False
if config_entry.version < 2:
# update to 2.1 (fix transitions for lights and switches) # update to 2.1 (fix transitions for lights and switches)
new_entities_data = [*new_data[CONF_ENTITIES]] new_entities_data = [*new_data[CONF_ENTITIES]]
for entity in new_entities_data: 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 entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0
new_data[CONF_ENTITIES] = new_entities_data 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( 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( _LOGGER.debug(
@@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True 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: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Close connection to PCHK host represented by config_entry.""" """Close connection to PCHK host represented by config_entry."""
# forward unloading to platforms # forward unloading to platforms

View File

@@ -110,7 +110,7 @@ async def validate_connection(data: ConfigType) -> str | None:
class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a LCN config flow.""" """Handle a LCN config flow."""
VERSION = 2 VERSION = 3
MINOR_VERSION = 1 MINOR_VERSION = 1
async def async_step_user( async def async_step_user(

View File

@@ -3,18 +3,19 @@
from collections.abc import Callable from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry 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.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN from .const import CONF_DOMAIN_DATA, DOMAIN
from .helpers import ( from .helpers import (
AddressType, AddressType,
DeviceConnectionType, DeviceConnectionType,
InputType, InputType,
generate_unique_id, generate_unique_id,
get_device_connection, get_device_connection,
get_resource,
) )
@@ -48,7 +49,11 @@ class LcnEntity(Entity):
def unique_id(self) -> str: def unique_id(self) -> str:
"""Return a unique ID.""" """Return a unique ID."""
return generate_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: async def async_added_to_hass(self) -> None:

View File

@@ -19,7 +19,6 @@ from homeassistant.const import (
CONF_ENTITIES, CONF_ENTITIES,
CONF_LIGHTS, CONF_LIGHTS,
CONF_NAME, CONF_NAME,
CONF_RESOURCE,
CONF_SENSORS, CONF_SENSORS,
CONF_SWITCHES, CONF_SWITCHES,
) )
@@ -29,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_CLIMATES, CONF_CLIMATES,
CONF_DOMAIN_DATA,
CONF_HARDWARE_SERIAL, CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE, CONF_HARDWARE_TYPE,
CONF_SCENES, CONF_SCENES,
@@ -79,9 +79,9 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
if domain_name == "cover": if domain_name == "cover":
return cast(str, domain_data["motor"]) return cast(str, domain_data["motor"])
if domain_name == "climate": if domain_name == "climate":
return f"{domain_data['source']}.{domain_data['setpoint']}" return cast(str, domain_data["setpoint"])
if domain_name == "scene": if domain_name == "scene":
return f"{domain_data['register']}.{domain_data['scene']}" return f"{domain_data['register']}{domain_data['scene']}"
raise ValueError("Unknown domain") raise ValueError("Unknown domain")
@@ -115,7 +115,9 @@ def purge_entity_registry(
references_entry_data = set() references_entry_data = set()
for entity_data in imported_entry_data[CONF_ENTITIES]: for entity_data in imported_entry_data[CONF_ENTITIES]:
entity_unique_id = generate_unique_id( 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_id = entity_registry.async_get_entity_id(
entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id entity_data[CONF_DOMAIN], DOMAIN, entity_unique_id

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pypck"], "loggers": ["pypck"],
"requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"] "requirements": ["pypck==0.8.5", "lcn-frontend==0.2.4"]
} }

View File

@@ -19,7 +19,6 @@ from homeassistant.const import (
CONF_DOMAIN, CONF_DOMAIN,
CONF_ENTITIES, CONF_ENTITIES,
CONF_NAME, CONF_NAME,
CONF_RESOURCE,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import ( from homeassistant.helpers import (
@@ -343,7 +342,6 @@ async def websocket_add_entity(
entity_config = { entity_config = {
CONF_ADDRESS: msg[CONF_ADDRESS], CONF_ADDRESS: msg[CONF_ADDRESS],
CONF_NAME: msg[CONF_NAME], CONF_NAME: msg[CONF_NAME],
CONF_RESOURCE: resource,
CONF_DOMAIN: domain_name, CONF_DOMAIN: domain_name,
CONF_DOMAIN_DATA: domain_data, CONF_DOMAIN_DATA: domain_data,
} }
@@ -371,7 +369,15 @@ async def websocket_add_entity(
vol.Required("entry_id"): cv.string, vol.Required("entry_id"): cv.string,
vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA, vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA,
vol.Required(CONF_DOMAIN): cv.string, 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 @websocket_api.async_response
@@ -390,7 +396,10 @@ async def websocket_delete_entity(
if ( if (
tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS] tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS]
and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN] 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, None,

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from typing import Any from typing import Any
@@ -120,6 +121,8 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
) )
RETRY_POLL_MAXIMUM = 3 RETRY_POLL_MAXIMUM = 3
SCAN_INTERVAL = timedelta(seconds=5)
PARALLEL_UPDATES = 1
async def async_setup_entry( async def async_setup_entry(

View File

@@ -7,38 +7,19 @@ import mimetypes
import voluptuous as vol import voluptuous as vol
from homeassistant.components.camera import ( from homeassistant.components.camera import Camera
PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA, from homeassistant.config_entries import ConfigEntry
Camera,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME 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.exceptions import ServiceValidationError
from homeassistant.helpers import ( from homeassistant.helpers import config_validation as cv, entity_platform
config_validation as cv, from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
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 .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH from .const import SERVICE_UPDATE_FILE_PATH
from .util import check_file_path_access from .util import check_file_path_access
_LOGGER = logging.getLogger(__name__) _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( async def async_setup_entry(
hass: HomeAssistant, 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): class LocalFile(Camera):
"""Representation of a local file camera.""" """Representation of a local file camera."""

View File

@@ -50,18 +50,12 @@ DATA_SCHEMA_SETUP = vol.Schema(
CONFIG_FLOW = { CONFIG_FLOW = {
"user": SchemaFlowFormStep( "user": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP, schema=DATA_SCHEMA_SETUP, validate_user_input=validate_options
validate_user_input=validate_options, )
),
"import": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP,
validate_user_input=validate_options,
),
} }
OPTIONS_FLOW = { OPTIONS_FLOW = {
"init": SchemaFlowFormStep( "init": SchemaFlowFormStep(
DATA_SCHEMA_OPTIONS, DATA_SCHEMA_OPTIONS, validate_user_input=validate_options
validate_user_input=validate_options,
) )
} }

View File

@@ -53,11 +53,5 @@
"file_path_not_accessible": { "file_path_not_accessible": {
"message": "Path {file_path} is 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