Compare commits

...

31 Commits

Author SHA1 Message Date
Franck Nijhof
28027ddca4 2026.2.1 (#162450) 2026-02-06 22:44:07 +01:00
Franck Nijhof
fe0d7b3cca Bump version to 2026.2.1 2026-02-06 20:49:26 +00:00
jameson_uk
0dcc4e9527 dep: bump aioamazondevices to 11.1.3 (#162437) 2026-02-06 20:47:38 +00:00
Artur Pragacz
b13b189703 Make bad entity ID detection more lenient (#162425) 2026-02-06 20:47:37 +00:00
epenet
150829f599 Fix invalid yardian snaphots (#162422) 2026-02-06 20:47:36 +00:00
Joost Lekkerkerker
57dd9d9c23 Remove double unit of measurement for yardian (#162412) 2026-02-06 20:47:34 +00:00
Sab44
e2056cb12c Bump librehardwaremonitor-api to version 1.9.1 (#162409) 2026-02-06 20:47:33 +00:00
Joost Lekkerkerker
fa2c8992cf Remove entity id overwrite for ambient station (#162403) 2026-02-06 20:47:32 +00:00
Matt Zimmerman
ddf5c7fe3a Add missing config flow strings to SmartTub (#162375)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:31 +00:00
Matt Zimmerman
7034ed6d3f Bump python-smarttub to 0.0.47 (#162367)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 20:47:29 +00:00
Aaron Godfrey
9015b53c1b Fix conversion of data for todo.* actions (#162366) 2026-02-06 20:47:28 +00:00
Jordan Harvey
1cfa6561f7 Update pynintendoparental requirement to version 2.3.2.1 (#162362) 2026-02-06 20:47:27 +00:00
Shay Levy
eead02dcca Fix Shelly Linkedgo Thermostat status update (#162339) 2026-02-06 20:47:26 +00:00
Arie Catsman
456e51a221 Bump pyenphase to 2.4.5 (#162324) 2026-02-06 20:47:25 +00:00
Luo Chen
5d984ce186 Fix unicode escaping in MCP server tool response (#162319)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-06 20:47:24 +00:00
Oliver
61f45489ac Add mapping for stopped state to denonavr media player (#162283) 2026-02-06 20:47:23 +00:00
Tomás Correia
f72c643b38 Fix multipart upload to use consistent part sizes for R2/S3 (#162278) 2026-02-06 20:47:22 +00:00
Oliver
27bc26e886 Bump denonavr to 1.3.2 (#162271) 2026-02-06 20:47:20 +00:00
Thomas55555
0e9f03cbc1 Bump google_air_quality_api to 3.0.1 (#162233) 2026-02-06 20:47:19 +00:00
David Bonnes
9480c33fb0 Bump evohome-async to 1.1.3 (#162232) 2026-02-06 20:47:18 +00:00
Jonathan
3e6b8663e8 Fix device_class of backup reserve sensor (#161178) 2026-02-06 20:47:17 +00:00
epenet
1c69a83793 Fix redundant off preset in Tuya climate (#161040) 2026-02-06 20:47:16 +00:00
Franck Nijhof
3e8923f105 2026.2.0 (#162224) 2026-02-04 20:35:11 +01:00
Franck Nijhof
17cca3e69d Bump version to 2026.2.0 2026-02-04 18:53:49 +00:00
Franck Nijhof
12714c489f Bump version to 2026.2.0b5 2026-02-04 18:45:36 +00:00
Robert Resch
f788d61b4a Revert "Bump intents (#162205)" (#162226) 2026-02-04 18:36:12 +00:00
Simone Chemelli
5c726af00b Fix logic and tests for Alexa Devices utils module (#162223) 2026-02-04 18:36:10 +00:00
Joost Lekkerkerker
d1d207fbb2 Add guard for Apple TV text focus state (#162207) 2026-02-04 18:36:09 +00:00
David Bonnes
6c7f8df7f7 Fix evohome not updating scheduled setpoints in state attrs (#162043) 2026-02-04 18:36:07 +00:00
Kevin Stillhammer
6f8c9b1504 Bump fressnapftracker to 0.2.2 (#161913) 2026-02-04 18:36:06 +00:00
Kevin Stillhammer
4f9aedbc84 Filter out invalid trackers in fressnapf_tracker (#161670) 2026-02-04 18:36:04 +00:00
55 changed files with 1486 additions and 260 deletions

View File

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

View File

@@ -59,13 +59,15 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# Replace unique id for "DND" switch and remove from Speaker Group
await async_update_unique_id(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
# DND keys
old_key = "do_not_disturb"
new_key = "dnd"
# Remove DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator)
# Remove old DND switch from virtual groups
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
# Replace unique id for DND switch
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
known_devices: set[str] = set()

View File

@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
async def async_update_unique_id(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
domain: str,
platform: str,
old_key: str,
new_key: str,
) -> None:
@@ -63,7 +63,9 @@ async def async_update_unique_id(
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
if entity_id := entity_registry.async_get_entity_id(
DOMAIN, platform, unique_id
):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
@@ -74,12 +76,13 @@ async def async_update_unique_id(
async def async_remove_dnd_from_virtual_group(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
key: str,
) -> None:
"""Remove entity DND from virtual group."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-do_not_disturb"
unique_id = f"{serial_num}-{key}"
entity_id = entity_registry.async_get_entity_id(
DOMAIN, SWITCH_DOMAIN, unique_id
)
@@ -104,7 +107,7 @@ async def async_remove_unsupported_notification_sensors(
):
unique_id = f"{serial_num}-{notification_key}"
entity_id = entity_registry.async_get_entity_id(
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
)
is_unsupported = not coordinator.data[serial_num].notifications_supported

View File

@@ -26,10 +26,9 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AmbientStation, AmbientStationConfigEntry
from . import AmbientStationConfigEntry
from .const import ATTR_LAST_DATA, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
from .entity import AmbientWeatherEntity
@@ -683,22 +682,6 @@ async def async_setup_entry(
class AmbientWeatherSensor(AmbientWeatherEntity, SensorEntity):
"""Define an Ambient sensor."""
def __init__(
self,
ambient: AmbientStation,
mac_address: str,
station_name: str,
description: EntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(ambient, mac_address, station_name, description)
if description.key == TYPE_SOLARRADIATION_LX:
# Since TYPE_SOLARRADIATION and TYPE_SOLARRADIATION_LX will have the same
# name in the UI, we influence the entity ID of TYPE_SOLARRADIATION_LX here
# to differentiate them:
self.entity_id = f"sensor.{station_name}_solar_rad_lx"
@callback
def update_from_latest_data(self) -> None:
"""Fetch new state data for the sensor."""

View File

@@ -2,15 +2,16 @@
from __future__ import annotations
from pyatv.const import KeyboardFocusState
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
from pyatv.interface import AppleTV, KeyboardListener
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AppleTvConfigEntry
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
from .entity import AppleTVEntity
@@ -21,10 +22,22 @@ async def async_setup_entry(
) -> None:
"""Load Apple TV binary sensor based on a config entry."""
# apple_tv config entries always have a unique id
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
manager = config_entry.runtime_data
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
cb: CALLBACK_TYPE
def setup_entities(atv: AppleTV) -> None:
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
assert config_entry.unique_id is not None
name: str = config_entry.data[CONF_NAME]
async_add_entities(
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
)
cb()
cb = async_dispatcher_connect(
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
)
config_entry.async_on_unload(cb)
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):

View File

@@ -196,44 +196,46 @@ class R2BackupAgent(BackupAgent):
)
upload_id = multipart_upload["UploadId"]
try:
parts = []
parts: list[dict[str, Any]] = []
part_number = 1
buffer_size = 0 # bytes
buffer: list[bytes] = []
buffer = bytearray() # bytes buffer to store the data
stream = await open_stream()
async for chunk in stream:
buffer_size += len(chunk)
buffer.append(chunk)
buffer.extend(chunk)
# upload parts of exactly MULTIPART_MIN_PART_SIZE_BYTES to ensure
# all non-trailing parts have the same size (required by S3/R2)
while len(buffer) >= MULTIPART_MIN_PART_SIZE_BYTES:
part_data = bytes(buffer[:MULTIPART_MIN_PART_SIZE_BYTES])
del buffer[:MULTIPART_MIN_PART_SIZE_BYTES]
# If buffer size meets minimum part size, upload it as a part
if buffer_size >= MULTIPART_MIN_PART_SIZE_BYTES:
_LOGGER.debug(
"Uploading part number %d, size %d", part_number, buffer_size
"Uploading part number %d, size %d",
part_number,
len(part_data),
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
Body=part_data,
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
part_number += 1
buffer_size = 0
buffer = []
# Upload the final buffer as the last part (no minimum size requirement)
if buffer:
_LOGGER.debug(
"Uploading final part number %d, size %d", part_number, buffer_size
"Uploading final part number %d, size %d", part_number, len(buffer)
)
part = await self._client.upload_part(
Bucket=self._bucket,
Key=self._with_prefix(tar_filename),
PartNumber=part_number,
UploadId=upload_id,
Body=b"".join(buffer),
Body=bytes(buffer),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
}

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["denonavr"],
"requirements": ["denonavr==1.3.1"],
"requirements": ["denonavr==1.3.2"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",

View File

@@ -17,6 +17,7 @@ from denonavr.const import (
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STOPPED,
)
from denonavr.exceptions import (
AvrCommandError,
@@ -103,6 +104,7 @@ DENON_STATE_MAPPING = {
STATE_OFF: MediaPlayerState.OFF,
STATE_PLAYING: MediaPlayerState.PLAYING,
STATE_PAUSED: MediaPlayerState.PAUSED,
STATE_STOPPED: MediaPlayerState.IDLE,
}

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.3"],
"requirements": ["pyenphase==2.4.5"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
self._device_state_attrs = {
"activeFaults": self._evo_device.active_faults,
"setpoints": self._setpoints,
"setpoints": self.setpoints,
}
super()._handle_coordinator_update()

View File

@@ -4,7 +4,7 @@
"codeowners": ["@zxdavb"],
"documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"loggers": ["evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.0.6"]
"requirements": ["evohome-async==1.1.3"]
}

View File

@@ -1,11 +1,22 @@
"""The Fressnapf Tracker integration."""
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
import logging
from fressnapftracker import (
ApiClient,
AuthClient,
Device,
FressnapfTrackerAuthenticationError,
FressnapfTrackerError,
FressnapfTrackerInvalidTrackerResponseError,
Tracker,
)
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import CONF_USER_ID, DOMAIN
from .coordinator import (
@@ -21,6 +32,43 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
]
_LOGGER = logging.getLogger(__name__)
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
"""Test if the tracker returns valid data and return it.
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
"""
client = ApiClient(
serial_number=device.serialnumber,
device_token=device.token,
client=get_async_client(hass),
)
try:
return await client.get_tracker()
except FressnapfTrackerInvalidTrackerResponseError:
_LOGGER.warning(
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
device.serialnumber,
)
async_create_issue(
hass,
DOMAIN,
f"invalid_fressnapf_tracker_{device.serialnumber}",
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="invalid_fressnapf_tracker",
translation_placeholders={
"tracker_id": device.serialnumber,
},
)
return None
except FressnapfTrackerError as err:
raise ConfigEntryNotReady(err) from err
async def async_setup_entry(
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
@@ -40,12 +88,15 @@ async def async_setup_entry(
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
for device in devices:
tracker = await _get_valid_tracker(hass, device)
if tracker is None:
continue
coordinator = FressnapfTrackerDataUpdateCoordinator(
hass,
entry,
device,
initial_data=tracker,
)
await coordinator.async_config_entry_first_refresh()
coordinators.append(coordinator)
entry.runtime_data = coordinators

View File

@@ -34,6 +34,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
hass: HomeAssistant,
config_entry: FressnapfTrackerConfigEntry,
device: Device,
initial_data: Tracker,
) -> None:
"""Initialize."""
super().__init__(
@@ -49,6 +50,7 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
device_token=device.token,
client=get_async_client(hass),
)
self.data = initial_data
async def _async_update_data(self) -> Tracker:
try:

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["fressnapftracker==0.2.1"]
"requirements": ["fressnapftracker==0.2.2"]
}

View File

@@ -92,5 +92,11 @@
"not_seen_recently": {
"message": "The flashlight cannot be activated when the tracker has not moved recently."
}
},
"issues": {
"invalid_fressnapf_tracker": {
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
"title": "Invalid Fressnapf GPS tracker detected"
}
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_air_quality_api"],
"quality_scale": "bronze",
"requirements": ["google_air_quality_api==3.0.0"]
"requirements": ["google_air_quality_api==3.0.1"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.8.4"]
"requirements": ["librehardwaremonitor-api==1.9.1"]
}

View File

@@ -110,7 +110,7 @@ async def create_server(
return [
types.TextContent(
type="text",
text=json.dumps(tool_response),
text=json.dumps(tool_response, ensure_ascii=False),
)
]

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["pynintendoauth", "pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2"]
"requirements": ["pynintendoauth==1.0.2", "pynintendoparental==2.3.2.1"]
}

View File

@@ -104,7 +104,6 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
)
config = coordinator.device.config
self._status = coordinator.device.status
self._attr_min_temp = config[key]["min"]
self._attr_max_temp = config[key]["max"]
@@ -142,6 +141,11 @@ class RpcLinkedgoThermostatClimate(ShellyRpcAttributeEntity, ClimateEntity):
THERMOSTAT_TO_HA_MODE[mode] for mode in modes
]
@property
def _status(self) -> dict[str, Any]:
"""Return the full device status."""
return self.coordinator.device.status
@property
def current_humidity(self) -> float | None:
"""Return the current humidity."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
"requirements": ["python-smarttub==0.0.46"]
"requirements": ["python-smarttub==0.0.47"]
}

View File

@@ -9,6 +9,14 @@
},
"step": {
"reauth_confirm": {
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "[%key:component::smarttub::config::step::user::data_description::email%]",
"password": "[%key:component::smarttub::config::step::user::data_description::password%]"
},
"description": "The SmartTub integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
},
@@ -17,6 +25,10 @@
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"email": "The email address associated with your SmartTub account",
"password": "The password for your SmartTub account"
},
"description": "Enter your SmartTub email address and password to log in",
"title": "Login"
}

View File

@@ -436,7 +436,6 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="vpp_backup_reserve_percent",
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(key="version"),

View File

@@ -45,9 +45,9 @@ def _task_api_data(item: TodoItem, api_data: Task | None = None) -> dict[str, An
}
if due := item.due:
if isinstance(due, datetime.datetime):
item_data["due_datetime"] = due.isoformat()
item_data["due_datetime"] = due
else:
item_data["due_date"] = due.isoformat()
item_data["due_date"] = due
# In order to not lose any recurrence metadata for the task, we need to
# ensure that we send the `due_string` param if the task has it set.
# NOTE: It's ok to send stale data for non-recurring tasks. Any provided

View File

@@ -48,6 +48,7 @@ TUYA_HVAC_TO_HA = {
"heat": HVACMode.HEAT,
"hot": HVACMode.HEAT,
"manual": HVACMode.HEAT_COOL,
"off": HVACMode.OFF,
"wet": HVACMode.DRY,
"wind": HVACMode.FAN_ONLY,
}
@@ -442,7 +443,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
if hvac_mode_wrapper:
self._attr_hvac_modes = [HVACMode.OFF]
for mode in hvac_mode_wrapper.options:
self._attr_hvac_modes.append(HVACMode(mode))
if mode != HVACMode.OFF:
# OFF is always added first
self._attr_hvac_modes.append(HVACMode(mode))
elif switch_wrapper:
self._attr_hvac_modes = [

View File

@@ -61,7 +61,6 @@ SENSOR_DESCRIPTIONS: tuple[YardianSensorEntityDescription, ...] = (
YardianSensorEntityDescription(
key="active_zone_count",
translation_key="active_zone_count",
native_unit_of_measurement="zones",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda coordinator: len(coordinator.data.active_zones),
),

View File

@@ -38,7 +38,7 @@
"sensor": {
"active_zone_count": {
"name": "Active zones",
"unit_of_measurement": "Zones"
"unit_of_measurement": "zones"
},
"rain_delay": {
"name": "Rain delay"

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "0b4"
PATCH_VERSION: Final = "1"
__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

@@ -42,6 +42,7 @@ from . import device_registry as dr, entity_registry as er, service, translation
from .deprecation import deprecated_function
from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider
from .event import async_call_later
from .frame import report_usage
from .issue_registry import IssueSeverity, async_create_issue
from .typing import UNDEFINED, ConfigType, DiscoveryInfoType, VolDictType, VolSchemaType
@@ -822,13 +823,28 @@ class EntityPlatform:
# An entity may suggest the entity_id by setting entity_id itself
if not hasattr(entity, "internal_integration_suggested_object_id"):
if entity.entity_id is not None and not valid_entity_id(entity.entity_id):
if entity.unique_id is not None:
report_usage(
f"sets an invalid entity ID: '{entity.entity_id}'. "
"In most cases, entities should not set entity_id,"
" but if they do, it should be a valid entity ID.",
integration_domain=self.platform_name,
breaks_in_ha_version="2027.2.0",
)
else:
entity.add_to_platform_abort()
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
try:
entity.internal_integration_suggested_object_id = (
split_entity_id(entity.entity_id)[1]
if entity.entity_id is not None
else None
)
except ValueError:
entity.add_to_platform_abort()
raise HomeAssistantError(f"Invalid entity ID: {entity.entity_id}")
entity.internal_integration_suggested_object_id = (
split_entity_id(entity.entity_id)[1]
if entity.entity_id is not None
else None
)
raise HomeAssistantError(
f"Invalid entity ID: {entity.entity_id}"
) from None
# Get entity_id from unique ID registration
if entity.unique_id is not None:

View File

@@ -40,7 +40,7 @@ hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.6
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.2.0b4"
version = "2026.2.1"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

2
requirements.txt generated
View File

@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6

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.1.1
aioamazondevices==11.1.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -797,7 +797,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.3.1
denonavr==1.3.2
# homeassistant.components.devialet
devialet==1.5.7
@@ -938,7 +938,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
evohome-async==1.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -1011,7 +1011,7 @@ freebox-api==1.3.0
freesms==0.2.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1222,7 +1222,7 @@ holidays==0.84
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1397,7 +1397,7 @@ libpyfoscamcgi==0.0.9
libpyvivotek==0.6.1
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.8.4
librehardwaremonitor-api==1.9.1
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -2032,7 +2032,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.3
pyenphase==2.4.5
# homeassistant.components.envisalink
pyenvisalink==4.7
@@ -2251,7 +2251,7 @@ pynina==1.0.2
pynintendoauth==1.0.2
# homeassistant.components.nintendo_parental_controls
pynintendoparental==2.3.2
pynintendoparental==2.3.2.1
# homeassistant.components.nobo_hub
pynobo==1.8.1
@@ -2597,7 +2597,7 @@ python-ripple-api==0.0.3
python-roborock==4.8.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
python-smarttub==0.0.47
# homeassistant.components.snoo
python-snoo==0.8.3

View File

@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.5
# homeassistant.components.alexa_devices
aioamazondevices==11.1.1
aioamazondevices==11.1.3
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -706,7 +706,7 @@ deluge-client==1.10.2
demetriek==1.3.0
# homeassistant.components.denonavr
denonavr==1.3.1
denonavr==1.3.2
# homeassistant.components.devialet
devialet==1.5.7
@@ -826,7 +826,7 @@ eternalegypt==0.0.18
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
evohome-async==1.0.6
evohome-async==1.1.3
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -890,7 +890,7 @@ forecast-solar==4.2.0
freebox-api==1.3.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
google-photos-library-api==0.12.1
# homeassistant.components.google_air_quality
google_air_quality_api==3.0.0
google_air_quality_api==3.0.1
# homeassistant.components.slide
# homeassistant.components.slide_local
@@ -1080,7 +1080,7 @@ holidays==0.84
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.2.3
home-assistant-intents==2026.1.28
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1228,7 +1228,7 @@ libpyfoscamcgi==0.0.9
libpyvivotek==0.6.1
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.8.4
librehardwaremonitor-api==1.9.1
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1730,7 +1730,7 @@ pyegps==0.2.5
pyemoncms==0.1.3
# homeassistant.components.enphase_envoy
pyenphase==2.4.3
pyenphase==2.4.5
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1907,7 +1907,7 @@ pynina==1.0.2
pynintendoauth==1.0.2
# homeassistant.components.nintendo_parental_controls
pynintendoparental==2.3.2
pynintendoparental==2.3.2.1
# homeassistant.components.nobo_hub
pynobo==1.8.1
@@ -2187,7 +2187,7 @@ python-rabbitair==0.0.8
python-roborock==4.8.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
python-smarttub==0.0.47
# homeassistant.components.snoo
python-snoo==0.8.3

View File

@@ -81,8 +81,8 @@ async def test_alexa_unique_id_migration(
)
entity = entity_registry.async_get_or_create(
SWITCH_DOMAIN,
DOMAIN,
SWITCH_DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,

View File

@@ -367,6 +367,57 @@ async def test_agents_upload_network_failure(
assert "Upload failed for cloudflare_r2" in caplog.text
async def test_multipart_upload_consistent_part_sizes(
hass: HomeAssistant,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that multipart upload uses consistent part sizes.
S3/R2 requires all non-trailing parts to have the same size. This test
verifies that varying chunk sizes still result in consistent part sizes.
"""
agent = R2BackupAgent(hass, mock_config_entry)
# simulate varying chunk data sizes
# total data: 12 + 12 + 10 + 12 + 5 = 51 MiB
chunk_sizes = [12, 12, 10, 12, 5] # in units of 1 MiB
mib = 2**20
async def mock_stream():
for size in chunk_sizes:
yield b"x" * (size * mib)
async def open_stream():
return mock_stream()
# Record the sizes of each uploaded part
uploaded_part_sizes: list[int] = []
async def record_upload_part(**kwargs):
body = kwargs.get("Body", b"")
uploaded_part_sizes.append(len(body))
return {"ETag": f"etag-{len(uploaded_part_sizes)}"}
mock_client.upload_part.side_effect = record_upload_part
await agent._upload_multipart("test.tar", open_stream)
# Verify that all non-trailing parts have the same size
assert len(uploaded_part_sizes) >= 2, "Expected at least 2 parts"
non_trailing_parts = uploaded_part_sizes[:-1]
assert all(size == MULTIPART_MIN_PART_SIZE_BYTES for size in non_trailing_parts), (
f"All non-trailing parts should be {MULTIPART_MIN_PART_SIZE_BYTES} bytes, got {non_trailing_parts}"
)
# Verify the trailing part contains the remainder
total_data = sum(chunk_sizes) * mib
expected_trailing = total_data % MULTIPART_MIN_PART_SIZE_BYTES
if expected_trailing == 0:
expected_trailing = MULTIPART_MIN_PART_SIZE_BYTES
assert uploaded_part_sizes[-1] == expected_trailing
async def test_agents_download(
hass_client: ClientSessionGenerator,
mock_client: MagicMock,

View File

@@ -168,7 +168,7 @@ async def setup_evohome(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
mock_post_request(install),
),
patch("evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
patch("_evohome.auth.AbstractAuth._make_request", mock_make_request(install)),
):
evo: EvohomeClient | None = None

View File

@@ -167,6 +167,836 @@
),
])
# ---
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Bathroom Dn',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432579',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.bathroom_dn',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Bathroom Dn',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432579',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.bathroom_dn',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.dead_zone-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': None,
'friendly_name': 'Dead Zone',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': False,
}),
'zone_id': '3432521',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.dead_zone',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.dead_zone-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': None,
'friendly_name': 'Dead Zone',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': False,
}),
'zone_id': '3432521',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.dead_zone',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.front_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Front Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'temporary',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0,
'until': '2022-03-07T19:00:00+00:00',
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432577',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 21.0,
}),
'context': <ANY>,
'entity_id': 'climate.front_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.front_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Front Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'temporary',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'TemporaryOverride',
'target_heat_temperature': 21.0,
'until': '2022-03-07T19:00:00+00:00',
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432577',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 21.0,
}),
'context': <ANY>,
'entity_id': 'climate.front_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kids_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Kids Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3449703',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kids_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kids_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Kids Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3449703',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kids_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kitchen-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Kitchen',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432578',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kitchen',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.kitchen-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 20.0,
'friendly_name': 'Kitchen',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 20.0,
}),
'zone_id': '3432578',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.kitchen',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_bedroom-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.0,
'friendly_name': 'Main Bedroom',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 21.0,
}),
'zone_id': '3432580',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_bedroom',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_bedroom-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 21.0,
'friendly_name': 'Main Bedroom',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'none',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'FollowSchedule',
'target_heat_temperature': 16.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 21.0,
}),
'zone_id': '3432580',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_bedroom',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Main Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432576',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.main_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.0,
'friendly_name': 'Main Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 17.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.0,
}),
'zone_id': '3432576',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 17.0,
}),
'context': <ANY>,
'entity_id': 'climate.main_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.my_home-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.7,
'friendly_name': 'My Home',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'icon': 'mdi:thermostat',
'max_temp': 35,
'min_temp': 7,
'preset_mode': 'eco',
'preset_modes': list([
'Reset',
'eco',
'away',
'home',
'Custom',
]),
'status': dict({
'activeSystemFaults': tuple(
),
'system_id': '3432522',
'system_mode_status': dict({
'is_permanent': True,
'mode': 'AutoWithEco',
}),
}),
'supported_features': <ClimateEntityFeature: 400>,
}),
'context': <ANY>,
'entity_id': 'climate.my_home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.my_home-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.7,
'friendly_name': 'My Home',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'icon': 'mdi:thermostat',
'max_temp': 35,
'min_temp': 7,
'preset_mode': 'eco',
'preset_modes': list([
'Reset',
'eco',
'away',
'home',
'Custom',
]),
'status': dict({
'activeSystemFaults': tuple(
),
'system_id': '3432522',
'system_mode_status': dict({
'is_permanent': True,
'mode': 'AutoWithEco',
}),
}),
'supported_features': <ClimateEntityFeature: 400>,
}),
'context': <ANY>,
'entity_id': 'climate.my_home',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.spare_room-state-initial]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Spare Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 14.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.1,
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 15.9,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3450733',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 14.0,
}),
'context': <ANY>,
'entity_id': 'climate.spare_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_entities_update_over_time[default][climate.spare_room-state-updated]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 19.5,
'friendly_name': 'Spare Room',
'hvac_modes': list([
<HVACMode.OFF: 'off'>,
<HVACMode.HEAT: 'heat'>,
]),
'max_temp': 35.0,
'min_temp': 5.0,
'preset_mode': 'permanent',
'preset_modes': list([
'none',
'temporary',
'permanent',
]),
'status': dict({
'activeFaults': tuple(
),
'setpoint_status': dict({
'setpoint_mode': 'PermanentOverride',
'target_heat_temperature': 14.0,
}),
'setpoints': dict({
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'next_sp_temp': 18.6,
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
'this_sp_temp': 16.0,
}),
'temperature_status': dict({
'is_available': True,
'temperature': 19.5,
}),
'zone_id': '3450733',
}),
'supported_features': <ClimateEntityFeature: 401>,
'temperature': 14.0,
}),
'context': <ANY>,
'entity_id': 'climate.spare_room',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_setup_platform[botched][climate.bathroom_dn-state]
StateSnapshot({
'attributes': ReadOnlyDict({

View File

@@ -5,6 +5,7 @@ All evohome systems have controllers and at least one zone.
from __future__ import annotations
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
@@ -32,6 +33,8 @@ from homeassistant.exceptions import HomeAssistantError
from .conftest import setup_evohome
from .const import TEST_INSTALLS
from tests.common import async_fire_time_changed
@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"])
async def test_setup_platform(
@@ -43,7 +46,7 @@ async def test_setup_platform(
) -> None:
"""Test entities and their states after setup of evohome."""
# Cannot use the evohome fixture, as need to set dtm first
# Cannot use the evohome fixture here, as need to set dtm first
# - some extended state attrs are relative the current time
freezer.move_to("2024-07-10T12:00:00Z")
@@ -54,6 +57,36 @@ async def test_setup_platform(
assert x == snapshot(name=f"{x.entity_id}-state")
@pytest.mark.parametrize("install", ["default"])
async def test_entities_update_over_time(
hass: HomeAssistant,
config: dict[str, str],
install: str,
snapshot: SnapshotAssertion,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test extended attributes update as time passes.
Verifies that time-dependent state attrs (e.g. schedules) vary as time advances.
"""
# Cannot use the evohome fixture here, as need to set dtm first
# - some extended state attrs are relative the current time
freezer.move_to("2024-07-10T05:30:00Z")
# stay inside this context to have the mocked RESTful API
async for _ in setup_evohome(hass, config, install=install):
for x in hass.states.async_all(Platform.CLIMATE):
assert x == snapshot(name=f"{x.entity_id}-state-initial")
freezer.tick(timedelta(hours=12))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
for x in hass.states.async_all(Platform.CLIMATE):
assert x == snapshot(name=f"{x.entity_id}-state-updated")
@pytest.mark.parametrize("install", TEST_INSTALLS)
async def test_ctl_set_hvac_mode(
hass: HomeAssistant,

View File

@@ -31,13 +31,9 @@ _MSG_USR = (
"special characters accepted via the vendor's website are not valid here."
)
LOG_HINT_429_CREDS = ("evohome.credentials", logging.ERROR, _MSG_429)
LOG_HINT_OTH_CREDS = ("evohome.credentials", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_CREDS = ("evohome.credentials", logging.ERROR, _MSG_USR)
LOG_HINT_429_AUTH = ("evohome.auth", logging.ERROR, _MSG_429)
LOG_HINT_OTH_AUTH = ("evohome.auth", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_AUTH = ("evohome.auth", logging.ERROR, _MSG_USR)
LOG_HINT_429_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_429)
LOG_HINT_OTH_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_OTH)
LOG_HINT_USR_AUTH = ("evohomeasync2.auth", logging.ERROR, _MSG_USR)
LOG_FAIL_CONNECTION = (
"homeassistant.components.evohome",
@@ -110,10 +106,10 @@ EXC_BAD_GATEWAY = aiohttp.ClientResponseError(
)
AUTHENTICATION_TESTS: dict[Exception, list] = {
EXC_BAD_CONNECTION: [LOG_HINT_OTH_CREDS, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_CREDS, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
EXC_BAD_GATEWAY: [LOG_HINT_OTH_CREDS, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_CREDS, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
EXC_BAD_CONNECTION: [LOG_HINT_OTH_AUTH, LOG_FAIL_CONNECTION, LOG_SETUP_FAILED],
EXC_BAD_CREDENTIALS: [LOG_HINT_USR_AUTH, LOG_FAIL_CREDENTIALS, LOG_SETUP_FAILED],
EXC_BAD_GATEWAY: [LOG_HINT_OTH_AUTH, LOG_FAIL_GATEWAY, LOG_SETUP_FAILED],
EXC_TOO_MANY_REQUESTS: [LOG_HINT_429_AUTH, LOG_FAIL_TOO_MANY, LOG_SETUP_FAILED],
}
CLIENT_REQUEST_TESTS: dict[Exception, list] = {
@@ -137,7 +133,8 @@ async def test_authentication_failure_v2(
with (
patch(
"evohome.credentials.CredentialsManagerBase._request", side_effect=exception
"_evohome.credentials.CredentialsManagerBase._request",
side_effect=exception,
),
caplog.at_level(logging.WARNING),
):
@@ -165,7 +162,7 @@ async def test_client_request_failure_v2(
"evohomeasync2.auth.CredentialsManagerBase._post_request",
mock_post_request("default"),
),
patch("evohome.auth.AbstractAuth._request", side_effect=exception),
patch("_evohome.auth.AbstractAuth._request", side_effect=exception),
caplog.at_level(logging.WARNING),
):
result = await async_setup_component(hass, DOMAIN, {DOMAIN: config})

View File

@@ -35,6 +35,38 @@ MOCK_SERIAL_NUMBER = "ABC123456"
MOCK_DEVICE_TOKEN = "mock_device_token"
def create_mock_tracker() -> Tracker:
"""Create a fresh mock Tracker instance."""
return Tracker(
name="Fluffy",
battery=85,
charging=False,
position=Position(
lat=52.520008,
lng=13.404954,
accuracy=10,
timestamp="2024-01-15T12:00:00Z",
),
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(
flash_light=True, energy_saving_mode=True, live_tracking=True
),
),
led_brightness=LedBrightness(status="ok", value=50),
energy_saving=EnergySaving(status="ok", value=1),
deep_sleep=None,
led_activatable=LedActivatable(
has_led=True,
seen_recently=True,
nonempty_battery=True,
not_charging=True,
overall=True,
),
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@@ -102,42 +134,26 @@ def mock_auth_client(mock_device: Device) -> Generator[MagicMock]:
@pytest.fixture
def mock_api_client() -> Generator[MagicMock]:
"""Mock the ApiClient."""
def mock_api_client_init() -> Generator[MagicMock]:
"""Mock the ApiClient used by _tracker_is_valid in __init__.py."""
with patch(
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient"
) as mock_api_client:
client = mock_api_client.return_value
client.get_tracker = AsyncMock(
return_value=Tracker(
name="Fluffy",
battery=85,
charging=False,
position=Position(
lat=52.520008,
lng=13.404954,
accuracy=10,
timestamp="2024-01-15T12:00:00Z",
),
tracker_settings=TrackerSettings(
generation="GPS Tracker 2.0",
features=TrackerFeatures(
flash_light=True, energy_saving_mode=True, live_tracking=True
),
),
led_brightness=LedBrightness(status="ok", value=50),
energy_saving=EnergySaving(status="ok", value=1),
deep_sleep=None,
led_activatable=LedActivatable(
has_led=True,
seen_recently=True,
nonempty_battery=True,
not_charging=True,
overall=True,
),
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
)
)
"homeassistant.components.fressnapf_tracker.ApiClient",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
yield client
@pytest.fixture
def mock_api_client_coordinator() -> Generator[MagicMock]:
"""Mock the ApiClient used by the coordinator."""
with patch(
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
client.set_led_brightness = AsyncMock(return_value=None)
client.set_energy_saving = AsyncMock(return_value=None)
yield client
@@ -162,7 +178,8 @@ def mock_config_entry() -> MockConfigEntry:
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
mock_api_client_coordinator: MagicMock,
mock_auth_client: MagicMock,
) -> MockConfigEntry:
"""Set up the integration for testing."""

View File

@@ -216,7 +216,9 @@ async def test_user_flow_duplicate_phone_number(
),
],
)
@pytest.mark.usefixtures("mock_api_client", "mock_auth_client")
@pytest.mark.usefixtures(
"mock_api_client_init", "mock_api_client_coordinator", "mock_auth_client"
)
async def test_reauth_reconfigure_flow(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -270,7 +272,7 @@ async def test_reauth_reconfigure_flow(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_phone_number(
hass: HomeAssistant,
mock_auth_client: MagicMock,
@@ -333,7 +335,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_sms_code(
hass: HomeAssistant,
mock_auth_client: MagicMock,
@@ -393,7 +395,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
),
],
)
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
async def test_reauth_reconfigure_flow_invalid_user_id(
hass: HomeAssistant,
mock_auth_client: MagicMock,

View File

@@ -40,12 +40,12 @@ async def test_device_tracker_no_position(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_tracker_no_position: Tracker,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test device tracker is unavailable when position is None."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
mock_api_client_init.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -1,19 +1,40 @@
"""Test the Fressnapf Tracker integration init."""
from unittest.mock import AsyncMock, MagicMock
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from fressnapftracker import (
FressnapfTrackerError,
FressnapfTrackerInvalidTrackerResponseError,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.fressnapf_tracker.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from .conftest import MOCK_SERIAL_NUMBER
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
@pytest.fixture
def mock_api_client_malformed_tracker() -> Generator[MagicMock]:
"""Mock the ApiClient for a malformed tracker response in _tracker_is_valid."""
with patch(
"homeassistant.components.fressnapf_tracker.ApiClient",
autospec=True,
) as mock_api_client:
client = mock_api_client.return_value
client.get_tracker = AsyncMock(
side_effect=FressnapfTrackerInvalidTrackerResponseError("Invalid tracker")
)
yield client
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_setup_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -27,8 +48,7 @@ async def test_setup_entry(
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_api_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -48,15 +68,18 @@ async def test_unload_entry(
@pytest.mark.usefixtures("mock_auth_client")
async def test_setup_entry_api_error(
async def test_setup_entry_tracker_is_valid_api_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test setup fails when API returns error."""
"""Test setup retries when API returns error during _tracker_is_valid."""
mock_config_entry.add_to_hass(hass)
mock_api_client.get_tracker = AsyncMock(side_effect=Exception("API Error"))
mock_api_client_init.get_tracker = AsyncMock(
side_effect=FressnapfTrackerError("API Error")
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
@@ -78,3 +101,48 @@ async def test_state_entity_device_snapshots(
assert device_entry == snapshot(name=f"{device_entry.name}-entry"), (
f"device entry snapshot failed for {device_entry.name}"
)
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
async def test_invalid_tracker(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that an issue is created when an invalid tracker is detected."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(issue_registry.issues) == 1
issue_id = f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}"
assert issue_registry.async_get_issue(DOMAIN, issue_id)
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
async def test_invalid_tracker_already_exists(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test that an existing issue is not duplicated."""
ir.async_create_issue(
hass,
DOMAIN,
f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="invalid_fressnapf_tracker",
translation_placeholders={"tracker_id": MOCK_SERIAL_NUMBER},
)
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert len(issue_registry.issues) == 1

View File

@@ -63,10 +63,10 @@ async def test_not_added_when_no_led(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test light entity is created correctly."""
mock_api_client.get_tracker.return_value = TRACKER_NO_LED
mock_api_client_init.get_tracker.return_value = TRACKER_NO_LED
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -81,7 +81,7 @@ async def test_not_added_when_no_led(
@pytest.mark.usefixtures("init_integration")
async def test_turn_on(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light on."""
entity_id = "light.fluffy_flashlight"
@@ -97,13 +97,13 @@ async def test_turn_on(
blocking=True,
)
mock_api_client.set_led_brightness.assert_called_once_with(100)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(100)
@pytest.mark.usefixtures("init_integration")
async def test_turn_on_with_brightness(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light on with brightness."""
entity_id = "light.fluffy_flashlight"
@@ -116,13 +116,13 @@ async def test_turn_on_with_brightness(
)
# 128/255 * 100 = 50
mock_api_client.set_led_brightness.assert_called_once_with(50)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(50)
@pytest.mark.usefixtures("init_integration")
async def test_turn_off(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the light off."""
entity_id = "light.fluffy_flashlight"
@@ -138,7 +138,7 @@ async def test_turn_off(
blocking=True,
)
mock_api_client.set_led_brightness.assert_called_once_with(0)
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(0)
@pytest.mark.parametrize(
@@ -153,12 +153,13 @@ async def test_turn_off(
async def test_turn_on_led_not_activatable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
mock_api_client_coordinator: MagicMock,
activatable_parameter: str,
) -> None:
"""Test turning on the light when LED is not activatable raises."""
setattr(
mock_api_client.get_tracker.return_value.led_activatable,
mock_api_client_init.get_tracker.return_value.led_activatable,
activatable_parameter,
False,
)
@@ -177,7 +178,7 @@ async def test_turn_on_led_not_activatable(
blocking=True,
)
mock_api_client.set_led_brightness.assert_not_called()
mock_api_client_coordinator.set_led_brightness.assert_not_called()
@pytest.mark.parametrize(
@@ -191,11 +192,11 @@ async def test_turn_on_led_not_activatable(
],
)
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_turn_on_off_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
api_exception: FressnapfTrackerError,
expected_exception: type[HomeAssistantError],
service: str,
@@ -208,7 +209,7 @@ async def test_turn_on_off_error(
entity_id = "light.fluffy_flashlight"
mock_api_client.set_led_brightness.side_effect = api_exception
mock_api_client_coordinator.set_led_brightness.side_effect = api_exception
with pytest.raises(expected_exception):
await hass.services.async_call(
LIGHT_DOMAIN,

View File

@@ -62,10 +62,10 @@ async def test_not_added_when_no_energy_saving_mode(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_init: MagicMock,
) -> None:
"""Test switch entity is created correctly."""
mock_api_client.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
mock_api_client_init.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -80,7 +80,7 @@ async def test_not_added_when_no_energy_saving_mode(
@pytest.mark.usefixtures("init_integration")
async def test_turn_on(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the switch on."""
entity_id = "switch.fluffy_sleep_mode"
@@ -96,13 +96,13 @@ async def test_turn_on(
blocking=True,
)
mock_api_client.set_energy_saving.assert_called_once_with(True)
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(True)
@pytest.mark.usefixtures("init_integration")
async def test_turn_off(
hass: HomeAssistant,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
) -> None:
"""Test turning the switch off."""
entity_id = "switch.fluffy_sleep_mode"
@@ -118,7 +118,7 @@ async def test_turn_off(
blocking=True,
)
mock_api_client.set_energy_saving.assert_called_once_with(False)
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(False)
@pytest.mark.parametrize(
@@ -132,11 +132,11 @@ async def test_turn_off(
],
)
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
@pytest.mark.usefixtures("mock_auth_client")
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
async def test_turn_on_off_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_api_client: MagicMock,
mock_api_client_coordinator: MagicMock,
api_exception: FressnapfTrackerError,
expected_exception: type[HomeAssistantError],
service: str,
@@ -149,7 +149,7 @@ async def test_turn_on_off_error(
entity_id = "switch.fluffy_sleep_mode"
mock_api_client.set_energy_saving.side_effect = api_exception
mock_api_client_coordinator.set_energy_saving.side_effect = api_exception
with pytest.raises(expected_exception):
await hass.services.async_call(
SWITCH_DOMAIN,

View File

@@ -439,7 +439,7 @@
'state': '5.030',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan-entry]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -454,7 +454,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -462,12 +462,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'CPU Fan Fan',
'object_id_base': 'CPU Fan Speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'CPU Fan Fan',
'original_name': 'CPU Fan Speed',
'platform': 'libre_hardware_monitor',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -477,17 +477,17 @@
'unit_of_measurement': 'RPM',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan-state]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -549,7 +549,7 @@
'state': '55.0',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan-entry]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -564,7 +564,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -572,12 +572,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'Pump Fan Fan',
'object_id_base': 'Pump Fan Speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Pump Fan Fan',
'original_name': 'Pump Fan Speed',
'platform': 'libre_hardware_monitor',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -587,24 +587,24 @@
'unit_of_measurement': 'RPM',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan-state]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan-entry]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -619,7 +619,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -627,12 +627,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'System Fan #1 Fan',
'object_id_base': 'System Fan #1 Speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'System Fan #1 Fan',
'original_name': 'System Fan #1 Speed',
'platform': 'libre_hardware_monitor',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -642,16 +642,16 @@
'unit_of_measurement': None,
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan-state]
# name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Speed',
'max_value': None,
'min_value': None,
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -933,7 +933,7 @@
'state': '36.0',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan-entry]
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -948,7 +948,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -956,12 +956,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'GPU Fan 1 Fan',
'object_id_base': 'GPU Fan 1 Speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'GPU Fan 1 Fan',
'original_name': 'GPU Fan 1 Speed',
'platform': 'libre_hardware_monitor',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -971,24 +971,24 @@
'unit_of_measurement': 'RPM',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan-state]
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan-entry]
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -1003,7 +1003,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -1011,12 +1011,12 @@
'labels': set({
}),
'name': None,
'object_id_base': 'GPU Fan 2 Fan',
'object_id_base': 'GPU Fan 2 Speed',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'GPU Fan 2 Fan',
'original_name': 'GPU Fan 2 Speed',
'platform': 'libre_hardware_monitor',
'previous_unique_id': None,
'suggested_object_id': None,
@@ -1026,17 +1026,17 @@
'unit_of_measurement': 'RPM',
})
# ---
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan-state]
# name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1452,14 +1452,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1467,14 +1467,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1482,13 +1482,13 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Speed',
'max_value': None,
'min_value': None,
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1706,14 +1706,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1721,14 +1721,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1830,14 +1830,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1845,14 +1845,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Pump Fan Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_pump_fan_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -1860,13 +1860,13 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
'friendly_name': '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Speed',
'max_value': None,
'min_value': None,
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_fan',
'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -2084,14 +2084,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 1 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_1_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -2099,14 +2099,14 @@
}),
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Fan',
'friendly_name': '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Fan 2 Speed',
'max_value': '0',
'min_value': '0',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'RPM',
}),
'context': <ANY>,
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_fan',
'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_fan_2_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -6,6 +6,7 @@ from http import HTTPStatus
import json
import logging
from typing import Any
from unittest.mock import AsyncMock, patch
import aiohttp
import mcp
@@ -478,3 +479,42 @@ async def test_get_unknown_prompt(
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
with pytest.raises(McpError):
await session.get_prompt(name="Unknown")
@pytest.mark.parametrize("llm_hass_api", [llm.LLM_API_ASSIST])
async def test_mcp_tool_call_unicode(
hass: HomeAssistant,
setup_integration: None,
mcp_url: str,
mcp_client: Any,
hass_supervisor_access_token: str,
) -> None:
"""Test the tool call endpoint preserves unicode characters."""
# Mock the API instance
mock_api = AsyncMock()
mock_api.api.name = "Assist"
mock_api.tools = []
mock_api.custom_serializer = None
mock_api.async_call_tool.return_value = {"message": "这是一个测试"}
# We need to ensure when the server calls llm.async_get_api, it gets our mock
# async_get_api is awaited, so we need an AsyncMock
with patch(
"homeassistant.helpers.llm.async_get_api", new_callable=AsyncMock
) as mock_get_api:
mock_get_api.return_value = mock_api
async with mcp_client(hass, mcp_url, hass_supervisor_access_token) as session:
result = await session.call_tool(
name="AnyTool",
arguments={},
)
assert not result.isError
assert len(result.content) == 1
assert result.content[0].type == "text"
# Check that the text contains the raw unicode characters, NOT the escaped version
response_text = result.content[0].text
assert "这是一个测试" in response_text
assert "\\u" not in response_text

View File

@@ -57,6 +57,7 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from . import (
MOCK_MAC,
init_integration,
mutate_rpc_device_status,
patch_platforms,
register_device,
register_entity,
@@ -1047,6 +1048,16 @@ async def test_rpc_linkedgo_st802_thermostat(
assert (state := hass.states.get(entity_id))
assert state.state == HVACMode.OFF
# Test current temperature update
assert (state := hass.states.get(entity_id))
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.1
mutate_rpc_device_status(monkeypatch, mock_rpc_device, "number:201", "value", 22.4)
mock_rpc_device.mock_update()
assert (state := hass.states.get(entity_id))
assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.4
async def test_rpc_linkedgo_st1820_thermostat(
hass: HomeAssistant,

View File

@@ -2448,7 +2448,7 @@
'object_id_base': 'VPP backup reserve',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'original_device_class': None,
'original_icon': None,
'original_name': 'VPP backup reserve',
'platform': 'tesla_fleet',
@@ -2463,7 +2463,6 @@
# name: test_sensors[sensor.energy_site_vpp_backup_reserve-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Energy Site VPP backup reserve',
'unit_of_measurement': '%',
}),
@@ -2478,7 +2477,6 @@
# name: test_sensors[sensor.energy_site_vpp_backup_reserve-statealt]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery',
'friendly_name': 'Energy Site VPP backup reserve',
'unit_of_measurement': '%',
}),

View File

@@ -1,7 +1,9 @@
"""Unit tests for the Todoist todo platform."""
from datetime import date, datetime
from typing import Any
from unittest.mock import AsyncMock
from zoneinfo import ZoneInfo
import pytest
from todoist_api_python.models import Task
@@ -113,7 +115,7 @@ async def test_todo_item_state(
),
)
],
{"description": "", "due_date": "2023-11-18"},
{"description": "", "due_date": date(2023, 11, 18)},
{
"uid": "task-id-1",
"summary": "Soda",
@@ -138,7 +140,9 @@ async def test_todo_item_state(
],
{
"description": "",
"due_datetime": "2023-11-18T06:30:00-06:00",
"due_datetime": datetime(
2023, 11, 18, 6, 30, tzinfo=ZoneInfo("US/Central")
),
},
{
"uid": "task-id-1",
@@ -333,7 +337,7 @@ async def test_update_todo_item_status(
{
"task_id": "task-id-1",
"content": "Soda",
"due_date": "2023-11-18",
"due_date": date(2023, 11, 18),
"description": "",
},
{
@@ -361,7 +365,9 @@ async def test_update_todo_item_status(
{
"task_id": "task-id-1",
"content": "Soda",
"due_datetime": "2023-11-18T06:30:00-06:00",
"due_datetime": datetime(
2023, 11, 18, 6, 30, tzinfo=ZoneInfo("US/Central")
),
"description": "",
},
{
@@ -455,7 +461,7 @@ async def test_update_todo_item_status(
"task_id": "task-id-1",
"content": "Soda",
"description": "6-pack",
"due_date": "2024-02-01",
"due_date": date(2024, 2, 1),
"due_string": "every day",
},
{

View File

@@ -90,7 +90,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'target_temp_step': 1.0,
}),
@@ -137,7 +136,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 1.0,
@@ -460,7 +458,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'target_temp_step': 1.0,
}),
@@ -509,7 +506,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'supported_features': <ClimateEntityFeature: 17>,
'target_temp_step': 1.0,
@@ -538,7 +534,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'target_temp_step': 1.0,
}),
@@ -587,7 +582,6 @@
'preset_modes': list([
'auto',
'manual',
'off',
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 1.0,

View File

@@ -1,4 +1,57 @@
# serializer version: 1
# name: test_sensor_entities[sensor.yardian_smart_sprinkler_active_zones-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.yardian_smart_sprinkler_active_zones',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Active zones',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Active zones',
'platform': 'yardian',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'active_zone_count',
'unique_id': 'yid123_active_zone_count',
'unit_of_measurement': 'zones',
})
# ---
# name: test_sensor_entities[sensor.yardian_smart_sprinkler_active_zones-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Yardian Smart Sprinkler Active zones',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'zones',
}),
'context': <ANY>,
'entity_id': 'sensor.yardian_smart_sprinkler_active_zones',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1',
})
# ---
# name: test_sensor_entities[sensor.yardian_smart_sprinkler_rain_delay-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -1967,11 +1967,39 @@ async def test_invalid_entity_id(
assert entity.hass is None
assert entity.platform is None
assert "Invalid entity ID: invalid_entity_id" in caplog.text
# Ensure the valid entity was still added
assert entity2.hass is not None
assert entity2.platform is not None
async def test_invalid_entity_id_report_usage(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that setting an invalid entity_id reports usage."""
platform = MockEntityPlatform(hass)
entity = MockEntity(entity_id="test_domain.INVALID-ENTITY-ID", unique_id="unique")
mock_integration = Mock(is_built_in=True, domain="test_platform")
with (
caplog.at_level(logging.WARNING),
patch(
"homeassistant.helpers.frame.async_get_issue_integration",
return_value=mock_integration,
),
):
await platform.async_add_entities([entity])
assert (
"Detected that integration 'test_platform' "
"sets an invalid entity ID: 'test_domain.INVALID-ENTITY-ID'"
) in caplog.text
# Ensure the entity was still added
assert entity.hass is not None
assert entity.platform is not None
class MockBlockingEntity(MockEntity):
"""Class to mock an entity that will block adding entities."""