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
- name: Install Cosign
uses: sigstore/cosign-installer@v3.8.1
uses: sigstore/cosign-installer@v3.8.2
with:
cosign-release: "v2.2.3"

View File

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

2
CODEOWNERS generated
View File

@@ -1051,6 +1051,8 @@ build.json @home-assistant/supervisor
/tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
/tests/components/nsw_rural_fire_service_feed/ @exxamalte
/homeassistant/components/ntfy/ @tr4nt0r
/tests/components/ntfy/ @tr4nt0r
/homeassistant/components/nuheat/ @tstabrawa
/tests/components/nuheat/ @tstabrawa
/homeassistant/components/nuki/ @pschmitt @pvizeli @pree

View File

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

View File

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

View File

@@ -3,12 +3,12 @@
"step": {
"user": {
"data": {
"tracked_addons": "Addons",
"tracked_addons": "Add-ons",
"tracked_integrations": "Integrations",
"tracked_custom_integrations": "Custom integrations"
},
"data_description": {
"tracked_addons": "Select the addons you want to track",
"tracked_addons": "Select the add-ons you want to track",
"tracked_integrations": "Select the integrations you want to track",
"tracked_custom_integrations": "Select the custom integrations you want to track"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,46 +8,18 @@ from typing import Final
from canary.api import Api
from requests.exceptions import ConnectTimeout, HTTPError
import voluptuous as vol
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_FFMPEG_ARGUMENTS,
DEFAULT_FFMPEG_ARGUMENTS,
DEFAULT_TIMEOUT,
DOMAIN,
)
from .const import CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS, DEFAULT_TIMEOUT
from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
_LOGGER: Final = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES: Final = timedelta(seconds=30)
CONFIG_SCHEMA: Final = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(
CONF_TIMEOUT, default=DEFAULT_TIMEOUT
): cv.positive_int,
}
)
},
),
extra=vol.ALLOW_EXTRA,
)
PLATFORMS: Final[list[Platform]] = [
Platform.ALARM_CONTROL_PANEL,
Platform.CAMERA,
@@ -55,37 +27,6 @@ PLATFORMS: Final[list[Platform]] = [
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Canary integration."""
if hass.config_entries.async_entries(DOMAIN):
return True
ffmpeg_arguments = DEFAULT_FFMPEG_ARGUMENTS
if CAMERA_DOMAIN in config:
camera_config = next(
(item for item in config[CAMERA_DOMAIN] if item["platform"] == DOMAIN),
None,
)
if camera_config:
ffmpeg_arguments = camera_config.get(
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
)
if DOMAIN in config:
if ffmpeg_arguments != DEFAULT_FFMPEG_ARGUMENTS:
config[DOMAIN][CONF_FFMPEG_ARGUMENTS] = ffmpeg_arguments
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config[DOMAIN],
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: CanaryConfigEntry) -> bool:
"""Set up Canary from a config entry."""
if not entry.options:

View File

@@ -54,10 +54,6 @@ class CanaryConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return CanaryOptionsFlowHandler()
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle a flow initiated by configuration file."""
return await self.async_step_user(import_data)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,8 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from types import MappingProxyType
from typing import Any
from elevenlabs import AsyncElevenLabs
@@ -43,7 +43,7 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
def to_voice_settings(options: Mapping[str, Any]) -> VoiceSettings:
"""Return voice settings."""
return VoiceSettings(
stability=options.get(CONF_STABILITY, DEFAULT_STABILITY),

View File

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

View File

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

View File

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

View File

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

View File

@@ -336,6 +336,12 @@ class EsphomeAssistSatellite(
"code": event.data["code"],
"message": event.data["message"],
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START:
assert event.data is not None
if tts_output := event.data["tts_output"]:
path = tts_output["url"]
url = async_process_play_media_url(self.hass, path)
data_to_send = {"url": url}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_END:
if self._tts_streaming_task is None:
# No TTS

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,11 @@ async def get_hosts_list_if_supported(
supports_hosts: bool = True
fbx_devices: list[dict[str, Any]] = []
try:
fbx_devices = await fbx_api.lan.get_hosts_list() or []
fbx_interfaces = await fbx_api.lan.get_interfaces() or []
for interface in fbx_interfaces:
fbx_devices.extend(
await fbx_api.lan.get_hosts_list(interface["name"]) or []
)
except HttpRequestError as err:
if (
(matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err)))

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any],
options: Mapping[str, Any],
genai_client: genai.Client,
) -> dict:
"""Return a schema for Google Generative AI completion options."""

View File

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

View File

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

View File

@@ -51,7 +51,6 @@ from homeassistant.helpers.hassio import (
get_supervisor_ip as _get_supervisor_ip,
is_hassio as _is_hassio,
)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.hassio import (
HassioServiceInfo as _HassioServiceInfo,
)
@@ -160,7 +159,6 @@ CONFIG_SCHEMA = vol.Schema(
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_UPDATE = "addon_update"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
@@ -241,7 +239,6 @@ MAP_SERVICE_API = {
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_UPDATE: APIEndpointSettings("/addons/{addon}/update", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
),
@@ -411,16 +408,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
if service.service == SERVICE_ADDON_UPDATE:
async_create_issue(
hass,
DOMAIN,
"update_service_deprecated",
breaks_in_ha_version="2025.5",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="update_service_deprecated",
)
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()

View File

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

View File

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

View File

@@ -225,10 +225,6 @@
"unsupported_virtualization_image": {
"title": "Unsupported system - Incorrect OS image for virtualization",
"description": "System is unsupported because the Home Assistant OS image in use is not intended for use in a virtualized environment. Use the link to learn more and how to fix this."
},
"update_service_deprecated": {
"title": "Deprecated update add-on action",
"description": "The update add-on action has been deprecated and will be removed in 2025.5. Please use the update entity and the respective action to update the add-on instead."
}
},
"entity": {
@@ -313,16 +309,6 @@
}
}
},
"addon_update": {
"name": "Update add-on",
"description": "Updates an add-on. This action should be used with caution since add-on updates can contain breaking changes. It is highly recommended that you review release notes/change logs before updating an add-on.",
"fields": {
"addon": {
"name": "[%key:component::hassio::services::addon_start::fields::addon::name%]",
"description": "The add-on to update."
}
}
},
"host_reboot": {
"name": "Reboot the host system",
"description": "Reboots the host system."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,10 @@
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::huawei_lte::config::step::user::data_description::password%]",
"username": "[%key:component::huawei_lte::config::step::user::data_description::username%]"
}
},
"user": {
@@ -35,6 +39,12 @@
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"password": "Password for accessing the router's API. Typically, the same as the one used for the router's web interface.",
"url": "Base URL to the API of the router. Typically, something like `http://192.168.X.1`. This is the beginning of the location shown in a browser when accessing the router's web interface.",
"username": "Username for accessing the router's API. Typically, the same as the one used for the router's web interface. Usually, either `admin`, or left empty (recommended if that works).",
"verify_ssl": "Whether to verify the SSL certificate of the router when accessing it. Applicable only if the router is accessed via HTTPS."
},
"description": "Enter device access details.",
"title": "Configure Huawei LTE"
}
@@ -48,6 +58,12 @@
"recipient": "SMS notification recipients",
"track_wired_clients": "Track wired network clients",
"unauthenticated_mode": "Unauthenticated mode (change requires reload)"
},
"data_description": {
"name": "Used to distinguish between notification services in case there are multiple Huawei LTE devices configured. Changes to this option value take effect after Home Assistant restart.",
"recipient": "Comma separated list of default recipient SMS phone numbers for the notification service, used in case the notification sender does not specify any.",
"track_wired_clients": "Whether the device tracker entities track also clients attached to the router's wired Ethernet network, in addition to wireless clients.",
"unauthenticated_mode": "Whether to run in unauthenticated mode. Unauthenticated mode provides a limited set of features, but may help in case there are problems accessing the router's web interface from a browser while the integration is active. Changes to this option value take effect after integration reload."
}
}
}

