mirror of
https://github.com/home-assistant/core.git
synced 2025-09-24 04:19:33 +00:00
Compare commits
12 Commits
dev
...
progress_s
Author | SHA1 | Date | |
---|---|---|---|
![]() |
489393cd01 | ||
![]() |
fe9dde125f | ||
![]() |
b7c249d9ce | ||
![]() |
ad1d5565ea | ||
![]() |
0fb052201a | ||
![]() |
4c1b3776a6 | ||
![]() |
81c3d34bfe | ||
![]() |
26ae0a505e | ||
![]() |
09862c0821 | ||
![]() |
77cacbe577 | ||
![]() |
3abe9017f7 | ||
![]() |
6d46f28eaf |
@@ -4,13 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aioacaia.acaiascale import AcaiaScale
|
||||
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
|
||||
from bleak import BleakScanner
|
||||
|
||||
from homeassistant.components.bluetooth import async_get_scanner
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -45,7 +42,6 @@ class AcaiaCoordinator(DataUpdateCoordinator[None]):
|
||||
name=entry.title,
|
||||
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
|
||||
notify_callback=self.async_update_listeners,
|
||||
scanner=cast(BleakScanner, async_get_scanner(hass)),
|
||||
)
|
||||
|
||||
@property
|
||||
|
@@ -26,5 +26,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioacaia"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioacaia==0.1.17"]
|
||||
"requirements": ["aioacaia==0.1.14"]
|
||||
}
|
||||
|
@@ -12,25 +12,10 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .analytics import (
|
||||
Analytics,
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
DeviceAnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from .analytics import Analytics
|
||||
from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA
|
||||
from .http import AnalyticsDevicesView
|
||||
|
||||
__all__ = [
|
||||
"AnalyticsInput",
|
||||
"AnalyticsModifications",
|
||||
"DeviceAnalyticsModifications",
|
||||
"EntityAnalyticsModifications",
|
||||
"async_devices_payload",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
DATA_COMPONENT: HassKey[Analytics] = HassKey(DOMAIN)
|
||||
|
@@ -4,10 +4,9 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from asyncio import timeout
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass, field
|
||||
from dataclasses import asdict as dataclass_asdict, dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
@@ -36,14 +35,11 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
from homeassistant.loader import (
|
||||
Integration,
|
||||
IntegrationNotFound,
|
||||
async_get_integration,
|
||||
async_get_integrations,
|
||||
)
|
||||
from homeassistant.setup import async_get_loaded_integrations
|
||||
@@ -79,116 +75,12 @@ from .const import (
|
||||
ATTR_USER_COUNT,
|
||||
ATTR_UUID,
|
||||
ATTR_VERSION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
PREFERENCE_SCHEMA,
|
||||
STORAGE_KEY,
|
||||
STORAGE_VERSION,
|
||||
)
|
||||
|
||||
DATA_ANALYTICS_MODIFIERS = "analytics_modifiers"
|
||||
|
||||
type AnalyticsModifier = Callable[
|
||||
[HomeAssistant, AnalyticsInput], Awaitable[AnalyticsModifications]
|
||||
]
|
||||
|
||||
|
||||
@singleton(DATA_ANALYTICS_MODIFIERS)
|
||||
def _async_get_modifiers(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, AnalyticsModifier | None]:
|
||||
"""Return the analytics modifiers."""
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsInput:
|
||||
"""Analytics input for a single integration.
|
||||
|
||||
This is sent to integrations that implement the platform.
|
||||
"""
|
||||
|
||||
device_ids: Iterable[str] = field(default_factory=list)
|
||||
entity_ids: Iterable[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalyticsModifications:
|
||||
"""Analytics config for a single integration.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
devices: Mapping[str, DeviceAnalyticsModifications] | None = None
|
||||
entities: Mapping[str, EntityAnalyticsModifications] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeviceAnalyticsModifications:
|
||||
"""Analytics config for a single device.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityAnalyticsModifications:
|
||||
"""Analytics config for a single entity.
|
||||
|
||||
This is used by integrations that implement the platform.
|
||||
"""
|
||||
|
||||
remove: bool = False
|
||||
capabilities: dict[str, Any] | None | UndefinedType = UNDEFINED
|
||||
|
||||
|
||||
class AnalyticsPlatformProtocol(Protocol):
|
||||
"""Define the format of analytics platforms."""
|
||||
|
||||
async def async_modify_analytics(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
analytics_input: AnalyticsInput,
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
|
||||
|
||||
async def _async_get_analytics_platform(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsPlatformProtocol | None:
|
||||
"""Get analytics platform."""
|
||||
try:
|
||||
integration = await async_get_integration(hass, domain)
|
||||
except IntegrationNotFound:
|
||||
return None
|
||||
try:
|
||||
return await integration.async_get_platform(DOMAIN)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
|
||||
async def _async_get_modifier(
|
||||
hass: HomeAssistant, domain: str
|
||||
) -> AnalyticsModifier | None:
|
||||
"""Get analytics modifier."""
|
||||
modifiers = _async_get_modifiers(hass)
|
||||
modifier = modifiers.get(domain, UNDEFINED)
|
||||
|
||||
if modifier is not UNDEFINED:
|
||||
return modifier
|
||||
|
||||
platform = await _async_get_analytics_platform(hass, domain)
|
||||
if platform is None:
|
||||
modifiers[domain] = None
|
||||
return None
|
||||
|
||||
modifier = getattr(platform, "async_modify_analytics", None)
|
||||
modifiers[domain] = modifier
|
||||
return modifier
|
||||
|
||||
|
||||
def gen_uuid() -> str:
|
||||
"""Generate a new UUID."""
|
||||
@@ -501,20 +393,17 @@ def _domains_from_yaml_config(yaml_configuration: dict[str, Any]) -> set[str]:
|
||||
return domains
|
||||
|
||||
|
||||
DEFAULT_ANALYTICS_CONFIG = AnalyticsModifications()
|
||||
DEFAULT_DEVICE_ANALYTICS_CONFIG = DeviceAnalyticsModifications()
|
||||
DEFAULT_ENTITY_ANALYTICS_CONFIG = EntityAnalyticsModifications()
|
||||
|
||||
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
async def async_devices_payload(hass: HomeAssistant) -> dict:
|
||||
"""Return detailed information about entities and devices."""
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
integration_inputs: dict[str, tuple[list[str], list[str]]] = {}
|
||||
integration_configs: dict[str, AnalyticsModifications] = {}
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Get device list
|
||||
for device_entry in dev_reg.devices.values():
|
||||
if not device_entry.primary_config_entry:
|
||||
continue
|
||||
@@ -527,96 +416,27 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
continue
|
||||
|
||||
integration_domain = config_entry.domain
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[0].append(device_entry.id)
|
||||
|
||||
# Get entity list
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
|
||||
integration_input = integration_inputs.setdefault(integration_domain, ([], []))
|
||||
integration_input[1].append(entity_entry.entity_id)
|
||||
|
||||
# Call integrations that implement the analytics platform
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
if (
|
||||
modifier := await _async_get_modifier(hass, integration_domain)
|
||||
) is not None:
|
||||
try:
|
||||
integration_config = await modifier(
|
||||
hass, AnalyticsInput(*integration_input)
|
||||
)
|
||||
except Exception as err: # noqa: BLE001
|
||||
LOGGER.exception(
|
||||
"Calling async_modify_analytics for integration '%s' failed: %s",
|
||||
integration_domain,
|
||||
err,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
if not isinstance(integration_config, AnalyticsModifications):
|
||||
LOGGER.error( # type: ignore[unreachable]
|
||||
"Calling async_modify_analytics for integration '%s' did not return an AnalyticsConfig",
|
||||
integration_domain,
|
||||
)
|
||||
integration_configs[integration_domain] = AnalyticsModifications(
|
||||
remove=True
|
||||
)
|
||||
continue
|
||||
|
||||
integration_configs[integration_domain] = integration_config
|
||||
|
||||
integrations_info: dict[str, dict[str, Any]] = {}
|
||||
|
||||
# We need to refer to other devices, for example in `via_device` field.
|
||||
# We don't however send the original device ids outside of Home Assistant,
|
||||
# instead we refer to devices by (integration_domain, index_in_integration_device_list).
|
||||
device_id_mapping: dict[str, tuple[str, int]] = {}
|
||||
|
||||
# Fill out information about devices
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
|
||||
devices_info = integration_info["devices"]
|
||||
|
||||
for device_id in integration_input[0]:
|
||||
device_config = DEFAULT_DEVICE_ANALYTICS_CONFIG
|
||||
if integration_config.devices is not None:
|
||||
device_config = integration_config.devices.get(device_id, device_config)
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
if device_config.remove:
|
||||
continue
|
||||
|
||||
device_entry = dev_reg.devices[device_id]
|
||||
|
||||
device_id_mapping[device_entry.id] = (integration_domain, len(devices_info))
|
||||
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
devices_info.append(
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": device_entry.entry_type,
|
||||
"has_configuration_url": device_entry.configuration_url is not None,
|
||||
"hw_version": device_entry.hw_version,
|
||||
"manufacturer": device_entry.manufacturer,
|
||||
"model": device_entry.model,
|
||||
"model_id": device_entry.model_id,
|
||||
"sw_version": device_entry.sw_version,
|
||||
"via_device": device_entry.via_device_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Fill out via_device with new device ids
|
||||
for integration_info in integrations_info.values():
|
||||
@@ -625,15 +445,10 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
continue
|
||||
device_info["via_device"] = device_id_mapping.get(device_info["via_device"])
|
||||
|
||||
# Fill out information about entities
|
||||
for integration_domain, integration_input in integration_inputs.items():
|
||||
integration_config = integration_configs.get(
|
||||
integration_domain, DEFAULT_ANALYTICS_CONFIG
|
||||
)
|
||||
|
||||
if integration_config.remove:
|
||||
continue
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
for entity_entry in ent_reg.entities.values():
|
||||
integration_domain = entity_entry.platform
|
||||
integration_info = integrations_info.setdefault(
|
||||
integration_domain, {"devices": [], "entities": []}
|
||||
)
|
||||
@@ -641,52 +456,35 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: # noqa: C901
|
||||
devices_info = integration_info["devices"]
|
||||
entities_info = integration_info["entities"]
|
||||
|
||||
for entity_id in integration_input[1]:
|
||||
entity_config = DEFAULT_ENTITY_ANALYTICS_CONFIG
|
||||
if integration_config.entities is not None:
|
||||
entity_config = integration_config.entities.get(
|
||||
entity_id, entity_config
|
||||
)
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
if entity_config.remove:
|
||||
continue
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None,
|
||||
"capabilities": entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
|
||||
entity_state = hass.states.get(entity_entry.entity_id)
|
||||
|
||||
entity_info = {
|
||||
# LIMITATION: `assumed_state` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
# It is also not present, if entity is not in the state machine,
|
||||
# which can happen for disabled entities.
|
||||
"assumed_state": entity_state.attributes.get(ATTR_ASSUMED_STATE, False)
|
||||
if entity_state is not None
|
||||
else None,
|
||||
"capabilities": entity_config.capabilities
|
||||
if entity_config.capabilities is not UNDEFINED
|
||||
else entity_entry.capabilities,
|
||||
"domain": entity_entry.domain,
|
||||
"entity_category": entity_entry.entity_category,
|
||||
"has_entity_name": entity_entry.has_entity_name,
|
||||
"modified_by_integration": ["capabilities"]
|
||||
if entity_config.capabilities is not UNDEFINED
|
||||
else None,
|
||||
"original_device_class": entity_entry.original_device_class,
|
||||
# LIMITATION: `unit_of_measurement` can be overridden by users;
|
||||
# we should replace it with the original value in the future.
|
||||
"unit_of_measurement": entity_entry.unit_of_measurement,
|
||||
}
|
||||
|
||||
if (
|
||||
((device_id_ := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id_)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
if (
|
||||
((device_id := entity_entry.device_id) is not None)
|
||||
and ((new_device_id := device_id_mapping.get(device_id)) is not None)
|
||||
and (new_device_id[0] == integration_domain)
|
||||
):
|
||||
device_info = devices_info[new_device_id[1]]
|
||||
device_info["entities"].append(entity_info)
|
||||
else:
|
||||
entities_info.append(entity_info)
|
||||
|
||||
integrations = {
|
||||
domain: integration
|
||||
|
@@ -1,24 +0,0 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import (
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
entities: dict[str, EntityAnalyticsModifications] = {}
|
||||
for entity_id in analytics_input.entity_ids:
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
if entity_entry.capabilities is not None:
|
||||
entities[entity_id] = EntityAnalyticsModifications(capabilities=None)
|
||||
|
||||
return AnalyticsModifications(entities=entities)
|
@@ -10,7 +10,6 @@ from asyncio import Future
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothScannerDevice,
|
||||
@@ -39,16 +38,13 @@ def _get_manager(hass: HomeAssistant) -> HomeAssistantBluetoothManager:
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_get_scanner(hass: HomeAssistant) -> BleakScanner:
|
||||
"""Return a HaBleakScannerWrapper cast to BleakScanner.
|
||||
def async_get_scanner(hass: HomeAssistant) -> HaBleakScannerWrapper:
|
||||
"""Return a HaBleakScannerWrapper.
|
||||
|
||||
This is a wrapper around our BleakScanner singleton that allows
|
||||
multiple integrations to share the same BleakScanner.
|
||||
|
||||
The wrapper is cast to BleakScanner for type compatibility with
|
||||
libraries expecting a BleakScanner instance.
|
||||
"""
|
||||
return cast(BleakScanner, HaBleakScannerWrapper())
|
||||
return HaBleakScannerWrapper()
|
||||
|
||||
|
||||
@hass_callback
|
||||
|
@@ -25,11 +25,7 @@ async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo
|
||||
return await cloud.payments.subscription_info()
|
||||
except PaymentsApiError as exception:
|
||||
_LOGGER.error("Failed to fetch subscription information - %s", exception)
|
||||
except TimeoutError:
|
||||
_LOGGER.error(
|
||||
"A timeout of %s was reached while trying to fetch subscription information",
|
||||
REQUEST_TIMEOUT,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
|
@@ -8,13 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Context,
|
||||
HomeAssistant,
|
||||
async_get_hass,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, async_get_hass, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, intent, singleton
|
||||
|
||||
@@ -36,7 +30,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .default_agent import DefaultAgent
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
|
||||
@singleton.singleton("conversation_agent")
|
||||
@@ -147,7 +140,6 @@ class AgentManager:
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self.default_agent: DefaultAgent | None = None
|
||||
self.triggers_details: list[TriggerDetails] = []
|
||||
|
||||
@callback
|
||||
def async_get_agent(self, agent_id: str) -> AbstractConversationAgent | None:
|
||||
@@ -199,20 +191,4 @@ class AgentManager:
|
||||
|
||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||
"""Set up the default agent."""
|
||||
agent.update_triggers(self.triggers_details)
|
||||
self.default_agent = agent
|
||||
|
||||
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||
"""Register a trigger."""
|
||||
self.triggers_details.append(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
@callback
|
||||
def unregister_trigger() -> None:
|
||||
"""Unregister the trigger."""
|
||||
self.triggers_details.remove(trigger_details)
|
||||
if self.default_agent is not None:
|
||||
self.default_agent.update_triggers(self.triggers_details)
|
||||
|
||||
return unregister_trigger
|
||||
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Callable, Iterable
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
import functools
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import IO, Any, cast
|
||||
|
||||
@@ -51,7 +53,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.core import Event, callback
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
@@ -73,16 +74,17 @@ from .const import DOMAIN, ConversationEntityFeature
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
from .trigger import TriggerDetails
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
_ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
||||
|
||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
|
||||
REGEX_TYPE = type(re.compile(""))
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||
@@ -108,6 +110,14 @@ class LanguageIntents:
|
||||
fuzzy_responses: FuzzyLanguageResponses | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerData:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SentenceTriggerResult:
|
||||
"""Result when matching a sentence trigger in an automation."""
|
||||
@@ -230,23 +240,21 @@ class DefaultAgent(ConversationEntity):
|
||||
"""Initialize the default agent."""
|
||||
self.hass = hass
|
||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# intent -> [sentences]
|
||||
self._config_intents: dict[str, Any] = config_intents
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self._triggers_details: list[TriggerDetails] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
|
||||
# Slot lists for entities, areas, etc.
|
||||
self._slot_lists: dict[str, SlotList] | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
|
||||
# Used to filter slot lists before intent matching
|
||||
self._exposed_names_trie: Trie | None = None
|
||||
self._unexposed_names_trie: Trie | None = None
|
||||
|
||||
# Sentences that will trigger a callback (skipping intent recognition)
|
||||
self.trigger_sentences: list[TriggerData] = []
|
||||
self._trigger_intents: Intents | None = None
|
||||
self._unsub_clear_slot_list: list[Callable[[], None]] | None = None
|
||||
self._load_intents_lock = asyncio.Lock()
|
||||
|
||||
# LRU cache to avoid unnecessary intent matching
|
||||
self._intent_cache = IntentCache(capacity=128)
|
||||
|
||||
@@ -1190,8 +1198,8 @@ class DefaultAgent(ConversationEntity):
|
||||
fuzzy_responses=fuzzy_responses,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_clear_slot_list(self, event: Event[Any] | None = None) -> None:
|
||||
@core.callback
|
||||
def _async_clear_slot_list(self, event: core.Event[Any] | None = None) -> None:
|
||||
"""Clear slot lists when a registry has changed."""
|
||||
# Two subscribers can be scheduled at same time
|
||||
_LOGGER.debug("Clearing slot lists")
|
||||
@@ -1361,14 +1369,22 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
return response_template.async_render(response_args)
|
||||
|
||||
@callback
|
||||
def update_triggers(self, triggers_details: list[TriggerDetails]) -> None:
|
||||
"""Update triggers."""
|
||||
self._triggers_details = triggers_details
|
||||
@core.callback
|
||||
def register_trigger(
|
||||
self,
|
||||
sentences: list[str],
|
||||
callback: TRIGGER_CALLBACK_TYPE,
|
||||
) -> core.CALLBACK_TYPE:
|
||||
"""Register a list of sentences that will trigger a callback when recognized."""
|
||||
trigger_data = TriggerData(sentences=sentences, callback=callback)
|
||||
self.trigger_sentences.append(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
return functools.partial(self._unregister_trigger, trigger_data)
|
||||
|
||||
@core.callback
|
||||
def _rebuild_trigger_intents(self) -> None:
|
||||
"""Rebuild the HassIL intents object from the current trigger sentences."""
|
||||
intents_dict = {
|
||||
@@ -1377,8 +1393,8 @@ class DefaultAgent(ConversationEntity):
|
||||
# Use trigger data index as a virtual intent name for HassIL.
|
||||
# This works because the intents are rebuilt on every
|
||||
# register/unregister.
|
||||
str(trigger_id): {"data": [{"sentences": trigger_details.sentences}]}
|
||||
for trigger_id, trigger_details in enumerate(self._triggers_details)
|
||||
str(trigger_id): {"data": [{"sentences": trigger_data.sentences}]}
|
||||
for trigger_id, trigger_data in enumerate(self.trigger_sentences)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1398,6 +1414,14 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
_LOGGER.debug("Rebuilt trigger intents: %s", intents_dict)
|
||||
|
||||
@core.callback
|
||||
def _unregister_trigger(self, trigger_data: TriggerData) -> None:
|
||||
"""Unregister a set of trigger sentences."""
|
||||
self.trigger_sentences.remove(trigger_data)
|
||||
|
||||
# Force rebuild on next use
|
||||
self._trigger_intents = None
|
||||
|
||||
async def async_recognize_sentence_trigger(
|
||||
self, user_input: ConversationInput
|
||||
) -> SentenceTriggerResult | None:
|
||||
@@ -1406,7 +1430,7 @@ class DefaultAgent(ConversationEntity):
|
||||
Calls the registered callbacks if there's a match and returns a sentence
|
||||
trigger result.
|
||||
"""
|
||||
if not self._triggers_details:
|
||||
if not self.trigger_sentences:
|
||||
# No triggers registered
|
||||
return None
|
||||
|
||||
@@ -1451,7 +1475,7 @@ class DefaultAgent(ConversationEntity):
|
||||
|
||||
# Gather callback responses in parallel
|
||||
trigger_callbacks = [
|
||||
self._triggers_details[trigger_id].callback(user_input, trigger_result)
|
||||
self.trigger_sentences[trigger_id].callback(user_input, trigger_result)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
|
@@ -169,11 +169,12 @@ async def websocket_list_sentences(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||
) -> None:
|
||||
"""List custom registered sentences."""
|
||||
manager = get_agent_manager(hass)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
|
||||
sentences = []
|
||||
for trigger_details in manager.triggers_details:
|
||||
sentences.extend(trigger_details.sentences)
|
||||
for trigger_data in agent.trigger_sentences:
|
||||
sentences.extend(trigger_data.sentences)
|
||||
|
||||
connection.send_result(msg["id"], {"trigger_sentences": sentences})
|
||||
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hassil.recognize import RecognizeResult
|
||||
@@ -26,18 +24,6 @@ from .agent_manager import get_agent_manager
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput
|
||||
|
||||
TRIGGER_CALLBACK_TYPE = Callable[
|
||||
[ConversationInput, RecognizeResult], Awaitable[str | None]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TriggerDetails:
|
||||
"""List of sentences and the callback for a trigger."""
|
||||
|
||||
sentences: list[str]
|
||||
callback: TRIGGER_CALLBACK_TYPE
|
||||
|
||||
|
||||
def has_no_punctuation(value: list[str]) -> list[str]:
|
||||
"""Validate result does not contain punctuation."""
|
||||
@@ -148,6 +134,6 @@ async def async_attach_trigger(
|
||||
# two trigger copies for who will provide a response.
|
||||
return None
|
||||
|
||||
return get_agent_manager(hass).register_trigger(
|
||||
TriggerDetails(sentences=sentences, callback=call_action)
|
||||
)
|
||||
agent = get_agent_manager(hass).default_agent
|
||||
assert agent is not None
|
||||
return agent.register_trigger(sentences, call_action)
|
||||
|
@@ -9,7 +9,6 @@
|
||||
"conversation",
|
||||
"dhcp",
|
||||
"energy",
|
||||
"file",
|
||||
"go2rtc",
|
||||
"history",
|
||||
"homeassistant_alerts",
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/droplet",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pydroplet==2.3.3"],
|
||||
"requirements": ["pydroplet==2.3.2"],
|
||||
"zeroconf": ["_droplet._tcp.local."]
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==41.9.0",
|
||||
"aioesphomeapi==41.6.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.3.0"
|
||||
],
|
||||
|
@@ -194,21 +194,6 @@ class EsphomeAssistSatelliteWakeWordSelect(
|
||||
self._attr_options = [NO_WAKE_WORD, *sorted(self._wake_words)]
|
||||
|
||||
option = self._attr_current_option
|
||||
|
||||
if (
|
||||
(self._wake_word_index == 0)
|
||||
and (len(config.active_wake_words) == 1)
|
||||
and (option in (None, NO_WAKE_WORD))
|
||||
):
|
||||
option = next(
|
||||
(
|
||||
wake_word
|
||||
for wake_word, wake_word_id in self._wake_words.items()
|
||||
if wake_word_id == config.active_wake_words[0]
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if (
|
||||
(option is None)
|
||||
or ((wake_word_id := self._wake_words.get(option)) is None)
|
||||
|
@@ -7,22 +7,11 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, 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 DOMAIN
|
||||
from .services import async_register_services
|
||||
|
||||
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the file component."""
|
||||
async_register_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a file component entry."""
|
||||
|
@@ -6,7 +6,3 @@ CONF_TIMESTAMP = "timestamp"
|
||||
|
||||
DEFAULT_NAME = "File"
|
||||
FILE_ICON = "mdi:file"
|
||||
|
||||
SERVICE_READ_FILE = "read_file"
|
||||
ATTR_FILE_NAME = "file_name"
|
||||
ATTR_FILE_ENCODING = "file_encoding"
|
||||
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"read_file": {
|
||||
"service": "mdi:file"
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,88 +0,0 @@
|
||||
"""File Service calls."""
|
||||
|
||||
from collections.abc import Callable
|
||||
import json
|
||||
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import ATTR_FILE_ENCODING, ATTR_FILE_NAME, DOMAIN, SERVICE_READ_FILE
|
||||
|
||||
|
||||
def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for File integration."""
|
||||
|
||||
if not hass.services.has_service(DOMAIN, SERVICE_READ_FILE):
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
read_file,
|
||||
schema=vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_FILE_NAME): cv.string,
|
||||
vol.Required(ATTR_FILE_ENCODING): cv.string,
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
ENCODING_LOADERS: dict[str, tuple[Callable, type[Exception]]] = {
|
||||
"json": (json.loads, json.JSONDecodeError),
|
||||
"yaml": (yaml.safe_load, yaml.YAMLError),
|
||||
}
|
||||
|
||||
|
||||
def read_file(call: ServiceCall) -> dict:
|
||||
"""Handle read_file service call."""
|
||||
file_name = call.data[ATTR_FILE_NAME]
|
||||
file_encoding = call.data[ATTR_FILE_ENCODING].lower()
|
||||
|
||||
if not call.hass.config.is_allowed_path(file_name):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_access_to_path",
|
||||
translation_placeholders={"filename": file_name},
|
||||
)
|
||||
|
||||
if file_encoding not in ENCODING_LOADERS:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unsupported_file_encoding",
|
||||
translation_placeholders={
|
||||
"filename": file_name,
|
||||
"encoding": file_encoding,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with open(file_name, encoding="utf-8") as file:
|
||||
file_content = file.read()
|
||||
except FileNotFoundError as err:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_not_found",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
except OSError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_read_error",
|
||||
translation_placeholders={"filename": file_name},
|
||||
) from err
|
||||
|
||||
loader, error_type = ENCODING_LOADERS[file_encoding]
|
||||
try:
|
||||
data = loader(file_content)
|
||||
except error_type as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="file_decoding",
|
||||
translation_placeholders={"filename": file_name, "encoding": file_encoding},
|
||||
) from err
|
||||
|
||||
return {"data": data}
|
@@ -1,14 +0,0 @@
|
||||
# Describes the format for available file services
|
||||
read_file:
|
||||
fields:
|
||||
file_name:
|
||||
example: "www/my_file.json"
|
||||
selector:
|
||||
text:
|
||||
file_encoding:
|
||||
example: "JSON"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "JSON"
|
||||
- "YAML"
|
@@ -64,37 +64,6 @@
|
||||
},
|
||||
"write_access_failed": {
|
||||
"message": "Write access to {filename} failed: {exc}."
|
||||
},
|
||||
"no_access_to_path": {
|
||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
},
|
||||
"unsupported_file_encoding": {
|
||||
"message": "Cannot read {filename}, unsupported file encoding {encoding}."
|
||||
},
|
||||
"file_decoding": {
|
||||
"message": "Cannot read file {filename} as {encoding}."
|
||||
},
|
||||
"file_not_found": {
|
||||
"message": "File {filename} not found."
|
||||
},
|
||||
"file_read_error": {
|
||||
"message": "Error reading {filename}."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"read_file": {
|
||||
"name": "Read file",
|
||||
"description": "Reads a file and returns the contents.",
|
||||
"fields": {
|
||||
"file_name": {
|
||||
"name": "File name",
|
||||
"description": "Name of the file to read."
|
||||
},
|
||||
"file_encoding": {
|
||||
"name": "File encoding",
|
||||
"description": "Encoding of the file (JSON, YAML.)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import Any, cast
|
||||
|
||||
from google.cloud import texttospeech
|
||||
import voluptuous as vol
|
||||
@@ -63,7 +63,6 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
_name: str | None = None
|
||||
entry: ConfigEntry | None = None
|
||||
abort_reason: str | None = None
|
||||
|
||||
def _parse_uploaded_file(self, uploaded_file_id: str) -> dict[str, Any]:
|
||||
"""Read and parse an uploaded JSON file."""
|
||||
@@ -88,8 +87,6 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
data = {CONF_SERVICE_ACCOUNT_INFO: service_account_info}
|
||||
if self.entry:
|
||||
if TYPE_CHECKING:
|
||||
assert self.abort_reason
|
||||
return self.async_update_reload_and_abort(
|
||||
self.entry, data=data, reason=self.abort_reason
|
||||
)
|
||||
|
@@ -73,7 +73,6 @@ from . import ( # noqa: F401
|
||||
config_flow,
|
||||
diagnostics,
|
||||
sensor,
|
||||
switch,
|
||||
system_health,
|
||||
update,
|
||||
)
|
||||
@@ -150,7 +149,7 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
|
||||
# If new platforms are added, be sure to import them above
|
||||
# so we do not make other components that depend on hassio
|
||||
# wait for the import of the platforms
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.UPDATE]
|
||||
|
||||
CONF_FRONTEND_REPO = "development_repo"
|
||||
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -546,15 +545,3 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
await super()._async_refresh(
|
||||
log_failures, raise_on_auth_failed, scheduled, raise_on_entry_error
|
||||
)
|
||||
|
||||
async def force_addon_info_data_refresh(self, addon_slug: str) -> None:
|
||||
"""Force refresh of addon info data for a specific addon."""
|
||||
try:
|
||||
slug, info = await self._update_addon_info(addon_slug)
|
||||
if info is not None and DATA_KEY_ADDONS in self.data:
|
||||
if slug in self.data[DATA_KEY_ADDONS]:
|
||||
data = deepcopy(self.data)
|
||||
data[DATA_KEY_ADDONS][slug].update(info)
|
||||
self.async_set_updated_data(data)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
|
||||
|
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/hassio",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["aiohasupervisor==0.3.3b0"],
|
||||
"requirements": ["aiohasupervisor==0.3.2"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -250,10 +250,6 @@
|
||||
"unsupported_os_version": {
|
||||
"title": "Unsupported system - Home Assistant OS version",
|
||||
"description": "System is unsupported because the Home Assistant OS version in use is not supported. For troubleshooting information, select Learn more."
|
||||
},
|
||||
"unsupported_home_assistant_core_version": {
|
||||
"title": "Unsupported system - Home Assistant Core version",
|
||||
"description": "System is unsupported because the Home Assistant Core version in use is not supported. For troubleshooting information, select Learn more."
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
@@ -1,90 +0,0 @@
|
||||
"""Switch platform for Hass.io addons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohasupervisor import SupervisorError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_ICON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
|
||||
from .entity import HassioAddonEntity
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
ENTITY_DESCRIPTION = SwitchEntityDescription(
|
||||
key=ATTR_STATE,
|
||||
name=None,
|
||||
icon="mdi:puzzle",
|
||||
entity_registry_enabled_default=False,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Switch set up for Hass.io config entry."""
|
||||
coordinator = hass.data[ADDONS_COORDINATOR]
|
||||
|
||||
async_add_entities(
|
||||
HassioAddonSwitch(
|
||||
addon=addon,
|
||||
coordinator=coordinator,
|
||||
entity_description=ENTITY_DESCRIPTION,
|
||||
)
|
||||
for addon in coordinator.data[DATA_KEY_ADDONS].values()
|
||||
)
|
||||
|
||||
|
||||
class HassioAddonSwitch(HassioAddonEntity, SwitchEntity):
|
||||
"""Switch for Hass.io add-ons."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the add-on is on."""
|
||||
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
|
||||
state = addon_data.get(self.entity_description.key)
|
||||
return state == ATTR_STARTED
|
||||
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the add-on if any."""
|
||||
if not self.available:
|
||||
return None
|
||||
addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {})
|
||||
if addon_data.get(ATTR_ICON):
|
||||
return f"/api/hassio/addons/{self._addon_slug}/icon"
|
||||
return None
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity on."""
|
||||
supervisor_client = get_supervisor_client(self.hass)
|
||||
try:
|
||||
await supervisor_client.addons.start_addon(self._addon_slug)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Failed to start addon %s: %s", self._addon_slug, err)
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the entity off."""
|
||||
supervisor_client = get_supervisor_client(self.hass)
|
||||
try:
|
||||
await supervisor_client.addons.stop_addon(self._addon_slug)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.error("Failed to stop addon %s: %s", self._addon_slug, err)
|
||||
raise HomeAssistantError(err) from err
|
||||
|
||||
await self.coordinator.force_addon_info_data_refresh(self._addon_slug)
|
@@ -6,14 +6,9 @@ import logging
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.issue_registry import (
|
||||
IssueSeverity,
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
|
||||
from .const import CONF_TRAFFIC_MODE, DOMAIN, TRAVEL_MODE_PUBLIC
|
||||
from .const import CONF_TRAFFIC_MODE, TRAVEL_MODE_PUBLIC
|
||||
from .coordinator import (
|
||||
HereConfigEntry,
|
||||
HERERoutingDataUpdateCoordinator,
|
||||
@@ -29,8 +24,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
|
||||
"""Set up HERE Travel Time from a config entry."""
|
||||
api_key = config_entry.data[CONF_API_KEY]
|
||||
|
||||
alert_for_multiple_entries(hass)
|
||||
|
||||
cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator]
|
||||
if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}:
|
||||
cls = HERETransitDataUpdateCoordinator
|
||||
@@ -49,29 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry)
|
||||
return True
|
||||
|
||||
|
||||
def alert_for_multiple_entries(hass: HomeAssistant) -> None:
|
||||
"""Check if there are multiple entries for the same API key."""
|
||||
if len(hass.config_entries.async_entries(DOMAIN)) > 1:
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"multiple_here_travel_time_entries",
|
||||
learn_more_url="https://www.home-assistant.io/integrations/here_travel_time/",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="multiple_here_travel_time_entries",
|
||||
translation_placeholders={
|
||||
"pricing_page": "https://www.here.com/get-started/pricing",
|
||||
},
|
||||
)
|
||||
else:
|
||||
async_delete_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"multiple_here_travel_time_entries",
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: HereConfigEntry
|
||||
) -> bool:
|
||||
|
@@ -44,7 +44,7 @@ from .coordinator import (
|
||||
HERETransitDataUpdateCoordinator,
|
||||
)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=30)
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
|
||||
def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]:
|
||||
|
@@ -107,11 +107,5 @@
|
||||
"name": "Destination"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"multiple_here_travel_time_entries": {
|
||||
"title": "More than one HERE Travel Time integration detected",
|
||||
"description": "HERE deprecated the previous free tier. The new Base Plan has only 5000 instead of the previous 30000 free requests per month.\n\nSince you have more than one HERE Travel Time integration configured, you will need to disable or remove the additional integrations to avoid exceeding the free request limit.\nYou can ignore this issue if you are okay with the additional cost."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
@@ -72,8 +72,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Base flow to install firmware."""
|
||||
|
||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||
_failed_addon_name: str
|
||||
_failed_addon_reason: str
|
||||
_picked_firmware_type: PickedFirmwareType
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
@@ -85,10 +83,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self._hardware_name: str = "unknown" # To be set in a subclass
|
||||
self._zigbee_integration = ZigbeeIntegration.ZHA
|
||||
|
||||
self.addon_install_task: asyncio.Task | None = None
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
self.addon_uninstall_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task[None] | None = None
|
||||
self.firmware_install_task: asyncio.Task | None = None
|
||||
self.installing_firmware_name: str | None = None
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
@@ -184,17 +180,91 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing firmware."""
|
||||
assert self._device is not None
|
||||
|
||||
if not self.firmware_install_task:
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
self._install_firmware(
|
||||
fw_update_url,
|
||||
fw_type,
|
||||
firmware_name,
|
||||
expected_installed_firmware_type,
|
||||
),
|
||||
f"Install {firmware_name} firmware",
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to index download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError):
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to image download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
# Otherwise, fail
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
),
|
||||
f"Flash {firmware_name} firmware",
|
||||
)
|
||||
|
||||
if not self.firmware_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
@@ -208,102 +278,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
try:
|
||||
await self.firmware_install_task
|
||||
except AbortFlow as err:
|
||||
return self.async_show_progress_done(
|
||||
next_step_id=err.reason,
|
||||
)
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Failed to flash firmware")
|
||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
||||
finally:
|
||||
self.firmware_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def _install_firmware(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
fw_type: str,
|
||||
firmware_name: str,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
) -> None:
|
||||
"""Install firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
raise AbortFlow(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
assert self._device is not None
|
||||
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update manifest", exc_info=True)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||
return
|
||||
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug("Skipping firmware upgrade due to image download failure")
|
||||
return
|
||||
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
|
||||
await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
)
|
||||
|
||||
async def _configure_and_start_otbr_addon(self) -> None:
|
||||
"""Configure and start the OTBR addon."""
|
||||
|
||||
@@ -369,15 +349,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_unsupported_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when unsupported firmware is detected."""
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def async_step_zigbee_installation_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -431,6 +402,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
async def _async_continue_picked_firmware(self) -> ConfigFlowResult:
|
||||
"""Continue to the picked firmware step."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if self._picked_firmware_type == PickedFirmwareType.ZIGBEE:
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
@@ -486,18 +463,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Install Zigbee firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_step_addon_operation_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when add-on installation or start failed."""
|
||||
return self.async_abort(
|
||||
reason=self._failed_addon_reason,
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": self._failed_addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pre_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -561,6 +526,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Install Thread firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_install_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -570,70 +541,43 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
_LOGGER.debug("OTBR addon info: %s", addon_info)
|
||||
|
||||
if not self.addon_install_task:
|
||||
self.addon_install_task = self.hass.async_create_task(
|
||||
addon_manager.async_install_addon_waiting(),
|
||||
"OTBR addon install",
|
||||
)
|
||||
|
||||
if not self.addon_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="install_otbr_addon",
|
||||
progress_action="install_addon",
|
||||
try:
|
||||
await addon_manager.async_install_addon_waiting()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_install_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": addon_manager.addon_name,
|
||||
},
|
||||
progress_task=self.addon_install_task,
|
||||
)
|
||||
) from err
|
||||
|
||||
try:
|
||||
await self.addon_install_task
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = addon_manager.addon_name
|
||||
self._failed_addon_reason = "addon_install_failed"
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="finish_thread_installation")
|
||||
return await self.async_step_finish_thread_installation()
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_start_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
|
||||
if not self.addon_start_task:
|
||||
self.addon_start_task = self.hass.async_create_task(
|
||||
self._configure_and_start_otbr_addon()
|
||||
)
|
||||
|
||||
if not self.addon_start_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="start_otbr_addon",
|
||||
progress_action="start_otbr_addon",
|
||||
try:
|
||||
await self._configure_and_start_otbr_addon()
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_start_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
},
|
||||
progress_task=self.addon_start_task,
|
||||
)
|
||||
) from err
|
||||
|
||||
try:
|
||||
await self.addon_start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = otbr_manager.addon_name
|
||||
self._failed_addon_reason = (
|
||||
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_start_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||
return await self.async_step_pre_confirm_otbr()
|
||||
|
||||
async def async_step_pre_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
@@ -6,7 +6,6 @@ from typing import Any
|
||||
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.models.bell_button import BellButton
|
||||
from aiohue.v2.models.button import Button
|
||||
from aiohue.v2.models.relative_rotary import RelativeRotary, RelativeRotaryDirection
|
||||
|
||||
@@ -40,27 +39,19 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_add_entity(
|
||||
event_type: EventType,
|
||||
resource: Button | RelativeRotary | BellButton,
|
||||
resource: Button | RelativeRotary,
|
||||
) -> None:
|
||||
"""Add entity from Hue resource."""
|
||||
if isinstance(resource, RelativeRotary):
|
||||
async_add_entities(
|
||||
[HueRotaryEventEntity(bridge, api.sensors.relative_rotary, resource)]
|
||||
)
|
||||
elif isinstance(resource, BellButton):
|
||||
async_add_entities(
|
||||
[HueBellButtonEventEntity(bridge, api.sensors.bell_button, resource)]
|
||||
)
|
||||
else:
|
||||
async_add_entities(
|
||||
[HueButtonEventEntity(bridge, api.sensors.button, resource)]
|
||||
)
|
||||
|
||||
for controller in (
|
||||
api.sensors.button,
|
||||
api.sensors.relative_rotary,
|
||||
api.sensors.bell_button,
|
||||
):
|
||||
for controller in (api.sensors.button, api.sensors.relative_rotary):
|
||||
# add all current items in controller
|
||||
for item in controller:
|
||||
async_add_entity(EventType.RESOURCE_ADDED, item)
|
||||
@@ -76,8 +67,6 @@ async def async_setup_entry(
|
||||
class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
"""Representation of a Hue Event entity from a button resource."""
|
||||
|
||||
resource: Button | BellButton
|
||||
|
||||
entity_description = EventEntityDescription(
|
||||
key="button",
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
@@ -102,9 +91,7 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
}
|
||||
|
||||
@callback
|
||||
def _handle_event(
|
||||
self, event_type: EventType, resource: Button | BellButton
|
||||
) -> None:
|
||||
def _handle_event(self, event_type: EventType, resource: Button) -> None:
|
||||
"""Handle status event for this resource (or it's parent)."""
|
||||
if event_type == EventType.RESOURCE_UPDATED and resource.id == self.resource.id:
|
||||
if resource.button is None or resource.button.button_report is None:
|
||||
@@ -115,18 +102,6 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity):
|
||||
super()._handle_event(event_type, resource)
|
||||
|
||||
|
||||
class HueBellButtonEventEntity(HueButtonEventEntity):
|
||||
"""Representation of a Hue Event entity from a bell_button resource."""
|
||||
|
||||
resource: Button | BellButton
|
||||
|
||||
entity_description = EventEntityDescription(
|
||||
key="bell_button",
|
||||
device_class=EventDeviceClass.DOORBELL,
|
||||
has_entity_name=True,
|
||||
)
|
||||
|
||||
|
||||
class HueRotaryEventEntity(HueBaseEntity, EventEntity):
|
||||
"""Representation of a Hue Event entity from a RelativeRotary resource."""
|
||||
|
||||
|
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.8.0"],
|
||||
"requirements": ["aiohue==4.7.5"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
@@ -13,18 +13,13 @@ from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.sensors import (
|
||||
CameraMotionController,
|
||||
ContactController,
|
||||
GroupedMotionController,
|
||||
MotionController,
|
||||
SecurityAreaMotionController,
|
||||
TamperController,
|
||||
)
|
||||
from aiohue.v2.models.camera_motion import CameraMotion
|
||||
from aiohue.v2.models.contact import Contact, ContactState
|
||||
from aiohue.v2.models.entertainment_configuration import EntertainmentStatus
|
||||
from aiohue.v2.models.grouped_motion import GroupedMotion
|
||||
from aiohue.v2.models.motion import Motion
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.security_area_motion import SecurityAreaMotion
|
||||
from aiohue.v2.models.tamper import Tamper, TamperState
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -34,54 +29,21 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from ..bridge import HueBridge, HueConfigEntry
|
||||
from ..const import DOMAIN
|
||||
from ..bridge import HueConfigEntry
|
||||
from .entity import HueBaseEntity
|
||||
|
||||
type SensorType = (
|
||||
CameraMotion
|
||||
| Contact
|
||||
| Motion
|
||||
| EntertainmentConfiguration
|
||||
| Tamper
|
||||
| GroupedMotion
|
||||
| SecurityAreaMotion
|
||||
)
|
||||
type SensorType = CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper
|
||||
type ControllerType = (
|
||||
CameraMotionController
|
||||
| ContactController
|
||||
| MotionController
|
||||
| EntertainmentConfigurationController
|
||||
| TamperController
|
||||
| GroupedMotionController
|
||||
| SecurityAreaMotionController
|
||||
)
|
||||
|
||||
|
||||
def _resource_valid(resource: SensorType, controller: ControllerType) -> bool:
|
||||
"""Return True if the resource is valid."""
|
||||
if isinstance(resource, GroupedMotion):
|
||||
# filter out GroupedMotion sensors that are not linked to a valid group/parent
|
||||
if resource.owner.rtype not in (
|
||||
ResourceTypes.ROOM,
|
||||
ResourceTypes.ZONE,
|
||||
ResourceTypes.SERVICE_GROUP,
|
||||
):
|
||||
return False
|
||||
# guard against GroupedMotion without parent (should not happen, but just in case)
|
||||
if not (parent := controller.get_parent(resource.id)):
|
||||
return False
|
||||
# filter out GroupedMotion sensors that have only one member, because Hue creates one
|
||||
# default grouped Motion sensor per zone/room, which is not useful to expose in HA
|
||||
if len(parent.children) <= 1:
|
||||
return False
|
||||
# default/other checks can go here (none for now)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HueConfigEntry,
|
||||
@@ -97,17 +59,11 @@ async def async_setup_entry(
|
||||
|
||||
@callback
|
||||
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
|
||||
"""Add Hue Binary Sensor from resource added callback."""
|
||||
if not _resource_valid(resource, controller):
|
||||
return
|
||||
"""Add Hue Binary Sensor."""
|
||||
async_add_entities([make_binary_sensor_entity(resource)])
|
||||
|
||||
# add all current items in controller
|
||||
async_add_entities(
|
||||
make_binary_sensor_entity(sensor)
|
||||
for sensor in controller
|
||||
if _resource_valid(sensor, controller)
|
||||
)
|
||||
async_add_entities(make_binary_sensor_entity(sensor) for sensor in controller)
|
||||
|
||||
# register listener for new sensors
|
||||
config_entry.async_on_unload(
|
||||
@@ -122,8 +78,6 @@ async def async_setup_entry(
|
||||
register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor)
|
||||
register_items(api.sensors.contact, HueContactSensor)
|
||||
register_items(api.sensors.tamper, HueTamperSensor)
|
||||
register_items(api.sensors.grouped_motion, HueGroupedMotionSensor)
|
||||
register_items(api.sensors.security_area_motion, HueMotionAwareSensor)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
@@ -148,83 +102,6 @@ class HueMotionSensor(HueBaseEntity, BinarySensorEntity):
|
||||
return self.resource.motion.value
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueGroupedMotionSensor(HueMotionSensor):
|
||||
"""Representation of a Hue Grouped Motion sensor."""
|
||||
|
||||
controller: GroupedMotionController
|
||||
resource: GroupedMotion
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: GroupedMotionController,
|
||||
resource: GroupedMotion,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the GroupedMotion sensor to the parent the sensor is associated with
|
||||
# which can either be a special ServiceGroup or a Zone/Room
|
||||
parent = self.controller.get_parent(resource.id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, parent.id)},
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueMotionAwareSensor(HueMotionSensor):
|
||||
"""Representation of a Motion sensor based on Hue Motion Aware.
|
||||
|
||||
Note that we only create sensors for the SecurityAreaMotion resource
|
||||
and not for the ConvenienceAreaMotion resource, because the latter
|
||||
does not have a state when it's not directly controlling lights.
|
||||
The SecurityAreaMotion resource is always available with a state, allowing
|
||||
Home Assistant users to actually use it as a motion sensor in their HA automations.
|
||||
"""
|
||||
|
||||
controller: SecurityAreaMotionController
|
||||
resource: SecurityAreaMotion
|
||||
|
||||
entity_description = BinarySensorEntityDescription(
|
||||
key="motion_sensor",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
has_entity_name=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return sensor name."""
|
||||
return self.controller.get_motion_area_configuration(self.resource.id).name
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: SecurityAreaMotionController,
|
||||
resource: SecurityAreaMotion,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the MotionAware sensor to the group the sensor is associated with
|
||||
self._motion_area_configuration = self.controller.get_motion_area_configuration(
|
||||
resource.id
|
||||
)
|
||||
group_id = self._motion_area_configuration.group.rid
|
||||
self.group = self.bridge.api.groups[group_id]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.group.id)},
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
# subscribe to updates of the MotionAreaConfiguration to update the name
|
||||
self.async_on_remove(
|
||||
self.bridge.api.config.subscribe(
|
||||
self._handle_event, self._motion_area_configuration.id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity):
|
||||
"""Representation of a Hue Entertainment Configuration as binary sensor."""
|
||||
|
@@ -9,7 +9,6 @@ from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.groups import Room, Zone
|
||||
from aiohue.v2.models.device import Device
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.service_group import ServiceGroup
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_CONNECTIONS,
|
||||
@@ -40,16 +39,16 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
dev_controller = api.devices
|
||||
|
||||
@callback
|
||||
def add_device(hue_resource: Device | Room | Zone | ServiceGroup) -> dr.DeviceEntry:
|
||||
def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry:
|
||||
"""Register a Hue device in device registry."""
|
||||
if isinstance(hue_resource, (Room, Zone, ServiceGroup)):
|
||||
if isinstance(hue_resource, (Room, Zone)):
|
||||
# Register a Hue Room/Zone as service in HA device registry.
|
||||
return dev_reg.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, hue_resource.id)},
|
||||
name=hue_resource.metadata.name,
|
||||
model=hue_resource.type.value.replace("_", " ").title(),
|
||||
model=hue_resource.type.value.title(),
|
||||
manufacturer=api.config.bridge_device.product_data.manufacturer_name,
|
||||
via_device=(DOMAIN, api.config.bridge_device.id),
|
||||
suggested_area=hue_resource.metadata.name
|
||||
@@ -86,7 +85,7 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
|
||||
@callback
|
||||
def handle_device_event(
|
||||
evt_type: EventType, hue_resource: Device | Room | Zone | ServiceGroup
|
||||
evt_type: EventType, hue_resource: Device | Room | Zone
|
||||
) -> None:
|
||||
"""Handle event from Hue controller."""
|
||||
if evt_type == EventType.RESOURCE_DELETED:
|
||||
@@ -102,7 +101,6 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
known_devices = [add_device(hue_device) for hue_device in hue_devices]
|
||||
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
|
||||
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
|
||||
known_devices += [add_device(sg) for sg in api.config.service_group]
|
||||
|
||||
# Check for nodes that no longer exist and remove them
|
||||
for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
|
||||
@@ -113,4 +111,3 @@ async def async_setup_devices(bridge: HueBridge):
|
||||
entry.async_on_unload(dev_controller.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.groups.room.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.groups.zone.subscribe(handle_device_event))
|
||||
entry.async_on_unload(api.config.service_group.subscribe(handle_device_event))
|
||||
|
@@ -162,11 +162,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
"""Turn the grouped_light on."""
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = normalize_hue_colortemp(
|
||||
kwargs.get(ATTR_COLOR_TEMP_KELVIN),
|
||||
color_util.color_temperature_kelvin_to_mired(self.max_color_temp_kelvin),
|
||||
color_util.color_temperature_kelvin_to_mired(self.min_color_temp_kelvin),
|
||||
)
|
||||
color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
flash = kwargs.get(ATTR_FLASH)
|
||||
|
||||
|
@@ -23,12 +23,11 @@ def normalize_hue_transition(transition: float | None) -> float | None:
|
||||
return transition
|
||||
|
||||
|
||||
def normalize_hue_colortemp(
|
||||
colortemp_k: int | None, min_mireds: int, max_mireds: int
|
||||
) -> int | None:
|
||||
def normalize_hue_colortemp(colortemp_k: int | None) -> int | None:
|
||||
"""Return color temperature within Hue's ranges."""
|
||||
if colortemp_k is None:
|
||||
return None
|
||||
colortemp_mireds = color_util.color_temperature_kelvin_to_mired(colortemp_k)
|
||||
# Hue only accepts a range between min_mireds..max_mireds
|
||||
return min(max(colortemp_mireds, min_mireds), max_mireds)
|
||||
colortemp = color_util.color_temperature_kelvin_to_mired(colortemp_k)
|
||||
# Hue only accepts a range between 153..500
|
||||
colortemp = min(colortemp, 500)
|
||||
return max(colortemp, 153)
|
||||
|
@@ -40,8 +40,8 @@ from .helpers import (
|
||||
normalize_hue_transition,
|
||||
)
|
||||
|
||||
FALLBACK_MIN_MIREDS = 153 # hue default for most lights
|
||||
FALLBACK_MAX_MIREDS = 500 # hue default for most lights
|
||||
FALLBACK_MIN_KELVIN = 6500
|
||||
FALLBACK_MAX_KELVIN = 2000
|
||||
FALLBACK_KELVIN = 5800 # halfway
|
||||
|
||||
# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
|
||||
@@ -177,31 +177,25 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_KELVIN
|
||||
|
||||
@property
|
||||
def max_color_temp_mireds(self) -> int:
|
||||
"""Return the warmest color_temp in mireds (so highest number) that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_maximum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
return FALLBACK_MAX_MIREDS
|
||||
|
||||
@property
|
||||
def min_color_temp_mireds(self) -> int:
|
||||
"""Return the coldest color_temp in mireds (so lowest number) that this light supports."""
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_temp.mirek_schema.mirek_minimum
|
||||
# return a fallback value if the light doesn't provide limits
|
||||
return FALLBACK_MIN_MIREDS
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return the coldest color_temp_kelvin that this light supports."""
|
||||
return color_util.color_temperature_mired_to_kelvin(self.min_color_temp_mireds)
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
color_temp.mirek_schema.mirek_minimum
|
||||
)
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_MAX_KELVIN
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the warmest color_temp_kelvin that this light supports."""
|
||||
return color_util.color_temperature_mired_to_kelvin(self.max_color_temp_mireds)
|
||||
if color_temp := self.resource.color_temperature:
|
||||
return color_util.color_temperature_mired_to_kelvin(
|
||||
color_temp.mirek_schema.mirek_maximum
|
||||
)
|
||||
# return a fallback value to prevent issues with mired->kelvin conversions
|
||||
return FALLBACK_MIN_KELVIN
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str] | None:
|
||||
@@ -226,11 +220,7 @@ class HueLight(HueBaseEntity, LightEntity):
|
||||
"""Turn the device on."""
|
||||
transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
|
||||
xy_color = kwargs.get(ATTR_XY_COLOR)
|
||||
color_temp = normalize_hue_colortemp(
|
||||
kwargs.get(ATTR_COLOR_TEMP_KELVIN),
|
||||
self.min_color_temp_mireds,
|
||||
self.max_color_temp_mireds,
|
||||
)
|
||||
color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP_KELVIN))
|
||||
brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
|
||||
if self._last_brightness and brightness is None:
|
||||
# The Hue bridge sets the brightness to 1% when turning on a bulb
|
||||
|
@@ -9,16 +9,13 @@ from aiohue.v2 import HueBridgeV2
|
||||
from aiohue.v2.controllers.events import EventType
|
||||
from aiohue.v2.controllers.sensors import (
|
||||
DevicePowerController,
|
||||
GroupedLightLevelController,
|
||||
LightLevelController,
|
||||
SensorsController,
|
||||
TemperatureController,
|
||||
ZigbeeConnectivityController,
|
||||
)
|
||||
from aiohue.v2.models.device_power import DevicePower
|
||||
from aiohue.v2.models.grouped_light_level import GroupedLightLevel
|
||||
from aiohue.v2.models.light_level import LightLevel
|
||||
from aiohue.v2.models.resource import ResourceTypes
|
||||
from aiohue.v2.models.temperature import Temperature
|
||||
from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity
|
||||
|
||||
@@ -30,50 +27,20 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from ..bridge import HueBridge, HueConfigEntry
|
||||
from ..const import DOMAIN
|
||||
from .entity import HueBaseEntity
|
||||
|
||||
type SensorType = (
|
||||
DevicePower | LightLevel | Temperature | ZigbeeConnectivity | GroupedLightLevel
|
||||
)
|
||||
type SensorType = DevicePower | LightLevel | Temperature | ZigbeeConnectivity
|
||||
type ControllerType = (
|
||||
DevicePowerController
|
||||
| LightLevelController
|
||||
| TemperatureController
|
||||
| ZigbeeConnectivityController
|
||||
| GroupedLightLevelController
|
||||
)
|
||||
|
||||
|
||||
def _resource_valid(
|
||||
resource: SensorType, controller: ControllerType, api: HueBridgeV2
|
||||
) -> bool:
|
||||
"""Return True if the resource is valid."""
|
||||
if isinstance(resource, GroupedLightLevel):
|
||||
# filter out GroupedLightLevel sensors that are not linked to a valid group/parent
|
||||
if resource.owner.rtype not in (
|
||||
ResourceTypes.ROOM,
|
||||
ResourceTypes.ZONE,
|
||||
ResourceTypes.SERVICE_GROUP,
|
||||
):
|
||||
return False
|
||||
# guard against GroupedLightLevel without parent (should not happen, but just in case)
|
||||
parent_id = resource.owner.rid
|
||||
parent = api.groups.get(parent_id) or api.config.get(parent_id)
|
||||
if not parent:
|
||||
return False
|
||||
# filter out GroupedLightLevel sensors that have only one member, because Hue creates one
|
||||
# default grouped LightLevel sensor per zone/room, which is not useful to expose in HA
|
||||
if len(parent.children) <= 1:
|
||||
return False
|
||||
# default/other checks can go here (none for now)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HueConfigEntry,
|
||||
@@ -91,16 +58,10 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_add_sensor(event_type: EventType, resource: SensorType) -> None:
|
||||
"""Add Hue Sensor."""
|
||||
if not _resource_valid(resource, controller, api):
|
||||
return
|
||||
async_add_entities([make_sensor_entity(resource)])
|
||||
|
||||
# add all current items in controller
|
||||
async_add_entities(
|
||||
make_sensor_entity(sensor)
|
||||
for sensor in controller
|
||||
if _resource_valid(sensor, controller, api)
|
||||
)
|
||||
async_add_entities(make_sensor_entity(sensor) for sensor in controller)
|
||||
|
||||
# register listener for new sensors
|
||||
config_entry.async_on_unload(
|
||||
@@ -114,7 +75,6 @@ async def async_setup_entry(
|
||||
register_items(ctrl_base.light_level, HueLightLevelSensor)
|
||||
register_items(ctrl_base.device_power, HueBatterySensor)
|
||||
register_items(ctrl_base.zigbee_connectivity, HueZigbeeConnectivitySensor)
|
||||
register_items(api.sensors.grouped_light_level, HueGroupedLightLevelSensor)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
@@ -180,31 +140,6 @@ class HueLightLevelSensor(HueSensorBase):
|
||||
}
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueGroupedLightLevelSensor(HueLightLevelSensor):
|
||||
"""Representation of a LightLevel (illuminance) sensor from a Hue GroupedLightLevel resource."""
|
||||
|
||||
controller: GroupedLightLevelController
|
||||
resource: GroupedLightLevel
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bridge: HueBridge,
|
||||
controller: GroupedLightLevelController,
|
||||
resource: GroupedLightLevel,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(bridge, controller, resource)
|
||||
# link the GroupedLightLevel sensor to the parent the sensor is associated with
|
||||
# which can either be a special ServiceGroup or a Zone/Room
|
||||
api = self.bridge.api
|
||||
parent_id = resource.owner.rid
|
||||
parent = api.groups.get(parent_id) or api.config.get(parent_id)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, parent.id)},
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable-next=hass-enforce-class-module
|
||||
class HueBatterySensor(HueSensorBase):
|
||||
"""Representation of a Hue Battery sensor."""
|
||||
|
@@ -1,28 +0,0 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import (
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
entities: dict[str, EntityAnalyticsModifications] = {}
|
||||
for entity_id in analytics_input.entity_ids:
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
if entity_entry.capabilities is not None:
|
||||
capabilities = dict(entity_entry.capabilities)
|
||||
capabilities["options"] = len(capabilities["options"])
|
||||
entities[entity_id] = EntityAnalyticsModifications(
|
||||
capabilities=capabilities
|
||||
)
|
||||
|
||||
return AnalyticsModifications(entities=entities)
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "logbook",
|
||||
"name": "Activity",
|
||||
"name": "Logbook",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["frontend", "http", "recorder"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/logbook",
|
||||
|
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"title": "Activity",
|
||||
"title": "Logbook",
|
||||
"services": {
|
||||
"log": {
|
||||
"name": "Log",
|
||||
"description": "Tracks a custom activity.",
|
||||
"description": "Creates a custom entry in the logbook.",
|
||||
"fields": {
|
||||
"name": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
@@ -11,15 +11,15 @@
|
||||
},
|
||||
"message": {
|
||||
"name": "Message",
|
||||
"description": "Message of the activity."
|
||||
"description": "Message of the logbook entry."
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity ID",
|
||||
"description": "Entity to reference in the activity."
|
||||
"description": "Entity to reference in the logbook entry."
|
||||
},
|
||||
"domain": {
|
||||
"name": "Domain",
|
||||
"description": "Determines which icon is used in the activity. The icon illustrates the integration domain related to this activity."
|
||||
"description": "Determines which icon is used in the logbook entry. The icon illustrates the integration domain related to this logbook entry."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -152,8 +152,6 @@ PUMP_CONTROL_MODE_MAP = {
|
||||
clusters.PumpConfigurationAndControl.Enums.ControlModeEnum.kUnknownEnumValue: None,
|
||||
}
|
||||
|
||||
TEMPERATURE_SCALING_FACTOR = 100
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -1143,23 +1141,6 @@ DISCOVERY_SCHEMAS = [
|
||||
device_type=(device_types.Thermostat,),
|
||||
allow_multi=True, # also used for climate entity
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThermostatOutdoorTemperature",
|
||||
translation_key="outdoor_temperature",
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
suggested_display_precision=1,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
device_to_ha=lambda x: (
|
||||
None if x is None else x / TEMPERATURE_SCALING_FACTOR
|
||||
),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
entity_class=MatterSensor,
|
||||
required_attributes=(clusters.Thermostat.Attributes.OutdoorTemperature,),
|
||||
device_type=(device_types.Thermostat, device_types.RoomAirConditioner),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SENSOR,
|
||||
entity_description=MatterOperationalStateSensorEntityDescription(
|
||||
|
@@ -485,9 +485,6 @@
|
||||
"apparent_current": {
|
||||
"name": "Apparent current"
|
||||
},
|
||||
"outdoor_temperature": {
|
||||
"name": "Outdoor temperature"
|
||||
},
|
||||
"reactive_current": {
|
||||
"name": "Reactive current"
|
||||
},
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.09.23"],
|
||||
"requirements": ["yt-dlp[default]==2025.09.05"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
@@ -148,7 +148,7 @@ from .const import (
|
||||
DEFAULT_HVAC_ON_VALUE,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEFAULT_TEMP_UNIT,
|
||||
DOMAIN,
|
||||
MODBUS_DOMAIN as DOMAIN,
|
||||
RTUOVERTCP,
|
||||
SERIAL,
|
||||
TCP,
|
||||
|
@@ -159,7 +159,6 @@ DEFAULT_TEMP_UNIT = "C"
|
||||
DEFAULT_HVAC_ON_VALUE = 1
|
||||
DEFAULT_HVAC_OFF_VALUE = 0
|
||||
MODBUS_DOMAIN = "modbus"
|
||||
DOMAIN = "modbus"
|
||||
|
||||
ACTIVE_SCAN_INTERVAL = 2 # limit to force an extra update
|
||||
|
||||
|
@@ -117,7 +117,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
|
||||
conv_brightness = self._convert_brightness_to_modbus(brightness)
|
||||
|
||||
await self._hub.async_pb_call(
|
||||
device_address=self._device_address,
|
||||
unit=self._device_address,
|
||||
address=self._brightness_address,
|
||||
value=conv_brightness,
|
||||
use_call=CALL_TYPE_WRITE_REGISTER,
|
||||
@@ -133,7 +133,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
|
||||
conv_color_temp_kelvin = self._convert_color_temp_to_modbus(color_temp_kelvin)
|
||||
|
||||
await self._hub.async_pb_call(
|
||||
device_address=self._device_address,
|
||||
unit=self._device_address,
|
||||
address=self._color_temp_address,
|
||||
value=conv_color_temp_kelvin,
|
||||
use_call=CALL_TYPE_WRITE_REGISTER,
|
||||
@@ -150,7 +150,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
|
||||
|
||||
if self._brightness_address:
|
||||
brightness_result = await self._hub.async_pb_call(
|
||||
device_address=self._device_address,
|
||||
unit=self._device_address,
|
||||
value=1,
|
||||
address=self._brightness_address,
|
||||
use_call=CALL_TYPE_REGISTER_HOLDING,
|
||||
@@ -167,7 +167,7 @@ class ModbusLight(ModbusToggleEntity, LightEntity):
|
||||
|
||||
if self._color_temp_address:
|
||||
color_result = await self._hub.async_pb_call(
|
||||
device_address=self._device_address,
|
||||
unit=self._device_address,
|
||||
value=1,
|
||||
address=self._color_temp_address,
|
||||
use_call=CALL_TYPE_REGISTER_HOLDING,
|
||||
|
@@ -56,7 +56,7 @@ from .const import (
|
||||
CONF_STOPBITS,
|
||||
DEFAULT_HUB,
|
||||
DEVICE_ID,
|
||||
DOMAIN,
|
||||
MODBUS_DOMAIN as DOMAIN,
|
||||
PLATFORMS,
|
||||
RTUOVERTCP,
|
||||
SERIAL,
|
||||
@@ -169,43 +169,43 @@ async def async_modbus_setup(
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_modbus)
|
||||
|
||||
def _get_service_call_details(
|
||||
service: ServiceCall,
|
||||
) -> tuple[ModbusHub, int, int]:
|
||||
"""Return the details required to process the service call."""
|
||||
device_address = service.data.get(ATTR_SLAVE, service.data.get(ATTR_UNIT, 1))
|
||||
address = service.data[ATTR_ADDRESS]
|
||||
hub = hub_collect[service.data[ATTR_HUB]]
|
||||
return (hub, device_address, address)
|
||||
|
||||
async def async_write_register(service: ServiceCall) -> None:
|
||||
"""Write Modbus registers."""
|
||||
hub, device_address, address = _get_service_call_details(service)
|
||||
slave = 1
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
|
||||
if ATTR_SLAVE in service.data:
|
||||
slave = int(float(service.data[ATTR_SLAVE]))
|
||||
address = int(float(service.data[ATTR_ADDRESS]))
|
||||
value = service.data[ATTR_VALUE]
|
||||
hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
|
||||
if isinstance(value, list):
|
||||
await hub.async_pb_call(
|
||||
device_address, address, value, CALL_TYPE_WRITE_REGISTERS
|
||||
slave,
|
||||
address,
|
||||
[int(float(i)) for i in value],
|
||||
CALL_TYPE_WRITE_REGISTERS,
|
||||
)
|
||||
else:
|
||||
await hub.async_pb_call(
|
||||
device_address, address, value, CALL_TYPE_WRITE_REGISTER
|
||||
slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER
|
||||
)
|
||||
|
||||
async def async_write_coil(service: ServiceCall) -> None:
|
||||
"""Write Modbus coil."""
|
||||
hub, device_address, address = _get_service_call_details(service)
|
||||
|
||||
slave = 1
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
if ATTR_SLAVE in service.data:
|
||||
slave = int(float(service.data[ATTR_SLAVE]))
|
||||
address = service.data[ATTR_ADDRESS]
|
||||
state = service.data[ATTR_STATE]
|
||||
|
||||
hub = hub_collect[service.data.get(ATTR_HUB, DEFAULT_HUB)]
|
||||
if isinstance(state, list):
|
||||
await hub.async_pb_call(
|
||||
device_address, address, state, CALL_TYPE_WRITE_COILS
|
||||
)
|
||||
await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS)
|
||||
else:
|
||||
await hub.async_pb_call(
|
||||
device_address, address, state, CALL_TYPE_WRITE_COIL
|
||||
)
|
||||
await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL)
|
||||
|
||||
for x_write in (
|
||||
(SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int),
|
||||
@@ -370,17 +370,11 @@ class ModbusHub:
|
||||
_LOGGER.info(f"modbus {self.name} communication closed")
|
||||
|
||||
async def low_level_pb_call(
|
||||
self,
|
||||
device_address: int | None,
|
||||
address: int,
|
||||
value: int | list[int],
|
||||
use_call: str,
|
||||
self, slave: int | None, address: int, value: int | list[int], use_call: str
|
||||
) -> ModbusPDU | None:
|
||||
"""Call sync. pymodbus."""
|
||||
kwargs: dict[str, Any] = (
|
||||
{DEVICE_ID: device_address}
|
||||
if device_address is not None
|
||||
else {DEVICE_ID: 1}
|
||||
{DEVICE_ID: slave} if slave is not None else {DEVICE_ID: 1}
|
||||
)
|
||||
entry = self._pb_request[use_call]
|
||||
|
||||
@@ -392,26 +386,28 @@ class ModbusHub:
|
||||
try:
|
||||
result: ModbusPDU = await entry.func(address, **kwargs)
|
||||
except ModbusException as exception_error:
|
||||
error = f"Error: device: {device_address} address: {address} -> {exception_error!s}"
|
||||
error = f"Error: device: {slave} address: {address} -> {exception_error!s}"
|
||||
self._log_error(error)
|
||||
return None
|
||||
if not result:
|
||||
error = f"Error: device: {device_address} address: {address} -> pymodbus returned None"
|
||||
error = (
|
||||
f"Error: device: {slave} address: {address} -> pymodbus returned None"
|
||||
)
|
||||
self._log_error(error)
|
||||
return None
|
||||
if not hasattr(result, entry.attr):
|
||||
error = f"Error: device: {device_address} address: {address} -> {result!s}"
|
||||
error = f"Error: device: {slave} address: {address} -> {result!s}"
|
||||
self._log_error(error)
|
||||
return None
|
||||
if result.isError():
|
||||
error = f"Error: device: {device_address} address: {address} -> pymodbus returned isError True"
|
||||
error = f"Error: device: {slave} address: {address} -> pymodbus returned isError True"
|
||||
self._log_error(error)
|
||||
return None
|
||||
return result
|
||||
|
||||
async def async_pb_call(
|
||||
self,
|
||||
device_address: int | None,
|
||||
unit: int | None,
|
||||
address: int,
|
||||
value: int | list[int],
|
||||
use_call: str,
|
||||
@@ -419,7 +415,7 @@ class ModbusHub:
|
||||
"""Convert async to sync pymodbus call."""
|
||||
if not self._client:
|
||||
return None
|
||||
result = await self.low_level_pb_call(device_address, address, value, use_call)
|
||||
result = await self.low_level_pb_call(unit, address, value, use_call)
|
||||
if self._msg_wait:
|
||||
await asyncio.sleep(self._msg_wait)
|
||||
return result
|
||||
|
@@ -36,7 +36,7 @@ from .const import (
|
||||
CONF_VIRTUAL_COUNT,
|
||||
DEFAULT_HUB,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
MODBUS_DOMAIN as DOMAIN,
|
||||
PLATFORMS,
|
||||
SERIAL,
|
||||
DataType,
|
||||
|
@@ -39,7 +39,6 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.components.cover import CoverDeviceClass
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
|
||||
from homeassistant.components.image import DEFAULT_CONTENT_TYPE
|
||||
from homeassistant.components.light import (
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
@@ -168,7 +167,6 @@ from .const import (
|
||||
CONF_COMMAND_ON_TEMPLATE,
|
||||
CONF_COMMAND_TEMPLATE,
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_CONTENT_TYPE,
|
||||
CONF_CURRENT_HUMIDITY_TEMPLATE,
|
||||
CONF_CURRENT_HUMIDITY_TOPIC,
|
||||
CONF_CURRENT_TEMP_TEMPLATE,
|
||||
@@ -207,8 +205,6 @@ from .const import (
|
||||
CONF_HUMIDITY_MIN,
|
||||
CONF_HUMIDITY_STATE_TEMPLATE,
|
||||
CONF_HUMIDITY_STATE_TOPIC,
|
||||
CONF_IMAGE_ENCODING,
|
||||
CONF_IMAGE_TOPIC,
|
||||
CONF_KEEPALIVE,
|
||||
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||
CONF_MAX_KELVIN,
|
||||
@@ -334,8 +330,6 @@ from .const import (
|
||||
CONF_TLS_INSECURE,
|
||||
CONF_TRANSITION,
|
||||
CONF_TRANSPORT,
|
||||
CONF_URL_TEMPLATE,
|
||||
CONF_URL_TOPIC,
|
||||
CONF_WHITE_COMMAND_TOPIC,
|
||||
CONF_WHITE_SCALE,
|
||||
CONF_WILL_MESSAGE,
|
||||
@@ -440,7 +434,6 @@ SUBENTRY_PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.COVER,
|
||||
Platform.FAN,
|
||||
Platform.IMAGE,
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
@@ -627,43 +620,6 @@ HUMIDITY_SELECTOR = vol.All(
|
||||
),
|
||||
vol.Coerce(int),
|
||||
)
|
||||
IMAGE_CONTENT_TYPE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
SelectOptionDict(
|
||||
value="image/jpeg", label="Joint Photographic Expert Group image (JPEG)"
|
||||
),
|
||||
SelectOptionDict(
|
||||
value="image/png", label="Portable Network Graphics (PNG)"
|
||||
),
|
||||
SelectOptionDict(
|
||||
value="image/apng", label="Animated Portable Network Graphics (APNG)"
|
||||
),
|
||||
SelectOptionDict(value="image/avif", label="AV1 Image File Format (AVIF)"),
|
||||
SelectOptionDict(
|
||||
value="image/gif", label="Graphics Interchange Format (GIF)"
|
||||
),
|
||||
SelectOptionDict(
|
||||
value="image/svg+xml", label="Scalable Vector Graphics (SVG)"
|
||||
),
|
||||
SelectOptionDict(value="image/webp", label="Web Picture format (WEBP)"),
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
IMAGE_ENCODING_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=["raw", "b64"],
|
||||
translation_key="image_encoding",
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
)
|
||||
IMAGE_PROCESSING_MODE_SELECTOR = SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=["image_url", "image_data"],
|
||||
translation_key="image_processing_mode",
|
||||
)
|
||||
)
|
||||
KELVIN_SELECTOR = NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
mode=NumberSelectorMode.BOX,
|
||||
@@ -1063,7 +1019,6 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
Platform.CLIMATE.value: validate_climate_platform_config,
|
||||
Platform.COVER.value: validate_cover_platform_config,
|
||||
Platform.FAN.value: validate_fan_platform_config,
|
||||
Platform.IMAGE.value: None,
|
||||
Platform.LIGHT.value: validate_light_platform_config,
|
||||
Platform.LOCK.value: None,
|
||||
Platform.NOTIFY.value: None,
|
||||
@@ -1254,18 +1209,6 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
default=lambda config: bool(config.get(CONF_DIRECTION_COMMAND_TOPIC)),
|
||||
),
|
||||
},
|
||||
Platform.IMAGE.value: {
|
||||
"image_processing_mode": PlatformField(
|
||||
selector=IMAGE_PROCESSING_MODE_SELECTOR,
|
||||
required=True,
|
||||
exclude_from_config=True,
|
||||
default=(
|
||||
lambda config: "image_url"
|
||||
if config.get(CONF_IMAGE_TOPIC) is None
|
||||
else "image_data"
|
||||
),
|
||||
)
|
||||
},
|
||||
Platform.LIGHT.value: {
|
||||
CONF_SCHEMA: PlatformField(
|
||||
selector=LIGHT_SCHEMA_SELECTOR,
|
||||
@@ -2349,40 +2292,6 @@ PLATFORM_MQTT_FIELDS: dict[str, dict[str, PlatformField]] = {
|
||||
conditions=({"fan_feature_direction": True},),
|
||||
),
|
||||
},
|
||||
Platform.IMAGE.value: {
|
||||
CONF_IMAGE_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
conditions=({"image_processing_mode": "image_data"},),
|
||||
),
|
||||
CONF_CONTENT_TYPE: PlatformField(
|
||||
selector=IMAGE_CONTENT_TYPE_SELECTOR,
|
||||
required=True,
|
||||
default=DEFAULT_CONTENT_TYPE,
|
||||
conditions=({"image_processing_mode": "image_data"},),
|
||||
),
|
||||
CONF_IMAGE_ENCODING: PlatformField(
|
||||
selector=IMAGE_ENCODING_SELECTOR,
|
||||
required=False,
|
||||
conditions=({"image_processing_mode": "image_data"},),
|
||||
default="raw",
|
||||
),
|
||||
CONF_URL_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
required=True,
|
||||
validator=valid_subscribe_topic,
|
||||
error="invalid_subscribe_topic",
|
||||
conditions=({"image_processing_mode": "image_url"},),
|
||||
),
|
||||
CONF_URL_TEMPLATE: PlatformField(
|
||||
selector=TEMPLATE_SELECTOR,
|
||||
required=False,
|
||||
validator=validate(cv.template),
|
||||
error="invalid_template",
|
||||
),
|
||||
},
|
||||
Platform.LIGHT.value: {
|
||||
CONF_COMMAND_TOPIC: PlatformField(
|
||||
selector=TEXT_SELECTOR,
|
||||
|
@@ -38,12 +38,9 @@ CONF_CODE_FORMAT = "code_format"
|
||||
CONF_CODE_TRIGGER_REQUIRED = "code_trigger_required"
|
||||
CONF_COMMAND_TEMPLATE = "command_template"
|
||||
CONF_COMMAND_TOPIC = "command_topic"
|
||||
CONF_CONTENT_TYPE = "content_type"
|
||||
CONF_DEFAULT_ENTITY_ID = "default_entity_id"
|
||||
CONF_DISCOVERY_PREFIX = "discovery_prefix"
|
||||
CONF_ENCODING = "encoding"
|
||||
CONF_IMAGE_ENCODING = "image_encoding"
|
||||
CONF_IMAGE_TOPIC = "image_topic"
|
||||
CONF_JSON_ATTRS_TOPIC = "json_attributes_topic"
|
||||
CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template"
|
||||
CONF_KEEPALIVE = "keepalive"
|
||||
@@ -234,8 +231,6 @@ CONF_TILT_MIN = "tilt_min"
|
||||
CONF_TILT_OPEN_POSITION = "tilt_opened_value"
|
||||
CONF_TILT_STATE_OPTIMISTIC = "tilt_optimistic"
|
||||
CONF_TRANSITION = "transition"
|
||||
CONF_URL_TEMPLATE = "url_template"
|
||||
CONF_URL_TOPIC = "url_topic"
|
||||
CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
|
||||
CONF_XY_COMMAND_TOPIC = "xy_command_topic"
|
||||
CONF_XY_STATE_TOPIC = "xy_state_topic"
|
||||
|
@@ -25,13 +25,6 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import subscription
|
||||
from .config import MQTT_BASE_SCHEMA
|
||||
from .const import (
|
||||
CONF_CONTENT_TYPE,
|
||||
CONF_IMAGE_ENCODING,
|
||||
CONF_IMAGE_TOPIC,
|
||||
CONF_URL_TEMPLATE,
|
||||
CONF_URL_TOPIC,
|
||||
)
|
||||
from .entity import MqttEntity, async_setup_entity_entry_helper
|
||||
from .models import (
|
||||
DATA_MQTT,
|
||||
@@ -46,6 +39,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
CONF_CONTENT_TYPE = "content_type"
|
||||
CONF_IMAGE_ENCODING = "image_encoding"
|
||||
CONF_IMAGE_TOPIC = "image_topic"
|
||||
CONF_URL_TEMPLATE = "url_template"
|
||||
CONF_URL_TOPIC = "url_topic"
|
||||
|
||||
DEFAULT_NAME = "MQTT Image"
|
||||
|
||||
GET_IMAGE_TIMEOUT = 10
|
||||
@@ -68,7 +67,7 @@ PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
|
||||
vol.Optional(CONF_NAME): vol.Any(cv.string, None),
|
||||
vol.Exclusive(CONF_URL_TOPIC, "image_topic"): valid_subscribe_topic,
|
||||
vol.Exclusive(CONF_IMAGE_TOPIC, "image_topic"): valid_subscribe_topic,
|
||||
vol.Optional(CONF_IMAGE_ENCODING): vol.In({"b64", "raw"}),
|
||||
vol.Optional(CONF_IMAGE_ENCODING): "b64",
|
||||
vol.Optional(CONF_URL_TEMPLATE): cv.template,
|
||||
}
|
||||
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
|
||||
@@ -147,7 +146,7 @@ class MqttImage(MqttEntity, ImageEntity):
|
||||
def _image_data_received(self, msg: ReceiveMessage) -> None:
|
||||
"""Handle new MQTT messages."""
|
||||
try:
|
||||
if self._config.get(CONF_IMAGE_ENCODING) == "b64":
|
||||
if CONF_IMAGE_ENCODING in self._config:
|
||||
self._last_image = b64decode(msg.payload)
|
||||
else:
|
||||
if TYPE_CHECKING:
|
||||
|
@@ -264,7 +264,6 @@
|
||||
"fan_feature_preset_modes": "Preset modes support",
|
||||
"fan_feature_oscillation": "Oscillation support",
|
||||
"fan_feature_direction": "Direction support",
|
||||
"image_processing_mode": "Image processing mode",
|
||||
"options": "Add option",
|
||||
"schema": "Schema",
|
||||
"state_class": "State class",
|
||||
@@ -291,7 +290,6 @@
|
||||
"fan_feature_preset_modes": "The fan supports preset modes.",
|
||||
"fan_feature_oscillation": "The fan supports oscillation.",
|
||||
"fan_feature_direction": "The fan supports direction.",
|
||||
"image_processing_mode": "Select how the image data is received.",
|
||||
"options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement.",
|
||||
"schema": "The schema to use. [Learn more.]({url}#comparison-of-light-mqtt-schemas)",
|
||||
"state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
|
||||
@@ -328,11 +326,8 @@
|
||||
"command_topic": "Command topic",
|
||||
"command_off_template": "Command \"off\" template",
|
||||
"command_on_template": "Command \"on\" template",
|
||||
"content_type": "Content type",
|
||||
"force_update": "Force update",
|
||||
"green_template": "Green template",
|
||||
"image_encoding": "Image encoding",
|
||||
"image_topic": "Image topic",
|
||||
"last_reset_value_template": "Last reset value template",
|
||||
"modes": "Supported operation modes",
|
||||
"mode_command_topic": "Operation mode command topic",
|
||||
@@ -353,8 +348,6 @@
|
||||
"state_topic": "State topic",
|
||||
"state_value_template": "State value template",
|
||||
"supported_color_modes": "Supported color modes",
|
||||
"url_template": "URL template",
|
||||
"url_topic": "URL topic",
|
||||
"value_template": "Value template"
|
||||
},
|
||||
"data_description": {
|
||||
@@ -370,11 +363,8 @@
|
||||
"command_on_template": "The [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) for \"on\" state changes. Available variables: `state`, `brightness`, `color_temp`, `red`, `green`, `blue`, `hue`, `sat`, `flash`, `transition` and `effect`. Values `red`, `green`, `blue` and `brightness` are provided as integers from range 0-255. Value of `hue` is provided as float from range 0-360. Value of `sat` is provided as float from range 0-100. Value of `color_temp` is provided as integer representing Kelvin units.",
|
||||
"command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic. [Learn more.]({url}#command_template)",
|
||||
"command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)",
|
||||
"content_type": "The content type or the image data that is received at the image topic.",
|
||||
"force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)",
|
||||
"green_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract green color from the state payload value. Expected result of the template is an integer from 0-255 range.",
|
||||
"image_encoding": "Select the encoding of the received image data",
|
||||
"image_topic": "The MQTT topic subscribed to receive messages containing the image data. [Learn more.]({url}#image_topic)",
|
||||
"last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
|
||||
"modes": "A list of supported operation modes. [Learn more.]({url}#modes)",
|
||||
"mode_command_topic": "The MQTT topic to publish commands to change the climate operation mode. [Learn more.]({url}#mode_command_topic)",
|
||||
@@ -394,8 +384,6 @@
|
||||
"state_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract state from the state payload value.",
|
||||
"state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
|
||||
"supported_color_modes": "A list of color modes supported by the light. Possible color modes are On/Off, Brightness, Color temperature, HS, XY, RGB, RGBW, RGBWW, White. Note that if On/Off or Brightness are used, that must be the only value in the list. [Learn more.]({url}#supported_color_modes)",
|
||||
"url_template": "[Template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract an URL from the received URL topic payload value. [Learn more.]({url}#url_template)",
|
||||
"url_topic": "The MQTT topic subscribed to receive messages containing the image URL. [Learn more.]({url}#url_topic)",
|
||||
"value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value. [Learn more.]({url}#value_template)"
|
||||
},
|
||||
"sections": {
|
||||
@@ -1273,12 +1261,6 @@
|
||||
"diagnostic": "Diagnostic"
|
||||
}
|
||||
},
|
||||
"image_encoding": {
|
||||
"options": {
|
||||
"raw": "Raw data",
|
||||
"b64": "Base64 encoding"
|
||||
}
|
||||
},
|
||||
"image_processing_mode": {
|
||||
"options": {
|
||||
"image_data": "Image data is received",
|
||||
@@ -1307,7 +1289,6 @@
|
||||
"climate": "[%key:component::climate::title%]",
|
||||
"cover": "[%key:component::cover::title%]",
|
||||
"fan": "[%key:component::fan::title%]",
|
||||
"image": "[%key:component::image::title%]",
|
||||
"light": "[%key:component::light::title%]",
|
||||
"lock": "[%key:component::lock::title%]",
|
||||
"notify": "[%key:component::notify::title%]",
|
||||
|
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
|
||||
from music_assistant_models.api import ServerInfoMessage
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
@@ -113,10 +113,6 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
# If the entry was ignored or disabled, don't make any changes
|
||||
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
# Test connectivity to the current URL first
|
||||
current_url = existing_entry.data[CONF_URL]
|
||||
try:
|
||||
|
@@ -19,7 +19,7 @@
|
||||
"loggers": ["roborock"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": [
|
||||
"python-roborock==2.47.1",
|
||||
"python-roborock==2.44.1",
|
||||
"vacuum-map-parser-roborock==0.1.4"
|
||||
]
|
||||
}
|
||||
|
@@ -1,43 +0,0 @@
|
||||
"""Analytics platform."""
|
||||
|
||||
from homeassistant.components.analytics import (
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
)
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
FILTERED_PLATFORM_CAPABILITY: dict[str, str] = {
|
||||
Platform.FAN: "preset_modes",
|
||||
Platform.SELECT: "options",
|
||||
}
|
||||
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant, analytics_input: AnalyticsInput
|
||||
) -> AnalyticsModifications:
|
||||
"""Modify the analytics."""
|
||||
ent_reg = er.async_get(hass)
|
||||
|
||||
entities: dict[str, EntityAnalyticsModifications] = {}
|
||||
for entity_id in analytics_input.entity_ids:
|
||||
platform = split_entity_id(entity_id)[0]
|
||||
if platform not in FILTERED_PLATFORM_CAPABILITY:
|
||||
continue
|
||||
|
||||
entity_entry = ent_reg.entities[entity_id]
|
||||
if entity_entry.capabilities is not None:
|
||||
filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform]
|
||||
if filtered_capability not in entity_entry.capabilities:
|
||||
continue
|
||||
|
||||
capabilities = dict(entity_entry.capabilities)
|
||||
capabilities[filtered_capability] = len(capabilities[filtered_capability])
|
||||
|
||||
entities[entity_id] = EntityAnalyticsModifications(
|
||||
capabilities=capabilities
|
||||
)
|
||||
|
||||
return AnalyticsModifications(entities=entities)
|
@@ -260,10 +260,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
self._motor_reverse_mode_enum = enum_type
|
||||
|
||||
@property
|
||||
def _is_position_reversed(self) -> bool:
|
||||
"""Check if the cover position and direction should be reversed."""
|
||||
# The default is True
|
||||
# Having motor_reverse_mode == "back" cancels the inversion
|
||||
def _is_motor_forward(self) -> bool:
|
||||
"""Check if the cover direction should be reversed based on motor_reverse_mode.
|
||||
|
||||
If the motor is "forward" (=default) then the positions need to be reversed.
|
||||
"""
|
||||
return not (
|
||||
self._motor_reverse_mode_enum
|
||||
and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
|
||||
@@ -280,7 +281,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
|
||||
return round(
|
||||
self._current_position.remap_value_to(
|
||||
position, 0, 100, reverse=self._is_position_reversed
|
||||
position, 0, 100, reverse=self._is_motor_forward
|
||||
)
|
||||
)
|
||||
|
||||
@@ -334,7 +335,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
"code": self._set_position.dpcode,
|
||||
"value": round(
|
||||
self._set_position.remap_value_from(
|
||||
100, 0, 100, reverse=self._is_position_reversed
|
||||
100, 0, 100, reverse=self._is_motor_forward
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -360,7 +361,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
"code": self._set_position.dpcode,
|
||||
"value": round(
|
||||
self._set_position.remap_value_from(
|
||||
0, 0, 100, reverse=self._is_position_reversed
|
||||
0, 0, 100, reverse=self._is_motor_forward
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -383,7 +384,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
kwargs[ATTR_POSITION],
|
||||
0,
|
||||
100,
|
||||
reverse=self._is_position_reversed,
|
||||
reverse=self._is_motor_forward,
|
||||
)
|
||||
),
|
||||
}
|
||||
@@ -416,7 +417,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
|
||||
kwargs[ATTR_TILT_POSITION],
|
||||
0,
|
||||
100,
|
||||
reverse=self._is_position_reversed,
|
||||
reverse=self._is_motor_forward,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
@@ -134,7 +134,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
device_registry = dr.async_get(hass)
|
||||
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
|
||||
|
||||
async with radio_mgr.create_zigpy_app(connect=False) as app:
|
||||
async with radio_mgr.connect_zigpy_app() as app:
|
||||
for dev in app.devices.values():
|
||||
dev_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, str(dev.ieee))},
|
||||
|
@@ -56,7 +56,7 @@ async def async_get_last_network_settings(
|
||||
|
||||
radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry)
|
||||
|
||||
async with radio_mgr.create_zigpy_app(connect=False) as app:
|
||||
async with radio_mgr.connect_zigpy_app() as app:
|
||||
try:
|
||||
settings = max(app.backups, key=lambda b: b.backup_time)
|
||||
except ValueError:
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
import collections
|
||||
from contextlib import suppress
|
||||
import json
|
||||
@@ -14,7 +13,6 @@ import voluptuous as vol
|
||||
from zha.application.const import RadioType
|
||||
import zigpy.backups
|
||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||
from zigpy.exceptions import CannotWriteNetworkSettings, DestructiveWriteNetworkSettings
|
||||
|
||||
from homeassistant.components import onboarding, usb
|
||||
from homeassistant.components.file_upload import process_uploaded_file
|
||||
@@ -23,6 +21,7 @@ from homeassistant.components.homeassistant_hardware import silabs_multiprotocol
|
||||
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_ZEROCONF,
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
ConfigEntryState,
|
||||
@@ -33,7 +32,6 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
|
||||
@@ -42,7 +40,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_BAUDRATE, CONF_FLOW_CONTROL, CONF_RADIO_TYPE, DOMAIN
|
||||
from .helpers import get_zha_gateway
|
||||
from .radio_manager import (
|
||||
DEVICE_SCHEMA,
|
||||
HARDWARE_DISCOVERY_SCHEMA,
|
||||
@@ -52,22 +49,12 @@ from .radio_manager import (
|
||||
)
|
||||
|
||||
CONF_MANUAL_PATH = "Enter Manually"
|
||||
SUPPORTED_PORT_SETTINGS = (
|
||||
CONF_BAUDRATE,
|
||||
CONF_FLOW_CONTROL,
|
||||
)
|
||||
DECONZ_DOMAIN = "deconz"
|
||||
|
||||
# The ZHA config flow takes different branches depending on if you are migrating to a
|
||||
# new adapter via discovery or setting it up from scratch
|
||||
|
||||
# For the fast path, we automatically migrate everything and restore the most recent backup
|
||||
MIGRATION_STRATEGY_RECOMMENDED = "migration_strategy_recommended"
|
||||
MIGRATION_STRATEGY_ADVANCED = "migration_strategy_advanced"
|
||||
|
||||
# Similarly, setup follows the same approach: we create a new network
|
||||
SETUP_STRATEGY_RECOMMENDED = "setup_strategy_recommended"
|
||||
SETUP_STRATEGY_ADVANCED = "setup_strategy_advanced"
|
||||
|
||||
# For the advanced paths, we allow users to pick how to form a network: form a brand new
|
||||
# network, use the settings currently on the stick, restore from a database backup, or
|
||||
# restore from a JSON backup
|
||||
FORMATION_STRATEGY = "formation_strategy"
|
||||
FORMATION_FORM_NEW_NETWORK = "form_new_network"
|
||||
FORMATION_FORM_INITIAL_NETWORK = "form_initial_network"
|
||||
@@ -183,35 +170,24 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
self._hass = hass
|
||||
self._radio_mgr.hass = hass
|
||||
|
||||
async def _get_config_entry_data(self) -> dict:
|
||||
"""Extract ZHA config entry data from the radio manager."""
|
||||
async def _async_create_radio_entry(self) -> ConfigFlowResult:
|
||||
"""Create a config entry with the current flow state."""
|
||||
assert self._radio_mgr.radio_type is not None
|
||||
assert self._radio_mgr.device_path is not None
|
||||
assert self._radio_mgr.device_settings is not None
|
||||
|
||||
try:
|
||||
device_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, self._radio_mgr.device_path
|
||||
)
|
||||
except OSError as error:
|
||||
raise AbortFlow(
|
||||
reason="cannot_resolve_path",
|
||||
description_placeholders={"path": self._radio_mgr.device_path},
|
||||
) from error
|
||||
device_settings = self._radio_mgr.device_settings.copy()
|
||||
device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, self._radio_mgr.device_path
|
||||
)
|
||||
|
||||
return {
|
||||
CONF_DEVICE: DEVICE_SCHEMA(
|
||||
{
|
||||
**self._radio_mgr.device_settings,
|
||||
CONF_DEVICE_PATH: device_path,
|
||||
}
|
||||
),
|
||||
CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
|
||||
}
|
||||
|
||||
@abstractmethod
|
||||
async def _async_create_radio_entry(self) -> ConfigFlowResult:
|
||||
"""Create a config entry with the current flow state."""
|
||||
return self.async_create_entry(
|
||||
title=self._title,
|
||||
data={
|
||||
CONF_DEVICE: DEVICE_SCHEMA(device_settings),
|
||||
CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_choose_serial_port(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -312,44 +288,43 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
if user_input is not None:
|
||||
self._title = user_input[CONF_DEVICE_PATH]
|
||||
self._radio_mgr.device_path = user_input[CONF_DEVICE_PATH]
|
||||
self._radio_mgr.device_settings = DEVICE_SCHEMA(
|
||||
{
|
||||
CONF_DEVICE_PATH: self._radio_mgr.device_path,
|
||||
CONF_BAUDRATE: user_input[CONF_BAUDRATE],
|
||||
# `None` shows up as the empty string in the frontend
|
||||
CONF_FLOW_CONTROL: (
|
||||
user_input[CONF_FLOW_CONTROL]
|
||||
if user_input[CONF_FLOW_CONTROL] != "none"
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
self._radio_mgr.device_settings = user_input.copy()
|
||||
|
||||
if await self._radio_mgr.radio_type.controller.probe(user_input):
|
||||
return await self.async_step_verify_radio()
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
device_settings = self._radio_mgr.device_settings or {}
|
||||
schema = {
|
||||
vol.Required(
|
||||
CONF_DEVICE_PATH, default=self._radio_mgr.device_path or vol.UNDEFINED
|
||||
): str
|
||||
}
|
||||
|
||||
source = self.context.get("source")
|
||||
for (
|
||||
param,
|
||||
value,
|
||||
) in DEVICE_SCHEMA.schema.items():
|
||||
if param not in SUPPORTED_PORT_SETTINGS:
|
||||
continue
|
||||
|
||||
if source == SOURCE_ZEROCONF and param == CONF_BAUDRATE:
|
||||
value = 115200
|
||||
param = vol.Required(CONF_BAUDRATE, default=value)
|
||||
elif (
|
||||
self._radio_mgr.device_settings is not None
|
||||
and param in self._radio_mgr.device_settings
|
||||
):
|
||||
param = vol.Required(
|
||||
str(param), default=self._radio_mgr.device_settings[param]
|
||||
)
|
||||
|
||||
schema[param] = value
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manual_port_config",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DEVICE_PATH,
|
||||
default=self._radio_mgr.device_path or vol.UNDEFINED,
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_BAUDRATE,
|
||||
default=device_settings.get(CONF_BAUDRATE) or 115200,
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_FLOW_CONTROL,
|
||||
default=device_settings.get(CONF_FLOW_CONTROL) or "none",
|
||||
): vol.In(["hardware", "software", "none"]),
|
||||
}
|
||||
),
|
||||
data_schema=vol.Schema(schema),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -358,15 +333,10 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Add a warning step to dissuade the use of deprecated radios."""
|
||||
assert self._radio_mgr.radio_type is not None
|
||||
await self._radio_mgr.async_read_backups_from_database()
|
||||
|
||||
# Skip this step if we are using a recommended radio
|
||||
if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS:
|
||||
# ZHA disables the single instance check and will decide at runtime if we
|
||||
# are migrating or setting up from scratch
|
||||
if self.hass.config_entries.async_entries(DOMAIN):
|
||||
return await self.async_step_choose_migration_strategy()
|
||||
return await self.async_step_choose_setup_strategy()
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="verify_radio",
|
||||
@@ -378,91 +348,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_choose_setup_strategy(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose how to set up the integration from scratch."""
|
||||
|
||||
# Allow onboarding for new users to just create a new network automatically
|
||||
if (
|
||||
not onboarding.async_is_onboarded(self.hass)
|
||||
and not self.hass.config_entries.async_entries(DOMAIN)
|
||||
and not self._radio_mgr.backups
|
||||
):
|
||||
return await self.async_step_setup_strategy_recommended()
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="choose_setup_strategy",
|
||||
menu_options=[
|
||||
SETUP_STRATEGY_RECOMMENDED,
|
||||
SETUP_STRATEGY_ADVANCED,
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_setup_strategy_recommended(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Recommended setup strategy: form a brand-new network."""
|
||||
return await self.async_step_form_new_network()
|
||||
|
||||
async def async_step_setup_strategy_advanced(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Advanced setup strategy: let the user choose."""
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
|
||||
async def async_step_choose_migration_strategy(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose how to deal with the current radio's settings during migration."""
|
||||
return self.async_show_menu(
|
||||
step_id="choose_migration_strategy",
|
||||
menu_options=[
|
||||
MIGRATION_STRATEGY_RECOMMENDED,
|
||||
MIGRATION_STRATEGY_ADVANCED,
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_migration_strategy_recommended(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Recommended migration strategy: automatically migrate everything."""
|
||||
|
||||
# Assume the most recent backup is the correct one
|
||||
self._radio_mgr.chosen_backup = self._radio_mgr.backups[0]
|
||||
return await self.async_step_maybe_reset_old_radio()
|
||||
|
||||
async def async_step_maybe_reset_old_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Erase the old radio's network settings before migration."""
|
||||
|
||||
# Like in the options flow, pull the correct settings from the config entry
|
||||
config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
|
||||
if config_entries:
|
||||
assert len(config_entries) == 1
|
||||
config_entry = config_entries[0]
|
||||
|
||||
# Create a radio manager to connect to the old stick to reset it
|
||||
temp_radio_mgr = ZhaRadioManager()
|
||||
temp_radio_mgr.hass = self.hass
|
||||
temp_radio_mgr.device_path = config_entry.data[CONF_DEVICE][
|
||||
CONF_DEVICE_PATH
|
||||
]
|
||||
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
|
||||
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
|
||||
|
||||
await temp_radio_mgr.async_reset_adapter()
|
||||
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
async def async_step_migration_strategy_advanced(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Advanced migration strategy: let the user choose."""
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
|
||||
async def async_step_choose_formation_strategy(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -549,7 +434,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
except ValueError:
|
||||
errors["base"] = "invalid_backup_json"
|
||||
else:
|
||||
return await self.async_step_maybe_reset_old_radio()
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="upload_manual_backup",
|
||||
@@ -589,7 +474,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
index = choices.index(user_input[CHOOSE_AUTOMATIC_BACKUP])
|
||||
self._radio_mgr.chosen_backup = self._radio_mgr.backups[index]
|
||||
|
||||
return await self.async_step_maybe_reset_old_radio()
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="choose_automatic_backup",
|
||||
@@ -606,37 +491,16 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm restore for EZSP radios that require permanent IEEE writes."""
|
||||
if user_input is not None:
|
||||
if user_input[OVERWRITE_COORDINATOR_IEEE]:
|
||||
# On confirmation, overwrite destructively
|
||||
try:
|
||||
await self._radio_mgr.restore_backup(overwrite_ieee=True)
|
||||
except CannotWriteNetworkSettings as exc:
|
||||
return self.async_abort(
|
||||
reason="cannot_restore_backup",
|
||||
description_placeholders={"error": str(exc)},
|
||||
)
|
||||
|
||||
return await self._async_create_radio_entry()
|
||||
|
||||
# On rejection, explain why we can't restore
|
||||
return self.async_abort(reason="cannot_restore_backup_no_ieee_confirm")
|
||||
|
||||
# On first attempt, just try to restore nondestructively
|
||||
try:
|
||||
await self._radio_mgr.restore_backup()
|
||||
except DestructiveWriteNetworkSettings:
|
||||
# Restore cannot happen automatically, we need to ask for permission
|
||||
pass
|
||||
except CannotWriteNetworkSettings as exc:
|
||||
return self.async_abort(
|
||||
reason="cannot_restore_backup",
|
||||
description_placeholders={"error": str(exc)},
|
||||
)
|
||||
else:
|
||||
call_step_2 = await self._radio_mgr.async_restore_backup_step_1()
|
||||
if not call_step_2:
|
||||
return await self._async_create_radio_entry()
|
||||
|
||||
if user_input is not None:
|
||||
await self._radio_mgr.async_restore_backup_step_2(
|
||||
user_input[OVERWRITE_COORDINATOR_IEEE]
|
||||
)
|
||||
return await self._async_create_radio_entry()
|
||||
|
||||
# If it fails, show the form
|
||||
return self.async_show_form(
|
||||
step_id="maybe_confirm_ezsp_restore",
|
||||
data_schema=vol.Schema(
|
||||
@@ -684,22 +548,24 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a ZHA config flow start."""
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
return await self.async_step_choose_serial_port(user_input)
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm a discovery."""
|
||||
|
||||
self._set_confirm_only()
|
||||
|
||||
zha_config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
# Don't permit discovery if ZHA is already set up
|
||||
if self._async_current_entries():
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
# Without confirmation, discovery can automatically progress into parts of the
|
||||
# config flow logic that interacts with hardware.
|
||||
if user_input is not None or (
|
||||
not onboarding.async_is_onboarded(self.hass) and not zha_config_entries
|
||||
):
|
||||
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
|
||||
# Probe the radio type if we don't have one yet
|
||||
if self._radio_mgr.radio_type is None:
|
||||
probe_result = await self._radio_mgr.detect_radio_type()
|
||||
@@ -820,13 +686,11 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
self._title = title
|
||||
self._radio_mgr.device_path = device_path
|
||||
self._radio_mgr.radio_type = radio_type
|
||||
self._radio_mgr.device_settings = DEVICE_SCHEMA(
|
||||
{
|
||||
CONF_DEVICE_PATH: device_path,
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: None,
|
||||
}
|
||||
)
|
||||
self._radio_mgr.device_settings = {
|
||||
CONF_DEVICE_PATH: device_path,
|
||||
CONF_BAUDRATE: 115200,
|
||||
CONF_FLOW_CONTROL: None,
|
||||
}
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
@@ -857,30 +721,6 @@ class ZhaConfigFlowHandler(BaseZhaFlow, ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def _async_create_radio_entry(self) -> ConfigFlowResult:
|
||||
"""Create a config entry with the current flow state."""
|
||||
|
||||
# ZHA is still single instance only, even though we use discovery to allow for
|
||||
# migrating to a new radio
|
||||
zha_config_entries = self.hass.config_entries.async_entries(DOMAIN)
|
||||
data = await self._get_config_entry_data()
|
||||
|
||||
if len(zha_config_entries) == 1:
|
||||
return self.async_update_reload_and_abort(
|
||||
entry=zha_config_entries[0],
|
||||
title=self._title,
|
||||
data=data,
|
||||
reload_even_if_entry_is_unchanged=True,
|
||||
reason="reconfigure_successful",
|
||||
)
|
||||
if not zha_config_entries:
|
||||
return self.async_create_entry(
|
||||
title=self._title,
|
||||
data=data,
|
||||
)
|
||||
# This should never be reached
|
||||
return self.async_abort(reason="single_instance_allowed")
|
||||
|
||||
|
||||
class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
|
||||
"""Handle an options flow."""
|
||||
@@ -898,20 +738,8 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Launch the options flow."""
|
||||
if user_input is not None:
|
||||
# Perform a backup first
|
||||
try:
|
||||
zha_gateway = get_zha_gateway(self.hass)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# The backup itself will be stored in `zigbee.db`, which the radio
|
||||
# manager will read when the class is initialized
|
||||
application_controller = zha_gateway.application_controller
|
||||
await application_controller.backups.create_backup(load_devices=True)
|
||||
|
||||
# Then unload the integration
|
||||
# OperationNotAllowed: ZHA is not running
|
||||
with suppress(OperationNotAllowed):
|
||||
# OperationNotAllowed: ZHA is not running
|
||||
await self.hass.config_entries.async_unload(self.config_entry.entry_id)
|
||||
|
||||
return await self.async_step_prompt_migrate_or_reconfigure()
|
||||
@@ -962,11 +790,18 @@ class ZhaOptionsFlowHandler(BaseZhaFlow, OptionsFlow):
|
||||
|
||||
async def _async_create_radio_entry(self):
|
||||
"""Re-implementation of the base flow's final step to update the config."""
|
||||
device_settings = self._radio_mgr.device_settings.copy()
|
||||
device_settings[CONF_DEVICE_PATH] = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, self._radio_mgr.device_path
|
||||
)
|
||||
|
||||
# Avoid creating both `.options` and `.data` by directly writing `data` here
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry=self.config_entry,
|
||||
data=await self._get_config_entry_data(),
|
||||
data={
|
||||
CONF_DEVICE: device_settings,
|
||||
CONF_RADIO_TYPE: self._radio_mgr.radio_type.name,
|
||||
},
|
||||
options=self.config_entry.options,
|
||||
)
|
||||
|
||||
|
@@ -21,7 +21,7 @@
|
||||
"zha",
|
||||
"universal_silabs_flasher"
|
||||
],
|
||||
"requirements": ["zha==0.0.72"],
|
||||
"requirements": ["zha==0.0.71"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "10C4",
|
||||
|
@@ -1,4 +1,4 @@
|
||||
"""ZHA radio manager."""
|
||||
"""Config flow for ZHA."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -29,7 +29,6 @@ from zigpy.exceptions import NetworkNotFormed
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
|
||||
from . import repairs
|
||||
@@ -41,17 +40,22 @@ from .const import (
|
||||
)
|
||||
from .helpers import get_zha_data
|
||||
|
||||
# Only the common radio types will be autoprobed, ordered by new device popularity.
|
||||
# XBee takes too long to probe since it scans through all possible bauds and likely has
|
||||
# very few users to begin with.
|
||||
AUTOPROBE_RADIOS = (
|
||||
RadioType.ezsp,
|
||||
RadioType.znp,
|
||||
RadioType.deconz,
|
||||
RadioType.zigate,
|
||||
)
|
||||
|
||||
RECOMMENDED_RADIOS = (
|
||||
RadioType.ezsp,
|
||||
RadioType.znp,
|
||||
RadioType.deconz,
|
||||
)
|
||||
|
||||
# Only the common radio types will be autoprobed, ordered by new device popularity.
|
||||
# XBee takes too long to probe since it scans through all possible bauds and likely has
|
||||
# very few users to begin with.
|
||||
AUTOPROBE_RADIOS = RECOMMENDED_RADIOS
|
||||
|
||||
CONNECT_DELAY_S = 1.0
|
||||
RETRY_DELAY_S = 1.0
|
||||
|
||||
@@ -154,38 +158,22 @@ class ZhaRadioManager:
|
||||
|
||||
return mgr
|
||||
|
||||
@property
|
||||
def zigpy_database_path(self) -> str:
|
||||
"""Path to `zigbee.db`."""
|
||||
config = get_zha_data(self.hass).yaml_config
|
||||
|
||||
return config.get(
|
||||
CONF_DATABASE,
|
||||
self.hass.config.path(DEFAULT_DATABASE_NAME),
|
||||
)
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def create_zigpy_app(
|
||||
self, *, connect: bool = True
|
||||
) -> AsyncIterator[ControllerApplication]:
|
||||
async def connect_zigpy_app(self) -> AsyncIterator[ControllerApplication]:
|
||||
"""Connect to the radio with the current config and then clean up."""
|
||||
assert self.radio_type is not None
|
||||
|
||||
config = get_zha_data(self.hass).yaml_config
|
||||
app_config = config.get(CONF_ZIGPY, {}).copy()
|
||||
|
||||
database_path: str | None = self.zigpy_database_path
|
||||
database_path = config.get(
|
||||
CONF_DATABASE,
|
||||
self.hass.config.path(DEFAULT_DATABASE_NAME),
|
||||
)
|
||||
|
||||
# Don't create `zigbee.db` if it doesn't already exist
|
||||
try:
|
||||
if database_path is not None and not await self.hass.async_add_executor_job(
|
||||
os.path.exists, database_path
|
||||
):
|
||||
database_path = None
|
||||
except OSError as error:
|
||||
raise HomeAssistantError(
|
||||
f"Could not read the ZHA database {database_path}: {error}"
|
||||
) from error
|
||||
if not await self.hass.async_add_executor_job(os.path.exists, database_path):
|
||||
database_path = None
|
||||
|
||||
app_config[CONF_DATABASE] = database_path
|
||||
app_config[CONF_DEVICE] = self.device_settings
|
||||
@@ -197,45 +185,22 @@ class ZhaRadioManager:
|
||||
)
|
||||
|
||||
try:
|
||||
if connect:
|
||||
try:
|
||||
await app.connect()
|
||||
except OSError as error:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to connect to Zigbee adapter: {error}"
|
||||
) from error
|
||||
|
||||
yield app
|
||||
finally:
|
||||
await app.shutdown()
|
||||
await asyncio.sleep(CONNECT_DELAY_S)
|
||||
|
||||
async def restore_backup(
|
||||
self,
|
||||
backup: zigpy.backups.NetworkBackup | None = None,
|
||||
*,
|
||||
overwrite_ieee: bool = False,
|
||||
**kwargs: Any,
|
||||
self, backup: zigpy.backups.NetworkBackup, **kwargs: Any
|
||||
) -> None:
|
||||
"""Restore the provided network backup, passing through kwargs."""
|
||||
if backup is None:
|
||||
backup = self.chosen_backup
|
||||
|
||||
assert backup is not None
|
||||
|
||||
if self.current_settings is not None and self.current_settings.supersedes(
|
||||
backup
|
||||
self.chosen_backup
|
||||
):
|
||||
return
|
||||
|
||||
if overwrite_ieee:
|
||||
backup = _allow_overwrite_ezsp_ieee(backup)
|
||||
|
||||
async with self.create_zigpy_app() as app:
|
||||
await app.can_write_network_settings(
|
||||
network_info=backup.network_info,
|
||||
node_info=backup.node_info,
|
||||
)
|
||||
async with self.connect_zigpy_app() as app:
|
||||
await app.connect()
|
||||
await app.backups.restore_backup(backup, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
@@ -277,27 +242,15 @@ class ZhaRadioManager:
|
||||
|
||||
return ProbeResult.PROBING_FAILED
|
||||
|
||||
async def _async_read_backups_from_database(
|
||||
self,
|
||||
) -> list[zigpy.backups.NetworkBackup]:
|
||||
"""Read the list of backups from the database, internal."""
|
||||
async with self.create_zigpy_app(connect=False) as app:
|
||||
backups = app.backups.backups.copy()
|
||||
backups.sort(reverse=True, key=lambda b: b.backup_time)
|
||||
|
||||
return backups
|
||||
|
||||
async def async_read_backups_from_database(self) -> None:
|
||||
"""Read the list of backups from the database."""
|
||||
self.backups = await self._async_read_backups_from_database()
|
||||
|
||||
async def async_load_network_settings(
|
||||
self, *, create_backup: bool = False
|
||||
) -> zigpy.backups.NetworkBackup | None:
|
||||
"""Connect to the radio and load its current network settings."""
|
||||
backup = None
|
||||
|
||||
async with self.create_zigpy_app() as app:
|
||||
async with self.connect_zigpy_app() as app:
|
||||
await app.connect()
|
||||
|
||||
# Check if the stick has any settings and load them
|
||||
try:
|
||||
await app.load_network_info()
|
||||
@@ -320,20 +273,66 @@ class ZhaRadioManager:
|
||||
|
||||
async def async_form_network(self) -> None:
|
||||
"""Form a brand-new network."""
|
||||
|
||||
# When forming a new network, we delete the ZHA database to prevent old devices
|
||||
# from appearing in an unusable state
|
||||
with suppress(OSError):
|
||||
await self.hass.async_add_executor_job(os.remove, self.zigpy_database_path)
|
||||
|
||||
async with self.create_zigpy_app() as app:
|
||||
async with self.connect_zigpy_app() as app:
|
||||
await app.connect()
|
||||
await app.form_network()
|
||||
|
||||
async def async_reset_adapter(self) -> None:
|
||||
"""Reset the current adapter."""
|
||||
async with self.create_zigpy_app() as app:
|
||||
async with self.connect_zigpy_app() as app:
|
||||
await app.connect()
|
||||
await app.reset_network_info()
|
||||
|
||||
async def async_restore_backup_step_1(self) -> bool:
|
||||
"""Prepare restoring backup.
|
||||
|
||||
Returns True if async_restore_backup_step_2 should be called.
|
||||
"""
|
||||
assert self.chosen_backup is not None
|
||||
|
||||
if self.radio_type != RadioType.ezsp:
|
||||
await self.restore_backup(self.chosen_backup)
|
||||
return False
|
||||
|
||||
# We have no way to partially load network settings if no network is formed
|
||||
if self.current_settings is None:
|
||||
# Since we are going to be restoring the backup anyways, write it to the
|
||||
# radio without overwriting the IEEE but don't take a backup with these
|
||||
# temporary settings
|
||||
temp_backup = _prevent_overwrite_ezsp_ieee(self.chosen_backup)
|
||||
await self.restore_backup(temp_backup, create_new=False)
|
||||
await self.async_load_network_settings()
|
||||
|
||||
assert self.current_settings is not None
|
||||
|
||||
metadata = self.current_settings.network_info.metadata["ezsp"]
|
||||
|
||||
if (
|
||||
self.current_settings.node_info.ieee == self.chosen_backup.node_info.ieee
|
||||
or metadata["can_rewrite_custom_eui64"]
|
||||
or not metadata["can_burn_userdata_custom_eui64"]
|
||||
):
|
||||
# No point in prompting the user if the backup doesn't have a new IEEE
|
||||
# address or if there is no way to overwrite the IEEE address a second time
|
||||
await self.restore_backup(self.chosen_backup)
|
||||
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def async_restore_backup_step_2(self, overwrite_ieee: bool) -> None:
|
||||
"""Restore backup and optionally overwrite IEEE."""
|
||||
assert self.chosen_backup is not None
|
||||
|
||||
backup = self.chosen_backup
|
||||
|
||||
if overwrite_ieee:
|
||||
backup = _allow_overwrite_ezsp_ieee(backup)
|
||||
|
||||
# If the user declined to overwrite the IEEE *and* we wrote the backup to
|
||||
# their empty radio above, restoring it again would be redundant.
|
||||
await self.restore_backup(backup)
|
||||
|
||||
|
||||
class ZhaMultiPANMigrationHelper:
|
||||
"""Helper class for automatic migration when upgrading the firmware of a radio.
|
||||
@@ -443,7 +442,9 @@ class ZhaMultiPANMigrationHelper:
|
||||
# Restore the backup, permanently overwriting the device IEEE address
|
||||
for retry in range(MIGRATION_RETRIES):
|
||||
try:
|
||||
await self._radio_mgr.restore_backup(overwrite_ieee=True)
|
||||
if await self._radio_mgr.async_restore_backup_step_1():
|
||||
await self._radio_mgr.async_restore_backup_step_2(True)
|
||||
|
||||
break
|
||||
except OSError as err:
|
||||
if retry >= MIGRATION_RETRIES - 1:
|
||||
|
@@ -136,7 +136,7 @@ class NetworkSettingsInconsistentFlow(RepairsFlow):
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> FlowResult:
|
||||
"""Step to use the new settings found on the radio."""
|
||||
async with self._radio_mgr.create_zigpy_app(connect=False) as app:
|
||||
async with self._radio_mgr.connect_zigpy_app() as app:
|
||||
app.backups.add_backup(self._new_state)
|
||||
|
||||
await self.hass.config_entries.async_reload(self._entry_id)
|
||||
|
@@ -24,50 +24,17 @@
|
||||
},
|
||||
"manual_port_config": {
|
||||
"title": "Serial port settings",
|
||||
"description": "ZHA was not able to automatically detect serial port settings for your adapter. This usually is an issue with the firmware or permissions.\n\nIf you are using firmware with nonstandard settings, enter the serial port settings",
|
||||
"description": "Enter the serial port settings",
|
||||
"data": {
|
||||
"path": "Serial device path",
|
||||
"baudrate": "Serial port speed",
|
||||
"flow_control": "Serial port flow control"
|
||||
},
|
||||
"data_description": {
|
||||
"path": "Path to the serial port or `socket://` TCP address",
|
||||
"baudrate": "Baudrate to use when communicating with the serial port, usually 115200 or 460800",
|
||||
"flow_control": "Check your adapter's documentation for the correct option, usually `None` or `Hardware`"
|
||||
"baudrate": "Port speed",
|
||||
"flow_control": "Data flow control"
|
||||
}
|
||||
},
|
||||
"verify_radio": {
|
||||
"title": "Radio is not recommended",
|
||||
"description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
|
||||
},
|
||||
"choose_setup_strategy": {
|
||||
"title": "Set up Zigbee",
|
||||
"description": "Choose how you want to set up Zigbee. Automatic setup is recommended unless you are restoring your network from a backup or setting up an adapter with nonstandard settings.",
|
||||
"menu_options": {
|
||||
"setup_strategy_recommended": "Set up automatically (recommended)",
|
||||
"setup_strategy_advanced": "Advanced setup"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"setup_strategy_recommended": "This is the quickest option to create a new network and get started.",
|
||||
"setup_strategy_advanced": "This will let you restore from a backup."
|
||||
}
|
||||
},
|
||||
"choose_migration_strategy": {
|
||||
"title": "Migrate to a new adapter",
|
||||
"description": "Choose how you want to migrate your Zigbee network backup from your old adapter to a new one.",
|
||||
"menu_options": {
|
||||
"migration_strategy_recommended": "Migrate automatically (recommended)",
|
||||
"migration_strategy_advanced": "Advanced migration"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"migration_strategy_recommended": "This is the quickest option to migrate to a new adapter.",
|
||||
"migration_strategy_advanced": "This will let you restore a specific network backup or upload your own."
|
||||
}
|
||||
},
|
||||
"maybe_reset_old_radio": {
|
||||
"title": "Resetting old radio",
|
||||
"description": "A backup was created earlier and your old radio is being reset as part of the migration."
|
||||
},
|
||||
"choose_formation_strategy": {
|
||||
"title": "Network formation",
|
||||
"description": "Choose the network settings for your radio.",
|
||||
@@ -77,13 +44,6 @@
|
||||
"reuse_settings": "Keep radio network settings",
|
||||
"choose_automatic_backup": "Restore an automatic backup",
|
||||
"upload_manual_backup": "Upload a manual backup"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"form_new_network": "This will create a new Zigbee network.",
|
||||
"form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
|
||||
"reuse_settings": "This will let ZHA import the settings from a stick that was used with other software, migrating some of the network automatically.",
|
||||
"choose_automatic_backup": "This will let you change your adapter's network settings back to a previous state, in case you have changed them.",
|
||||
"upload_manual_backup": "This will let you upload a backup JSON file from ZHA or the Zigbee2MQTT `coordinator_backup.json` file."
|
||||
}
|
||||
},
|
||||
"choose_automatic_backup": {
|
||||
@@ -116,12 +76,8 @@
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"not_zha_device": "This device is not a ZHA device",
|
||||
"usb_probe_failed": "Failed to probe the USB device",
|
||||
"cannot_resolve_path": "Could not resolve device path: {path}",
|
||||
"wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
|
||||
"invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA",
|
||||
"cannot_restore_backup": "The adapter you are restoring to does not properly support backup restoration. Please upgrade the firmware.\n\nError: {error}",
|
||||
"cannot_restore_backup_no_ieee_confirm": "The adapter you are restoring to has outdated firmware and cannot write the adapter IEEE address multiple times. Please upgrade the firmware or confirm permanent overwrite in the previous step.",
|
||||
"reconfigure_successful": "ZHA has successfully migrated from your old adapter to the new one. Give your Zigbee network a few minutes to stabilize.\n\nIf you no longer need the old adapter, you can now unplug it."
|
||||
"invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -129,7 +85,7 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Reconfigure ZHA",
|
||||
"description": "A backup will be performed and ZHA will be stopped. Do you wish to continue?"
|
||||
"description": "ZHA will be stopped. Do you wish to continue?"
|
||||
},
|
||||
"prompt_migrate_or_reconfigure": {
|
||||
"title": "Migrate or re-configure",
|
||||
@@ -137,10 +93,6 @@
|
||||
"menu_options": {
|
||||
"intent_migrate": "Migrate to a new radio",
|
||||
"intent_reconfigure": "Re-configure the current radio"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"intent_migrate": "This will help you migrate your Zigbee network from your old radio to a new one.",
|
||||
"intent_reconfigure": "This will let you change the serial port for your current Zigbee radio."
|
||||
}
|
||||
},
|
||||
"intent_migrate": {
|
||||
@@ -178,18 +130,6 @@
|
||||
"title": "[%key:component::zha::config::step::verify_radio::title%]",
|
||||
"description": "[%key:component::zha::config::step::verify_radio::description%]"
|
||||
},
|
||||
"choose_migration_strategy": {
|
||||
"title": "[%key:component::zha::config::step::choose_migration_strategy::title%]",
|
||||
"description": "[%key:component::zha::config::step::choose_migration_strategy::description%]",
|
||||
"menu_options": {
|
||||
"migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_recommended%]",
|
||||
"migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_options::migration_strategy_advanced%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"migration_strategy_recommended": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_recommended%]",
|
||||
"migration_strategy_advanced": "[%key:component::zha::config::step::choose_migration_strategy::menu_option_descriptions::migration_strategy_advanced%]"
|
||||
}
|
||||
},
|
||||
"choose_formation_strategy": {
|
||||
"title": "[%key:component::zha::config::step::choose_formation_strategy::title%]",
|
||||
"description": "[%key:component::zha::config::step::choose_formation_strategy::description%]",
|
||||
@@ -199,13 +139,6 @@
|
||||
"reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::reuse_settings%]",
|
||||
"choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::choose_automatic_backup%]",
|
||||
"upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_options::upload_manual_backup%]"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"form_new_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
|
||||
"form_initial_network": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::form_new_network%]",
|
||||
"reuse_settings": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::reuse_settings%]",
|
||||
"choose_automatic_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::choose_automatic_backup%]",
|
||||
"upload_manual_backup": "[%key:component::zha::config::step::choose_formation_strategy::menu_option_descriptions::upload_manual_backup%]"
|
||||
}
|
||||
},
|
||||
"choose_automatic_backup": {
|
||||
@@ -235,12 +168,10 @@
|
||||
"invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]"
|
||||
},
|
||||
"abort": {
|
||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||
"not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]",
|
||||
"usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]",
|
||||
"cannot_resolve_path": "[%key:component::zha::config::abort::cannot_resolve_path%]",
|
||||
"wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]",
|
||||
"cannot_restore_backup": "[%key:component::zha::config::abort::cannot_restore_backup%]",
|
||||
"cannot_restore_backup_no_ieee_confirm": "[%key:component::zha::config::abort::cannot_restore_backup_no_ieee_confirm%]"
|
||||
"wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]"
|
||||
}
|
||||
},
|
||||
"config_panel": {
|
||||
@@ -601,10 +532,6 @@
|
||||
"menu_options": {
|
||||
"use_new_settings": "Keep the new settings",
|
||||
"restore_old_settings": "Restore backup (recommended)"
|
||||
},
|
||||
"menu_option_descriptions": {
|
||||
"use_new_settings": "This will keep the new settings written to the stick. Only choose this option if you have intentionally changed settings.",
|
||||
"restore_old_settings": "This will restore your network settings back to the last working state."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -5,14 +5,15 @@ from __future__ import annotations
|
||||
import abc
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Container, Hashable, Iterable, Mapping
|
||||
from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping
|
||||
from contextlib import suppress
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import functools
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Generic, Required, TypedDict, TypeVar, cast
|
||||
from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -593,17 +594,19 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
) -> list[_FlowResultT]:
|
||||
"""Convert a list of FlowHandler to a partial FlowResult that can be serialized."""
|
||||
return [
|
||||
self._flow_result(
|
||||
flow_id=flow.flow_id,
|
||||
handler=flow.handler,
|
||||
context=flow.context,
|
||||
step_id=flow.cur_step["step_id"],
|
||||
)
|
||||
if flow.cur_step
|
||||
else self._flow_result(
|
||||
flow_id=flow.flow_id,
|
||||
handler=flow.handler,
|
||||
context=flow.context,
|
||||
(
|
||||
self._flow_result(
|
||||
flow_id=flow.flow_id,
|
||||
handler=flow.handler,
|
||||
context=flow.context,
|
||||
step_id=flow.cur_step["step_id"],
|
||||
)
|
||||
if flow.cur_step
|
||||
else self._flow_result(
|
||||
flow_id=flow.flow_id,
|
||||
handler=flow.handler,
|
||||
context=flow.context,
|
||||
)
|
||||
)
|
||||
for flow in flows
|
||||
if include_uninitialized or flow.cur_step is not None
|
||||
@@ -638,7 +641,11 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
|
||||
__progress_task: asyncio.Task[Any] | None = None
|
||||
__no_progress_task_reported = False
|
||||
_decorated_progress_tasks: dict[str, asyncio.Task[Any]] = {}
|
||||
deprecated_show_progress = False
|
||||
abort_reason: str = "abort"
|
||||
abort_description_placeholders: Mapping[str, str] = MappingProxyType({})
|
||||
progress_next_step: _FlowResultT | None = None
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
@@ -761,6 +768,30 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_abort(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> _FlowResultT:
|
||||
"""Abort the flow."""
|
||||
return self.async_abort(
|
||||
reason=self.abort_reason,
|
||||
description_placeholders=self.abort_description_placeholders,
|
||||
)
|
||||
|
||||
async def async_step_progress_done(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> _FlowResultT:
|
||||
"""Progress done. Return the next step.
|
||||
|
||||
Used by progress_step decorator to keep the API consistent.
|
||||
If no next step is set, abort the flow.
|
||||
"""
|
||||
if self.progress_next_step is None:
|
||||
return self.async_abort(
|
||||
reason=self.abort_reason,
|
||||
description_placeholders=self.abort_description_placeholders,
|
||||
)
|
||||
return self.progress_next_step
|
||||
|
||||
@callback
|
||||
def async_external_step(
|
||||
self,
|
||||
@@ -930,3 +961,84 @@ class section:
|
||||
def __call__(self, value: Any) -> Any:
|
||||
"""Validate input."""
|
||||
return self.schema(value)
|
||||
|
||||
|
||||
type _FuncType[_T: FlowHandler[Any, Any, Any], _R: FlowResult[Any, Any], **_P] = (
|
||||
Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]
|
||||
)
|
||||
|
||||
|
||||
def progress_step[
|
||||
HandlerT: FlowHandler[Any, Any, Any],
|
||||
ResultT: FlowResult[Any, Any],
|
||||
**P,
|
||||
](
|
||||
progress_action: str | None = None,
|
||||
description_placeholders: (
|
||||
dict[str, str] | Callable[[Any], dict[str, str]] | None
|
||||
) = None,
|
||||
) -> Callable[[_FuncType[HandlerT, ResultT, P]], _FuncType[HandlerT, ResultT, P]]:
|
||||
"""Decorator to create a progress step from an async function.
|
||||
|
||||
The decorated function should contain the actual work to be done.
|
||||
It receives (self, user_input) and should return the next step id or raise AbortFlow
|
||||
It can call self.async_update_progress(progress) to update progress.
|
||||
|
||||
Args:
|
||||
progress_action: The progress action name for the UI. If None, inferred from method name.
|
||||
description_placeholders: Static dict or callable that returns dict for progress UI placeholders.
|
||||
"""
|
||||
|
||||
def decorator(
|
||||
func: _FuncType[HandlerT, ResultT, P],
|
||||
) -> _FuncType[HandlerT, ResultT, P]:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(
|
||||
self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs
|
||||
) -> ResultT:
|
||||
step_id = func.__name__.replace("async_step_", "")
|
||||
action = progress_action or step_id
|
||||
|
||||
# Check if we have a progress task running
|
||||
progress_task = self._decorated_progress_tasks.get(step_id)
|
||||
|
||||
if progress_task is None:
|
||||
# First call - create and start the progress task
|
||||
progress_task = self.hass.async_create_task(
|
||||
func(self, *args, **kwargs), # type: ignore[arg-type]
|
||||
f"Progress step {step_id}",
|
||||
)
|
||||
self._decorated_progress_tasks[step_id] = progress_task
|
||||
|
||||
if not progress_task.done():
|
||||
# Handle description placeholders
|
||||
placeholders = None
|
||||
if description_placeholders is not None:
|
||||
if callable(description_placeholders):
|
||||
placeholders = description_placeholders(self)
|
||||
else:
|
||||
placeholders = description_placeholders
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
progress_action=action,
|
||||
progress_task=progress_task,
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
# Task is done or this is a subsequent call
|
||||
try:
|
||||
self.progress_next_step = await progress_task
|
||||
except AbortFlow as err:
|
||||
self.abort_reason = err.reason
|
||||
self.abort_description_placeholders = err.description_placeholders or {}
|
||||
return self.async_show_progress_done(next_step_id="abort")
|
||||
finally:
|
||||
# Clean up task reference
|
||||
self._decorated_progress_tasks.pop(step_id, None)
|
||||
|
||||
return self.async_show_progress_done(next_step_id="progress_done")
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
@@ -3,7 +3,7 @@
|
||||
aiodhcpwatcher==1.2.1
|
||||
aiodiscover==2.7.1
|
||||
aiodns==3.5.0
|
||||
aiohasupervisor==0.3.3b0
|
||||
aiohasupervisor==0.3.2
|
||||
aiohttp-asyncmdnsresolver==0.1.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
aiohttp==3.12.15
|
||||
@@ -31,7 +31,6 @@ ciso8601==2.3.3
|
||||
cronsim==2.6
|
||||
cryptography==45.0.7
|
||||
dbus-fast==2.44.3
|
||||
file-read-backwards==2.0.0
|
||||
fnv-hash-fast==1.5.0
|
||||
go2rtc-client==0.2.1
|
||||
ha-ffmpeg==3.2.2
|
||||
|
@@ -27,7 +27,7 @@ dependencies = [
|
||||
# Integrations may depend on hassio integration without listing it to
|
||||
# change behavior based on presence of supervisor. Deprecated with #127228
|
||||
# Lib can be removed with 2025.11
|
||||
"aiohasupervisor==0.3.3b0",
|
||||
"aiohasupervisor==0.3.2",
|
||||
"aiohttp==3.12.15",
|
||||
"aiohttp_cors==0.8.1",
|
||||
"aiohttp-fast-zlib==0.3.0",
|
||||
|
2
requirements.txt
generated
2
requirements.txt
generated
@@ -4,7 +4,7 @@
|
||||
|
||||
# Home Assistant Core
|
||||
aiodns==3.5.0
|
||||
aiohasupervisor==0.3.3b0
|
||||
aiohasupervisor==0.3.2
|
||||
aiohttp==3.12.15
|
||||
aiohttp_cors==0.8.1
|
||||
aiohttp-fast-zlib==0.3.0
|
||||
|
16
requirements_all.txt
generated
16
requirements_all.txt
generated
@@ -173,7 +173,7 @@ aio-geojson-usgs-earthquakes==0.3
|
||||
aio-georss-gdacs==0.10
|
||||
|
||||
# homeassistant.components.acaia
|
||||
aioacaia==0.1.17
|
||||
aioacaia==0.1.14
|
||||
|
||||
# homeassistant.components.airq
|
||||
aioairq==0.4.6
|
||||
@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==41.9.0
|
||||
aioesphomeapi==41.6.0
|
||||
|
||||
# homeassistant.components.flo
|
||||
aioflo==2021.11.0
|
||||
@@ -265,7 +265,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.3.3b0
|
||||
aiohasupervisor==0.3.2
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.19.0
|
||||
@@ -277,7 +277,7 @@ aiohomekit==3.2.18
|
||||
aiohttp_sse==2.2.0
|
||||
|
||||
# homeassistant.components.hue
|
||||
aiohue==4.8.0
|
||||
aiohue==4.7.5
|
||||
|
||||
# homeassistant.components.imap
|
||||
aioimaplib==2.0.1
|
||||
@@ -1954,7 +1954,7 @@ pydrawise==2025.9.0
|
||||
pydroid-ipcam==3.0.0
|
||||
|
||||
# homeassistant.components.droplet
|
||||
pydroplet==2.3.3
|
||||
pydroplet==2.3.2
|
||||
|
||||
# homeassistant.components.ebox
|
||||
pyebox==1.1.4
|
||||
@@ -2528,7 +2528,7 @@ python-rabbitair==0.0.8
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==2.47.1
|
||||
python-roborock==2.44.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.44
|
||||
@@ -3204,7 +3204,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.0.0
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.09.23
|
||||
yt-dlp[default]==2025.09.05
|
||||
|
||||
# homeassistant.components.zabbix
|
||||
zabbix-utils==2.0.3
|
||||
@@ -3222,7 +3222,7 @@ zeroconf==0.147.2
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.72
|
||||
zha==0.0.71
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
16
requirements_test_all.txt
generated
16
requirements_test_all.txt
generated
@@ -161,7 +161,7 @@ aio-geojson-usgs-earthquakes==0.3
|
||||
aio-georss-gdacs==0.10
|
||||
|
||||
# homeassistant.components.acaia
|
||||
aioacaia==0.1.17
|
||||
aioacaia==0.1.14
|
||||
|
||||
# homeassistant.components.airq
|
||||
aioairq==0.4.6
|
||||
@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==41.9.0
|
||||
aioesphomeapi==41.6.0
|
||||
|
||||
# homeassistant.components.flo
|
||||
aioflo==2021.11.0
|
||||
@@ -250,7 +250,7 @@ aioguardian==2022.07.0
|
||||
aioharmony==0.5.3
|
||||
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.3.3b0
|
||||
aiohasupervisor==0.3.2
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.19.0
|
||||
@@ -262,7 +262,7 @@ aiohomekit==3.2.18
|
||||
aiohttp_sse==2.2.0
|
||||
|
||||
# homeassistant.components.hue
|
||||
aiohue==4.8.0
|
||||
aiohue==4.7.5
|
||||
|
||||
# homeassistant.components.imap
|
||||
aioimaplib==2.0.1
|
||||
@@ -1638,7 +1638,7 @@ pydrawise==2025.9.0
|
||||
pydroid-ipcam==3.0.0
|
||||
|
||||
# homeassistant.components.droplet
|
||||
pydroplet==2.3.3
|
||||
pydroplet==2.3.2
|
||||
|
||||
# homeassistant.components.ecoforest
|
||||
pyecoforest==0.4.0
|
||||
@@ -2101,7 +2101,7 @@ python-pooldose==0.5.0
|
||||
python-rabbitair==0.0.8
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==2.47.1
|
||||
python-roborock==2.44.1
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.44
|
||||
@@ -2657,7 +2657,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.0.0
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.09.23
|
||||
yt-dlp[default]==2025.09.05
|
||||
|
||||
# homeassistant.components.zamg
|
||||
zamg==0.3.6
|
||||
@@ -2672,7 +2672,7 @@ zeroconf==0.147.2
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.72
|
||||
zha==0.0.71
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.67.1
|
||||
|
@@ -13,10 +13,6 @@ from syrupy.matchers import path_type
|
||||
|
||||
from homeassistant.components.analytics.analytics import (
|
||||
Analytics,
|
||||
AnalyticsInput,
|
||||
AnalyticsModifications,
|
||||
DeviceAnalyticsModifications,
|
||||
EntityAnalyticsModifications,
|
||||
async_devices_payload,
|
||||
)
|
||||
from homeassistant.components.analytics.const import (
|
||||
@@ -37,7 +33,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.loader import IntegrationNotFound
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration, mock_platform
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
@@ -1240,7 +1236,6 @@ async def test_devices_payload_with_entities(
|
||||
"domain": "light",
|
||||
"entity_category": None,
|
||||
"has_entity_name": True,
|
||||
"modified_by_integration": None,
|
||||
"original_device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
@@ -1250,7 +1245,6 @@ async def test_devices_payload_with_entities(
|
||||
"domain": "number",
|
||||
"entity_category": "config",
|
||||
"has_entity_name": True,
|
||||
"modified_by_integration": None,
|
||||
"original_device_class": "temperature",
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
@@ -1272,7 +1266,6 @@ async def test_devices_payload_with_entities(
|
||||
"domain": "light",
|
||||
"entity_category": None,
|
||||
"has_entity_name": False,
|
||||
"modified_by_integration": None,
|
||||
"original_device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
@@ -1294,7 +1287,6 @@ async def test_devices_payload_with_entities(
|
||||
"domain": "sensor",
|
||||
"entity_category": None,
|
||||
"has_entity_name": False,
|
||||
"modified_by_integration": None,
|
||||
"original_device_class": "temperature",
|
||||
"unit_of_measurement": "°C",
|
||||
},
|
||||
@@ -1310,121 +1302,6 @@ async def test_devices_payload_with_entities(
|
||||
"domain": "light",
|
||||
"entity_category": None,
|
||||
"has_entity_name": True,
|
||||
"modified_by_integration": None,
|
||||
"original_device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
],
|
||||
"is_custom_integration": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def test_analytics_platforms(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test analytics platforms."""
|
||||
assert await async_setup_component(hass, "analytics", {})
|
||||
|
||||
mock_config_entry = MockConfigEntry(domain="test")
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={("device", "1")},
|
||||
manufacturer="test-manufacturer",
|
||||
model_id="test-model-id",
|
||||
)
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={("device", "2")},
|
||||
manufacturer="test-manufacturer",
|
||||
model_id="test-model-id-2",
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
domain="sensor",
|
||||
platform="test",
|
||||
unique_id="1",
|
||||
capabilities={"options": ["secret1", "secret2"]},
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
domain="sensor",
|
||||
platform="test",
|
||||
unique_id="2",
|
||||
capabilities={"options": ["secret1", "secret2"]},
|
||||
)
|
||||
|
||||
async def async_modify_analytics(
|
||||
hass: HomeAssistant,
|
||||
analytics_input: AnalyticsInput,
|
||||
) -> AnalyticsModifications:
|
||||
first = True
|
||||
devices_configs = {}
|
||||
for device_id in analytics_input.device_ids:
|
||||
device_config = DeviceAnalyticsModifications()
|
||||
devices_configs[device_id] = device_config
|
||||
if first:
|
||||
first = False
|
||||
else:
|
||||
device_config.remove = True
|
||||
|
||||
first = True
|
||||
entities_configs = {}
|
||||
for entity_id in analytics_input.entity_ids:
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
entity_config = EntityAnalyticsModifications()
|
||||
entities_configs[entity_id] = entity_config
|
||||
if first:
|
||||
first = False
|
||||
entity_config.capabilities = dict(entity_entry.capabilities)
|
||||
entity_config.capabilities["options"] = len(
|
||||
entity_config.capabilities["options"]
|
||||
)
|
||||
else:
|
||||
entity_config.remove = True
|
||||
|
||||
return AnalyticsModifications(
|
||||
devices=devices_configs,
|
||||
entities=entities_configs,
|
||||
)
|
||||
|
||||
platform_mock = Mock(async_modify_analytics=async_modify_analytics)
|
||||
mock_platform(hass, "test.analytics", platform_mock)
|
||||
|
||||
client = await hass_client()
|
||||
response = await client.get("/api/analytics/devices")
|
||||
assert response.status == HTTPStatus.OK
|
||||
assert await response.json() == {
|
||||
"version": "home-assistant:1",
|
||||
"home_assistant": MOCK_VERSION,
|
||||
"integrations": {
|
||||
"test": {
|
||||
"devices": [
|
||||
{
|
||||
"entities": [],
|
||||
"entry_type": None,
|
||||
"has_configuration_url": False,
|
||||
"hw_version": None,
|
||||
"manufacturer": "test-manufacturer",
|
||||
"model": None,
|
||||
"model_id": "test-model-id",
|
||||
"sw_version": None,
|
||||
"via_device": None,
|
||||
},
|
||||
],
|
||||
"entities": [
|
||||
{
|
||||
"assumed_state": None,
|
||||
"capabilities": {"options": 2},
|
||||
"domain": "sensor",
|
||||
"entity_category": None,
|
||||
"has_entity_name": False,
|
||||
"modified_by_integration": ["capabilities"],
|
||||
"original_device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
|
@@ -1,41 +0,0 @@
|
||||
"""Tests for analytics platform."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.analytics import async_devices_payload
|
||||
from homeassistant.components.automation import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics(
|
||||
hass: HomeAssistant, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test the analytics platform."""
|
||||
await async_setup_component(hass, "analytics", {})
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
domain="automation",
|
||||
platform="automation",
|
||||
unique_id="automation1",
|
||||
suggested_object_id="automation1",
|
||||
capabilities={"id": "automation1"},
|
||||
)
|
||||
|
||||
result = await async_devices_payload(hass)
|
||||
assert result["integrations"][DOMAIN]["entities"] == [
|
||||
{
|
||||
"assumed_state": None,
|
||||
"capabilities": None,
|
||||
"domain": "automation",
|
||||
"entity_category": None,
|
||||
"has_entity_name": False,
|
||||
"modified_by_integration": [
|
||||
"capabilities",
|
||||
],
|
||||
"original_device_class": None,
|
||||
"unit_of_measurement": None,
|
||||
},
|
||||
]
|
@@ -1,7 +1,6 @@
|
||||
"""Test cloud subscription functions."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from hass_nabucasa import Cloud, payments_api
|
||||
import pytest
|
||||
@@ -31,35 +30,19 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud:
|
||||
)
|
||||
|
||||
|
||||
async def test_fetching_subscription_with_api_error(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mocked_cloud: Cloud,
|
||||
) -> None:
|
||||
"""Test that we handle API errors."""
|
||||
mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError(
|
||||
"There was an error with the API"
|
||||
)
|
||||
|
||||
assert await async_subscription_info(mocked_cloud) is None
|
||||
assert (
|
||||
"Failed to fetch subscription information - There was an error with the API"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
||||
async def test_fetching_subscription_with_timeout_error(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mocked_cloud: Cloud,
|
||||
) -> None:
|
||||
"""Test that we handle timeout error."""
|
||||
mocked_cloud.payments.subscription_info = lambda: asyncio.sleep(1)
|
||||
with patch("homeassistant.components.cloud.subscription.REQUEST_TIMEOUT", 0):
|
||||
assert await async_subscription_info(mocked_cloud) is None
|
||||
mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError(
|
||||
"Timeout reached while calling API"
|
||||
)
|
||||
|
||||
assert await async_subscription_info(mocked_cloud) is None
|
||||
assert (
|
||||
"A timeout of 0 was reached while trying to fetch subscription information"
|
||||
"Failed to fetch subscription information - Timeout reached while calling API"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
|
@@ -12,14 +12,9 @@ from syrupy.assertion import SnapshotAssertion
|
||||
import yaml
|
||||
|
||||
from homeassistant.components import conversation, cover, media_player, weather
|
||||
from homeassistant.components.conversation import (
|
||||
async_get_agent,
|
||||
default_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from homeassistant.components.conversation import async_get_agent, default_agent
|
||||
from homeassistant.components.conversation.default_agent import METADATA_CUSTOM_SENTENCE
|
||||
from homeassistant.components.conversation.models import ConversationInput
|
||||
from homeassistant.components.conversation.trigger import TriggerDetails
|
||||
from homeassistant.components.cover import SERVICE_OPEN_COVER
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_get_assistant_settings,
|
||||
@@ -420,10 +415,10 @@ async def test_trigger_sentences(hass: HomeAssistant) -> None:
|
||||
trigger_sentences = ["It's party time", "It is time to party"]
|
||||
trigger_response = "Cowabunga!"
|
||||
|
||||
manager = get_agent_manager(hass)
|
||||
agent = async_get_agent(hass)
|
||||
|
||||
callback = AsyncMock(return_value=trigger_response)
|
||||
unregister = manager.register_trigger(TriggerDetails(trigger_sentences, callback))
|
||||
unregister = agent.register_trigger(trigger_sentences, callback)
|
||||
|
||||
result = await conversation.async_converse(hass, "Not the trigger", None, Context())
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR
|
||||
@@ -466,7 +461,7 @@ async def test_trigger_sentence_response_translation(
|
||||
"""Test translation of default response 'done'."""
|
||||
hass.config.language = language
|
||||
|
||||
manager = get_agent_manager(hass)
|
||||
agent = async_get_agent(hass)
|
||||
|
||||
translations = {
|
||||
"en": {"component.conversation.conversation.agent.done": "English done"},
|
||||
@@ -478,8 +473,8 @@ async def test_trigger_sentence_response_translation(
|
||||
"homeassistant.components.conversation.default_agent.translation.async_get_translations",
|
||||
return_value=translations.get(language),
|
||||
):
|
||||
unregister = manager.register_trigger(
|
||||
TriggerDetails(["test sentence"], AsyncMock(return_value=None))
|
||||
unregister = agent.register_trigger(
|
||||
["test sentence"], AsyncMock(return_value=None)
|
||||
)
|
||||
result = await conversation.async_converse(
|
||||
hass, "test sentence", None, Context()
|
||||
|
@@ -1887,10 +1887,10 @@ async def test_wake_word_select(
|
||||
assert satellite is not None
|
||||
assert satellite.async_get_configuration().active_wake_words == ["hey_jarvis"]
|
||||
|
||||
# First wake word should be selected by default
|
||||
# No wake word should be selected by default
|
||||
state = hass.states.get("select.test_wake_word")
|
||||
assert state is not None
|
||||
assert state.state == "Hey Jarvis"
|
||||
assert state.state == NO_WAKE_WORD
|
||||
|
||||
# Changing the select should set the active wake word
|
||||
await hass.services.async_call(
|
||||
@@ -1955,21 +1955,6 @@ async def test_wake_word_select(
|
||||
# Only primary wake word remains
|
||||
assert satellite.async_get_configuration().active_wake_words == ["okay_nabu"]
|
||||
|
||||
# Remove the primary wake word
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{ATTR_ENTITY_ID: "select.test_wake_word", "option": NO_WAKE_WORD},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
async with asyncio.timeout(1):
|
||||
await configuration_set.wait()
|
||||
|
||||
# No active wake word remain
|
||||
assert not satellite.async_get_configuration().active_wake_words
|
||||
|
||||
|
||||
async def test_secondary_pipeline(
|
||||
hass: HomeAssistant,
|
||||
|
@@ -186,7 +186,7 @@ async def test_wake_word_select_no_active_wake_words(
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test wake word select has no wake word selected if none are active."""
|
||||
"""Test wake word select uses first available wake word if none are active."""
|
||||
device_config = AssistSatelliteConfiguration(
|
||||
available_wake_words=[
|
||||
AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]),
|
||||
@@ -215,42 +215,3 @@ async def test_wake_word_select_no_active_wake_words(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == NO_WAKE_WORD
|
||||
|
||||
|
||||
async def test_wake_word_select_first_active_wake_word(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test wake word select uses first available wake word if one is active."""
|
||||
device_config = AssistSatelliteConfiguration(
|
||||
available_wake_words=[
|
||||
AssistSatelliteWakeWord("okay_nabu", "Okay Nabu", ["en"]),
|
||||
AssistSatelliteWakeWord("hey_jarvis", "Hey Jarvis", ["en"]),
|
||||
],
|
||||
active_wake_words=["okay_nabu"],
|
||||
max_active_wake_words=1,
|
||||
)
|
||||
mock_client.get_voice_assistant_configuration.return_value = device_config
|
||||
|
||||
mock_device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
device_info={
|
||||
"voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT
|
||||
| VoiceAssistantFeature.ANNOUNCE
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
satellite = get_satellite_entity(hass, mock_device.device_info.mac_address)
|
||||
assert satellite is not None
|
||||
|
||||
# First wake word should be selected
|
||||
state = hass.states.get("select.test_wake_word")
|
||||
assert state is not None
|
||||
assert state.state == "Okay Nabu"
|
||||
|
||||
# Second wake word should not be selected
|
||||
state_2 = hass.states.get("select.test_wake_word_2")
|
||||
assert state_2 is not None
|
||||
assert state_2.state == NO_WAKE_WORD
|
||||
|
@@ -5,9 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.file import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,14 +30,3 @@ def mock_is_allowed_path(hass: HomeAssistant, is_allowed: bool) -> Generator[Mag
|
||||
hass.config, "is_allowed_path", return_value=is_allowed
|
||||
) as allowed_path_mock:
|
||||
yield allowed_path_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_ha_file_integration(hass: HomeAssistant):
|
||||
"""Set up Home Assistant and load File integration."""
|
||||
await async_setup_component(
|
||||
hass,
|
||||
DOMAIN,
|
||||
{DOMAIN: {}},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
@@ -1 +0,0 @@
|
||||
{ "key": "value", "key1": "value1" }
|
@@ -1 +0,0 @@
|
||||
{ "key": "value", "key1": value1 }
|
@@ -1,4 +0,0 @@
|
||||
test:
|
||||
- element: "X"
|
||||
- element: "Y"
|
||||
unexpected: "Z"
|
@@ -1,5 +0,0 @@
|
||||
mylist:
|
||||
- name: list_item_1
|
||||
id: 1
|
||||
- name: list_item_2
|
||||
id: 2
|
@@ -1,4 +0,0 @@
|
||||
- name: list_item_1
|
||||
id: 1
|
||||
- name: list_item_2
|
||||
id: 2
|
@@ -1,39 +0,0 @@
|
||||
# serializer version: 1
|
||||
# name: test_read_file[tests/components/file/fixtures/file_read.json-json]
|
||||
dict({
|
||||
'data': dict({
|
||||
'key': 'value',
|
||||
'key1': 'value1',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_read_file[tests/components/file/fixtures/file_read.yaml-yaml]
|
||||
dict({
|
||||
'data': dict({
|
||||
'mylist': list([
|
||||
dict({
|
||||
'id': 1,
|
||||
'name': 'list_item_1',
|
||||
}),
|
||||
dict({
|
||||
'id': 2,
|
||||
'name': 'list_item_2',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_read_file[tests/components/file/fixtures/file_read_list.yaml-yaml]
|
||||
dict({
|
||||
'data': list([
|
||||
dict({
|
||||
'id': 1,
|
||||
'name': 'list_item_1',
|
||||
}),
|
||||
dict({
|
||||
'id': 2,
|
||||
'name': 'list_item_2',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
@@ -1,147 +0,0 @@
|
||||
"""The tests for the notify file platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.file import DOMAIN
|
||||
from homeassistant.components.file.services import (
|
||||
ATTR_FILE_ENCODING,
|
||||
ATTR_FILE_NAME,
|
||||
SERVICE_READ_FILE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "file_encoding"),
|
||||
[
|
||||
("tests/components/file/fixtures/file_read.json", "json"),
|
||||
("tests/components/file/fixtures/file_read.yaml", "yaml"),
|
||||
("tests/components/file/fixtures/file_read_list.yaml", "yaml"),
|
||||
],
|
||||
)
|
||||
async def test_read_file(
|
||||
hass: HomeAssistant,
|
||||
mock_is_allowed_path: MagicMock,
|
||||
setup_ha_file_integration,
|
||||
file_name: str,
|
||||
file_encoding: str,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test reading files in supported formats."""
|
||||
result = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
{
|
||||
ATTR_FILE_NAME: file_name,
|
||||
ATTR_FILE_ENCODING: file_encoding,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert result == snapshot
|
||||
|
||||
|
||||
async def test_read_file_disallowed_path(
|
||||
hass: HomeAssistant,
|
||||
setup_ha_file_integration,
|
||||
) -> None:
|
||||
"""Test reading in a disallowed path generates error."""
|
||||
file_name = "tests/components/file/fixtures/file_read.json"
|
||||
|
||||
with pytest.raises(ServiceValidationError) as sve:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
{
|
||||
ATTR_FILE_NAME: file_name,
|
||||
ATTR_FILE_ENCODING: "json",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert file_name in str(sve.value)
|
||||
assert sve.value.translation_key == "no_access_to_path"
|
||||
assert sve.value.translation_domain == DOMAIN
|
||||
|
||||
|
||||
async def test_read_file_bad_encoding_option(
|
||||
hass: HomeAssistant,
|
||||
mock_is_allowed_path: MagicMock,
|
||||
setup_ha_file_integration,
|
||||
) -> None:
|
||||
"""Test handling error if an invalid encoding is specified."""
|
||||
file_name = "tests/components/file/fixtures/file_read.json"
|
||||
|
||||
with pytest.raises(ServiceValidationError) as sve:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
{
|
||||
ATTR_FILE_NAME: file_name,
|
||||
ATTR_FILE_ENCODING: "invalid",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert file_name in str(sve.value)
|
||||
assert "invalid" in str(sve.value)
|
||||
assert sve.value.translation_key == "unsupported_file_encoding"
|
||||
assert sve.value.translation_domain == DOMAIN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("file_name", "file_encoding"),
|
||||
[
|
||||
("tests/components/file/fixtures/file_read.not_json", "json"),
|
||||
("tests/components/file/fixtures/file_read.not_yaml", "yaml"),
|
||||
],
|
||||
)
|
||||
async def test_read_file_decoding_error(
|
||||
hass: HomeAssistant,
|
||||
mock_is_allowed_path: MagicMock,
|
||||
setup_ha_file_integration,
|
||||
file_name: str,
|
||||
file_encoding: str,
|
||||
) -> None:
|
||||
"""Test decoding errors are handled correctly."""
|
||||
with pytest.raises(HomeAssistantError) as hae:
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
{
|
||||
ATTR_FILE_NAME: file_name,
|
||||
ATTR_FILE_ENCODING: file_encoding,
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert file_name in str(hae.value)
|
||||
assert file_encoding in str(hae.value)
|
||||
assert hae.value.translation_key == "file_decoding"
|
||||
assert hae.value.translation_domain == DOMAIN
|
||||
|
||||
|
||||
async def test_read_file_dne(
|
||||
hass: HomeAssistant,
|
||||
mock_is_allowed_path: MagicMock,
|
||||
setup_ha_file_integration,
|
||||
) -> None:
|
||||
"""Test handling error if file does not exist."""
|
||||
file_name = "tests/components/file/fixtures/file_dne.yaml"
|
||||
|
||||
with pytest.raises(HomeAssistantError) as hae:
|
||||
_ = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_READ_FILE,
|
||||
{
|
||||
ATTR_FILE_NAME: file_name,
|
||||
ATTR_FILE_ENCODING: "yaml",
|
||||
},
|
||||
blocking=True,
|
||||
return_response=True,
|
||||
)
|
||||
assert file_name in str(hae.value)
|
@@ -19,8 +19,7 @@
|
||||
"done": true,
|
||||
"errors": [],
|
||||
"created": "2025-05-14T08:56:22.807078+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
},
|
||||
{
|
||||
"name": "backup_store_addons",
|
||||
@@ -58,8 +57,7 @@
|
||||
}
|
||||
],
|
||||
"created": "2025-05-14T08:56:22.844160+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
},
|
||||
{
|
||||
"name": "backup_addon_save",
|
||||
@@ -76,11 +74,9 @@
|
||||
}
|
||||
],
|
||||
"created": "2025-05-14T08:56:22.850376+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
}
|
||||
],
|
||||
"extra": null
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "backup_store_folders",
|
||||
@@ -123,8 +119,7 @@
|
||||
}
|
||||
],
|
||||
"created": "2025-05-14T08:56:22.858385+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
},
|
||||
{
|
||||
"name": "backup_folder_save",
|
||||
@@ -141,8 +136,7 @@
|
||||
}
|
||||
],
|
||||
"created": "2025-05-14T08:56:22.859973+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
},
|
||||
{
|
||||
"name": "backup_folder_save",
|
||||
@@ -159,13 +153,10 @@
|
||||
}
|
||||
],
|
||||
"created": "2025-05-14T08:56:22.860792+00:00",
|
||||
"child_jobs": [],
|
||||
"extra": null
|
||||
"child_jobs": []
|
||||
}
|
||||
],
|
||||
"extra": null
|
||||
]
|
||||
}
|
||||
],
|
||||
"extra": null
|
||||
]
|
||||
}
|
||||
}
|
||||
|
@@ -268,7 +268,6 @@ TEST_JOB_NOT_DONE = supervisor_jobs.Job(
|
||||
errors=[],
|
||||
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
|
||||
child_jobs=[],
|
||||
extra=None,
|
||||
)
|
||||
TEST_JOB_DONE = supervisor_jobs.Job(
|
||||
name="backup_manager_partial_backup",
|
||||
@@ -280,7 +279,6 @@ TEST_JOB_DONE = supervisor_jobs.Job(
|
||||
errors=[],
|
||||
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
|
||||
child_jobs=[],
|
||||
extra=None,
|
||||
)
|
||||
TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job(
|
||||
name="backup_manager_partial_restore",
|
||||
@@ -301,7 +299,6 @@ TEST_RESTORE_JOB_DONE_WITH_ERROR = supervisor_jobs.Job(
|
||||
],
|
||||
created=datetime.fromisoformat("1970-01-01T00:00:00Z"),
|
||||
child_jobs=[],
|
||||
extra=None,
|
||||
)
|
||||
|
||||
|
||||
|
@@ -1,320 +0,0 @@
|
||||
"""The tests for the hassio switch."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import MOCK_REPOSITORIES, MOCK_STORE_ADDONS
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
|
||||
MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_all(
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
addon_installed: AsyncMock,
|
||||
store_info: AsyncMock,
|
||||
addon_changelog: AsyncMock,
|
||||
addon_stats: AsyncMock,
|
||||
resolution_info: AsyncMock,
|
||||
) -> None:
|
||||
"""Mock all setup requests."""
|
||||
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
|
||||
aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"})
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"supervisor": "222",
|
||||
"homeassistant": "0.110.0",
|
||||
"hassos": "1.2.3",
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/host/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"chassis": "vm",
|
||||
"operating_system": "Debian GNU/Linux 10 (buster)",
|
||||
"kernel": "4.19.0-6-amd64",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/info",
|
||||
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/os/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"version_latest": "1.0.0",
|
||||
"version": "1.0.0",
|
||||
"update_available": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"result": "ok",
|
||||
"version": "1.0.0",
|
||||
"version_latest": "1.0.0",
|
||||
"auto_update": True,
|
||||
"addons": [
|
||||
{
|
||||
"name": "test",
|
||||
"state": "started",
|
||||
"slug": "test",
|
||||
"installed": True,
|
||||
"update_available": True,
|
||||
"icon": False,
|
||||
"version": "2.0.0",
|
||||
"version_latest": "2.0.1",
|
||||
"repository": "core",
|
||||
"url": "https://github.com/home-assistant/addons/test",
|
||||
},
|
||||
{
|
||||
"name": "test-two",
|
||||
"state": "stopped",
|
||||
"slug": "test-two",
|
||||
"installed": True,
|
||||
"update_available": False,
|
||||
"icon": True,
|
||||
"version": "3.1.0",
|
||||
"version_latest": "3.1.0",
|
||||
"repository": "core",
|
||||
"url": "https://github.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/core/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/supervisor/stats",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"cpu_percent": 0.99,
|
||||
"memory_usage": 182611968,
|
||||
"memory_limit": 3977146368,
|
||||
"memory_percent": 4.59,
|
||||
"network_rx": 362570232,
|
||||
"network_tx": 82374138,
|
||||
"blk_read": 46010945536,
|
||||
"blk_write": 15051526144,
|
||||
},
|
||||
},
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}}
|
||||
)
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/network/info",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {
|
||||
"host_internet": True,
|
||||
"supervisor_internet": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "expected", "addon_state"),
|
||||
[
|
||||
("switch.test", "on", "started"),
|
||||
("switch.test_two", "off", "stopped"),
|
||||
],
|
||||
)
|
||||
async def test_switch_state(
|
||||
hass: HomeAssistant,
|
||||
entity_id: str,
|
||||
expected: str,
|
||||
addon_state: str,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
entity_registry: er.EntityRegistry,
|
||||
addon_installed: AsyncMock,
|
||||
) -> None:
|
||||
"""Test hassio addon switch state."""
|
||||
addon_installed.return_value.state = addon_state
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entity is disabled by default.
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Enable the entity.
|
||||
entity_registry.async_update_entity(entity_id, disabled_by=None)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entity have the expected state.
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)]
|
||||
)
|
||||
async def test_switch_turn_on(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
entity_registry: er.EntityRegistry,
|
||||
addon_installed: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning on addon switch."""
|
||||
entity_id = "switch.test_two"
|
||||
addon_installed.return_value.state = "stopped"
|
||||
|
||||
# Mock the start addon API call
|
||||
aioclient_mock.post("http://127.0.0.1/addons/test-two/start", json={"result": "ok"})
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entity is disabled by default.
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Enable the entity.
|
||||
entity_registry.async_update_entity(entity_id, disabled_by=None)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify initial state is off
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
|
||||
# Turn on the switch
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_on",
|
||||
{"entity_id": entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify the API was called
|
||||
assert len(aioclient_mock.mock_calls) > 0
|
||||
start_call_found = False
|
||||
for call in aioclient_mock.mock_calls:
|
||||
if call[1].path == "/addons/test-two/start" and call[0] == "POST":
|
||||
start_call_found = True
|
||||
break
|
||||
assert start_call_found
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("store_addons", "store_repositories"), [(MOCK_STORE_ADDONS, MOCK_REPOSITORIES)]
|
||||
)
|
||||
async def test_switch_turn_off(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
entity_registry: er.EntityRegistry,
|
||||
addon_installed: AsyncMock,
|
||||
) -> None:
|
||||
"""Test turning off addon switch."""
|
||||
entity_id = "switch.test"
|
||||
addon_installed.return_value.state = "started"
|
||||
|
||||
# Mock the stop addon API call
|
||||
aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"})
|
||||
|
||||
config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
result = await async_setup_component(
|
||||
hass,
|
||||
"hassio",
|
||||
{"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}},
|
||||
)
|
||||
assert result
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify that the entity is disabled by default.
|
||||
assert hass.states.get(entity_id) is None
|
||||
|
||||
# Enable the entity.
|
||||
entity_registry.async_update_entity(entity_id, disabled_by=None)
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify initial state is on
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "on"
|
||||
|
||||
# Turn off the switch
|
||||
await hass.services.async_call(
|
||||
"switch",
|
||||
"turn_off",
|
||||
{"entity_id": entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
# Verify the API was called
|
||||
assert len(aioclient_mock.mock_calls) > 0
|
||||
stop_call_found = False
|
||||
for call in aioclient_mock.mock_calls:
|
||||
if call[1].path == "/addons/test/stop" and call[0] == "POST":
|
||||
stop_call_found = True
|
||||
break
|
||||
assert stop_call_found
|
@@ -18,7 +18,6 @@ from homeassistant.components.here_travel_time.const import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import DEFAULT_CONFIG
|
||||
|
||||
@@ -81,29 +80,3 @@ async def test_migrate_entry_v1_1_v1_2(
|
||||
assert updated_entry.state is ConfigEntryState.LOADED
|
||||
assert updated_entry.minor_version == 2
|
||||
assert updated_entry.options[CONF_TRAFFIC_MODE] is True
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("valid_response")
|
||||
async def test_issue_multiple_here_integrations_detected(
|
||||
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||
) -> None:
|
||||
"""Test that an issue is created when multiple HERE integrations are detected."""
|
||||
entry1 = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="1234567890",
|
||||
data=DEFAULT_CONFIG,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
entry2 = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="0987654321",
|
||||
data=DEFAULT_CONFIG,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
entry1.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry1.entry_id)
|
||||
entry2.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(entry2.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
@@ -27,7 +27,7 @@ def mock_zha():
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app",
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||
return_value=mock_connect_app,
|
||||
),
|
||||
patch(
|
||||
|
@@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]:
|
||||
side_effect=mock_probe,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app",
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||
return_value=mock_connect_app,
|
||||
),
|
||||
patch(
|
||||
|
@@ -856,7 +856,7 @@ async def test_options_flow_zigbee_to_thread(
|
||||
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert result["step_id"] == "install_otbr_addon"
|
||||
assert result["progress_action"] == "install_addon"
|
||||
assert result["progress_action"] == "install_otbr_addon"
|
||||
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
@@ -27,7 +27,7 @@ def mock_zha():
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app",
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||
return_value=mock_connect_app,
|
||||
),
|
||||
patch(
|
||||
|
@@ -27,7 +27,7 @@ def mock_zha_config_flow_setup() -> Generator[None]:
|
||||
side_effect=mock_probe,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.create_zigpy_app",
|
||||
"homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app",
|
||||
return_value=mock_connect_app,
|
||||
),
|
||||
patch(
|
||||
|
@@ -71,16 +71,10 @@ async def test_setup_entry(
|
||||
if num_entries > 0:
|
||||
zha_flows = hass.config_entries.flow.async_progress_by_handler("zha")
|
||||
assert len(zha_flows) == 1
|
||||
assert zha_flows[0]["step_id"] == "choose_setup_strategy"
|
||||
|
||||
setup_result = await hass.config_entries.flow.async_configure(
|
||||
zha_flows[0]["flow_id"],
|
||||
user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED},
|
||||
)
|
||||
assert setup_result["step_id"] == "choose_formation_strategy"
|
||||
assert zha_flows[0]["step_id"] == "choose_formation_strategy"
|
||||
|
||||
await hass.config_entries.flow.async_configure(
|
||||
setup_result["flow_id"],
|
||||
zha_flows[0]["flow_id"],
|
||||
user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -123,16 +117,10 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None:
|
||||
# Finish setting up ZHA
|
||||
zha_flows = hass.config_entries.flow.async_progress_by_handler("zha")
|
||||
assert len(zha_flows) == 1
|
||||
assert zha_flows[0]["step_id"] == "choose_setup_strategy"
|
||||
|
||||
setup_result = await hass.config_entries.flow.async_configure(
|
||||
zha_flows[0]["flow_id"],
|
||||
user_input={"next_step_id": zha.config_flow.SETUP_STRATEGY_ADVANCED},
|
||||
)
|
||||
assert setup_result["step_id"] == "choose_formation_strategy"
|
||||
assert zha_flows[0]["step_id"] == "choose_formation_strategy"
|
||||
|
||||
await hass.config_entries.flow.async_configure(
|
||||
setup_result["flow_id"],
|
||||
zha_flows[0]["flow_id"],
|
||||
user_input={"next_step_id": zha.config_flow.FORMATION_REUSE_SETTINGS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
@@ -2363,199 +2363,5 @@
|
||||
"sensitivity_max": 4
|
||||
},
|
||||
"type": "motion"
|
||||
},
|
||||
{
|
||||
"id": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345",
|
||||
"owner": {
|
||||
"rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b",
|
||||
"rtype": "motion_area_configuration"
|
||||
},
|
||||
"enabled": true,
|
||||
"motion": {
|
||||
"motion": false,
|
||||
"motion_valid": true,
|
||||
"motion_report": {
|
||||
"changed": "2023-09-23T08:13:42.394Z",
|
||||
"motion": false
|
||||
}
|
||||
},
|
||||
"sensitivity": {
|
||||
"sensitivity": 2,
|
||||
"sensitivity_max": 4
|
||||
},
|
||||
"type": "convenience_area_motion"
|
||||
},
|
||||
{
|
||||
"id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f",
|
||||
"owner": {
|
||||
"rid": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b",
|
||||
"rtype": "motion_area_configuration"
|
||||
},
|
||||
"enabled": true,
|
||||
"motion": {
|
||||
"motion": false,
|
||||
"motion_valid": true,
|
||||
"motion_report": {
|
||||
"changed": "2023-09-23T05:54:08.166Z",
|
||||
"motion": false
|
||||
}
|
||||
},
|
||||
"sensitivity": {
|
||||
"sensitivity": 2,
|
||||
"sensitivity_max": 4
|
||||
},
|
||||
"type": "security_area_motion"
|
||||
},
|
||||
{
|
||||
"id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b",
|
||||
"name": "Motion Aware Sensor 1",
|
||||
"group": {
|
||||
"rid": "6ddc9066-7e7d-4a03-a773-c73937968296",
|
||||
"rtype": "room"
|
||||
},
|
||||
"participants": [
|
||||
{
|
||||
"resource": {
|
||||
"rid": "a17253ed-168d-471a-8e59-01a101441511",
|
||||
"rtype": "motion_area_candidate"
|
||||
},
|
||||
"status": {
|
||||
"health": "healthy"
|
||||
}
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345",
|
||||
"rtype": "convenience_area_motion"
|
||||
},
|
||||
{
|
||||
"rid": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f",
|
||||
"rtype": "security_area_motion"
|
||||
}
|
||||
],
|
||||
"health": "healthy",
|
||||
"enabled": true,
|
||||
"type": "motion_area_configuration"
|
||||
},
|
||||
{
|
||||
"id": "9f8e7d6c-5b4a-3e2d-1c0b-9a8f7e6d5c4b",
|
||||
"owner": {
|
||||
"rid": "3ff06175-29e8-44a8-8fe7-af591b0025da",
|
||||
"rtype": "device"
|
||||
},
|
||||
"state": "no_update",
|
||||
"problems": [],
|
||||
"type": "device_software_update"
|
||||
},
|
||||
{
|
||||
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"owner": {
|
||||
"rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"rtype": "service_group"
|
||||
},
|
||||
"enabled": true,
|
||||
"light": {
|
||||
"light_level_report": {
|
||||
"changed": "2023-09-23T06:19:38.865Z",
|
||||
"light_level": 0
|
||||
}
|
||||
},
|
||||
"type": "grouped_light_level"
|
||||
},
|
||||
{
|
||||
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"owner": {
|
||||
"rid": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"rtype": "service_group"
|
||||
},
|
||||
"enabled": true,
|
||||
"motion": {
|
||||
"motion_report": {
|
||||
"changed": "2023-09-23T08:20:51.384Z",
|
||||
"motion": false
|
||||
}
|
||||
},
|
||||
"type": "grouped_motion"
|
||||
},
|
||||
{
|
||||
"id": "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f",
|
||||
"id_v1": "/sensors/75",
|
||||
"owner": {
|
||||
"rid": "3ff06175-29e8-44a8-8fe7-af591b0025da",
|
||||
"rtype": "device"
|
||||
},
|
||||
"relative_rotary": {
|
||||
"last_event": {
|
||||
"action": "start",
|
||||
"rotation": {
|
||||
"direction": "clock_wise",
|
||||
"steps": 30,
|
||||
"duration": 400
|
||||
}
|
||||
},
|
||||
"rotary_report": {
|
||||
"updated": "2023-09-21T10:00:03.276Z",
|
||||
"action": "start",
|
||||
"rotation": {
|
||||
"direction": "counter_clock_wise",
|
||||
"steps": 45,
|
||||
"duration": 400
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": "relative_rotary"
|
||||
},
|
||||
{
|
||||
"id": "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a",
|
||||
"children": [
|
||||
{
|
||||
"rid": "4f317b69-9da0-4b4f-84f2-7ca07b9fe345",
|
||||
"rtype": "convenience_area_motion"
|
||||
},
|
||||
{
|
||||
"rid": "5f317b69-9da0-4b4f-84f2-7ca07b9fe346",
|
||||
"rtype": "security_area_motion"
|
||||
}
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"rid": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"rtype": "grouped_motion"
|
||||
},
|
||||
{
|
||||
"rid": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"rtype": "grouped_light_level"
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"name": "Sensor group"
|
||||
},
|
||||
"type": "service_group"
|
||||
},
|
||||
{
|
||||
"id": "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b",
|
||||
"name": "Test clip resource",
|
||||
"type": "clip"
|
||||
},
|
||||
{
|
||||
"id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c",
|
||||
"type": "matter",
|
||||
"enabled": true,
|
||||
"max_fabrics": 5,
|
||||
"has_qr_code": false
|
||||
},
|
||||
{
|
||||
"id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
|
||||
"time": {
|
||||
"time_zone": "UTC",
|
||||
"time": "2023-09-23T10:30:00Z"
|
||||
},
|
||||
"type": "time"
|
||||
},
|
||||
{
|
||||
"id": "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e",
|
||||
"status": "ready",
|
||||
"type": "zigbee_device_discovery"
|
||||
}
|
||||
]
|
||||
|
@@ -19,7 +19,8 @@ async def test_binary_sensors(
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
|
||||
# there shouldn't have been any requests at this point
|
||||
assert len(mock_bridge_v2.mock_requests) == 0
|
||||
# 7 binary_sensors should be created from test data
|
||||
# 5 binary_sensors should be created from test data
|
||||
assert len(hass.states.async_all()) == 5
|
||||
|
||||
# test motion sensor
|
||||
sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion")
|
||||
@@ -80,20 +81,6 @@ async def test_binary_sensors(
|
||||
assert sensor.name == "Test Camera Motion"
|
||||
assert sensor.attributes["device_class"] == "motion"
|
||||
|
||||
# test grouped motion sensor
|
||||
sensor = hass.states.get("binary_sensor.sensor_group_motion")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "off"
|
||||
assert sensor.name == "Sensor group Motion"
|
||||
assert sensor.attributes["device_class"] == "motion"
|
||||
|
||||
# test motion aware sensor
|
||||
sensor = hass.states.get("binary_sensor.motion_aware_sensor_1")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "off"
|
||||
assert sensor.name == "Motion Aware Sensor 1"
|
||||
assert sensor.attributes["device_class"] == "motion"
|
||||
|
||||
|
||||
async def test_binary_sensor_add_update(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock
|
||||
@@ -123,84 +110,3 @@ async def test_binary_sensor_add_update(
|
||||
test_entity = hass.states.get(test_entity_id)
|
||||
assert test_entity is not None
|
||||
assert test_entity.state == "on"
|
||||
|
||||
|
||||
async def test_grouped_motion_sensor(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
|
||||
) -> None:
|
||||
"""Test HueGroupedMotionSensor functionality."""
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
|
||||
|
||||
# test grouped motion sensor exists and has correct state
|
||||
sensor = hass.states.get("binary_sensor.sensor_group_motion")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "off"
|
||||
assert sensor.attributes["device_class"] == "motion"
|
||||
|
||||
# test update of grouped motion sensor works on incoming event
|
||||
updated_sensor = {
|
||||
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"type": "grouped_motion",
|
||||
"motion": {
|
||||
"motion_report": {"changed": "2023-09-23T08:20:51.384Z", "motion": True}
|
||||
},
|
||||
}
|
||||
mock_bridge_v2.api.emit_event("update", updated_sensor)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("binary_sensor.sensor_group_motion")
|
||||
assert sensor.state == "on"
|
||||
|
||||
# test disabled grouped motion sensor == state unknown
|
||||
disabled_sensor = {
|
||||
"id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e",
|
||||
"type": "grouped_motion",
|
||||
"enabled": False,
|
||||
}
|
||||
mock_bridge_v2.api.emit_event("update", disabled_sensor)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("binary_sensor.sensor_group_motion")
|
||||
assert sensor.state == "unknown"
|
||||
|
||||
|
||||
async def test_motion_aware_sensor(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
|
||||
) -> None:
|
||||
"""Test HueMotionAwareSensor functionality."""
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.BINARY_SENSOR)
|
||||
|
||||
# test motion aware sensor exists and has correct state
|
||||
sensor = hass.states.get("binary_sensor.motion_aware_sensor_1")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "off"
|
||||
assert sensor.attributes["device_class"] == "motion"
|
||||
|
||||
# test update of motion aware sensor works on incoming event
|
||||
updated_sensor = {
|
||||
"id": "8b7e4f82-9c3d-4e1a-a5f6-8d9c7b2a3e4f",
|
||||
"type": "security_area_motion",
|
||||
"motion": {
|
||||
"motion": True,
|
||||
"motion_valid": True,
|
||||
"motion_report": {"changed": "2023-09-23T05:54:08.166Z", "motion": True},
|
||||
},
|
||||
}
|
||||
mock_bridge_v2.api.emit_event("update", updated_sensor)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("binary_sensor.motion_aware_sensor_1")
|
||||
assert sensor.state == "on"
|
||||
|
||||
# test name update when motion area configuration name changes
|
||||
updated_config = {
|
||||
"id": "5e6f7a8b-9c1d-4e2f-b3a4-5c6d7e8f9a0b",
|
||||
"type": "motion_area_configuration",
|
||||
"name": "Updated Motion Area",
|
||||
}
|
||||
mock_bridge_v2.api.emit_event("update", updated_config)
|
||||
await hass.async_block_till_done()
|
||||
# The entity name is derived from the motion area configuration name
|
||||
# but the entity ID doesn't change - we just verify the sensor still exists
|
||||
sensor = hass.states.get("binary_sensor.motion_aware_sensor_1")
|
||||
assert sensor is not None
|
||||
assert sensor.name == "Updated Motion Area"
|
||||
|
@@ -17,8 +17,8 @@ async def test_event(
|
||||
"""Test event entity for Hue integration."""
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.EVENT)
|
||||
# 8 entities should be created from test data
|
||||
assert len(hass.states.async_all()) == 8
|
||||
# 7 entities should be created from test data
|
||||
assert len(hass.states.async_all()) == 7
|
||||
|
||||
# pick one of the remote buttons
|
||||
state = hass.states.get("event.hue_dimmer_switch_with_4_controls_button_1")
|
||||
|
@@ -178,7 +178,7 @@ async def test_light_turn_on_service(
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_bridge_v2.mock_requests) == 6
|
||||
assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 454
|
||||
assert mock_bridge_v2.mock_requests[5]["json"]["color_temperature"]["mirek"] == 500
|
||||
|
||||
# test enable an effect
|
||||
await hass.services.async_call(
|
||||
|
@@ -27,8 +27,8 @@ async def test_sensors(
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.SENSOR)
|
||||
# there shouldn't have been any requests at this point
|
||||
assert len(mock_bridge_v2.mock_requests) == 0
|
||||
# 7 entities should be created from test data
|
||||
assert len(hass.states.async_all()) == 7
|
||||
# 6 entities should be created from test data
|
||||
assert len(hass.states.async_all()) == 6
|
||||
|
||||
# test temperature sensor
|
||||
sensor = hass.states.get("sensor.hue_motion_sensor_temperature")
|
||||
@@ -59,16 +59,6 @@ async def test_sensors(
|
||||
assert sensor.attributes["unit_of_measurement"] == "%"
|
||||
assert sensor.attributes["battery_state"] == "normal"
|
||||
|
||||
# test grouped light level sensor
|
||||
sensor = hass.states.get("sensor.sensor_group_illuminance")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "0"
|
||||
assert sensor.attributes["friendly_name"] == "Sensor group Illuminance"
|
||||
assert sensor.attributes["device_class"] == "illuminance"
|
||||
assert sensor.attributes["state_class"] == "measurement"
|
||||
assert sensor.attributes["unit_of_measurement"] == "lx"
|
||||
assert sensor.attributes["light_level"] == 0
|
||||
|
||||
# test disabled zigbee_connectivity sensor
|
||||
entity_id = "sensor.wall_switch_with_2_controls_zigbee_connectivity"
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
@@ -149,39 +139,3 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2: Mock) -> N
|
||||
test_entity = hass.states.get(test_entity_id)
|
||||
assert test_entity is not None
|
||||
assert test_entity.state == "22.5"
|
||||
|
||||
|
||||
async def test_grouped_light_level_sensor(
|
||||
hass: HomeAssistant, mock_bridge_v2: Mock, v2_resources_test_data: JsonArrayType
|
||||
) -> None:
|
||||
"""Test HueGroupedLightLevelSensor functionality."""
|
||||
await mock_bridge_v2.api.load_test_data(v2_resources_test_data)
|
||||
await setup_platform(hass, mock_bridge_v2, Platform.SENSOR)
|
||||
|
||||
# test grouped light level sensor exists and has correct state
|
||||
sensor = hass.states.get("sensor.sensor_group_illuminance")
|
||||
assert sensor is not None
|
||||
assert (
|
||||
sensor.state == "0"
|
||||
) # Light level 0 translates to 10^((0-1)/10000) ≈ 0 lux (rounded)
|
||||
assert sensor.attributes["device_class"] == "illuminance"
|
||||
assert sensor.attributes["light_level"] == 0
|
||||
|
||||
# test update of grouped light level sensor works on incoming event
|
||||
updated_sensor = {
|
||||
"id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
|
||||
"type": "grouped_light_level",
|
||||
"light": {
|
||||
"light_level": 30000,
|
||||
"light_level_report": {
|
||||
"changed": "2023-09-23T08:20:51.384Z",
|
||||
"light_level": 30000,
|
||||
},
|
||||
},
|
||||
}
|
||||
mock_bridge_v2.api.emit_event("update", updated_sensor)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("sensor.sensor_group_illuminance")
|
||||
assert (
|
||||
sensor.state == "999"
|
||||
) # Light level 30000 translates to 10^((30000-1)/10000) ≈ 999 lux
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user