mirror of
https://github.com/home-assistant/core.git
synced 2026-02-04 06:15:47 +01:00
Compare commits
35 Commits
2026.2.0b1
...
2026.2.0b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf8edf474 | ||
|
|
47e1a98bee | ||
|
|
2d8572b943 | ||
|
|
660cfdbd50 | ||
|
|
4208595da6 | ||
|
|
b6b2d2fc6f | ||
|
|
6c4c632848 | ||
|
|
78cf62176f | ||
|
|
df971c7a42 | ||
|
|
1fcabb7f2d | ||
|
|
9fb60c9ea2 | ||
|
|
9c11a4646f | ||
|
|
b036a78776 | ||
|
|
60bb3cb704 | ||
|
|
0e770958ac | ||
|
|
2a54c71b6c | ||
|
|
50463291ab | ||
|
|
43cc34042a | ||
|
|
a02244ccda | ||
|
|
a739619121 | ||
|
|
5db97a5f1c | ||
|
|
804ba9c9cc | ||
|
|
5ecbcea946 | ||
|
|
11be2b6289 | ||
|
|
eefae0307b | ||
|
|
d397ee28ea | ||
|
|
02c821128e | ||
|
|
71dc15d45f | ||
|
|
1078387b22 | ||
|
|
35fab27d15 | ||
|
|
915dc7a908 | ||
|
|
e5a9738983 | ||
|
|
2ff73219a2 | ||
|
|
5dc1270ed1 | ||
|
|
9e95ad5a85 |
5
homeassistant/brands/heatit.json
Normal file
5
homeassistant/brands/heatit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heatit",
|
||||
"name": "Heatit",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
5
homeassistant/brands/heiman.json
Normal file
5
homeassistant/brands/heiman.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heiman",
|
||||
"name": "Heiman",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -105,6 +106,9 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Remove notification sensors from unsupported devices
|
||||
await async_remove_unsupported_notification_sensors(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -122,6 +126,7 @@ async def async_setup_entry(
|
||||
AmazonSensorEntity(coordinator, serial_num, notification_desc)
|
||||
for notification_desc in NOTIFICATIONS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].notifications_supported
|
||||
]
|
||||
async_add_entities(sensors_list + notifications_list)
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@ from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -81,3 +87,27 @@ async def async_remove_dnd_from_virtual_group(
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
) -> None:
|
||||
"""Remove notification sensors from unsupported devices."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
for notification_key in (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
if entity_id and is_unsupported:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2026/02/02/about-device-database/",
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
|
||||
@@ -419,7 +419,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
|
||||
@@ -23,7 +23,7 @@ CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
|
||||
@@ -540,7 +540,17 @@ class APCUPSdSensor(APCUPSdEntity, SensorEntity):
|
||||
data = self.coordinator.data[key]
|
||||
|
||||
if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
# The date could be "N/A" for certain fields (e.g., XOFFBATT), indicating there is no value yet.
|
||||
if data == "N/A":
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
try:
|
||||
self._attr_native_value = dateutil.parser.parse(data)
|
||||
except (dateutil.parser.ParserError, OverflowError):
|
||||
# If parsing fails we should mark it as unknown, with a log for further debugging.
|
||||
_LOGGER.warning('Failed to parse date for %s: "%s"', key, data)
|
||||
self._attr_native_value = None
|
||||
return
|
||||
|
||||
self._attr_native_value, inferred_unit = infer_unit(data)
|
||||
|
||||
@@ -459,8 +459,17 @@ class BaseCloudLLMEntity(Entity):
|
||||
last_content: Any = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
files = await self._async_prepare_files_for_prompt(last_content.attachments)
|
||||
current_content = last_content.content
|
||||
last_content = [*(current_content or []), *files]
|
||||
|
||||
last_message = cast(dict[str, Any], messages[-1])
|
||||
assert (
|
||||
last_message["type"] == "message"
|
||||
and last_message["role"] == "user"
|
||||
and isinstance(last_message["content"], str)
|
||||
)
|
||||
last_message["content"] = [
|
||||
{"type": "input_text", "text": last_message["content"]},
|
||||
*files,
|
||||
]
|
||||
|
||||
tools: list[ToolParam] = []
|
||||
tool_choice: str | None = None
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.3"]
|
||||
"requirements": ["home-assistant-frontend==20260128.5"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["googleapiclient"],
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.2"]
|
||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyHik==0.4.1"]
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect binary sensor."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -73,6 +73,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect button entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -7,18 +7,44 @@ from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
|
||||
|
||||
def should_add_option_entity(
|
||||
description: EntityDescription,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
platform: Platform,
|
||||
) -> bool:
|
||||
"""Check if the option entity should be added for the appliance.
|
||||
|
||||
This function returns `True` if the option is available in the appliance options
|
||||
or if the entity was added in previous loads of this integration.
|
||||
"""
|
||||
description_key = description.key
|
||||
return description_key in appliance.options or (
|
||||
entity_registry.async_get_entity_id(
|
||||
platform, DOMAIN, f"{appliance.info.ha_id}-{description_key}"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _create_option_entities(
|
||||
entity_registry: er.EntityRegistry,
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
@@ -26,7 +52,9 @@ def _create_option_entities(
|
||||
"""Create the required option entities for the appliances."""
|
||||
option_entities_to_add = [
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
known_entity_unique_ids.update(
|
||||
@@ -39,13 +67,14 @@ def _create_option_entities(
|
||||
|
||||
|
||||
def _handle_paired_or_connected_appliance(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None,
|
||||
@@ -60,6 +89,7 @@ def _handle_paired_or_connected_appliance(
|
||||
already or it is the first time we see them when the appliance is connected.
|
||||
"""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
entity_registry = er.async_get(hass)
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = [
|
||||
entity
|
||||
@@ -69,7 +99,9 @@ def _handle_paired_or_connected_appliance(
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
for entity in get_option_entities_for_appliance(
|
||||
entry, appliance, entity_registry
|
||||
)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
for event_key in (
|
||||
@@ -80,6 +112,7 @@ def _handle_paired_or_connected_appliance(
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entity_registry,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
@@ -120,13 +153,14 @@ def _handle_depaired_appliance(
|
||||
|
||||
|
||||
def setup_home_connect_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData, er.EntityRegistry],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None = None,
|
||||
@@ -141,6 +175,7 @@ def setup_home_connect_entry(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(
|
||||
_handle_paired_or_connected_appliance,
|
||||
hass,
|
||||
entry,
|
||||
known_entity_unique_ids,
|
||||
get_entities_for_appliance,
|
||||
|
||||
@@ -96,6 +96,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect light."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -11,12 +11,13 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE
|
||||
from homeassistant.const import PERCENTAGE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import DOMAIN, UNIT_MAP
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
|
||||
@@ -136,12 +137,15 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
|
||||
for description in NUMBER_OPTIONS
|
||||
if description.key in appliance.options
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.NUMBER
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -152,6 +156,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect number."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -11,11 +11,13 @@ from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import (
|
||||
AVAILABLE_MAPS_ENUM,
|
||||
BEAN_AMOUNT_OPTIONS,
|
||||
@@ -358,12 +360,13 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
|
||||
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
|
||||
if desc.key in appliance.options
|
||||
if should_add_option_entity(desc, appliance, entity_registry, Platform.SELECT)
|
||||
]
|
||||
|
||||
|
||||
@@ -374,6 +377,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect select entities."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -115,7 +115,6 @@ SENSORS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="hot_water_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
@@ -540,6 +539,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect sensor."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -7,12 +7,14 @@ from aiohomeconnect.model import OptionKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .common import setup_home_connect_entry, should_add_option_entity
|
||||
from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
@@ -190,12 +192,15 @@ def _get_entities_for_appliance(
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
|
||||
for description in SWITCH_OPTIONS
|
||||
if description.key in appliance.options
|
||||
if should_add_option_entity(
|
||||
description, appliance, entity_registry, Platform.SWITCH
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -206,6 +211,7 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect switch."""
|
||||
setup_home_connect_entry(
|
||||
hass,
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["incomfortclient"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["incomfort-client==0.6.11"]
|
||||
"requirements": ["incomfort-client==0.6.12"]
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"boiler_int": "Boiler internal",
|
||||
"buffer": "Buffer",
|
||||
"central_heating": "Central heating",
|
||||
"central_heating_low": "Central heating low",
|
||||
"central_heating_rf": "Central heating rf",
|
||||
"cv_temperature_too_high_e1": "Temperature too high",
|
||||
"flame_detection_fault_e6": "Flame detection fault",
|
||||
|
||||
@@ -2,16 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from propcache.api import cached_property
|
||||
from xknx.devices import Fan as XknxFan
|
||||
from xknx.telegram.address import parse_device_group_address
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
async_get_current_platform,
|
||||
@@ -37,6 +40,58 @@ from .storage.const import (
|
||||
)
|
||||
from .storage.util import ConfigExtractor
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_migrate_yaml_uids(
|
||||
hass: HomeAssistant, platform_config: list[ConfigType]
|
||||
) -> None:
|
||||
"""Migrate entities unique_id for YAML switch-only fan entities."""
|
||||
# issue was introduced in 2026.1 - this migration in 2026.2
|
||||
ent_reg = er.async_get(hass)
|
||||
invalid_uid = str(None)
|
||||
if (
|
||||
none_entity_id := ent_reg.async_get_entity_id(Platform.FAN, DOMAIN, invalid_uid)
|
||||
) is None:
|
||||
return
|
||||
for config in platform_config:
|
||||
if not config.get(KNX_ADDRESS) and (
|
||||
new_uid_base := config.get(FanSchema.CONF_SWITCH_ADDRESS)
|
||||
):
|
||||
break
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"No YAML entry found to migrate fan entity '%s' unique_id from '%s'. Removing entry",
|
||||
none_entity_id,
|
||||
invalid_uid,
|
||||
)
|
||||
ent_reg.async_remove(none_entity_id)
|
||||
return
|
||||
new_uid = str(
|
||||
parse_device_group_address(
|
||||
new_uid_base[0], # list of group addresses - first item is sending address
|
||||
)
|
||||
)
|
||||
try:
|
||||
ent_reg.async_update_entity(none_entity_id, new_unique_id=str(new_uid))
|
||||
_LOGGER.info(
|
||||
"Migrating fan entity '%s' unique_id from '%s' to %s",
|
||||
none_entity_id,
|
||||
invalid_uid,
|
||||
new_uid,
|
||||
)
|
||||
except ValueError:
|
||||
# New unique_id already exists - remove invalid entry. User might have changed YAML
|
||||
_LOGGER.info(
|
||||
"Failed to migrate fan entity '%s' unique_id from '%s' to '%s'. "
|
||||
"Removing the invalid entry",
|
||||
none_entity_id,
|
||||
invalid_uid,
|
||||
new_uid,
|
||||
)
|
||||
ent_reg.async_remove(none_entity_id)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -57,6 +112,7 @@ async def async_setup_entry(
|
||||
|
||||
entities: list[_KnxFan] = []
|
||||
if yaml_platform_config := knx_module.config_yaml.get(Platform.FAN):
|
||||
async_migrate_yaml_uids(hass, yaml_platform_config)
|
||||
entities.extend(
|
||||
KnxYamlFan(knx_module, entity_config)
|
||||
for entity_config in yaml_platform_config
|
||||
@@ -177,7 +233,10 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
|
||||
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
|
||||
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
|
||||
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
if self._device.speed.group_address:
|
||||
self._attr_unique_id = str(self._device.speed.group_address)
|
||||
else:
|
||||
self._attr_unique_id = str(self._device.switch.group_address)
|
||||
|
||||
|
||||
class KnxUiFan(_KnxFan, KnxUiEntity):
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["ical"],
|
||||
"requirements": ["ical==12.1.2"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["ical==12.1.2"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"yaml_mode_deprecated": {
|
||||
"description": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode deprecated"
|
||||
"description": "Your YAML dashboard configuration uses the legacy `mode: yaml` option, which will be removed in Home Assistant 2026.8. Your YAML dashboards will continue to work, you just need to update how they are defined.\n\nTo update your configuration:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Add a dashboard entry instead:\n\n ```yaml\n lovelace:\n resource_mode: yaml\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n3. Restart Home Assistant\n\nNote: `resource_mode: yaml` keeps loading resources from YAML. If you want to manage resources through the UI instead, you can remove this line and move your resources to Settings > Dashboards > Resources.",
|
||||
"title": "Lovelace YAML configuration needs update"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["meteoclimatic"],
|
||||
"requirements": ["pymeteoclimatic==0.1.0"]
|
||||
"requirements": ["pymeteoclimatic==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -722,7 +722,7 @@ POLLED_SENSOR_TYPES: Final[tuple[MieleSensorDefinition[MieleFillingLevel], ...]]
|
||||
description=MieleSensorDescription[MieleFillingLevel](
|
||||
key="power_disk_level",
|
||||
translation_key="power_disk_level",
|
||||
value_fn=lambda value: None,
|
||||
value_fn=lambda value: value.power_disc_filling_level,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
|
||||
@@ -14,7 +14,6 @@ from onedrive_personal_sdk.exceptions import (
|
||||
NotFoundError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import ItemUpdate
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -72,15 +71,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
|
||||
)
|
||||
|
||||
# write instance id to description
|
||||
if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
|
||||
await _handle_item_operation(
|
||||
lambda: client.update_drive_item(
|
||||
backup_folder.id, ItemUpdate(description=instance_id)
|
||||
),
|
||||
folder_name,
|
||||
)
|
||||
|
||||
# update in case folder was renamed manually inside OneDrive
|
||||
if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
|
||||
hass.config_entries.async_update_entry(
|
||||
@@ -122,7 +112,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
|
||||
|
||||
async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None:
|
||||
"""Migrate backup files to metadata version 2."""
|
||||
"""Migrate backup files from metadata version 1 to version 2.
|
||||
|
||||
Version 1: Backup metadata was stored in the backup file's description field.
|
||||
Version 2: Backup metadata is stored in a separate .metadata.json file.
|
||||
"""
|
||||
files = await client.list_drive_items(backup_folder_id)
|
||||
for file in files:
|
||||
if file.description and '"metadata_version": 1' in (
|
||||
@@ -131,24 +125,11 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
|
||||
metadata = loads(metadata_json)
|
||||
del metadata["metadata_version"]
|
||||
metadata_filename = file.name.rsplit(".", 1)[0] + ".metadata.json"
|
||||
metadata_file = await client.upload_file(
|
||||
await client.upload_file(
|
||||
backup_folder_id,
|
||||
metadata_filename,
|
||||
dumps(metadata),
|
||||
)
|
||||
metadata_description = {
|
||||
"metadata_version": 2,
|
||||
"backup_id": metadata["backup_id"],
|
||||
"backup_file_id": file.id,
|
||||
}
|
||||
await client.update_drive_item(
|
||||
path_or_id=metadata_file.id,
|
||||
data=ItemUpdate(description=dumps(metadata_description)),
|
||||
)
|
||||
await client.update_drive_item(
|
||||
path_or_id=file.id,
|
||||
data=ItemUpdate(description=""),
|
||||
)
|
||||
_LOGGER.debug("Migrated backup file %s", file.name)
|
||||
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
from functools import wraps
|
||||
from html import unescape
|
||||
from json import dumps, loads
|
||||
import logging
|
||||
from time import time
|
||||
from typing import Any, Concatenate
|
||||
@@ -18,7 +15,6 @@ from onedrive_personal_sdk.exceptions import (
|
||||
HashMismatchError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import ItemUpdate
|
||||
from onedrive_personal_sdk.models.upload import FileInfo
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
@@ -30,6 +26,8 @@ from homeassistant.components.backup import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util.json import json_loads_object
|
||||
|
||||
from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
@@ -38,7 +36,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
MAX_CHUNK_SIZE = 60 * 1024 * 1024 # largest chunk possible, must be <= 60 MiB
|
||||
TARGET_CHUNKS = 20
|
||||
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
|
||||
METADATA_VERSION = 2
|
||||
CACHE_TTL = 300
|
||||
|
||||
|
||||
@@ -104,13 +101,10 @@ def handle_backup_errors[_R, **P](
|
||||
return wrapper
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class OneDriveBackup:
|
||||
"""Define a OneDrive backup."""
|
||||
|
||||
backup: AgentBackup
|
||||
backup_file_id: str
|
||||
metadata_file_id: str
|
||||
def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
|
||||
"""Return the suggested filenames for the backup and metadata."""
|
||||
base_name = suggested_filename(backup).rsplit(".", 1)[0]
|
||||
return f"{base_name}.tar", f"{base_name}.metadata.json"
|
||||
|
||||
|
||||
class OneDriveBackupAgent(BackupAgent):
|
||||
@@ -129,7 +123,7 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
self.name = entry.title
|
||||
assert entry.unique_id
|
||||
self.unique_id = entry.unique_id
|
||||
self._backup_cache: dict[str, OneDriveBackup] = {}
|
||||
self._cache_backup_metadata: dict[str, AgentBackup] = {}
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_backup_errors
|
||||
@@ -137,12 +131,11 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
self, backup_id: str, **kwargs: Any
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
backups = await self._list_cached_backups()
|
||||
if backup_id not in backups:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
backup_filename, _ = suggested_filenames(backup)
|
||||
|
||||
stream = await self._client.download_drive_item(
|
||||
backups[backup_id].backup_file_id, timeout=TIMEOUT
|
||||
f"{self._folder_id}:/{backup_filename}:", timeout=TIMEOUT
|
||||
)
|
||||
return stream.iter_chunked(1024)
|
||||
|
||||
@@ -155,9 +148,9 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
filename = suggested_filename(backup)
|
||||
backup_filename, metadata_filename = suggested_filenames(backup)
|
||||
file = FileInfo(
|
||||
filename,
|
||||
backup_filename,
|
||||
backup.size,
|
||||
self._folder_id,
|
||||
await open_stream(),
|
||||
@@ -173,7 +166,7 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
upload_chunk_size = max(upload_chunk_size, 320 * 1024)
|
||||
|
||||
try:
|
||||
backup_file = await LargeFileUploadClient.upload(
|
||||
await LargeFileUploadClient.upload(
|
||||
self._token_function,
|
||||
file,
|
||||
upload_chunk_size=upload_chunk_size,
|
||||
@@ -185,35 +178,27 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
"Hash validation failed, backup file might be corrupt"
|
||||
) from err
|
||||
|
||||
# store metadata in metadata file
|
||||
description = dumps(backup.as_dict())
|
||||
_LOGGER.debug("Creating metadata: %s", description)
|
||||
metadata_filename = filename.rsplit(".", 1)[0] + ".metadata.json"
|
||||
_LOGGER.debug("Uploaded backup to %s", backup_filename)
|
||||
|
||||
# Store metadata in separate metadata file (just backup.as_dict(), no extra fields)
|
||||
metadata_content = json_dumps(backup.as_dict())
|
||||
try:
|
||||
metadata_file = await self._client.upload_file(
|
||||
await self._client.upload_file(
|
||||
self._folder_id,
|
||||
metadata_filename,
|
||||
description,
|
||||
metadata_content,
|
||||
)
|
||||
except OneDriveException:
|
||||
await self._client.delete_drive_item(backup_file.id)
|
||||
# Clean up the backup file if metadata upload fails
|
||||
_LOGGER.debug(
|
||||
"Uploading metadata failed, deleting backup file %s", backup_filename
|
||||
)
|
||||
await self._client.delete_drive_item(
|
||||
f"{self._folder_id}:/{backup_filename}:"
|
||||
)
|
||||
raise
|
||||
|
||||
# add metadata to the metadata file
|
||||
metadata_description = {
|
||||
"metadata_version": METADATA_VERSION,
|
||||
"backup_id": backup.backup_id,
|
||||
"backup_file_id": backup_file.id,
|
||||
}
|
||||
try:
|
||||
await self._client.update_drive_item(
|
||||
path_or_id=metadata_file.id,
|
||||
data=ItemUpdate(description=dumps(metadata_description)),
|
||||
)
|
||||
except OneDriveException:
|
||||
await self._client.delete_drive_item(backup_file.id)
|
||||
await self._client.delete_drive_item(metadata_file.id)
|
||||
raise
|
||||
_LOGGER.debug("Uploaded metadata file %s", metadata_filename)
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_backup_errors
|
||||
@@ -223,66 +208,63 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
backups = await self._list_cached_backups()
|
||||
if backup_id not in backups:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
backup = backups[backup_id]
|
||||
backup = await self._find_backup_by_id(backup_id)
|
||||
backup_filename, metadata_filename = suggested_filenames(backup)
|
||||
|
||||
delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False)
|
||||
|
||||
await self._client.delete_drive_item(backup.backup_file_id, delete_permanently)
|
||||
await self._client.delete_drive_item(
|
||||
backup.metadata_file_id, delete_permanently
|
||||
f"{self._folder_id}:/{backup_filename}:", delete_permanently
|
||||
)
|
||||
await self._client.delete_drive_item(
|
||||
f"{self._folder_id}:/{metadata_filename}:", delete_permanently
|
||||
)
|
||||
|
||||
_LOGGER.debug("Deleted backup %s", backup_filename)
|
||||
self._cache_expiration = time()
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
return [
|
||||
backup.backup for backup in (await self._list_cached_backups()).values()
|
||||
]
|
||||
return list((await self._list_cached_metadata_files()).values())
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup:
|
||||
"""Return a backup."""
|
||||
backups = await self._list_cached_backups()
|
||||
if backup_id not in backups:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
return backups[backup_id].backup
|
||||
return await self._find_backup_by_id(backup_id)
|
||||
|
||||
async def _list_cached_backups(self) -> dict[str, OneDriveBackup]:
|
||||
"""List backups with a cache."""
|
||||
async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]:
|
||||
"""List metadata files with a cache."""
|
||||
if time() <= self._cache_expiration:
|
||||
return self._backup_cache
|
||||
return self._cache_backup_metadata
|
||||
|
||||
items = await self._client.list_drive_items(self._folder_id)
|
||||
|
||||
async def download_backup_metadata(item_id: str) -> AgentBackup | None:
|
||||
async def _download_metadata(item_id: str) -> AgentBackup | None:
|
||||
"""Download metadata file."""
|
||||
try:
|
||||
metadata_stream = await self._client.download_drive_item(item_id)
|
||||
except OneDriveException as err:
|
||||
_LOGGER.warning("Error downloading metadata for %s: %s", item_id, err)
|
||||
return None
|
||||
metadata_json = loads(await metadata_stream.read())
|
||||
return AgentBackup.from_dict(metadata_json)
|
||||
|
||||
backups: dict[str, OneDriveBackup] = {}
|
||||
return AgentBackup.from_dict(
|
||||
json_loads_object(await metadata_stream.read())
|
||||
)
|
||||
|
||||
items = await self._client.list_drive_items(self._folder_id)
|
||||
metadata_files: dict[str, AgentBackup] = {}
|
||||
for item in items:
|
||||
if item.description and f'"metadata_version": {METADATA_VERSION}' in (
|
||||
metadata_description_json := unescape(item.description)
|
||||
):
|
||||
backup = await download_backup_metadata(item.id)
|
||||
if backup is None:
|
||||
continue
|
||||
metadata_description = loads(metadata_description_json)
|
||||
backups[backup.backup_id] = OneDriveBackup(
|
||||
backup=backup,
|
||||
backup_file_id=metadata_description["backup_file_id"],
|
||||
metadata_file_id=item.id,
|
||||
)
|
||||
if item.name and item.name.endswith(".metadata.json"):
|
||||
if metadata := await _download_metadata(item.id):
|
||||
metadata_files[metadata.backup_id] = metadata
|
||||
|
||||
self._cache_backup_metadata = metadata_files
|
||||
self._cache_expiration = time() + CACHE_TTL
|
||||
self._backup_cache = backups
|
||||
return backups
|
||||
return self._cache_backup_metadata
|
||||
|
||||
async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
|
||||
"""Find a backup by its backup ID on remote."""
|
||||
metadata_files = await self._list_cached_metadata_files()
|
||||
if backup := metadata_files.get(backup_id):
|
||||
return backup
|
||||
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
|
||||
@@ -129,9 +129,6 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
except OneDriveException:
|
||||
self.logger.debug("Failed to create folder", exc_info=True)
|
||||
errors["base"] = "folder_creation_error"
|
||||
else:
|
||||
if folder.description and folder.description != instance_id:
|
||||
errors[CONF_FOLDER_NAME] = "folder_already_in_use"
|
||||
if not errors:
|
||||
title = (
|
||||
f"{self.approot.created_by.user.display_name}'s OneDrive"
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"error": {
|
||||
"folder_already_in_use": "Folder already used for backups from another Home Assistant instance",
|
||||
"folder_creation_error": "Failed to create folder",
|
||||
"folder_rename_error": "Failed to rename folder"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ class OpenThermEntity(Entity):
|
||||
"""Represent an OpenTherm entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
entity_description: OpenThermEntityDescription
|
||||
|
||||
def __init__(
|
||||
@@ -61,6 +60,8 @@ class OpenThermEntity(Entity):
|
||||
class OpenThermStatusEntity(OpenThermEntity):
|
||||
"""Represent an OpenTherm entity that receives status updates."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to updates from the component."""
|
||||
self.async_on_remove(
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyotgw"],
|
||||
"requirements": ["pyotgw==2.2.2"]
|
||||
"requirements": ["pyotgw==2.2.3"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.5"]
|
||||
"requirements": ["opower==0.17.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["ical"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["ical==12.1.2"]
|
||||
"requirements": ["ical==12.1.3"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.18.1"]
|
||||
"requirements": ["reolink-aio==0.18.2"]
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientResponseError
|
||||
from httpx import HTTPStatusError, RequestError
|
||||
import jwt
|
||||
from pysenz import SENZAPI, Thermostat
|
||||
@@ -70,11 +71,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
) from err
|
||||
except ClientResponseError as err:
|
||||
if err.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.BAD_REQUEST):
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_auth_failed",
|
||||
) from err
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
) from err
|
||||
except RequestError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_ready",
|
||||
) from err
|
||||
except Exception as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_auth_failed",
|
||||
) from err
|
||||
|
||||
coordinator: SENZDataUpdateCoordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -81,6 +81,12 @@ class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
|
||||
serial_number=thermostat.serial_number,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._thermostat = self.coordinator.data[self._thermostat.serial_number]
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
|
||||
@@ -59,7 +59,6 @@ from .coordinator import (
|
||||
)
|
||||
from .repairs import (
|
||||
async_manage_ble_scanner_firmware_unsupported_issue,
|
||||
async_manage_coiot_unconfigured_issue,
|
||||
async_manage_deprecated_firmware_issue,
|
||||
async_manage_open_wifi_ap_issue,
|
||||
async_manage_outbound_websocket_incorrectly_enabled_issue,
|
||||
@@ -233,7 +232,6 @@ async def _async_setup_block_entry(
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, runtime_data.platforms
|
||||
)
|
||||
await async_manage_coiot_unconfigured_issue(hass, entry)
|
||||
remove_empty_sub_devices(hass, entry)
|
||||
elif (
|
||||
sleep_period is None
|
||||
|
||||
@@ -47,6 +47,7 @@ from .const import (
|
||||
ATTR_DEVICE,
|
||||
ATTR_GENERATION,
|
||||
BATTERY_DEVICES_WITH_PERMANENT_CONNECTION,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
CONF_BLE_SCANNER_MODE,
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
@@ -72,6 +73,7 @@ from .const import (
|
||||
)
|
||||
from .utils import (
|
||||
async_create_issue_unsupported_firmware,
|
||||
async_manage_coiot_issues_task,
|
||||
get_block_device_sleep_period,
|
||||
get_device_entry_gen,
|
||||
get_host,
|
||||
@@ -442,26 +444,19 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]):
|
||||
DOMAIN,
|
||||
PUSH_UPDATE_ISSUE_ID.format(unique=self.mac),
|
||||
)
|
||||
ir.async_delete_issue(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID.format(unique=self.mac),
|
||||
)
|
||||
self._push_update_failures = 0
|
||||
elif update_type is BlockUpdateType.COAP_REPLY:
|
||||
self._push_update_failures += 1
|
||||
if self._push_update_failures == MAX_PUSH_UPDATE_FAILURES:
|
||||
LOGGER.debug(
|
||||
"Creating issue %s", PUSH_UPDATE_ISSUE_ID.format(unique=self.mac)
|
||||
)
|
||||
ir.async_create_issue(
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
DOMAIN,
|
||||
PUSH_UPDATE_ISSUE_ID.format(unique=self.mac),
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1",
|
||||
translation_key="push_update_failure",
|
||||
translation_placeholders={
|
||||
"device_name": self.config_entry.title,
|
||||
"ip_address": self.device.ip_address,
|
||||
},
|
||||
async_manage_coiot_issues_task(self.hass, self.config_entry),
|
||||
"coiot_issues",
|
||||
)
|
||||
if self._push_update_failures:
|
||||
LOGGER.debug(
|
||||
|
||||
@@ -5,12 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioshelly.block_device import BlockDevice
|
||||
from aioshelly.const import (
|
||||
MODEL_OUT_PLUG_S_G3,
|
||||
MODEL_PLUG,
|
||||
MODEL_PLUG_S_G3,
|
||||
RPC_GENERATIONS,
|
||||
)
|
||||
from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3, RPC_GENERATIONS
|
||||
from aioshelly.exceptions import DeviceConnectionError, RpcCallError
|
||||
from aioshelly.rpc_device import RpcDevice
|
||||
from awesomeversion import AwesomeVersion
|
||||
@@ -24,7 +19,6 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from .const import (
|
||||
BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID,
|
||||
BLE_SCANNER_MIN_FIRMWARE,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
CONF_BLE_SCANNER_MODE,
|
||||
DEPRECATED_FIRMWARE_ISSUE_ID,
|
||||
DEPRECATED_FIRMWARES,
|
||||
@@ -162,50 +156,6 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
async def async_manage_coiot_unconfigured_issue(
|
||||
hass: HomeAssistant,
|
||||
entry: ShellyConfigEntry,
|
||||
) -> None:
|
||||
"""Manage the CoIoT unconfigured issue."""
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=entry.unique_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.runtime_data.block is not None
|
||||
|
||||
device = entry.runtime_data.block.device
|
||||
|
||||
if device.model == MODEL_PLUG:
|
||||
# Shelly Plug Gen 1 does not have CoIoT settings
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
return
|
||||
|
||||
coiot_config = device.settings["coiot"]
|
||||
coiot_enabled = coiot_config.get("enabled")
|
||||
|
||||
coiot_peer = f"{await get_coiot_address(hass)}:{get_coiot_port(hass)}"
|
||||
# Check if CoIoT is disabled or peer address is not correctly set
|
||||
if not coiot_enabled or (
|
||||
(peer_config := coiot_config.get("peer")) and peer_config != coiot_peer
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="coiot_unconfigured",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
return
|
||||
|
||||
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_manage_open_wifi_ap_issue(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -1283,6 +1283,7 @@ RPC_SENSORS: Final = {
|
||||
key="voltmeter",
|
||||
sub_key="xvoltage",
|
||||
translation_key="voltmeter_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda _, status, key: (status[key].get("xvoltage") is None),
|
||||
unit=lambda config: config["xvoltage"]["unit"] or None,
|
||||
),
|
||||
@@ -1300,6 +1301,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xpercent",
|
||||
translation_key="analog_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "analog"
|
||||
or config[key]["enable"] is False
|
||||
@@ -1344,6 +1346,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xfreq",
|
||||
translation_key="pulse_counter_frequency_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "count"
|
||||
or config[key]["enable"] is False
|
||||
|
||||
@@ -22,6 +22,7 @@ from aioshelly.const import (
|
||||
MODEL_EM3,
|
||||
MODEL_I3,
|
||||
MODEL_NAMES,
|
||||
MODEL_PLUG,
|
||||
RPC_GENERATIONS,
|
||||
)
|
||||
from aioshelly.rpc_device import RpcDevice, WsServer
|
||||
@@ -55,6 +56,7 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import (
|
||||
API_WS_URL,
|
||||
BASIC_INPUTS_EVENTS_TYPES,
|
||||
COIOT_UNCONFIGURED_ISSUE_ID,
|
||||
COMPONENT_ID_PATTERN,
|
||||
CONF_COAP_PORT,
|
||||
CONF_GEN,
|
||||
@@ -67,6 +69,7 @@ from .const import (
|
||||
GEN2_RELEASE_URL,
|
||||
LOGGER,
|
||||
MAX_SCRIPT_SIZE,
|
||||
PUSH_UPDATE_ISSUE_ID,
|
||||
ROLE_GENERIC,
|
||||
RPC_INPUTS_EVENTS_TYPES,
|
||||
SHAIR_MAX_WORK_HOURS,
|
||||
@@ -1004,3 +1007,86 @@ def is_rpc_ble_scanner_supported(entry: ConfigEntry) -> bool:
|
||||
entry.runtime_data.rpc_supports_scripts
|
||||
and not entry.runtime_data.rpc_zigbee_firmware
|
||||
)
|
||||
|
||||
|
||||
async def check_coiot_config(device: BlockDevice, hass: HomeAssistant) -> bool:
|
||||
"""Check if CoIoT is correctly configured."""
|
||||
if device.model == MODEL_PLUG:
|
||||
# Shelly Plug Gen 1 does not have CoIoT settings
|
||||
return True
|
||||
|
||||
coiot_config = device.settings["coiot"]
|
||||
|
||||
# Check if CoIoT is disabled
|
||||
if not coiot_config.get("enabled"):
|
||||
return False
|
||||
|
||||
coiot_address = await get_coiot_address(hass)
|
||||
if coiot_address is None:
|
||||
LOGGER.debug(
|
||||
"Skipping CoIoT peer check for device %s as no local address is available",
|
||||
device.name,
|
||||
)
|
||||
return True
|
||||
|
||||
coiot_peer = f"{coiot_address}:{get_coiot_port(hass)}"
|
||||
# Check if CoIoT address is not correctly set
|
||||
if (peer_config := coiot_config.get("peer")) and peer_config != coiot_peer:
|
||||
LOGGER.debug(
|
||||
"CoIoT is unconfigured for device %s, peer_config: %s, coiot_peer: %s",
|
||||
device.name,
|
||||
peer_config,
|
||||
coiot_peer,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_manage_coiot_issues_task(
|
||||
hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""CoIoT configuration or push updates issues task."""
|
||||
config_issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=entry.unique_id)
|
||||
push_updates_issue_id = PUSH_UPDATE_ISSUE_ID.format(unique=entry.unique_id)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.runtime_data.block is not None
|
||||
|
||||
device = entry.runtime_data.block.device
|
||||
|
||||
if await check_coiot_config(device, hass):
|
||||
# CoIoT is correctly configured, create push updates issue
|
||||
ir.async_delete_issue(hass, DOMAIN, config_issue_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
push_updates_issue_id,
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/shelly/#shelly-device-configuration-generation-1",
|
||||
translation_key="push_update_failure",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# CoIoT is not correctly configured, create config issue
|
||||
ir.async_delete_issue(hass, DOMAIN, push_updates_issue_id)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
config_issue_id,
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="coiot_unconfigured",
|
||||
translation_placeholders={
|
||||
"device_name": device.name,
|
||||
"ip_address": device.ip_address,
|
||||
},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
|
||||
@@ -284,6 +284,8 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
|
||||
try:
|
||||
battery = self._psutil.sensors_battery()
|
||||
_LOGGER.debug("battery: %s", battery)
|
||||
except (FileNotFoundError, PermissionError) as err:
|
||||
_LOGGER.debug("OS error when accessing battery sensors: %s", err)
|
||||
except (AttributeError, FileNotFoundError):
|
||||
_LOGGER.debug("OS does not provide battery sensors")
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ from .const import (
|
||||
CONF_CONFIG_ENTRY_ID,
|
||||
DEFAULT_API_ENDPOINT,
|
||||
DOMAIN,
|
||||
PARSER_HTML,
|
||||
PARSER_MD,
|
||||
PARSER_MD2,
|
||||
PARSER_PLAIN_TEXT,
|
||||
PLATFORM_BROADCAST,
|
||||
PLATFORM_POLLING,
|
||||
PLATFORM_WEBHOOKS,
|
||||
@@ -119,11 +123,16 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
ATTR_PARSER_SCHEMA = vol.All(
|
||||
cv.string,
|
||||
vol.In([PARSER_HTML, PARSER_MD, PARSER_MD2, PARSER_PLAIN_TEXT]),
|
||||
)
|
||||
|
||||
BASE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(ATTR_PARSER): cv.string,
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
|
||||
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
|
||||
vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean,
|
||||
@@ -236,7 +245,7 @@ SERVICE_SCHEMA_EDIT_MESSAGE = vol.All(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_PARSER): cv.string,
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
|
||||
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
|
||||
}
|
||||
@@ -253,6 +262,7 @@ SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All(
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CAPTION): cv.string,
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Required(ATTR_MEDIA_TYPE): vol.In(
|
||||
(
|
||||
str(InputMediaType.ANIMATION),
|
||||
@@ -279,6 +289,7 @@ SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema(
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Required(ATTR_CAPTION): cv.string,
|
||||
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
|
||||
|
||||
@@ -674,6 +674,8 @@ class TelegramNotificationService:
|
||||
"Error editing message media",
|
||||
params[ATTR_MESSAGE_TAG],
|
||||
media=media,
|
||||
caption=kwargs.get(ATTR_CAPTION),
|
||||
parse_mode=params[ATTR_PARSER],
|
||||
chat_id=chat_id,
|
||||
message_id=message_id,
|
||||
inline_message_id=inline_message_id,
|
||||
|
||||
@@ -16,6 +16,8 @@ from homeassistant.components.light import (
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
ATTR_TRANSITION,
|
||||
DEFAULT_MAX_KELVIN,
|
||||
DEFAULT_MIN_KELVIN,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
ENTITY_ID_FORMAT,
|
||||
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
|
||||
@@ -265,6 +267,8 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
|
||||
_entity_id_format = ENTITY_ID_FORMAT
|
||||
_optimistic_entity = True
|
||||
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
|
||||
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
|
||||
|
||||
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
|
||||
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
|
||||
@@ -856,7 +860,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
|
||||
try:
|
||||
if render in (None, "None", ""):
|
||||
self._attr_min_color_temp_kelvin = None
|
||||
self._attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
|
||||
return
|
||||
|
||||
self._attr_min_color_temp_kelvin = (
|
||||
@@ -867,14 +871,14 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
"Template must supply an integer temperature within the range for"
|
||||
" this light, or 'None'"
|
||||
)
|
||||
self._attr_min_color_temp_kelvin = None
|
||||
self._attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
|
||||
|
||||
@callback
|
||||
def _update_min_mireds(self, render):
|
||||
"""Update the min mireds from the template."""
|
||||
try:
|
||||
if render in (None, "None", ""):
|
||||
self._attr_max_color_temp_kelvin = None
|
||||
self._attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
|
||||
return
|
||||
|
||||
self._attr_max_color_temp_kelvin = (
|
||||
@@ -885,7 +889,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
|
||||
"Template must supply an integer temperature within the range for"
|
||||
" this light, or 'None'"
|
||||
)
|
||||
self._attr_max_color_temp_kelvin = None
|
||||
self._attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
|
||||
|
||||
@callback
|
||||
def _update_supports_transition(self, render):
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["uiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["uiprotect==10.0.1", "unifi-discovery==1.2.0"],
|
||||
"requirements": ["uiprotect==10.1.0", "unifi-discovery==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"requirements": ["pyvesync==3.4.1"]
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0b1"
|
||||
PATCH_VERSION: Final = "0b3"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
|
||||
@@ -2701,6 +2701,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"heatit": {
|
||||
"name": "Heatit",
|
||||
"iot_standards": [
|
||||
"zwave"
|
||||
]
|
||||
},
|
||||
"heatmiser": {
|
||||
"name": "Heatmiser",
|
||||
"integration_type": "hub",
|
||||
@@ -2712,6 +2718,13 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
},
|
||||
"heiman": {
|
||||
"name": "Heiman",
|
||||
"iot_standards": [
|
||||
"matter",
|
||||
"zigbee"
|
||||
]
|
||||
},
|
||||
"heiwa": {
|
||||
"name": "Heiwa",
|
||||
"integration_type": "virtual",
|
||||
|
||||
2
homeassistant/generated/labs.py
generated
2
homeassistant/generated/labs.py
generated
@@ -7,7 +7,7 @@ LABS_PREVIEW_FEATURES = {
|
||||
"analytics": {
|
||||
"snapshots": {
|
||||
"feedback_url": "https://forms.gle/GqvRmgmghSDco8M46",
|
||||
"learn_more_url": "",
|
||||
"learn_more_url": "https://www.home-assistant.io/blog/2026/02/02/about-device-database/",
|
||||
"report_issue_url": "https://github.com/OHF-Device-Database/device-database/issues/new",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260128.3
|
||||
home-assistant-frontend==20260128.5
|
||||
home-assistant-intents==2026.1.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.2.0b1"
|
||||
version = "2026.2.0b3"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
20
requirements_all.txt
generated
20
requirements_all.txt
generated
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.0.2
|
||||
aioamazondevices==11.1.1
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -1219,7 +1219,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.3
|
||||
home-assistant-frontend==20260128.5
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
@@ -1258,7 +1258,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==12.1.2
|
||||
ical==12.1.3
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -1288,7 +1288,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==2.0.1
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.11
|
||||
incomfort-client==0.6.12
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb-client==1.50.0
|
||||
@@ -1691,7 +1691,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.5
|
||||
opower==0.17.0
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1865,7 +1865,7 @@ pyElectra==1.2.4
|
||||
pyEmby==1.10
|
||||
|
||||
# homeassistant.components.hikvision
|
||||
pyHik==0.4.1
|
||||
pyHik==0.4.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.3.8
|
||||
@@ -2209,7 +2209,7 @@ pymata-express==1.19
|
||||
pymediaroom==0.6.5.4
|
||||
|
||||
# homeassistant.components.meteoclimatic
|
||||
pymeteoclimatic==0.1.0
|
||||
pymeteoclimatic==0.1.1
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pymicro-vad==1.0.1
|
||||
@@ -2296,7 +2296,7 @@ pyoppleio-legacy==1.0.8
|
||||
pyosoenergyapi==1.2.4
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==2.2.2
|
||||
pyotgw==2.2.3
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -2754,7 +2754,7 @@ renault-api==0.5.3
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.18.1
|
||||
reolink-aio==0.18.2
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -3094,7 +3094,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.6
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.0.1
|
||||
uiprotect==10.1.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
20
requirements_test_all.txt
generated
20
requirements_test_all.txt
generated
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.0.2
|
||||
aioamazondevices==11.1.1
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -1077,7 +1077,7 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.3
|
||||
home-assistant-frontend==20260128.5
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
@@ -1110,7 +1110,7 @@ ibeacon-ble==1.2.0
|
||||
# homeassistant.components.local_calendar
|
||||
# homeassistant.components.local_todo
|
||||
# homeassistant.components.remote_calendar
|
||||
ical==12.1.2
|
||||
ical==12.1.3
|
||||
|
||||
# homeassistant.components.caldav
|
||||
icalendar==6.3.1
|
||||
@@ -1134,7 +1134,7 @@ imeon_inverter_api==0.4.0
|
||||
imgw_pib==2.0.1
|
||||
|
||||
# homeassistant.components.incomfort
|
||||
incomfort-client==0.6.11
|
||||
incomfort-client==0.6.12
|
||||
|
||||
# homeassistant.components.influxdb
|
||||
influxdb-client==1.50.0
|
||||
@@ -1465,7 +1465,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.5
|
||||
opower==0.17.0
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1602,7 +1602,7 @@ pyDuotecno==2024.10.1
|
||||
pyElectra==1.2.4
|
||||
|
||||
# homeassistant.components.hikvision
|
||||
pyHik==0.4.1
|
||||
pyHik==0.4.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.3.8
|
||||
@@ -1874,7 +1874,7 @@ pymailgunner==1.4
|
||||
pymata-express==1.19
|
||||
|
||||
# homeassistant.components.meteoclimatic
|
||||
pymeteoclimatic==0.1.0
|
||||
pymeteoclimatic==0.1.1
|
||||
|
||||
# homeassistant.components.assist_pipeline
|
||||
pymicro-vad==1.0.1
|
||||
@@ -1946,7 +1946,7 @@ pyopnsense==0.4.0
|
||||
pyosoenergyapi==1.2.4
|
||||
|
||||
# homeassistant.components.opentherm_gw
|
||||
pyotgw==2.2.2
|
||||
pyotgw==2.2.3
|
||||
|
||||
# homeassistant.auth.mfa_modules.notify
|
||||
# homeassistant.auth.mfa_modules.totp
|
||||
@@ -2320,7 +2320,7 @@ renault-api==0.5.3
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.18.1
|
||||
reolink-aio==0.18.2
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
@@ -2591,7 +2591,7 @@ uasiren==0.0.1
|
||||
uhooapi==1.2.6
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
uiprotect==10.0.1
|
||||
uiprotect==10.1.0
|
||||
|
||||
# homeassistant.components.landisgyr_heat_meter
|
||||
ultraheat-api==0.5.7
|
||||
|
||||
@@ -46,6 +46,7 @@ TEST_DEVICE_1 = AmazonDevice(
|
||||
scale="CELSIUS",
|
||||
),
|
||||
},
|
||||
notifications_supported=True,
|
||||
notifications={
|
||||
NOTIFICATION_ALARM: AmazonSchedule(
|
||||
type=NOTIFICATION_ALARM,
|
||||
@@ -93,5 +94,6 @@ TEST_DEVICE_2 = AmazonDevice(
|
||||
scale="CELSIUS",
|
||||
)
|
||||
},
|
||||
notifications_supported=False,
|
||||
notifications={},
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
'type': 'Timer',
|
||||
}),
|
||||
}),
|
||||
'notifications_supported': True,
|
||||
'online': True,
|
||||
'sensors': dict({
|
||||
'dnd': dict({
|
||||
@@ -103,6 +104,7 @@
|
||||
'type': 'Timer',
|
||||
}),
|
||||
}),
|
||||
'notifications_supported': True,
|
||||
'online': True,
|
||||
'sensors': dict({
|
||||
'dnd': dict({
|
||||
|
||||
@@ -7,6 +7,7 @@ from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.alexa_devices.const import DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -134,3 +135,42 @@ async def test_alexa_dnd_group_removal(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not hass.states.get(entity.entity_id)
|
||||
|
||||
|
||||
async def test_alexa_unsupported_notification_sensor_removal(
|
||||
hass: HomeAssistant,
|
||||
mock_amazon_devices_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test notification sensors are removed from devices that do not support them."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)},
|
||||
name=mock_config_entry.title,
|
||||
manufacturer="Amazon",
|
||||
model=SPEAKER_GROUP_MODEL,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
entity = entity_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
SENSOR_DOMAIN,
|
||||
unique_id=f"{TEST_DEVICE_1_SN}-Timer",
|
||||
device_id=device.id,
|
||||
config_entry=mock_config_entry,
|
||||
has_entity_name=True,
|
||||
)
|
||||
|
||||
mock_amazon_devices_client.get_devices_data.return_value[
|
||||
TEST_DEVICE_1_SN
|
||||
].notifications_supported = False
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not hass.states.get(entity.entity_id)
|
||||
|
||||
@@ -387,7 +387,7 @@ async def test_model_list(
|
||||
},
|
||||
{
|
||||
"label": "Claude Haiku 3.5",
|
||||
"value": "claude-3-5-haiku-latest",
|
||||
"value": "claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
"label": "Claude Haiku 3",
|
||||
@@ -500,7 +500,7 @@ async def test_model_list_error(
|
||||
CONF_LLM_HASS_API: [],
|
||||
},
|
||||
{
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-20241022",
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
},
|
||||
{
|
||||
@@ -513,7 +513,7 @@ async def test_model_list_error(
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-20241022",
|
||||
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 10,
|
||||
@@ -581,6 +581,7 @@ async def test_model_list_error(
|
||||
CONF_TEMPERATURE: 0.3,
|
||||
CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL],
|
||||
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import dateutil.parser
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -135,42 +134,64 @@ async def test_manual_update_entity(
|
||||
assert state.state == "15.0"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_request_status", [MOCK_MINIMAL_STATUS], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("mock_request_status", "entity_id", "known_status"),
|
||||
[
|
||||
pytest.param(
|
||||
# Even though the "LASTSTEST" field is not available, we should still create the entity.
|
||||
MOCK_MINIMAL_STATUS,
|
||||
"sensor.apc_ups_last_self_test",
|
||||
MOCK_MINIMAL_STATUS | {"LASTSTEST": "1970-01-01 00:00:00 +0000"},
|
||||
id="last_self_test_missing",
|
||||
),
|
||||
pytest.param(
|
||||
MOCK_MINIMAL_STATUS | {"XOFFBATT": "N/A"},
|
||||
"sensor.apc_ups_transfer_from_battery",
|
||||
MOCK_MINIMAL_STATUS | {"XOFFBATT": "1970-01-01 00:00:00 +0000"},
|
||||
id="xoffbatt_na",
|
||||
),
|
||||
pytest.param(
|
||||
MOCK_MINIMAL_STATUS | {"XOFFBATT": "invalid-time-string"},
|
||||
"sensor.apc_ups_transfer_from_battery",
|
||||
MOCK_MINIMAL_STATUS | {"XOFFBATT": "1970-01-01 00:00:00 +0000"},
|
||||
id="xoffbatt_invalid_time_string",
|
||||
),
|
||||
],
|
||||
indirect=["mock_request_status"],
|
||||
)
|
||||
async def test_sensor_unknown(
|
||||
hass: HomeAssistant,
|
||||
mock_request_status: AsyncMock,
|
||||
entity_id: str,
|
||||
known_status: dict[str, str],
|
||||
) -> None:
|
||||
"""Test if our integration can properly mark certain sensors as unknown when it becomes so."""
|
||||
ups_mode_id = "sensor.apc_ups_mode"
|
||||
last_self_test_id = "sensor.apc_ups_last_self_test"
|
||||
"""Test if our integration can properly mark certain sensors as known/unknown when it becomes so."""
|
||||
base_status = mock_request_status.return_value
|
||||
|
||||
assert hass.states.get(ups_mode_id).state == MOCK_MINIMAL_STATUS["UPSMODE"]
|
||||
# Last self test sensor should be added even if our status does not report it initially (it is
|
||||
# a sensor that appears only after a periodical or manual self test is performed).
|
||||
assert hass.states.get(last_self_test_id) is not None
|
||||
assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN
|
||||
# The state should be unknown initially.
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
# Simulate an event (a self test) such that "LASTSTEST" field is being reported, the state of
|
||||
# the sensor should be properly updated with the corresponding value.
|
||||
last_self_test_value = "1970-01-01 00:00:00 +0000"
|
||||
mock_request_status.return_value = MOCK_MINIMAL_STATUS | {
|
||||
"LASTSTEST": last_self_test_value
|
||||
}
|
||||
# Update to a payload that should make the entity known.
|
||||
mock_request_status.return_value = known_status
|
||||
future = utcnow() + timedelta(minutes=2)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
hass.states.get(last_self_test_id).state
|
||||
== dateutil.parser.parse(last_self_test_value).isoformat()
|
||||
)
|
||||
|
||||
# Simulate another event (e.g., daemon restart) such that "LASTSTEST" is no longer reported.
|
||||
mock_request_status.return_value = MOCK_MINIMAL_STATUS
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNKNOWN
|
||||
|
||||
# Revert back to the initial status, and the state should now be unknown again.
|
||||
mock_request_status.return_value = base_status
|
||||
future = utcnow() + timedelta(minutes=2)
|
||||
async_fire_time_changed(hass, future)
|
||||
await hass.async_block_till_done()
|
||||
# The state should become unknown again.
|
||||
assert hass.states.get(last_self_test_id).state == STATE_UNKNOWN
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("entity_key", "issue_key"), DEPRECATED_SENSORS.items())
|
||||
|
||||
@@ -206,6 +206,17 @@ async def test_prepare_chat_for_generation_appends_attachments(
|
||||
assert response["messages"] is messages
|
||||
mock_prepare_files_for_prompt.assert_awaited_once_with([attachment])
|
||||
|
||||
# Verify that files are actually added to the last user message
|
||||
last_message = messages[-1]
|
||||
assert last_message["type"] == "message"
|
||||
assert last_message["role"] == "user"
|
||||
assert isinstance(last_message["content"], list)
|
||||
assert last_message["content"][0] == {
|
||||
"type": "input_text",
|
||||
"text": "Describe the door",
|
||||
}
|
||||
assert last_message["content"][1] == files[0]
|
||||
|
||||
|
||||
async def test_prepare_chat_for_generation_passes_messages_through(
|
||||
hass: HomeAssistant, cloud_entity: BaseCloudLLMEntity
|
||||
|
||||
@@ -44,7 +44,12 @@ from homeassistant.components.number import (
|
||||
SERVICE_SET_VALUE,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
STATE_UNAVAILABLE,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -757,3 +762,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "number.oven_setpoint_temperature"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.NUMBER,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE}",
|
||||
suggested_object_id="oven_setpoint_temperature",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -44,6 +44,7 @@ from homeassistant.components.select import (
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
SERVICE_SELECT_OPTION,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
@@ -1105,3 +1106,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "select.washer_temperature"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.SELECT,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE}",
|
||||
suggested_object_id="washer_temperature",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -38,6 +38,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_RESTORED,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
@@ -878,3 +879,42 @@ async def test_options_available_when_program_is_null(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
|
||||
async def test_restore_option_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
client: MagicMock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
appliance: HomeAppliance,
|
||||
) -> None:
|
||||
"""Test restoration of option entities when program options are missing.
|
||||
|
||||
This test ensures that number entities representing options are restored
|
||||
to the entity registry and set to unavailable if the current available
|
||||
program does not include them, but they existed previously.
|
||||
"""
|
||||
entity_id = "switch.dishwasher_half_load"
|
||||
client.get_available_program = AsyncMock(
|
||||
return_value=ProgramDefinition(
|
||||
ProgramKey.UNKNOWN,
|
||||
options=[],
|
||||
)
|
||||
)
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
Platform.SWITCH,
|
||||
DOMAIN,
|
||||
f"{appliance.ha_id}-{OptionKey.DISHCARE_DISHWASHER_HALF_LOAD}",
|
||||
suggested_object_id="dishwasher_half_load",
|
||||
)
|
||||
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
assert not state.attributes.get(ATTR_RESTORED)
|
||||
|
||||
@@ -4,16 +4,19 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.knx.const import KNX_ADDRESS, FanConf
|
||||
from homeassistant.components.knx.const import DOMAIN, KNX_ADDRESS, FanConf
|
||||
from homeassistant.components.knx.schema import FanSchema
|
||||
from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import KnxEntityGenerator
|
||||
from .conftest import KNXTestKit
|
||||
|
||||
|
||||
async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
async def test_fan_percent(
|
||||
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test KNX fan with percentage speed."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
@@ -23,6 +26,9 @@ async def test_fan_percent(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
}
|
||||
}
|
||||
)
|
||||
entry = entity_registry.async_get("fan.test")
|
||||
assert entry
|
||||
assert entry.unique_id == "1/2/3"
|
||||
|
||||
# turn on fan with default speed (50%)
|
||||
await hass.services.async_call(
|
||||
@@ -109,7 +115,9 @@ async def test_fan_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
await knx.assert_telegram_count(0)
|
||||
|
||||
|
||||
async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
async def test_fan_switch(
|
||||
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test KNX fan with switch only."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
@@ -119,6 +127,9 @@ async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
}
|
||||
}
|
||||
)
|
||||
entry = entity_registry.async_get("fan.test")
|
||||
assert entry
|
||||
assert entry.unique_id == "1/2/3"
|
||||
|
||||
# turn on fan
|
||||
await hass.services.async_call(
|
||||
@@ -133,7 +144,9 @@ async def test_fan_switch(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
await knx.assert_write("1/2/3", False)
|
||||
|
||||
|
||||
async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
async def test_fan_switch_step(
|
||||
hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry
|
||||
) -> None:
|
||||
"""Test KNX fan with speed steps and switch address."""
|
||||
await knx.setup_integration(
|
||||
{
|
||||
@@ -145,6 +158,9 @@ async def test_fan_switch_step(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
}
|
||||
}
|
||||
)
|
||||
entry = entity_registry.async_get("fan.test")
|
||||
assert entry
|
||||
assert entry.unique_id == "1/1/1"
|
||||
|
||||
# turn on fan without percentage - actuator sets default speed
|
||||
await hass.services.async_call(
|
||||
@@ -216,6 +232,53 @@ async def test_fan_oscillation(hass: HomeAssistant, knx: KNXTestKit) -> None:
|
||||
assert state.attributes.get("oscillating") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fan_config",
|
||||
[
|
||||
{
|
||||
# before fix: unique_id is 'None', after fix: from switch address
|
||||
CONF_NAME: "test",
|
||||
FanSchema.CONF_SWITCH_ADDRESS: "1/2/3",
|
||||
},
|
||||
{
|
||||
# no change in unique_id here, but since no YAML to update from, the
|
||||
# invalid registry entry will be removed - it wouldn't be loaded anyway
|
||||
# this YAML will create a new entry (same unique_id as above for tests)
|
||||
CONF_NAME: "test",
|
||||
KNX_ADDRESS: "1/2/3",
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_fan_unique_id_fix(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXTestKit,
|
||||
entity_registry: er.EntityRegistry,
|
||||
fan_config: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test KNX fan unique_id migration fix."""
|
||||
invalid_unique_id = "None"
|
||||
knx.mock_config_entry.add_to_hass(hass)
|
||||
entity_registry.async_get_or_create(
|
||||
object_id_base="test",
|
||||
disabled_by=None,
|
||||
domain=Platform.FAN,
|
||||
platform=DOMAIN,
|
||||
unique_id=invalid_unique_id,
|
||||
config_entry=knx.mock_config_entry,
|
||||
)
|
||||
await knx.setup_integration(
|
||||
{FanSchema.PLATFORM: fan_config},
|
||||
add_entry_to_hass=False,
|
||||
)
|
||||
entry = entity_registry.async_get("fan.test")
|
||||
assert entry
|
||||
assert entry.unique_id == "1/2/3"
|
||||
# Verify the old entity with invalid unique_id has been updated or removed
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
Platform.FAN, DOMAIN, invalid_unique_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knx_data", "expected_read_response", "expected_state"),
|
||||
[
|
||||
|
||||
@@ -353,6 +353,56 @@
|
||||
'state': 'own_program',
|
||||
})
|
||||
# ---
|
||||
# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.rinse_aid_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -1826,6 +1876,56 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.rinse_aid_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -2485,6 +2585,56 @@
|
||||
'state': '-18.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.rinse_aid_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -3374,6 +3524,56 @@
|
||||
'state': 'plate_step_boost',
|
||||
})
|
||||
# ---
|
||||
# name: test_hob_sensor_states[platforms0-hob.json][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_hob_sensor_states[platforms0-hob.json][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_hob_sensor_states[platforms0-hob.json][sensor.rinse_aid_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -4970,6 +5170,56 @@
|
||||
'state': '19.54',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states[platforms0][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states[platforms0][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states[platforms0][sensor.refrigerator-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -7702,6 +7952,56 @@
|
||||
'state': '19.54',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states_api_push[platforms0][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states_api_push[platforms0][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_states_api_push[platforms0][sensor.refrigerator-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
@@ -9138,6 +9438,56 @@
|
||||
'state': '0.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.powerdisk_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'PowerDisk level',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'PowerDisk level',
|
||||
'platform': 'miele',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'power_disk_level',
|
||||
'unique_id': 'Dummy_Appliance_5-power_disk_level',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.powerdisk_level-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'PowerDisk level',
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.powerdisk_level',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '80.5',
|
||||
})
|
||||
# ---
|
||||
# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.rinse_aid_level-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Fixtures for OneDrive tests."""
|
||||
|
||||
from collections.abc import AsyncIterator, Generator
|
||||
from html import escape
|
||||
from json import dumps
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -146,7 +145,6 @@ def mock_folder() -> Folder:
|
||||
name="name",
|
||||
size=0,
|
||||
child_count=0,
|
||||
description="9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0",
|
||||
parent_reference=ItemParentReference(
|
||||
drive_id="mock_drive_id", id="id", path="path"
|
||||
),
|
||||
@@ -182,9 +180,9 @@ def mock_backup_file() -> File:
|
||||
def mock_metadata_file() -> File:
|
||||
"""Return a mocked metadata file."""
|
||||
return File(
|
||||
id="id",
|
||||
name="23e64aec.tar",
|
||||
size=34519040,
|
||||
id="metadata_id",
|
||||
name="23e64aec.metadata.json",
|
||||
size=1024,
|
||||
parent_reference=ItemParentReference(
|
||||
drive_id="mock_drive_id", id="id", path="path"
|
||||
),
|
||||
@@ -192,15 +190,6 @@ def mock_metadata_file() -> File:
|
||||
quick_xor_hash="hash",
|
||||
),
|
||||
mime_type="application/x-tar",
|
||||
description=escape(
|
||||
dumps(
|
||||
{
|
||||
"metadata_version": 2,
|
||||
"backup_id": "23e64aec",
|
||||
"backup_file_id": "id",
|
||||
}
|
||||
)
|
||||
),
|
||||
created_by=IDENTITY_SET,
|
||||
)
|
||||
|
||||
@@ -227,6 +216,7 @@ def mock_onedrive_client(
|
||||
yield b"backup data"
|
||||
|
||||
async def read(self) -> bytes:
|
||||
# Metadata contains just the backup metadata (no backup_file_id)
|
||||
return dumps(BACKUP_METADATA).encode()
|
||||
|
||||
client.download_drive_item.return_value = MockStreamReader()
|
||||
|
||||
@@ -209,7 +209,10 @@ async def test_agents_upload(
|
||||
assert resp.status == 201
|
||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||
mock_large_file_upload_client.assert_called_once()
|
||||
mock_onedrive_client.update_drive_item.assert_called_once()
|
||||
# upload_file should be called for the metadata file
|
||||
mock_onedrive_client.upload_file.assert_called_once()
|
||||
# update_drive_item should not be called (no description updates)
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 0
|
||||
|
||||
|
||||
async def test_agents_upload_corrupt_upload(
|
||||
@@ -284,42 +287,6 @@ async def test_agents_upload_metadata_upload_failed(
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 0
|
||||
|
||||
|
||||
async def test_agents_upload_metadata_metadata_failed(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_large_file_upload_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test metadata upload on file description update."""
|
||||
client = await hass_client()
|
||||
test_backup = AgentBackup.from_dict(BACKUP_METADATA)
|
||||
mock_onedrive_client.update_drive_item.side_effect = OneDriveException("test")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.BackupManager.async_get_backup",
|
||||
) as fetch_backup,
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=test_backup,
|
||||
),
|
||||
patch("pathlib.Path.open") as mocked_open,
|
||||
):
|
||||
mocked_open.return_value.read = Mock(side_effect=[b"test", b""])
|
||||
fetch_backup.return_value = test_backup
|
||||
resp = await client.post(
|
||||
f"/api/backup/upload?agent_id={DOMAIN}.{mock_config_entry.unique_id}",
|
||||
data={"file": StringIO("test")},
|
||||
)
|
||||
|
||||
assert resp.status == 201
|
||||
assert f"Uploading backup {test_backup.backup_id}" in caplog.text
|
||||
mock_large_file_upload_client.assert_called_once()
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 1
|
||||
assert mock_onedrive_client.delete_drive_item.call_count == 2
|
||||
|
||||
|
||||
async def test_agents_download(
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_onedrive_client: MagicMock,
|
||||
|
||||
@@ -4,7 +4,7 @@ from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
from onedrive_personal_sdk.models.items import AppRoot, Folder, ItemUpdate
|
||||
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -141,49 +141,6 @@ async def test_full_flow_with_owner_not_found(
|
||||
mock_onedrive_client.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_folder_already_in_use(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
mock_setup_entry: AsyncMock,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_instance_id: AsyncMock,
|
||||
mock_folder: Folder,
|
||||
) -> None:
|
||||
"""Ensure a folder that is already in use is not allowed."""
|
||||
|
||||
mock_folder.description = "1234"
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
await _do_get_token(hass, result, hass_client_no_auth, aioclient_mock)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {CONF_FOLDER_NAME: "folder_already_in_use"}
|
||||
|
||||
# clear error and try again
|
||||
mock_onedrive_client.create_folder.return_value.description = mock_instance_id
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], {CONF_FOLDER_NAME: "myFolder"}
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "John Doe's OneDrive"
|
||||
assert result["result"].unique_id == "mock_drive_id"
|
||||
assert result["data"][CONF_TOKEN][CONF_ACCESS_TOKEN] == "mock-access-token"
|
||||
assert result["data"][CONF_TOKEN]["refresh_token"] == "mock-refresh-token"
|
||||
assert result["data"][CONF_FOLDER_NAME] == "myFolder"
|
||||
assert result["data"][CONF_FOLDER_ID] == "my_folder_id"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_error_during_folder_creation(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -11,7 +11,7 @@ from onedrive_personal_sdk.exceptions import (
|
||||
NotFoundError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder, ItemUpdate
|
||||
from onedrive_personal_sdk.models.items import AppRoot, Drive, File, Folder
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -28,7 +28,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
)
|
||||
|
||||
from . import setup_integration
|
||||
from .const import BACKUP_METADATA, INSTANCE_ID
|
||||
from .const import BACKUP_METADATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -128,38 +128,27 @@ async def test_get_integration_folder_creation_error(
|
||||
assert "Failed to get backups_123 folder" in caplog.text
|
||||
|
||||
|
||||
async def test_update_instance_id_description(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_folder: Folder,
|
||||
) -> None:
|
||||
"""Test we write the instance id to the folder."""
|
||||
mock_folder.description = ""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_onedrive_client.update_drive_item.assert_called_with(
|
||||
mock_folder.id, ItemUpdate(description=INSTANCE_ID)
|
||||
)
|
||||
|
||||
|
||||
async def test_migrate_metadata_files(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_onedrive_client: MagicMock,
|
||||
mock_backup_file: File,
|
||||
) -> None:
|
||||
"""Test migration of metadata files."""
|
||||
"""Test migration of metadata files from v1 to v2.
|
||||
|
||||
V1 stored metadata in the backup file's description field.
|
||||
V2 stores metadata in a separate .metadata.json file.
|
||||
"""
|
||||
mock_backup_file.description = escape(
|
||||
dumps({**BACKUP_METADATA, "metadata_version": 1})
|
||||
)
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Should upload a new metadata file
|
||||
mock_onedrive_client.upload_file.assert_called_once()
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 2
|
||||
assert mock_onedrive_client.update_drive_item.call_args[1]["data"].description == ""
|
||||
# No longer updates descriptions (we don't rely on them anymore)
|
||||
assert mock_onedrive_client.update_drive_item.call_count == 0
|
||||
|
||||
|
||||
async def test_migrate_metadata_files_errors(
|
||||
|
||||
@@ -93,7 +93,7 @@ async def test_upload_service(
|
||||
|
||||
assert response
|
||||
assert response["files"]
|
||||
assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "id"
|
||||
assert cast(list[dict[str, Any]], response["files"])[0]["id"] == "metadata_id"
|
||||
|
||||
|
||||
async def test_upload_service_no_response(
|
||||
|
||||
@@ -98,7 +98,7 @@ async def test_migrate_config_entry(
|
||||
(
|
||||
time.time() - 3600,
|
||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||
ConfigEntryState.SETUP_ERROR,
|
||||
ConfigEntryState.SETUP_RETRY,
|
||||
),
|
||||
],
|
||||
ids=["unauthorized", "internal_server_error"],
|
||||
|
||||
@@ -22,6 +22,7 @@ from homeassistant.components.shelly.const import (
|
||||
CONF_GEN,
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
MAX_PUSH_UPDATE_FAILURES,
|
||||
REST_SENSORS_UPDATE_INTERVAL,
|
||||
RPC_SENSORS_POLLING_INTERVAL,
|
||||
)
|
||||
@@ -71,6 +72,16 @@ async def init_integration(
|
||||
return entry
|
||||
|
||||
|
||||
async def mock_block_device_push_update_failure(
|
||||
hass: HomeAssistant, mock_block_device: Mock
|
||||
) -> None:
|
||||
"""Create updates with COAP_REPLY indicating push update failure for block device."""
|
||||
for _ in range(MAX_PUSH_UPDATE_FAILURES):
|
||||
mock_block_device.mock_update_reply()
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
||||
def mutate_rpc_device_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mock_rpc_device: Mock,
|
||||
|
||||
@@ -39,7 +39,11 @@ MOCK_SETTINGS = {
|
||||
"num_inputs": 3,
|
||||
"num_outputs": 2,
|
||||
},
|
||||
"coiot": {"update_period": 15},
|
||||
"coiot": {
|
||||
"update_period": 15,
|
||||
"enabled": True,
|
||||
"peer": "10.10.10.10:5683",
|
||||
},
|
||||
"fw": "20201124-092159/v1.9.0@57ac4ad8",
|
||||
"inputs": [
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ from homeassistant.components.shelly.const import (
|
||||
CONF_SLEEP_PERIOD,
|
||||
DOMAIN,
|
||||
ENTRY_RELOAD_COOLDOWN,
|
||||
MAX_PUSH_UPDATE_FAILURES,
|
||||
RPC_RECONNECT_INTERVAL,
|
||||
UPDATE_PERIOD_MULTIPLIER,
|
||||
BLEScannerMode,
|
||||
@@ -36,6 +35,7 @@ from . import (
|
||||
MOCK_MAC,
|
||||
init_integration,
|
||||
inject_rpc_device_event,
|
||||
mock_block_device_push_update_failure,
|
||||
mock_polling_rpc_update,
|
||||
mock_rest_update,
|
||||
register_device,
|
||||
@@ -332,15 +332,7 @@ async def test_block_device_push_updates_failure(
|
||||
) -> None:
|
||||
"""Test block device with push updates failure."""
|
||||
await init_integration(hass, 1)
|
||||
|
||||
# Updates with COAP_REPLAY type should create an issue
|
||||
for _ in range(MAX_PUSH_UPDATE_FAILURES):
|
||||
mock_block_device.mock_update_reply()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain=DOMAIN, issue_id=f"push_update_{MOCK_MAC}"
|
||||
)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
# An update with COAP_PERIODIC type should clear the issue
|
||||
mock_block_device.mock_update()
|
||||
|
||||
@@ -52,7 +52,13 @@ async def test_block_config_entry_diagnostics(
|
||||
"model": MODEL_25,
|
||||
"sw_version": "some fw string",
|
||||
},
|
||||
"device_settings": {"coiot": {"update_period": 15}},
|
||||
"device_settings": {
|
||||
"coiot": {
|
||||
"update_period": 15,
|
||||
"enabled": True,
|
||||
"peer": "10.10.10.10:5683",
|
||||
}
|
||||
},
|
||||
"device_status": MOCK_STATUS_COAP,
|
||||
"last_error": "DeviceConnectionError()",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test repairs handling for Shelly."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY
|
||||
@@ -14,6 +15,7 @@ from homeassistant.components.shelly.const import (
|
||||
DOMAIN,
|
||||
OPEN_WIFI_AP_ISSUE_ID,
|
||||
OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID,
|
||||
PUSH_UPDATE_ISSUE_ID,
|
||||
BLEScannerMode,
|
||||
DeprecatedFirmwareInfo,
|
||||
)
|
||||
@@ -22,7 +24,7 @@ from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import MOCK_MAC, init_integration
|
||||
from . import MOCK_MAC, init_integration, mock_block_device_push_update_failure
|
||||
|
||||
from tests.components.repairs import (
|
||||
async_process_repairs_platforms,
|
||||
@@ -505,23 +507,28 @@ async def test_other_fixable_issues(
|
||||
assert result["type"] == "create_entry"
|
||||
|
||||
|
||||
async def test_coiot_missing_or_wrong_peer_issue(
|
||||
@pytest.mark.parametrize(
|
||||
"coiot",
|
||||
[
|
||||
{"enabled": False, "update_period": 15, "peer": "10.10.10.10:5683"},
|
||||
{"enabled": True, "update_period": 15, "peer": "7.7.7.7:5683"},
|
||||
],
|
||||
)
|
||||
async def test_coiot_disabled_or_wrong_peer_issue(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_block_device: Mock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
coiot: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test repair issues handling wrong or missing CoIoT configuration."""
|
||||
monkeypatch.setitem(
|
||||
mock_block_device.settings,
|
||||
"coiot",
|
||||
{"enabled": False, "update_period": 15, "peer": "wrong.peer.address:5683"},
|
||||
)
|
||||
"""Test repair issues handling wrong or disabled CoIoT configuration."""
|
||||
monkeypatch.setitem(mock_block_device.settings, "coiot", coiot)
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
@@ -551,16 +558,17 @@ async def test_coiot_exception(
|
||||
issue_registry: ir.IssueRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test no repair issues handling up-to-date CoIoT configuration."""
|
||||
"""Test CoIoT exception handling in fix flow."""
|
||||
monkeypatch.setitem(
|
||||
mock_block_device.settings,
|
||||
"coiot",
|
||||
{"enabled": True, "update_period": 15, "peer": "correct.peer.address:5683"},
|
||||
{"enabled": False, "update_period": 15, "peer": "7.7.7.7:5683"},
|
||||
)
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
@@ -598,12 +606,7 @@ async def test_coiot_configured_no_issue_created(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
raw_url: str,
|
||||
) -> None:
|
||||
"""Test no repair issues when CoIoT configuration is missing."""
|
||||
monkeypatch.setitem(
|
||||
mock_block_device.settings,
|
||||
"coiot",
|
||||
{"enabled": True, "update_period": 15, "peer": "10.10.10.10:5683"},
|
||||
)
|
||||
"""Test no repair issues when CoIoT configuration is valid."""
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
with patch(
|
||||
@@ -612,6 +615,7 @@ async def test_coiot_configured_no_issue_created(
|
||||
):
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
@@ -635,23 +639,47 @@ async def test_coiot_key_missing_no_issue_created(
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_coiot_no_hass_url(
|
||||
async def test_coiot_push_issue_when_missing_hass_url(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_block_device: Mock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test no repair issues handling up-to-date CoIoT configuration."""
|
||||
"""Test CoIoT push update issue created when HA URL is not available."""
|
||||
issue_id = PUSH_UPDATE_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.shelly.utils.get_url",
|
||||
side_effect=NoURLAvailableError(),
|
||||
):
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
|
||||
async def test_coiot_fix_flow_no_hass_url(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_block_device: Mock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test CoIoT repair issue when HA URL is not available."""
|
||||
monkeypatch.setitem(
|
||||
mock_block_device.settings,
|
||||
"coiot",
|
||||
{"enabled": True, "update_period": 15, "peer": "correct.peer.address:5683"},
|
||||
{"enabled": False, "update_period": 15, "peer": "7.7.7.7:5683"},
|
||||
)
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
@@ -688,11 +716,17 @@ async def test_coiot_issue_ignore(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test ignoring the CoIoT unconfigured issue."""
|
||||
monkeypatch.setitem(
|
||||
mock_block_device.settings,
|
||||
"coiot",
|
||||
{"enabled": False, "update_period": 15, "peer": "7.7.7.7:5683"},
|
||||
)
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
@@ -714,17 +748,19 @@ async def test_coiot_issue_ignore(
|
||||
assert issue.dismissed_version
|
||||
|
||||
|
||||
async def test_coiot_plug_1_no_issue_created(
|
||||
async def test_plug_1_push_update_issue_created(
|
||||
hass: HomeAssistant,
|
||||
mock_block_device: Mock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test no repair issues when device is Shelly Plug 1."""
|
||||
|
||||
mock_block_device.model = MODEL_PLUG
|
||||
issue_id = COIOT_UNCONFIGURED_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
"""Test push update repair issue when device is Shelly Plug 1."""
|
||||
monkeypatch.setattr(mock_block_device, "model", MODEL_PLUG)
|
||||
issue_id = PUSH_UPDATE_ISSUE_ID.format(unique=MOCK_MAC)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await hass.async_block_till_done()
|
||||
await init_integration(hass, 1)
|
||||
await init_integration(hass, 1, model=MODEL_PLUG)
|
||||
await mock_block_device_push_update_failure(hass, mock_block_device)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
@@ -389,6 +389,44 @@ async def test_exception_handling_disk_sensor(
|
||||
assert disk_sensor.attributes["unit_of_measurement"] == "%"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.freeze_time("2024-02-24 15:00:00", tz_offset=0)
|
||||
@pytest.mark.parametrize("exception_class", [FileNotFoundError, PermissionError])
|
||||
async def test_exception_handling_battery_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_psutil: Mock,
|
||||
mock_os: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception_class: type[Exception],
|
||||
) -> None:
|
||||
"""Test the battery failures."""
|
||||
mock_psutil.sensors_battery.side_effect = exception_class(
|
||||
"[Errno 2] No such file or directory: '/sys/class/power_supply'"
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery"))
|
||||
assert temp_entity.state == STATE_UNAVAILABLE
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery_empty"))
|
||||
assert temp_entity.state == STATE_UNAVAILABLE
|
||||
|
||||
assert "OS error when accessing battery sensors" in caplog.text
|
||||
|
||||
mock_psutil.sensors_battery.side_effect = None
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery"))
|
||||
assert temp_entity.state == "93"
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery_empty"))
|
||||
assert temp_entity.state == "2024-02-24T19:38:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_cpu_percentage_is_zero_returns_unknown(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -18,7 +18,7 @@ from telegram import (
|
||||
Message,
|
||||
Update,
|
||||
)
|
||||
from telegram.constants import ChatType, InputMediaType, ParseMode
|
||||
from telegram.constants import ChatType, InputMediaType
|
||||
from telegram.error import (
|
||||
InvalidToken,
|
||||
NetworkError,
|
||||
@@ -65,6 +65,9 @@ from homeassistant.components.telegram_bot.const import (
|
||||
CHAT_ACTION_TYPING,
|
||||
CONF_CONFIG_ENTRY_ID,
|
||||
DOMAIN,
|
||||
PARSER_HTML,
|
||||
PARSER_MD,
|
||||
PARSER_MD2,
|
||||
PARSER_PLAIN_TEXT,
|
||||
PLATFORM_BROADCAST,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
@@ -139,7 +142,7 @@ async def test_polling_platform_init(
|
||||
{
|
||||
ATTR_KEYBOARD: ["/command1, /command2", "/command3"],
|
||||
ATTR_MESSAGE: "test_message",
|
||||
ATTR_PARSER: ParseMode.HTML,
|
||||
ATTR_PARSER: PARSER_HTML,
|
||||
ATTR_DISABLE_NOTIF: True,
|
||||
ATTR_DISABLE_WEB_PREV: True,
|
||||
ATTR_MESSAGE_TAG: "mock_tag",
|
||||
@@ -1151,6 +1154,7 @@ async def test_edit_message_media(
|
||||
ATTR_MESSAGEID: 12345,
|
||||
ATTR_CHAT_ID: 123456,
|
||||
ATTR_KEYBOARD_INLINE: "/mock",
|
||||
ATTR_PARSER: PARSER_MD,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
@@ -1159,6 +1163,7 @@ async def test_edit_message_media(
|
||||
mock.assert_called_once()
|
||||
assert mock.call_args[1]["media"].__class__.__name__ == expected_media_class
|
||||
assert mock.call_args[1]["media"].caption == "mock caption"
|
||||
assert mock.call_args[1]["parse_mode"] == PARSER_MD
|
||||
assert mock.call_args[1]["chat_id"] == 123456
|
||||
assert mock.call_args[1]["message_id"] == 12345
|
||||
assert mock.call_args[1]["reply_markup"] == InlineKeyboardMarkup(
|
||||
@@ -1183,12 +1188,26 @@ async def test_edit_message(
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_EDIT_MESSAGE,
|
||||
{ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345},
|
||||
{
|
||||
ATTR_MESSAGE: "mock message",
|
||||
ATTR_CHAT_ID: 123456,
|
||||
ATTR_MESSAGEID: 12345,
|
||||
ATTR_PARSER: PARSER_PLAIN_TEXT,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
mock.assert_called_once()
|
||||
mock.assert_called_once_with(
|
||||
"mock message",
|
||||
chat_id=123456,
|
||||
message_id=12345,
|
||||
inline_message_id=None,
|
||||
parse_mode=None,
|
||||
disable_web_page_preview=None,
|
||||
reply_markup=None,
|
||||
read_timeout=None,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.telegram_bot.bot.Bot.edit_message_caption",
|
||||
@@ -1197,12 +1216,25 @@ async def test_edit_message(
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_EDIT_CAPTION,
|
||||
{ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345},
|
||||
{
|
||||
ATTR_CAPTION: "mock caption",
|
||||
ATTR_CHAT_ID: 123456,
|
||||
ATTR_MESSAGEID: 12345,
|
||||
ATTR_PARSER: PARSER_MD2,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
mock.assert_called_once()
|
||||
mock.assert_called_once_with(
|
||||
chat_id=123456,
|
||||
message_id=12345,
|
||||
inline_message_id=None,
|
||||
caption="mock caption",
|
||||
reply_markup=None,
|
||||
read_timeout=None,
|
||||
parse_mode=PARSER_MD2,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.telegram_bot.bot.Bot.edit_message_reply_markup",
|
||||
|
||||
Reference in New Issue
Block a user