View File

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

View File

@@ -61,6 +61,15 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
self._zones_last_update: dict[str, set[str]] = {}
self._areas_last_update: dict[str, set[int]] = {}
def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None:
"""Add/remove devices and dynamic entities, when amount of devices changed."""
self._async_add_remove_devices(data)
for mower_id in data:
if data[mower_id].capabilities.stay_out_zones:
self._async_add_remove_stay_out_zones(data)
if data[mower_id].capabilities.work_areas:
self._async_add_remove_work_areas(data)
async def _async_update_data(self) -> MowerDictionary:
"""Subscribe for websocket and poll data from the API."""
if not self.ws_connected:
@@ -73,20 +82,14 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
raise UpdateFailed(err) from err
except AuthError as err:
raise ConfigEntryAuthFailed(err) from err
self._async_add_remove_devices(data)
for mower_id in data:
if data[mower_id].capabilities.stay_out_zones:
self._async_add_remove_stay_out_zones(data)
for mower_id in data:
if data[mower_id].capabilities.work_areas:
self._async_add_remove_work_areas(data)
self._async_add_remove_devices_and_entities(data)
return data
@callback
def callback(self, ws_data: MowerDictionary) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
self.async_set_updated_data(ws_data)
self._async_add_remove_devices_and_entities(ws_data)
async def client_listen(
self,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ from pynecil import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.debounce import Debouncer
@@ -83,7 +84,11 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
self.device_info = await self.device.get_device_info()
except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={CONF_NAME: self.config_entry.title},
) from e
self.v223_features = AwesomeVersion(self.device_info.build) >= V223
@@ -108,7 +113,11 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
return await self.device.get_live_data()
except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={CONF_NAME: self.config_entry.title},
) from e
@property
def has_tip(self) -> bool:
@@ -187,4 +196,7 @@ class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
try:
return await self.github.latest_release()
except UpdateException as e:
raise UpdateFailed("Failed to check for latest IronOS update") from e
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_check_failed",
) from e

