Compare commits

..

35 Commits

Author SHA1 Message Date
Bram Kragten
fdf8edf474 Bump version to 2026.2.0b3 2026-02-03 18:03:54 +01:00
Bram Kragten
47e1a98bee Update frontend to 20260128.5 (#162156) 2026-02-03 18:03:04 +01:00
Joost Lekkerkerker
2d8572b943 Add Heatit virtual brand (#162155) 2026-02-03 18:03:02 +01:00
Joost Lekkerkerker
660cfdbd50 Add Heiman virtual brand (#162152) 2026-02-03 18:03:00 +01:00
Steven Travers
4208595da6 Modify Analytics text on feature labs (#162151) 2026-02-03 18:02:59 +01:00
Paul Bottein
b6b2d2fc6f Update title and description of YAML dashboard repair (#162138) 2026-02-03 18:02:58 +01:00
victorigualada
6c4c632848 Handle chat log attachments in Cloud integration (#162121) 2026-02-03 18:02:57 +01:00
Shay Levy
78cf62176f Fix Shelly xpercent sensor state_class (#162107) 2026-02-03 18:02:56 +01:00
Denis Shulyaka
df971c7a42 Anthropic: Switch default model to Haiku 4.5 (#162093) 2026-02-03 18:02:55 +01:00
mezz64
1fcabb7f2d Bump pyhik to 0.4.2 (#162092)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-03 18:02:53 +01:00
Åke Strandberg
9fb60c9ea2 Update Senz temperature sensor (#162016) 2026-02-03 18:02:52 +01:00
J. Diego Rodríguez Royo
9c11a4646f Remove coffee machine's hot water sensor's state class at Home Connect (#161246) 2026-02-03 17:58:47 +01:00
jameson_uk
b036a78776 Remove invalid notification sensors for Alexa devices (#160422)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-02-03 17:58:45 +01:00
Kamil Breguła
60bb3cb704 Handle missing battery stats in systemmonitor (#158287)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 17:58:43 +01:00
Bram Kragten
0e770958ac Bump version to 2026.2.0b2 2026-02-02 19:12:33 +01:00
Bram Kragten
2a54c71b6c Update frontend to 20260128.4 (#162096) 2026-02-02 19:11:59 +01:00
Steven Travers
50463291ab Add learn more data for Analytics in labs (#162094) 2026-02-02 19:11:59 +01:00
Andrea Turri
43cc34042a Fix Miele dishwasher PowerDisk filling level sensor not showing up (#162048) 2026-02-02 19:11:58 +01:00
Jan Bouwhuis
a02244ccda Bump incomfort-client to 0.6.12 (#162037) 2026-02-02 19:11:57 +01:00
Adrián Moreno
a739619121 Bump pymeteoclimatic to 0.1.1 (#162029) 2026-02-02 19:11:56 +01:00
Åke Strandberg
5db97a5f1c Improved error checking during startup of SENZ (#162026) 2026-02-02 19:11:54 +01:00
Josef Zweck
804ba9c9cc Remove file description dependency in onedrive (#162012) 2026-02-02 19:11:53 +01:00
Filip Bårdsnes Tomren
5ecbcea946 Update ical requirement version to 12.1.3 (#162010) 2026-02-02 19:11:52 +01:00
hanwg
11be2b6289 Fix parse_mode for Telegram bot actions (#162006) 2026-02-02 19:11:51 +01:00
cdnninja
eefae0307b Add integration type of hub to vesync (#162004) 2026-02-02 19:11:50 +01:00
Matthias Alphart
d397ee28ea Fix KNX fan unique_id for switch-only fans (#162002) 2026-02-02 19:11:49 +01:00
starkillerOG
02c821128e Bump reolink-aio to 0.18.2 (#161998) 2026-02-02 19:11:48 +01:00
Shay Levy
71dc15d45f Fix Shelly CoIoT repair issue (#161973)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-02 19:11:47 +01:00
Raphael Hehl
1078387b22 Bump uiprotect to version 10.1.0 (#161967)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-02-02 19:11:46 +01:00
tronikos
35fab27d15 Bump opower to 0.17.0 (#161962) 2026-02-02 19:11:45 +01:00
Yuxin Wang
915dc7a908 Mark datetime sensors as unknown when parsing fails (#161952) 2026-02-02 19:11:44 +01:00
mvn23
e5a9738983 Fix OpenTherm Gateway button availability (#161933) 2026-02-02 19:11:43 +01:00
mvn23
2ff73219a2 Bump pyotgw to 2.2.3 (#161928) 2026-02-02 19:11:42 +01:00
epenet
5dc1270ed1 Fix mired warning in template light (#161923) 2026-02-02 19:11:41 +01:00
J. Diego Rodríguez Royo
9e95ad5a85 Restore the Home Connect program option entities (#156401)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-02 19:11:40 +01:00
83 changed files with 1304 additions and 439 deletions

View File

@@ -0,0 +1,5 @@
{
"domain": "heatit",
"name": "Heatit",
"iot_standards": ["zwave"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "heiman",
"name": "Heiman",
"iot_standards": ["matter", "zigbee"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==11.0.2"]
"requirements": ["aioamazondevices==11.1.1"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["pyhik"],
"quality_scale": "legacy",
"requirements": ["pyHik==0.4.1"]
"requirements": ["pyHik==0.4.2"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["meteoclimatic"],
"requirements": ["pymeteoclimatic==0.1.0"]
"requirements": ["pymeteoclimatic==0.1.1"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyotgw"],
"requirements": ["pyotgw==2.2.2"]
"requirements": ["pyotgw==2.2.3"]
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.16.5"]
"requirements": ["opower==0.17.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==12.1.2"]
"requirements": ["ical==12.1.3"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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