Compare commits

..

12 Commits

Author SHA1 Message Date
Marc Mueller
489393cd01 Improve decorator typing 2025-09-23 16:46:03 +02:00
Petar Petrov
fe9dde125f test fix 2025-09-23 14:43:52 +03:00
Petar Petrov
b7c249d9ce Merge remote-tracking branch 'upstream/dev' into progress_step_decorator 2025-09-23 14:42:45 +03:00
Petar Petrov
ad1d5565ea Improve typing 2025-09-23 14:39:13 +03:00
Petar Petrov
0fb052201a Merge branch 'dev' into progress_step_decorator 2025-09-23 09:25:58 +03:00
Petar Petrov
4c1b3776a6 improve typing 2025-09-23 08:36:33 +03:00
Petar Petrov
81c3d34bfe call next step instead of returning its id 2025-09-23 08:31:56 +03:00
Petar Petrov
26ae0a505e rename 2025-09-22 12:14:45 +03:00
Petar Petrov
09862c0821 type fix 2025-09-22 12:12:34 +03:00
Petar Petrov
77cacbe577 PR comment 2025-09-22 11:24:42 +03:00
Petar Petrov
3abe9017f7 typing 2025-09-22 11:09:02 +03:00
Petar Petrov
6d46f28eaf Add @progress_step decorator for config flows 2025-09-22 10:26:24 +03:00
118 changed files with 958 additions and 4087 deletions

View File

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

View File

@@ -26,5 +26,5 @@
"iot_class": "local_push",
"loggers": ["aioacaia"],
"quality_scale": "platinum",
"requirements": ["aioacaia==0.1.17"]
"requirements": ["aioacaia==0.1.14"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
]

View File

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

View File

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

View File

@@ -9,7 +9,6 @@
"conversation",
"dhcp",
"energy",
"file",
"go2rtc",
"history",
"homeassistant_alerts",

View File

@@ -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."]
}

View File

@@ -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"
],

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
{
"services": {
"read_file": {
"service": "mdi:file"
}
}
}

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

View File

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

View File

@@ -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.)"
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

@@ -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, ...]:

View File

@@ -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."
}
}
}

View File

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

View File

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

View File

@@ -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."]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
}
}
}

View File

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

View File

@@ -485,9 +485,6 @@
"apparent_current": {
"name": "Apparent current"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"reactive_current": {
"name": "Reactive current"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ from .const import (
CONF_VIRTUAL_COUNT,
DEFAULT_HUB,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
MODBUS_DOMAIN as DOMAIN,
PLATFORMS,
SERIAL,
DataType,

View File

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

View File

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

View File

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

View File

@@ -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 sensors 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 hasnt 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%]",

View File

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

View File

@@ -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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["zha==0.0.72"],
"requirements": ["zha==0.0.71"],
"usb": [
{
"vid": "10C4",

View File

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

View File

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

View File

@@ -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."
}
}
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -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,
},
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
{ "key": "value", "key1": "value1" }

View File

@@ -1 +0,0 @@
{ "key": "value", "key1": value1 }

View File

@@ -1,4 +0,0 @@
test:
- element: "X"
- element: "Y"
unexpected: "Z"

View File

@@ -1,5 +0,0 @@
mylist:
- name: list_item_1
id: 1
- name: list_item_2
id: 2

View File

@@ -1,4 +0,0 @@
- name: list_item_1
id: 1
- name: list_item_2
id: 2

View File

@@ -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',
}),
]),
})
# ---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}
]

View File

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

View File

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

View File

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

View File

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