View File

@@ -284,6 +284,12 @@
},
"submit_setting_failed": {
"message": "Failed to submit setting to device, try again later"
},
"cannot_connect": {
"message": "Cannot connect to device {name}"
},
"update_check_failed": {
"message": "Failed to check for latest IronOS update"
}
}
}

View File

@@ -4,11 +4,11 @@ from __future__ import annotations
import logging
from pyecotrend_ista import KeycloakError, LoginError, PyEcotrendIsta, ServerError
from pyecotrend_ista import PyEcotrendIsta
from homeassistant.components.recorder import get_instance
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import DOMAIN
from .coordinator import IstaConfigEntry, IstaCoordinator
@@ -25,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool
entry.data[CONF_PASSWORD],
_LOGGER,
)
try:
await hass.async_add_executor_job(ista.login)
except ServerError as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_exception",
) from e
except (LoginError, KeycloakError) as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_exception",
translation_placeholders={CONF_EMAIL: entry.data[CONF_EMAIL]},
) from e
coordinator = IstaCoordinator(hass, entry, ista)
await coordinator.async_config_entry_first_refresh()
@@ -52,3 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_remove_entry(hass: HomeAssistant, entry: IstaConfigEntry) -> None:
"""Handle removal of an entry."""
statistic_ids = [f"{DOMAIN}:{name}" for name in entry.options.values()]
get_instance(hass).async_clear_statistics(statistic_ids)

View File

@@ -100,8 +100,19 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_PASSWORD],
_LOGGER,
)
def get_consumption_units() -> set[str]:
ista.login()
consumption_units = ista.get_consumption_unit_details()[
"consumptionUnits"
]
return {unit["id"] for unit in consumption_units}
try:
await self.hass.async_add_executor_job(ista.login)
consumption_units = await self.hass.async_add_executor_job(
get_consumption_units
)
except ServerError:
errors["base"] = "cannot_connect"
except (LoginError, KeycloakError):
@@ -110,6 +121,8 @@ class IstaConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if reauth_entry.unique_id not in consumption_units:
return self.async_abort(reason="unique_id_mismatch")
return self.async_update_reload_and_abort(reauth_entry, data=user_input)
return self.async_show_form(

View File

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

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.
appropriate-polling: done
brands: done
common-modules:
status: todo
comment: Group the 3 different executor jobs as one executor job
config-flow-test-coverage:
status: todo
comment: test_form/docstrings outdated, test already_configuret, test abort conditions in reauth,
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
@@ -47,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: The integration is a web service, there are no discoverable devices.

View File

@@ -2,7 +2,8 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The login details correspond to a different account. Please re-authenticate to the previously configured account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",

View File

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

View File

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

View File

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

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"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.models import (
BackFlush,
BaseWidgetOutput,
CoffeeBoiler,
SteamBoilerLevel,
@@ -84,6 +85,17 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI)
),
),
LaMarzoccoSensorEntityDescription(
key="last_cleaning_time",
translation_key="last_cleaning_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=(
lambda config: cast(
BackFlush, config[WidgetType.CM_BACK_FLUSH]
).last_cleaning_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
),
)

View File

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

View File

@@ -24,12 +24,17 @@ from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_PASSWORD,
CONF_PORT,
CONF_RESOURCE,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -38,6 +43,7 @@ from .const import (
CONF_DIM_MODE,
CONF_DOMAIN_DATA,
CONF_SK_NUM_TRIES,
CONF_TARGET_VALUE_LOCKED,
CONF_TRANSITION,
CONNECTION,
DEVICE_CONNECTIONS,
@@ -155,6 +161,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if config_entry.minor_version < 2:
new_data[CONF_ACKNOWLEDGE] = False
if config_entry.version < 2:
# update to 2.1 (fix transitions for lights and switches)
new_entities_data = [*new_data[CONF_ENTITIES]]
for entity in new_entities_data:
@@ -164,8 +171,19 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
entity[CONF_DOMAIN_DATA][CONF_TRANSITION] /= 1000.0
new_data[CONF_ENTITIES] = new_entities_data
if config_entry.version < 3:
# update to 3.1 (remove resource parameter, add climate target lock value parameter)
for entity in new_data[CONF_ENTITIES]:
entity.pop(CONF_RESOURCE, None)
if entity[CONF_DOMAIN] == Platform.CLIMATE:
entity[CONF_DOMAIN_DATA].setdefault(CONF_TARGET_VALUE_LOCKED, -1)
# migrate climate and scene unique ids
await async_migrate_entities(hass, config_entry)
hass.config_entries.async_update_entry(
config_entry, data=new_data, minor_version=1, version=2
config_entry, data=new_data, minor_version=1, version=3
)
_LOGGER.debug(
@@ -176,6 +194,29 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
return True
async def async_migrate_entities(
hass: HomeAssistant, config_entry: ConfigEntry
) -> None:
"""Migrate entity registry."""
@callback
def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
"""Update unique ID of entity entry."""
# fix unique entity ids for climate and scene
if "." in entity_entry.unique_id:
if entity_entry.domain == Platform.CLIMATE:
setpoint = entity_entry.unique_id.split(".")[-1]
return {
"new_unique_id": entity_entry.unique_id.rsplit("-", 1)[0]
+ f"-{setpoint}"
}
if entity_entry.domain == Platform.SCENE:
return {"new_unique_id": entity_entry.unique_id.replace(".", "")}
return None
await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Close connection to PCHK host represented by config_entry."""
# forward unloading to platforms

View File

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

View File

@@ -3,18 +3,19 @@
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import CONF_DOMAIN_DATA, DOMAIN
from .helpers import (
AddressType,
DeviceConnectionType,
InputType,
generate_unique_id,
get_device_connection,
get_resource,
)
@@ -48,7 +49,11 @@ class LcnEntity(Entity):
def unique_id(self) -> str:
"""Return a unique ID."""
return generate_unique_id(
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
self.config_entry.entry_id,
self.address,
get_resource(
self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA]
).lower(),
)
async def async_added_to_hass(self) -> None:

View File

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

View File

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

View File

@@ -19,7 +19,6 @@ from homeassistant.const import (
CONF_DOMAIN,
CONF_ENTITIES,
CONF_NAME,
CONF_RESOURCE,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
@@ -343,7 +342,6 @@ async def websocket_add_entity(
entity_config = {
CONF_ADDRESS: msg[CONF_ADDRESS],
CONF_NAME: msg[CONF_NAME],
CONF_RESOURCE: resource,
CONF_DOMAIN: domain_name,
CONF_DOMAIN_DATA: domain_data,
}
@@ -371,7 +369,15 @@ async def websocket_add_entity(
vol.Required("entry_id"): cv.string,
vol.Required(CONF_ADDRESS): ADDRESS_SCHEMA,
vol.Required(CONF_DOMAIN): cv.string,
vol.Required(CONF_RESOURCE): cv.string,
vol.Required(CONF_DOMAIN_DATA): vol.Any(
DOMAIN_DATA_BINARY_SENSOR,
DOMAIN_DATA_SENSOR,
DOMAIN_DATA_SWITCH,
DOMAIN_DATA_LIGHT,
DOMAIN_DATA_CLIMATE,
DOMAIN_DATA_COVER,
DOMAIN_DATA_SCENE,
),
}
)
@websocket_api.async_response
@@ -390,7 +396,10 @@ async def websocket_delete_entity(
if (
tuple(entity_config[CONF_ADDRESS]) == msg[CONF_ADDRESS]
and entity_config[CONF_DOMAIN] == msg[CONF_DOMAIN]
and entity_config[CONF_RESOURCE] == msg[CONF_RESOURCE]
and get_resource(
entity_config[CONF_DOMAIN], entity_config[CONF_DOMAIN_DATA]
)
== get_resource(msg[CONF_DOMAIN], msg[CONF_DOMAIN_DATA])
)
),
None,

View File

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

View File

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

View File

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

View File

@@ -7,38 +7,19 @@ import mimetypes
import voluptuous as vol
from homeassistant.components.camera import (
PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA,
Camera,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH
from .const import SERVICE_UPDATE_FILE_PATH
from .util import check_file_path_access
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_FILE_PATH): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
}
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -67,57 +48,6 @@ async def async_setup_entry(
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Camera that works with local files."""
file_path: str = config[CONF_FILE_PATH]
file_path_slug = slugify(file_path)
if not await hass.async_add_executor_job(check_file_path_access, file_path):
ir.async_create_issue(
hass,
DOMAIN,
f"no_access_path_{file_path_slug}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
severity=ir.IssueSeverity.WARNING,
translation_key="no_access_path",
translation_placeholders={
"file_path": file_path_slug,
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Local file",
},
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
)
class LocalFile(Camera):
"""Representation of a local file camera."""

View File

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

View File

@@ -53,11 +53,5 @@
"file_path_not_accessible": {
"message": "Path {file_path} is not accessible"
}
},
"issues": {
"no_access_path": {
"title": "Incorrect file path",
"description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue."
}
}
}

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