Compare commits

...

37 Commits

Author SHA1 Message Date
Bram Kragten
178d509d56 Bump version to 2025.3.0b2 2025-02-28 17:06:59 +01:00
Bram Kragten
09c129de40 Update frontend to 20250228.0 (#139531) 2025-02-28 17:06:51 +01:00
Joost Lekkerkerker
07128ba063 Bump yt-dlp to 2025.02.19 (#139526) 2025-02-28 17:06:50 +01:00
Robert Resch
a786ff53ff Don't split wheels builder anymore (#139522) 2025-02-28 17:06:50 +01:00
Robert Svensson
d2e19c829d Suppress unsupported event 'EVT_USP_RpsPowerDeniedByPsuOverload' by bumping aiounifi to v83 (#139519)
Bump aiounifi to v83
2025-02-28 17:06:49 +01:00
Jan Bouwhuis
94b342f26a Make the Tuya backend library compatible with the newer paho mqtt client. (#139518)
* Make the Tuya backend library compatible with the newer paho mqtt client.

* Improve classnames and docstrings
2025-02-28 17:06:48 +01:00
Josef Zweck
9e3e6b3f43 Add diagnostics to onedrive (#139516)
* Add diagnostics to onedrive

* redact PII

* add raw data
2025-02-28 17:06:47 +01:00
Erik Montnemery
4300900322 Improve error handling in CoreBackupReaderWriter (#139508) 2025-02-28 17:06:46 +01:00
Brett Adams
342e04974d Fix shift state in Teslemetry (#139505)
* Fix shift state

* Different fix
2025-02-28 17:06:46 +01:00
Erik Montnemery
fdb4c0a81f Fail recorder.backup.async_pre_backup if Home Assistant is not running (#139491)
Fail recorder.backup.async_pre_backup if hass is not running
2025-02-28 17:06:45 +01:00
Ivan Lopez Hernandez
6de878ffe4 Fix Gemini Schema validation for #139416 (#139478)
Fixed Schema validation for issue #139477
2025-02-28 17:06:44 +01:00
Joost Lekkerkerker
c63aaec09e Set SmartThings suggested display precision (#139470) 2025-02-28 17:06:43 +01:00
Joost Lekkerkerker
d8bf47c101 Only lowercase SmartThings media input source if we have it (#139468) 2025-02-28 17:06:42 +01:00
Joost Lekkerkerker
736ff8828d Bump pysmartthings to 2.1.0 (#139460) 2025-02-28 17:06:41 +01:00
Josef Zweck
b501999a4c Improve onedrive migration (#139458) 2025-02-28 17:06:40 +01:00
Jan-Philipp Benecke
3985f1c6c8 Change webdav namespace to absolut URI (#139456)
* Change webdav namespace to absolut URI

* Add const file
2025-02-28 17:06:39 +01:00
Joost Lekkerkerker
46ec3987a8 Bump pysmartthings to 2.0.1 (#139454) 2025-02-28 17:06:39 +01:00
Joost Lekkerkerker
df4e5a54e3 Fix SmartThings diagnostics (#139447) 2025-02-28 17:06:38 +01:00
J. Diego Rodríguez Royo
d8a259044f Bump aiohomeconnect to 0.15.1 (#139445) 2025-02-28 17:06:37 +01:00
Michael Hansen
0891669aee Move climate intent to homeassistant integration (#139371)
* Move climate intent to homeassistant integration

* Move get temperature intent to intent integration

* Clean up old test
2025-02-28 17:06:36 +01:00
Marcel van der Veldt
83c0351338 Add new mediatypes to Music Assistant integration (#139338)
* Bump Music Assistant client to 1.1.0

* Add some casts to help mypy

* Add handling of the new media types in Music Assistant

* mypy cleanup

* lint

* update snapshot

* Adjust tests

---------

Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-02-28 17:06:35 +01:00
Jeef
c5e5fe555d Bump weatherflow4py to 1.3.1 (#135529)
* version bump of dep

* update requirements
2025-02-28 17:06:34 +01:00
Bram Kragten
345ba73777 Bump version to 2025.3.0b1 2025-02-27 16:48:00 +01:00
Bram Kragten
e4200a79a2 Update frontend to 20250227.0 (#139437) 2025-02-27 16:47:52 +01:00
Marcel van der Veldt
381fa65ba0 Fix Music Assistant media player entity features (#139428)
* Fix Music Assistant supported media player features

* Update supported features when player config changes

* Add tests
2025-02-27 16:47:51 +01:00
starkillerOG
16314711b8 Bump reolink-aio to 0.12.1 (#139427) 2025-02-27 16:47:50 +01:00
J. Nick Koston
553abe4a4a Bump bleak-esphome to 2.8.0 (#139426) 2025-02-27 16:47:49 +01:00
Joost Lekkerkerker
6a1bbdb3a7 Add diagnostics to SmartThings (#139423) 2025-02-27 16:47:48 +01:00
Paulus Schoutsen
59d92c75bd Fix conversation agent fallback (#139421) 2025-02-27 16:47:47 +01:00
J. Nick Koston
7732e6878e Bump habluetooth to 3.24.1 (#139420) 2025-02-27 16:47:46 +01:00
Joost Lekkerkerker
2cde317d59 Bump pysmartthings to 2.0.0 (#139418)
* Bump pysmartthings to 2.0.0

* Fix

* Fix

* Fix

* Fix
2025-02-27 16:47:45 +01:00
Josef Zweck
0c08430507 Bump onedrive to 0.0.12 (#139410)
* Bump onedrive to 0.0.12

* Add alternative name
2025-02-27 16:47:45 +01:00
J. Diego Rodríguez Royo
fa6d7d5e3c Fix fetch options error for Home connect (#139392)
* Handle errors when obtaining options definitions

* Don't fetch program options if the program key is unknown

* Test to ensure that available program endpoint is not called on unknown program
2025-02-27 16:47:43 +01:00
Michael Hansen
585b950a46 Bump intents to 2025.2.26 (#139387) 2025-02-27 16:47:42 +01:00
puddly
3effc2e182 Bump ZHA to 0.0.51 (#139383)
* Bump ZHA to 0.0.51

* Fix unit tests not accounting for primary entities
2025-02-27 16:47:42 +01:00
fwestenberg
0e1602ff71 Bump stookwijzer==1.6.1 (#139380) 2025-02-27 16:47:41 +01:00
Bram Kragten
693584ce29 Bump version to 2025.3.0b0 2025-02-26 18:23:01 +01:00
82 changed files with 3790 additions and 634 deletions

View File

@@ -218,15 +218,7 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- name: Split requirements all
run: |
# We split requirements all into multiple files.
# This is to prevent the build from running out of memory when
# resolving packages on 32-bits systems (like armhf, armv7).
split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
- name: Build wheels (part 1)
- name: Build wheels
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
@@ -238,32 +230,4 @@ jobs:
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtaa"
- name: Build wheels (part 2)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtab"
- name: Build wheels (part 3)
uses: home-assistant/wheels@2024.11.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
arch: ${{ matrix.arch }}
wheels-key: ${{ secrets.WHEELS_KEY }}
env-file: true
apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
skip-binary: aiohttp;charset-normalizer;grpcio;multidict;SQLAlchemy;propcache;protobuf;pymicro-vad;yarl
constraints: "homeassistant/package_constraints.txt"
requirements-diff: "requirements_diff.txt"
requirements: "requirements_all.txtac"
requirements: "requirements_all.txt"

View File

@@ -1103,12 +1103,16 @@ class PipelineRun:
) & conversation.ConversationEntityFeature.CONTROL:
intent_filter = _async_local_fallback_intent_filter
# Try local intents first, if preferred.
elif self.pipeline.prefer_local_intents and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
# Try local intents
if (
intent_response is None
and self.pipeline.prefer_local_intents
and (
intent_response := await conversation.async_handle_intents(
self.hass,
user_input,
intent_filter=intent_filter,
)
)
):
# Local intent matched

View File

@@ -14,6 +14,7 @@ from itertools import chain
import json
from pathlib import Path, PurePath
import shutil
import sys
import tarfile
import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
@@ -308,6 +309,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
_message = "On-the-fly decryption is not supported for this backup."
class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
"""Raised when multiple exceptions occur."""
error_code = "multiple_errors"
class BackupManager:
"""Define the format that backup managers can have."""
@@ -1605,10 +1612,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
finally:
# Inform integrations the backup is done
# If there's an unhandled exception, we keep it so we can rethrow it in case
# the post backup actions also fail.
unhandled_exc = sys.exception()
try:
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
try:
await manager.async_post_backup_actions()
except BackupManagerError as err:
raise BackupReaderWriterError(str(err)) from err
except Exception as err:
if not unhandled_exc:
raise
# If there's an unhandled exception, we wrap both that and the exception
# from the post backup actions in an ExceptionGroup so the caller is
# aware of both exceptions.
raise BackupManagerExceptionGroup(
f"Multiple errors when creating backup: {unhandled_exc}, {err}",
[unhandled_exc, err],
) from None
def _mkdir_and_generate_backup_contents(
self,

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0",
"habluetooth==3.24.0"
"habluetooth==3.24.1"
]
}

View File

@@ -68,7 +68,6 @@ from .const import ( # noqa: F401
FAN_ON,
FAN_TOP,
HVAC_MODES,
INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
PRESET_ACTIVITY,
PRESET_AWAY,

View File

@@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99
DOMAIN = "climate"
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
SERVICE_SET_AUX_HEAT = "set_aux_heat"

View File

@@ -1,4 +1,4 @@
"""Intents for the client integration."""
"""Intents for the climate integration."""
from __future__ import annotations
@@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent
from . import (
ATTR_TEMPERATURE,
DOMAIN,
INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
@@ -20,49 +19,9 @@ from . import (
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the climate intents."""
intent.async_register(hass, GetTemperatureIntent())
intent.async_register(hass, SetTemperatureIntent())
class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
match_constraints = intent.MatchTargetsConstraints(
name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
class SetTemperatureIntent(intent.IntentHandler):
"""Handle SetTemperature intents."""

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
"requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.26"]
}

View File

@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.8.0"]
}

View File

@@ -18,7 +18,7 @@
"requirements": [
"aioesphomeapi==29.2.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.7.1"
"bleak-esphome==2.8.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250226.0"]
"requirements": ["home-assistant-frontend==20250228.0"]
}

View File

@@ -111,9 +111,20 @@ def _format_schema(schema: dict[str, Any]) -> Schema:
continue
if key == "any_of":
val = [_format_schema(subschema) for subschema in val]
if key == "type":
elif key == "type":
val = val.upper()
if key == "items":
elif key == "format":
# Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
# formats that are not supported are ignored
if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
if schema.get("type") == "number" and val not in ("float", "double"):
continue
if schema.get("type") == "integer" and val not in ("int32", "int64"):
continue
if schema.get("type") not in ("string", "number", "integer"):
continue
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}

View File

@@ -440,13 +440,27 @@ class HomeConnectCoordinator(
self, ha_id: str, program_key: ProgramKey
) -> dict[OptionKey, ProgramDefinitionOption]:
"""Get options with constraints for appliance."""
return {
option.key: option
for option in (
await self.client.get_available_program(ha_id, program_key=program_key)
).options
or []
}
if program_key is ProgramKey.UNKNOWN:
return {}
try:
return {
option.key: option
for option in (
await self.client.get_available_program(
ha_id, program_key=program_key
)
).options
or []
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching options for %s: %s",
ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
return {}
async def update_options(
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
@@ -456,8 +470,7 @@ class HomeConnectCoordinator(
events = self.data[ha_id].events
options_to_notify = options.copy()
options.clear()
if program_key is not ProgramKey.UNKNOWN:
options.update(await self.get_options_definitions(ha_id, program_key))
options.update(await self.get_options_definitions(ha_id, program_key))
for option in options.values():
option_value = option.constraints.default if option.constraints else None

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.15.0"],
"requirements": ["aiohomeconnect==0.15.1"],
"single_config_entry": true
}

View File

@@ -9,6 +9,7 @@ from aiohttp import web
import voluptuous as vol
from homeassistant.components import http
from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
@@ -140,6 +141,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, GetCurrentDateIntentHandler())
intent.async_register(hass, GetCurrentTimeIntentHandler())
intent.async_register(hass, RespondIntentHandler())
intent.async_register(hass, GetTemperatureIntent())
return True
@@ -444,6 +446,48 @@ class RespondIntentHandler(intent.IntentHandler):
return response
class GetTemperatureIntent(intent.IntentHandler):
"""Handle GetTemperature intents."""
intent_type = intent.INTENT_GET_TEMPERATURE
description = "Gets the current temperature of a climate device or entity"
slot_schema = {
vol.Optional("area"): intent.non_empty_string,
vol.Optional("name"): intent.non_empty_string,
}
platforms = {CLIMATE_DOMAIN}
async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
"""Handle the intent."""
hass = intent_obj.hass
slots = self.async_validate_slots(intent_obj.slots)
name: str | None = None
if "name" in slots:
name = slots["name"]["value"]
area: str | None = None
if "area" in slots:
area = slots["area"]["value"]
match_constraints = intent.MatchTargetsConstraints(
name=name,
area_name=area,
domains=[CLIMATE_DOMAIN],
assistant=intent_obj.assistant,
)
match_result = intent.async_match_targets(hass, match_constraints)
if not match_result.is_match:
raise intent.MatchFailedError(
result=match_result, constraints=match_constraints
)
response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER
response.async_set_states(matched_states=match_result.states)
return response
async def _async_process_intent(
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
) -> None:

View File

@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
"requirements": ["yt-dlp[default]==2025.01.26"],
"requirements": ["yt-dlp[default]==2025.02.19"],
"single_config_entry": true
}

View File

@@ -23,6 +23,7 @@ from .const import (
ATTR_ALBUM_TYPE,
ATTR_ALBUMS,
ATTR_ARTISTS,
ATTR_AUDIOBOOKS,
ATTR_CONFIG_ENTRY_ID,
ATTR_FAVORITE,
ATTR_ITEMS,
@@ -32,6 +33,7 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
ATTR_PODCASTS,
ATTR_RADIO,
ATTR_SEARCH,
ATTR_SEARCH_ALBUM,
@@ -48,7 +50,15 @@ from .schemas import (
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
from music_assistant_models.media_items import (
Album,
Artist,
Audiobook,
Playlist,
Podcast,
Radio,
Track,
)
from . import MusicAssistantConfigEntry
@@ -155,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
media_item_dict_from_mass_item(mass, item)
for item in search_results.radio
],
ATTR_AUDIOBOOKS: [
media_item_dict_from_mass_item(mass, item)
for item in search_results.audiobooks
],
ATTR_PODCASTS: [
media_item_dict_from_mass_item(mass, item)
for item in search_results.podcasts
],
}
)
return response
@@ -175,7 +193,13 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
"order_by": order_by,
}
library_result: (
list[Album] | list[Artist] | list[Track] | list[Radio] | list[Playlist]
list[Album]
| list[Artist]
| list[Track]
| list[Radio]
| list[Playlist]
| list[Audiobook]
| list[Podcast]
)
if media_type == MediaType.ALBUM:
library_result = await mass.music.get_library_albums(
@@ -199,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
library_result = await mass.music.get_library_playlists(
**base_params,
)
elif media_type == MediaType.AUDIOBOOK:
library_result = await mass.music.get_library_audiobooks(
**base_params,
)
elif media_type == MediaType.PODCAST:
library_result = await mass.music.get_library_podcasts(
**base_params,
)
else:
raise ServiceValidationError(f"Unsupported media type {media_type}")

View File

@@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists"
ATTR_ALBUMS = "albums"
ATTR_TRACKS = "tracks"
ATTR_PLAYLISTS = "playlists"
ATTR_AUDIOBOOKS = "audiobooks"
ATTR_PODCASTS = "podcasts"
ATTR_RADIO = "radio"
ATTR_ITEMS = "items"
ATTR_RADIO_MODE = "radio_mode"

View File

@@ -9,6 +9,7 @@ import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
@@ -80,19 +81,14 @@ if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.player import Player
SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.STOP
SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
@@ -212,11 +208,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Initialize MediaPlayer entity."""
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
self._attr_supported_features = SUPPORTED_FEATURES
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
self._set_supported_features()
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -241,6 +233,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
# we subscribe to the player config changed event to update
# the supported features of the player
async def player_config_changed(event: MassEvent) -> None:
self._set_supported_features()
await self.async_on_update()
self.async_write_ha_state()
self.async_on_remove(
self.mass.subscribe(
player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
)
)
@property
def active_queue(self) -> PlayerQueue | None:
"""Return the active queue for this player (if any)."""
@@ -682,3 +687,20 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if isinstance(queue_option, MediaPlayerEnqueue):
queue_option = QUEUE_OPTION_MAP.get(queue_option)
return queue_option
def _set_supported_features(self) -> None:
"""Set supported features based on player capabilities."""
supported_features = SUPPORTED_FEATURES_BASE
if PlayerFeature.SET_MEMBERS in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.GROUPING
if PlayerFeature.PAUSE in self.player.supported_features:
supported_features |= MediaPlayerEntityFeature.PAUSE
if self.player.mute_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.player.volume_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
supported_features |= MediaPlayerEntityFeature.VOLUME_SET
if self.player.power_control != PLAYER_CONTROL_NONE:
supported_features |= MediaPlayerEntityFeature.TURN_ON
supported_features |= MediaPlayerEntityFeature.TURN_OFF
self._attr_supported_features = supported_features

View File

@@ -15,6 +15,7 @@ from .const import (
ATTR_ALBUM,
ATTR_ALBUMS,
ATTR_ARTISTS,
ATTR_AUDIOBOOKS,
ATTR_BIT_DEPTH,
ATTR_CONTENT_TYPE,
ATTR_CURRENT_INDEX,
@@ -31,6 +32,7 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
ATTR_PODCASTS,
ATTR_PROVIDER,
ATTR_QUEUE_ID,
ATTR_QUEUE_ITEM_ID,
@@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
vol.Required(ATTR_RADIO): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
vol.Required(ATTR_AUDIOBOOKS): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
vol.Required(ATTR_PODCASTS): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
},
)

View File

@@ -21,7 +21,10 @@ play_media:
options:
- artist
- album
- audiobook
- folder
- playlist
- podcast
- track
- radio
artist:
@@ -118,7 +121,9 @@ search:
options:
- artist
- album
- audiobook
- playlist
- podcast
- track
- radio
artist:
@@ -160,7 +165,9 @@ get_library:
options:
- artist
- album
- audiobook
- playlist
- podcast
- track
- radio
favorite:

View File

@@ -195,8 +195,11 @@
"options": {
"artist": "Artist",
"album": "Album",
"audiobook": "Audiobook",
"folder": "Folder",
"track": "Track",
"playlist": "Playlist",
"podcast": "Podcast",
"radio": "Radio"
}
},

View File

@@ -41,14 +41,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Set up OneDrive from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
async def get_access_token() -> str:
await session.async_ensure_token_valid()
return cast(str, session.token[CONF_ACCESS_TOKEN])
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
client, get_access_token = await _get_onedrive_client(hass, entry)
# get approot, will be created automatically if it does not exist
approot = await _handle_item_operation(client.get_approot, "approot")
@@ -164,20 +157,47 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -
_LOGGER.debug(
"Migrating OneDrive config entry from version %s.%s", version, minor_version
)
client, _ = await _get_onedrive_client(hass, entry)
instance_id = await async_get_instance_id(hass)
try:
approot = await client.get_approot()
folder = await client.get_drive_item(
f"{approot.id}:/backups_{instance_id[:8]}:"
)
except OneDriveException:
_LOGGER.exception("Migration to version 1.2 failed")
return False
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_FOLDER_ID: "id", # will be updated during setup_entry
CONF_FOLDER_ID: folder.id,
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
},
minor_version=2,
)
_LOGGER.debug("Migration to version 1.2 successful")
return True
async def _get_onedrive_client(
hass: HomeAssistant, entry: OneDriveConfigEntry
) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]:
"""Get OneDrive client."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
async def get_access_token() -> str:
await session.async_ensure_token_valid()
return cast(str, session.token[CONF_ACCESS_TOKEN])
return (
OneDriveClient(get_access_token, async_get_clientsession(hass)),
get_access_token,
)
async def _handle_item_operation(
func: Callable[[], Awaitable[Item]], folder: str
) -> Item:

View File

@@ -0,0 +1,33 @@
"""Diagnostics support for OneDrive."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant
from .coordinator import OneDriveConfigEntry
TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: OneDriveConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data.coordinator
data = {
"drive": asdict(coordinator.data),
"config": {
**entry.data,
**entry.options,
},
}
return async_redact_data(data, TO_REDACT)

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.11"]
"requirements": ["onedrive-personal-sdk==0.0.12"]
}

View File

@@ -41,10 +41,7 @@ rules:
# Gold
devices: done
diagnostics:
status: exempt
comment: |
There is no data to diagnose.
diagnostics: done
discovery-update-info:
status: exempt
comment: |

View File

@@ -103,7 +103,7 @@ class OneDriveDriveStateSensor(
self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=coordinator.data.name,
name=coordinator.data.name or coordinator.config_entry.title,
identifiers={(DOMAIN, coordinator.data.id)},
manufacturer="Microsoft",
model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",

View File

@@ -2,7 +2,7 @@
from logging import getLogger
from homeassistant.core import HomeAssistant
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .util import async_migration_in_progress, get_instance
@@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None:
"""Perform operations before a backup starts."""
_LOGGER.info("Backup start notification, locking database for writes")
instance = get_instance(hass)
if hass.state is not CoreState.running:
raise HomeAssistantError("Home Assistant is not running")
if async_migration_in_progress(hass):
raise HomeAssistantError("Database migration in progress")
await instance.lock_database()

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.12.0"]
"requirements": ["reolink-aio==0.12.1"]
}

View File

@@ -46,7 +46,7 @@ class FullDevice:
"""Define an object to hold device data."""
device: Device
status: dict[str, dict[Capability, dict[Attribute, Status]]]
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]]
type SmartThingsConfigEntry = ConfigEntry[SmartThingsData]
@@ -146,8 +146,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
def process_status(
status: dict[str, dict[Capability, dict[Attribute, Status]]],
) -> dict[str, dict[Capability, dict[Attribute, Status]]]:
status: dict[str, dict[Capability | str, dict[Attribute | str, Status]]],
) -> dict[str, dict[Capability | str, dict[Attribute | str, Status]]]:
"""Remove disabled capabilities from status."""
if (main_component := status.get("main")) is None or (
disabled_capabilities_capability := main_component.get(
@@ -156,7 +156,7 @@ def process_status(
) is None:
return status
disabled_capabilities = cast(
list[Capability],
list[Capability | str],
disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value,
)
for capability in disabled_capabilities:

View File

@@ -41,7 +41,7 @@ async def async_setup_entry(
"""Add covers for a config entry."""
entry_data = entry.runtime_data
async_add_entities(
SmartThingsCover(entry_data.client, device, capability)
SmartThingsCover(entry_data.client, device, Capability(capability))
for device in entry_data.devices.values()
for capability in device.status[MAIN]
if capability in CAPABILITIES

View File

@@ -0,0 +1,49 @@
"""Diagnostics support for SmartThings."""
from __future__ import annotations
import asyncio
from dataclasses import asdict
from typing import Any
from pysmartthings import DeviceEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import SmartThingsConfigEntry
from .const import DOMAIN
EVENT_WAIT_TIME = 5
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device entry."""
client = entry.runtime_data.client
device_id = next(
identifier for identifier in device.identifiers if identifier[0] == DOMAIN
)[1]
device_status = await client.get_device_status(device_id)
events: list[DeviceEvent] = []
def register_event(event: DeviceEvent) -> None:
events.append(event)
listener = client.add_device_event_listener(device_id, register_event)
await asyncio.sleep(EVENT_WAIT_TIME)
listener()
status: dict[str, Any] = {}
for component, capabilities in device_status.items():
status[component] = {}
for capability, attributes in capabilities.items():
status[component][capability] = {}
for attribute, value in attributes.items():
status[component][capability][attribute] = asdict(value)
return {"events": [asdict(event) for event in events], "status": status}

View File

@@ -4,7 +4,14 @@ from __future__ import annotations
from typing import Any, cast
from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings
from pysmartthings import (
Attribute,
Capability,
Command,
DeviceEvent,
SmartThings,
Status,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@@ -25,7 +32,7 @@ class SmartThingsEntity(Entity):
"""Initialize the instance."""
self.client = client
self.capabilities = capabilities
self._internal_state = {
self._internal_state: dict[Capability | str, dict[Attribute | str, Status]] = {
capability: device.status[MAIN][capability]
for capability in capabilities
if capability in device.status[MAIN]
@@ -58,7 +65,7 @@ class SmartThingsEntity(Entity):
await super().async_added_to_hass()
for capability in self._internal_state:
self.async_on_remove(
self.client.add_device_event_listener(
self.client.add_device_capability_event_listener(
self.device.device.device_id,
MAIN,
capability,

View File

@@ -29,5 +29,5 @@
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"requirements": ["pysmartthings==1.2.0"]
"requirements": ["pysmartthings==2.1.0"]
}

View File

@@ -130,6 +130,7 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription):
unique_id_separator: str = "."
capability_ignore_list: list[set[Capability]] | None = None
options_attribute: Attribute | None = None
except_if_state_none: bool = False
CAPABILITY_TO_SENSORS: dict[
@@ -460,7 +461,7 @@ CAPABILITY_TO_SENSORS: dict[
translation_key="media_input_source",
device_class=SensorDeviceClass.ENUM,
options_attribute=Attribute.SUPPORTED_INPUT_SOURCES,
value_fn=lambda value: value.lower(),
value_fn=lambda value: value.lower() if value else None,
)
]
},
@@ -579,6 +580,8 @@ CAPABILITY_TO_SENSORS: dict[
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda value: value["energy"] / 1000,
suggested_display_precision=2,
except_if_state_none=True,
),
SmartThingsSensorEntityDescription(
key="power_meter",
@@ -587,6 +590,8 @@ CAPABILITY_TO_SENSORS: dict[
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda value: value["power"],
extra_state_attributes_fn=power_attributes,
suggested_display_precision=2,
except_if_state_none=True,
),
SmartThingsSensorEntityDescription(
key="deltaEnergy_meter",
@@ -595,6 +600,8 @@ CAPABILITY_TO_SENSORS: dict[
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda value: value["deltaEnergy"] / 1000,
suggested_display_precision=2,
except_if_state_none=True,
),
SmartThingsSensorEntityDescription(
key="powerEnergy_meter",
@@ -603,6 +610,8 @@ CAPABILITY_TO_SENSORS: dict[
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda value: value["powerEnergy"] / 1000,
suggested_display_precision=2,
except_if_state_none=True,
),
SmartThingsSensorEntityDescription(
key="energySaved_meter",
@@ -611,6 +620,8 @@ CAPABILITY_TO_SENSORS: dict[
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda value: value["energySaved"] / 1000,
suggested_display_precision=2,
except_if_state_none=True,
),
]
},
@@ -953,14 +964,20 @@ async def async_setup_entry(
async_add_entities(
SmartThingsSensor(entry_data.client, device, description, capability, attribute)
for device in entry_data.devices.values()
for capability, attributes in device.status[MAIN].items()
if capability in CAPABILITY_TO_SENSORS
for attribute in attributes
for description in CAPABILITY_TO_SENSORS[capability].get(attribute, [])
if not description.capability_ignore_list
or not any(
all(capability in device.status[MAIN] for capability in capability_list)
for capability_list in description.capability_ignore_list
for capability, attributes in CAPABILITY_TO_SENSORS.items()
if capability in device.status[MAIN]
for attribute, descriptions in attributes.items()
for description in descriptions
if (
not description.capability_ignore_list
or not any(
all(capability in device.status[MAIN] for capability in capability_list)
for capability_list in description.capability_ignore_list
)
)
and (
not description.except_if_state_none
or device.status[MAIN][capability][attribute].value is not None
)
)

View File

@@ -42,12 +42,12 @@ async def async_migrate_entry(
LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
longitude, latitude = await Stookwijzer.async_transform_coordinates(
xy = await Stookwijzer.async_transform_coordinates(
entry.data[CONF_LOCATION][CONF_LATITUDE],
entry.data[CONF_LOCATION][CONF_LONGITUDE],
)
if not longitude or not latitude:
if not xy:
ir.async_create_issue(
hass,
DOMAIN,
@@ -65,8 +65,8 @@ async def async_migrate_entry(
entry,
version=2,
data={
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_LATITUDE: xy["x"],
CONF_LONGITUDE: xy["y"],
},
)

View File

@@ -25,14 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
longitude, latitude = await Stookwijzer.async_transform_coordinates(
xy = await Stookwijzer.async_transform_coordinates(
user_input[CONF_LOCATION][CONF_LATITUDE],
user_input[CONF_LOCATION][CONF_LONGITUDE],
)
if longitude and latitude:
if xy:
return self.async_create_entry(
title="Stookwijzer",
data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]},
)
errors["base"] = "unknown"

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["stookwijzer==1.6.0"]
"requirements": ["stookwijzer==1.6.1"]
}

View File

@@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from propcache.api import cached_property
from teslemetry_stream import Signal
from teslemetry_stream.const import ShiftState
from homeassistant.components.sensor import (
RestoreSensor,
@@ -69,7 +70,7 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
polling_value_fn: Callable[[StateType], StateType] = lambda x: x
polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None
streaming_key: Signal | None = None
streaming_value_fn: Callable[[StateType], StateType] = lambda x: x
streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x
streaming_firmware: str = "2024.26"
@@ -212,7 +213,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
polling_available_fn=lambda x: True,
polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
streaming_key=Signal.GEAR,
streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)),
streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(),
options=list(SHIFT_STATES.values()),
device_class=SensorDeviceClass.ENUM,
entity_registry_enabled_default=False,

View File

@@ -3,7 +3,8 @@
from __future__ import annotations
import logging
from typing import Any, NamedTuple
from typing import TYPE_CHECKING, Any, NamedTuple
from urllib.parse import urlsplit
from tuya_sharing import (
CustomerDevice,
@@ -11,6 +12,7 @@ from tuya_sharing import (
SharingDeviceListener,
SharingTokenListener,
)
from tuya_sharing.mq import SharingMQ, SharingMQConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple):
listener: SharingDeviceListener
if TYPE_CHECKING:
import paho.mqtt.client as mqtt
class ManagerCompat(Manager):
"""Extended Manager class from the Tuya device sharing SDK.
The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
It overrides extend refresh_mq method to ensure correct paho.mqtt client calls.
This code can be removed when a version of tuya-device-sharing with
https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
"""
def refresh_mq(self):
"""Refresh the MQTT connection."""
if self.mq is not None:
self.mq.stop()
self.mq = None
home_ids = [home.id for home in self.user_homes]
device = [
device
for device in self.device_map.values()
if hasattr(device, "id") and getattr(device, "set_up", False)
]
sharing_mq = SharingMQCompat(self.customer_api, home_ids, device)
sharing_mq.start()
sharing_mq.add_message_listener(self.on_message)
self.mq = sharing_mq
class SharingMQCompat(SharingMQ):
"""Extended SharingMQ class from the Tuya device sharing SDK.
The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
It overrides _start method to ensure correct paho.mqtt client calls.
This code can be removed when a version of tuya-device-sharing with
https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
"""
def _start(self, mq_config: SharingMQConfig) -> mqtt.Client:
"""Start the MQTT client."""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
mqttc = mqtt.Client(client_id=mq_config.client_id)
mqttc.username_pw_set(mq_config.username, mq_config.password)
mqttc.user_data_set({"mqConfig": mq_config})
mqttc.on_connect = self._on_connect
mqttc.on_message = self._on_message
mqttc.on_subscribe = self._on_subscribe
mqttc.on_log = self._on_log
mqttc.on_disconnect = self._on_disconnect
url = urlsplit(mq_config.url)
if url.scheme == "ssl":
mqttc.tls_set()
mqttc.connect(url.hostname, url.port)
mqttc.loop_start()
return mqttc
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
if CONF_APP_TYPE in entry.data:
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
token_listener = TokenListener(hass, entry)
manager = Manager(
manager = ManagerCompat(
TUYA_CLIENT_ID,
entry.data[CONF_USER_CODE],
entry.data[CONF_TERMINAL_ID],

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
"requirements": ["aiounifi==82"],
"requirements": ["aiounifi==83"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
"iot_class": "cloud_polling",
"loggers": ["weatherflow4py"],
"requirements": ["weatherflow4py==1.0.6"]
"requirements": ["weatherflow4py==1.3.1"]
}

View File

@@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__)
METADATA_VERSION = "1"
BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
NAMESPACE = "https://home-assistant.io"
async def async_get_backup_agents(
@@ -100,14 +101,14 @@ def _is_current_metadata_version(properties: list[Property]) -> bool:
return any(
prop.value == METADATA_VERSION
for prop in properties
if prop.namespace == "homeassistant" and prop.name == "metadata_version"
if prop.namespace == NAMESPACE and prop.name == "metadata_version"
)
def _backup_id_from_properties(properties: list[Property]) -> str | None:
"""Return the backup ID from properties."""
for prop in properties:
if prop.namespace == "homeassistant" and prop.name == "backup_id":
if prop.namespace == NAMESPACE and prop.name == "backup_id":
return prop.value
return None
@@ -186,12 +187,12 @@ class WebDavBackupAgent(BackupAgent):
f"{self._backup_path}/{filename_meta}",
[
Property(
namespace="homeassistant",
namespace=NAMESPACE,
name="backup_id",
value=backup.backup_id,
),
Property(
namespace="homeassistant",
namespace=NAMESPACE,
name="metadata_version",
value=METADATA_VERSION,
),
@@ -252,11 +253,11 @@ class WebDavBackupAgent(BackupAgent):
self._backup_path,
[
PropertyRequest(
namespace="homeassistant",
namespace=NAMESPACE,
name="metadata_version",
),
PropertyRequest(
namespace="homeassistant",
namespace=NAMESPACE,
name="backup_id",
),
],

View File

@@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
def name(self) -> str | UndefinedType | None:
"""Return the name of the entity."""
meta = self.entity_data.entity.info_object
if meta.primary:
self._attr_name = None
return super().name
original_name = super().name
if original_name not in (UNDEFINED, None) or meta.fallback_name is None:

View File

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

View File

@@ -25,7 +25,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 3
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b2"
__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, 0)

View File

@@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
INTENT_RESPOND = "HassRespond"
INTENT_BROADCAST = "HassBroadcast"
INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)

View File

@@ -19,7 +19,6 @@ from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
)
from homeassistant.components.climate import INTENT_GET_TEMPERATURE
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
@@ -285,7 +284,7 @@ class AssistAPI(API):
"""API exposing Assist API to LLMs."""
IGNORE_INTENTS = {
INTENT_GET_TEMPERATURE,
intent.INTENT_GET_TEMPERATURE,
INTENT_GET_WEATHER,
INTENT_OPEN_COVER, # deprecated
INTENT_CLOSE_COVER, # deprecated
@@ -530,9 +529,11 @@ def _get_exposed_entities(
info["areas"] = ", ".join(area_names)
if attributes := {
attr_name: str(attr_value)
if isinstance(attr_value, (Enum, Decimal, int))
else attr_value
attr_name: (
str(attr_value)
if isinstance(attr_value, (Enum, Decimal, int))
else attr_value
)
for attr_name, attr_value in state.attributes.items()
if attr_name in interesting_attributes
}:

View File

@@ -33,12 +33,12 @@ dbus-fast==2.33.0
fnv-hash-fast==1.2.6
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
habluetooth==3.24.0
habluetooth==3.24.1
hass-nabucasa==0.92.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250226.0
home-assistant-intents==2025.2.5
home-assistant-frontend==20250228.0
home-assistant-intents==2025.2.26
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.5

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.3.0.dev0"
version = "2025.3.0b2"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"

26
requirements_all.txt generated
View File

@@ -264,7 +264,7 @@ aioharmony==0.4.1
aiohasupervisor==0.3.0
# homeassistant.components.home_connect
aiohomeconnect==0.15.0
aiohomeconnect==0.15.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.7
@@ -404,7 +404,7 @@ aiotedee==0.2.20
aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==82
aiounifi==83
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -603,7 +603,7 @@ bizkaibus==0.1.1
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
bleak-esphome==2.7.1
bleak-esphome==2.8.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.9.0
@@ -1109,7 +1109,7 @@ ha-philipsjs==3.2.2
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.24.0
habluetooth==3.24.1
# homeassistant.components.cloud
hass-nabucasa==0.92.0
@@ -1152,10 +1152,10 @@ hole==0.8.0
holidays==0.67
# homeassistant.components.frontend
home-assistant-frontend==20250226.0
home-assistant-frontend==20250228.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
home-assistant-intents==2025.2.26
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1565,7 +1565,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.11
onedrive-personal-sdk==0.0.12
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -2310,7 +2310,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==1.2.0
pysmartthings==2.1.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -2618,7 +2618,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.12.0
reolink-aio==0.12.1
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2808,7 +2808,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
stookwijzer==1.6.0
stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -3046,7 +3046,7 @@ waterfurnace==1.1.0
watergate-local-api==2024.4.1
# homeassistant.components.weatherflow_cloud
weatherflow4py==1.0.6
weatherflow4py==1.3.1
# homeassistant.components.cisco_webex_teams
webexpythonsdk==2.0.1
@@ -3131,7 +3131,7 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp[default]==2025.01.26
yt-dlp[default]==2025.02.19
# homeassistant.components.zabbix
zabbix-utils==2.0.2
@@ -3149,7 +3149,7 @@ zeroconf==0.145.1
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.50
zha==0.0.51
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@@ -249,7 +249,7 @@ aioharmony==0.4.1
aiohasupervisor==0.3.0
# homeassistant.components.home_connect
aiohomeconnect==0.15.0
aiohomeconnect==0.15.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.7
@@ -386,7 +386,7 @@ aiotedee==0.2.20
aiotractive==0.6.0
# homeassistant.components.unifi
aiounifi==82
aiounifi==83
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -534,7 +534,7 @@ bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
bleak-esphome==2.7.1
bleak-esphome==2.8.0
# homeassistant.components.bluetooth
bleak-retry-connector==3.9.0
@@ -950,7 +950,7 @@ ha-philipsjs==3.2.2
habiticalib==0.3.7
# homeassistant.components.bluetooth
habluetooth==3.24.0
habluetooth==3.24.1
# homeassistant.components.cloud
hass-nabucasa==0.92.0
@@ -981,10 +981,10 @@ hole==0.8.0
holidays==0.67
# homeassistant.components.frontend
home-assistant-frontend==20250226.0
home-assistant-frontend==20250228.0
# homeassistant.components.conversation
home-assistant-intents==2025.2.5
home-assistant-intents==2025.2.26
# homeassistant.components.homematicip_cloud
homematicip==1.1.7
@@ -1313,7 +1313,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
onedrive-personal-sdk==0.0.11
onedrive-personal-sdk==0.0.12
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1882,7 +1882,7 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
pysmartthings==1.2.0
pysmartthings==2.1.0
# homeassistant.components.smarty
pysmarty2==0.10.2
@@ -2121,7 +2121,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
reolink-aio==0.12.0
reolink-aio==0.12.1
# homeassistant.components.rflink
rflink==0.0.66
@@ -2269,7 +2269,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
stookwijzer==1.6.0
stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2453,7 +2453,7 @@ watchdog==6.0.0
watergate-local-api==2024.4.1
# homeassistant.components.weatherflow_cloud
weatherflow4py==1.0.6
weatherflow4py==1.3.1
# homeassistant.components.nasweb
webio-api==0.1.11
@@ -2526,7 +2526,7 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
yt-dlp[default]==2025.01.26
yt-dlp[default]==2025.02.19
# homeassistant.components.zamg
zamg==0.3.6
@@ -2538,7 +2538,7 @@ zeroconf==0.145.1
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.50
zha==0.0.51
# homeassistant.components.zwave_js
zwave-js-server-python==0.60.1

View File

@@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.7 \
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.26 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View File

@@ -8,6 +8,7 @@ from dataclasses import replace
from io import StringIO
import json
from pathlib import Path
import re
import tarfile
from typing import Any
from unittest.mock import (
@@ -35,6 +36,7 @@ from homeassistant.components.backup.agent import BackupAgentError
from homeassistant.components.backup.const import DATA_MANAGER
from homeassistant.components.backup.manager import (
BackupManagerError,
BackupManagerExceptionGroup,
BackupManagerState,
CreateBackupStage,
CreateBackupState,
@@ -1646,34 +1648,60 @@ async def test_exception_platform_pre(hass: HomeAssistant) -> None:
assert str(err.value) == "Error during pre-backup: Test exception"
@pytest.mark.parametrize(
("unhandled_error", "expected_exception", "expected_msg"),
[
(None, BackupManagerError, "Error during post-backup: Test exception"),
(
HomeAssistantError("Boom"),
BackupManagerExceptionGroup,
(
"Multiple errors when creating backup: Error during pre-backup: Boom, "
"Error during post-backup: Test exception (2 sub-exceptions)"
),
),
(
Exception("Boom"),
BackupManagerExceptionGroup,
(
"Multiple errors when creating backup: Error during pre-backup: Boom, "
"Error during post-backup: Test exception (2 sub-exceptions)"
),
),
],
)
@pytest.mark.usefixtures("mock_backup_generation")
async def test_exception_platform_post(hass: HomeAssistant) -> None:
async def test_exception_platform_post(
hass: HomeAssistant,
unhandled_error: Exception | None,
expected_exception: type[Exception],
expected_msg: str,
) -> None:
"""Test exception in post step."""
async def _mock_step(hass: HomeAssistant) -> None:
raise HomeAssistantError("Test exception")
remote_agent = mock_backup_agent("remote")
await setup_backup_platform(
hass,
domain="test",
platform=Mock(
async_pre_backup=AsyncMock(),
async_post_backup=_mock_step,
# We let the pre_backup fail to test that unhandled errors are not discarded
# when post backup fails
async_pre_backup=AsyncMock(side_effect=unhandled_error),
async_post_backup=AsyncMock(
side_effect=HomeAssistantError("Test exception")
),
async_get_backup_agents=AsyncMock(return_value=[remote_agent]),
),
)
await setup_backup_integration(hass)
with pytest.raises(BackupManagerError) as err:
with pytest.raises(expected_exception, match=re.escape(expected_msg)):
await hass.services.async_call(
DOMAIN,
"create",
blocking=True,
)
assert str(err.value) == "Error during post-backup: Test exception"
@pytest.mark.parametrize(
(

View File

@@ -14,7 +14,6 @@ from homeassistant.components.climate import (
HVACMode,
intent as climate_intent,
)
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
@@ -131,335 +130,6 @@ class MockClimateEntityNoSetTemperature(ClimateEntity):
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
async def test_get_temperature(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test HassClimateGetTemperature intent."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass)
climate_1 = MockClimateEntity()
climate_1._attr_name = "Climate 1"
climate_1._attr_unique_id = "1234"
climate_1._attr_current_temperature = 10.0
entity_registry.async_get_or_create(
DOMAIN, "test", "1234", suggested_object_id="climate_1"
)
climate_2 = MockClimateEntity()
climate_2._attr_name = "Climate 2"
climate_2._attr_unique_id = "5678"
climate_2._attr_current_temperature = 22.0
entity_registry.async_get_or_create(
DOMAIN, "test", "5678", suggested_object_id="climate_2"
)
await create_mock_platform(hass, [climate_1, climate_2])
# Add climate entities to different areas:
# climate_1 => living room
# climate_2 => bedroom
# nothing in office
living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom")
office_area = area_registry.async_create(name="Office")
entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id
)
entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id)
# First climate entity will be selected (no area)
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert response.matched_states
assert response.matched_states[0].entity_id == climate_1.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
# Select by area (climate_2)
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
# Select by name (climate_2)
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 2"}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
# Check area with no climate entities
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}},
assistant=conversation.DOMAIN,
)
# Exception should contain details of what we tried to match
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
constraints = error.value.constraints
assert constraints.name is None
assert constraints.area_name == office_area.name
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None
# Check wrong name
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Does not exist"}},
)
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME
constraints = error.value.constraints
assert constraints.name == "Does not exist"
assert constraints.area_name is None
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None
# Check wrong name with area
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}},
)
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
constraints = error.value.constraints
assert constraints.name == "Climate 1"
assert constraints.area_name == bedroom_area.name
assert constraints.domains and (set(constraints.domains) == {DOMAIN})
assert constraints.device_classes is None
async def test_get_temperature_no_entities(
hass: HomeAssistant,
) -> None:
"""Test HassClimateGetTemperature intent with no climate entities."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass)
await create_mock_platform(hass, [])
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
async def test_not_exposed(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test HassClimateGetTemperature intent when entities aren't exposed."""
assert await async_setup_component(hass, "homeassistant", {})
await climate_intent.async_setup_intents(hass)
climate_1 = MockClimateEntity()
climate_1._attr_name = "Climate 1"
climate_1._attr_unique_id = "1234"
climate_1._attr_current_temperature = 10.0
entity_registry.async_get_or_create(
DOMAIN, "test", "1234", suggested_object_id="climate_1"
)
climate_2 = MockClimateEntity()
climate_2._attr_name = "Climate 2"
climate_2._attr_unique_id = "5678"
climate_2._attr_current_temperature = 22.0
entity_registry.async_get_or_create(
DOMAIN, "test", "5678", suggested_object_id="climate_2"
)
await create_mock_platform(hass, [climate_1, climate_2])
# Add climate entities to same area
living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom")
entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id
)
entity_registry.async_update_entity(
climate_2.entity_id, area_id=living_room_area.id
)
# Should fail with empty name
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": ""}},
assistant=conversation.DOMAIN,
)
# Should fail with empty area
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": ""}},
assistant=conversation.DOMAIN,
)
# Expose second, hide first
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the area should work
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the exposed entity should work
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_2.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the *unexposed* entity should fail
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_1.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Expose first, hide second
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_1.entity_id
# Wrong area name
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA
# Neither are exposed
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with area
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with both names
for name in (climate_1.name, climate_2.name):
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
async def test_set_temperature(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,

View File

@@ -6,7 +6,7 @@
tuple(
),
dict({
'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.OBJECT: 'OBJECT'>, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.ARRAY: 'ARRAY'>, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.STRING: 'STRING'>, description=None, enum=None, format='lower', items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.NUMBER: 'NUMBER'>, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.INTEGER: 'INTEGER'>, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.OBJECT: 'OBJECT'>, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.STRING: 'STRING'>, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None),
'config': GenerateContentConfig(http_options=None, system_instruction="Current time is 05:00:00. Today's date is 2024-05-24.\nYou are a voice assistant for Home Assistant.\nAnswer questions about the world truthfully.\nAnswer in plain text. Keep it simple and to the point.\nOnly if the user wants to control a device, tell them to expose entities to their voice assistant in Home Assistant.", temperature=1.0, top_p=0.95, top_k=64.0, candidate_count=None, max_output_tokens=150, stop_sequences=None, response_logprobs=None, logprobs=None, presence_penalty=None, frequency_penalty=None, seed=None, response_mime_type=None, response_schema=None, routing_config=None, safety_settings=[SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>), SafetySetting(method=None, category=<HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT'>, threshold=<HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE: 'BLOCK_MEDIUM_AND_ABOVE'>)], tools=[Tool(function_declarations=[FunctionDeclaration(response=None, description='Test function', name='test_tool', parameters=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.OBJECT: 'OBJECT'>, description=None, enum=None, format=None, items=None, properties={'param1': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.ARRAY: 'ARRAY'>, description='Test parameters', enum=None, format=None, items=Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.STRING: 'STRING'>, description=None, enum=None, format=None, items=None, properties=None, required=None), properties=None, required=None), 'param2': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=[Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.NUMBER: 'NUMBER'>, description=None, enum=None, format=None, items=None, properties=None, required=None), Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.INTEGER: 'INTEGER'>, description=None, enum=None, format=None, items=None, properties=None, required=None)], max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=None, description=None, enum=None, format=None, items=None, properties=None, required=None), 'param3': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.OBJECT: 'OBJECT'>, description=None, enum=None, format=None, items=None, properties={'json': Schema(min_items=None, example=None, property_ordering=None, pattern=None, minimum=None, default=None, any_of=None, max_length=None, title=None, min_length=None, min_properties=None, max_items=None, maximum=None, nullable=None, max_properties=None, type=<Type.STRING: 'STRING'>, description=None, enum=None, format=None, items=None, properties=None, required=None)}, required=[])}, required=[]))], retrieval=None, google_search=None, google_search_retrieval=None, code_execution=None)], tool_config=None, labels=None, cached_content=None, response_modalities=None, media_resolution=None, speech_config=None, audio_timestamp=None, automatic_function_calling=AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None, ignore_call_history=None), thinking_config=None),
'history': list([
]),
'model': 'models/gemini-2.0-flash',

View File

@@ -493,6 +493,42 @@ async def test_escape_decode() -> None:
{"type": "string", "enum": ["a", "b", "c"]},
{"type": "STRING", "enum": ["a", "b", "c"]},
),
(
{"type": "string", "format": "enum", "enum": ["a", "b", "c"]},
{"type": "STRING", "format": "enum", "enum": ["a", "b", "c"]},
),
(
{"type": "string", "format": "date-time"},
{"type": "STRING", "format": "date-time"},
),
(
{"type": "string", "format": "byte"},
{"type": "STRING"},
),
(
{"type": "number", "format": "float"},
{"type": "NUMBER", "format": "float"},
),
(
{"type": "number", "format": "double"},
{"type": "NUMBER", "format": "double"},
),
(
{"type": "number", "format": "hex"},
{"type": "NUMBER"},
),
(
{"type": "integer", "format": "int32"},
{"type": "INTEGER", "format": "int32"},
),
(
{"type": "integer", "format": "int64"},
{"type": "INTEGER", "format": "int64"},
),
(
{"type": "integer", "format": "int8"},
{"type": "INTEGER"},
),
(
{"type": "integer", "enum": [1, 2, 3]},
{"type": "STRING", "enum": ["1", "2", "3"]},
@@ -515,11 +551,11 @@ async def test_escape_decode() -> None:
]
},
),
({"type": "string", "format": "lower"}, {"format": "lower", "type": "STRING"}),
({"type": "boolean", "format": "bool"}, {"format": "bool", "type": "BOOLEAN"}),
({"type": "string", "format": "lower"}, {"type": "STRING"}),
({"type": "boolean", "format": "bool"}, {"type": "BOOLEAN"}),
(
{"type": "number", "format": "percent"},
{"type": "NUMBER", "format": "percent"},
{"type": "NUMBER"},
),
(
{

View File

@@ -75,21 +75,35 @@ async def test_coordinator_update_failing_get_appliances(
assert config_entry.state == ConfigEntryState.SETUP_RETRY
async def test_coordinator_update_failing_get_settings_status(
@pytest.mark.parametrize(
"mock_method",
[
"get_settings",
"get_status",
"get_all_programs",
"get_available_commands",
"get_available_program",
],
)
async def test_coordinator_update_failing(
mock_method: str,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client_with_exception: MagicMock,
client: MagicMock,
) -> None:
"""Test that although is not possible to get settings and status, the config entry is loaded.
This is for cases where some appliances are reachable and some are not in the same configuration entry.
"""
# Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again
setattr(client, mock_method, AsyncMock(side_effect=HomeConnectError()))
assert config_entry.state == ConfigEntryState.NOT_LOADED
await integration_setup(client_with_exception)
await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
getattr(client, mock_method).assert_called()
@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True)
@pytest.mark.parametrize(

View File

@@ -23,6 +23,7 @@ from aiohomeconnect.model.error import (
SelectedProgramNotSetError,
)
from aiohomeconnect.model.program import (
EnumerateProgram,
ProgramDefinitionConstraints,
ProgramDefinitionOption,
)
@@ -234,6 +235,78 @@ async def test_program_options_retrieval(
assert hass.states.is_state(entity_id, STATE_UNKNOWN)
@pytest.mark.parametrize(
("array_of_programs_program_arg", "event_key"),
[
(
"active",
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
(
"selected",
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
],
)
async def test_no_options_retrieval_on_unknown_program(
array_of_programs_program_arg: str,
event_key: EventKey,
appliance_ha_id: str,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test that no options are retrieved when the program is unknown."""
async def get_all_programs_with_options_mock(ha_id: str) -> ArrayOfPrograms:
return ArrayOfPrograms(
**(
{
"programs": [
EnumerateProgram(ProgramKey.UNKNOWN, "unknown program")
],
array_of_programs_program_arg: Program(
ProgramKey.UNKNOWN, options=[]
),
}
)
)
client.get_all_programs = AsyncMock(side_effect=get_all_programs_with_options_mock)
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert client.get_available_program.call_count == 0
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
key=event_key,
raw_key=event_key.value,
timestamp=0,
level="",
handling="",
value=ProgramKey.UNKNOWN,
)
]
),
)
]
)
await hass.async_block_till_done()
assert client.get_available_program.call_count == 0
@pytest.mark.parametrize(
"event_key",
[

View File

@@ -0,0 +1,456 @@
"""Test temperature intents."""
from collections.abc import Generator
from typing import Any
import pytest
from homeassistant.components import conversation
from homeassistant.components.climate import (
ATTR_TEMPERATURE,
DOMAIN as CLIMATE_DOMAIN,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.homeassistant.exposed_entities import async_expose_entity
from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import area_registry as ar, entity_registry as er, intent
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.setup import async_setup_component
from tests.common import (
MockConfigEntry,
MockModule,
MockPlatform,
mock_config_flow,
mock_integration,
mock_platform,
)
TEST_DOMAIN = "test"
class MockFlow(ConfigFlow):
"""Test flow."""
@pytest.fixture(autouse=True)
def config_flow_fixture(hass: HomeAssistant) -> Generator[None]:
"""Mock config flow."""
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
with mock_config_flow(TEST_DOMAIN, MockFlow):
yield
@pytest.fixture(autouse=True)
def mock_setup_integration(hass: HomeAssistant) -> None:
"""Fixture to set up a mock integration."""
async def async_setup_entry_init(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Set up test config entry."""
await hass.config_entries.async_forward_entry_setups(
config_entry, [CLIMATE_DOMAIN]
)
return True
async def async_unload_entry_init(
hass: HomeAssistant,
config_entry: ConfigEntry,
) -> bool:
await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO])
return True
mock_platform(hass, f"{TEST_DOMAIN}.config_flow")
mock_integration(
hass,
MockModule(
TEST_DOMAIN,
async_setup_entry=async_setup_entry_init,
async_unload_entry=async_unload_entry_init,
),
)
async def create_mock_platform(
hass: HomeAssistant,
entities: list[ClimateEntity],
) -> MockConfigEntry:
"""Create a todo platform with the specified entities."""
async def async_setup_entry_platform(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up test event platform via config entry."""
async_add_entities(entities)
mock_platform(
hass,
f"{TEST_DOMAIN}.{CLIMATE_DOMAIN}",
MockPlatform(async_setup_entry=async_setup_entry_platform),
)
config_entry = MockConfigEntry(domain=TEST_DOMAIN)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry
class MockClimateEntity(ClimateEntity):
"""Mock Climate device to use in tests."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_mode = HVACMode.OFF
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
_attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the thermostat temperature."""
value = kwargs[ATTR_TEMPERATURE]
self._attr_target_temperature = value
class MockClimateEntityNoSetTemperature(ClimateEntity):
"""Mock Climate device to use in tests."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_mode = HVACMode.OFF
_attr_hvac_modes = [HVACMode.OFF, HVACMode.HEAT]
async def test_get_temperature(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test HassClimateGetTemperature intent."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
climate_1 = MockClimateEntity()
climate_1._attr_name = "Climate 1"
climate_1._attr_unique_id = "1234"
climate_1._attr_current_temperature = 10.0
entity_registry.async_get_or_create(
CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1"
)
climate_2 = MockClimateEntity()
climate_2._attr_name = "Climate 2"
climate_2._attr_unique_id = "5678"
climate_2._attr_current_temperature = 22.0
entity_registry.async_get_or_create(
CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2"
)
await create_mock_platform(hass, [climate_1, climate_2])
# Add climate entities to different areas:
# climate_1 => living room
# climate_2 => bedroom
# nothing in office
living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom")
office_area = area_registry.async_create(name="Office")
entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id
)
entity_registry.async_update_entity(climate_2.entity_id, area_id=bedroom_area.id)
# First climate entity will be selected (no area)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert response.matched_states
assert response.matched_states[0].entity_id == climate_1.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 10.0
# Select by area (climate_2)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
# Select by name (climate_2)
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 2"}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0
# Check area with no climate entities
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}},
assistant=conversation.DOMAIN,
)
# Exception should contain details of what we tried to match
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
constraints = error.value.constraints
assert constraints.name is None
assert constraints.area_name == office_area.name
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
assert constraints.device_classes is None
# Check wrong name
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Does not exist"}},
)
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.NAME
constraints = error.value.constraints
assert constraints.name == "Does not exist"
assert constraints.area_name is None
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
assert constraints.device_classes is None
# Check wrong name with area
with pytest.raises(intent.MatchFailedError) as error:
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}},
)
assert isinstance(error.value, intent.MatchFailedError)
assert error.value.result.no_match_reason == intent.MatchFailedReason.AREA
constraints = error.value.constraints
assert constraints.name == "Climate 1"
assert constraints.area_name == bedroom_area.name
assert constraints.domains and (set(constraints.domains) == {CLIMATE_DOMAIN})
assert constraints.device_classes is None
async def test_get_temperature_no_entities(
hass: HomeAssistant,
) -> None:
"""Test HassClimateGetTemperature intent with no climate entities."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
await create_mock_platform(hass, [])
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.DOMAIN
async def test_not_exposed(
hass: HomeAssistant,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test HassClimateGetTemperature intent when entities aren't exposed."""
assert await async_setup_component(hass, "homeassistant", {})
assert await async_setup_component(hass, "intent", {})
climate_1 = MockClimateEntity()
climate_1._attr_name = "Climate 1"
climate_1._attr_unique_id = "1234"
climate_1._attr_current_temperature = 10.0
entity_registry.async_get_or_create(
CLIMATE_DOMAIN, "test", "1234", suggested_object_id="climate_1"
)
climate_2 = MockClimateEntity()
climate_2._attr_name = "Climate 2"
climate_2._attr_unique_id = "5678"
climate_2._attr_current_temperature = 22.0
entity_registry.async_get_or_create(
CLIMATE_DOMAIN, "test", "5678", suggested_object_id="climate_2"
)
await create_mock_platform(hass, [climate_1, climate_2])
# Add climate entities to same area
living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom")
entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id
)
entity_registry.async_update_entity(
climate_2.entity_id, area_id=living_room_area.id
)
# Should fail with empty name
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": ""}},
assistant=conversation.DOMAIN,
)
# Should fail with empty area
with pytest.raises(intent.InvalidSlotInfo):
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": ""}},
assistant=conversation.DOMAIN,
)
# Expose second, hide first
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, True)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the area should work
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the exposed entity should work
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_2.name}},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_2.entity_id
# Using the name of the *unexposed* entity should fail
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": climate_1.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Expose first, hide second
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, True)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
# Second climate entity is exposed
response = await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1
assert response.matched_states[0].entity_id == climate_1.entity_id
# Wrong area name
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": bedroom_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.AREA
# Neither are exposed
async_expose_entity(hass, conversation.DOMAIN, climate_1.entity_id, False)
async_expose_entity(hass, conversation.DOMAIN, climate_2.entity_id, False)
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with area
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"area": {"value": living_room_area.name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT
# Should fail with both names
for name in (climate_1.name, climate_2.name):
with pytest.raises(intent.MatchFailedError) as err:
await intent.async_handle(
hass,
"test",
intent.INTENT_GET_TEMPERATURE,
{"name": {"value": name}},
assistant=conversation.DOMAIN,
)
assert err.value.result.no_match_reason == intent.MatchFailedReason.ASSISTANT

View File

@@ -2,11 +2,21 @@
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.api import MassEvent
from music_assistant_models.enums import EventType
from music_assistant_models.media_items import Album, Artist, Playlist, Radio, Track
from music_assistant_models.media_items import (
Album,
Artist,
Audiobook,
Playlist,
Podcast,
Radio,
Track,
)
from music_assistant_models.player import Player
from music_assistant_models.player_queue import PlayerQueue
from syrupy import SnapshotAssertion
@@ -60,6 +70,10 @@ async def setup_integration_from_fixtures(
music.get_playlist_tracks = AsyncMock(return_value=library_playlist_tracks)
library_radios = create_library_radios_from_fixture()
music.get_library_radios = AsyncMock(return_value=library_radios)
library_audiobooks = create_library_audiobooks_from_fixture()
music.get_library_audiobooks = AsyncMock(return_value=library_audiobooks)
library_podcasts = create_library_podcasts_from_fixture()
music.get_library_podcasts = AsyncMock(return_value=library_podcasts)
music.get_item_by_uri = AsyncMock()
config_entry.add_to_hass(hass)
@@ -130,19 +144,58 @@ def create_library_radios_from_fixture() -> list[Radio]:
return [Radio.from_dict(radio_data) for radio_data in fixture_data]
def create_library_audiobooks_from_fixture() -> list[Audiobook]:
"""Create MA Audiobooks from fixture."""
fixture_data = load_and_parse_fixture("library_audiobooks")
return [Audiobook.from_dict(radio_data) for radio_data in fixture_data]
def create_library_podcasts_from_fixture() -> list[Podcast]:
"""Create MA Podcasts from fixture."""
fixture_data = load_and_parse_fixture("library_podcasts")
return [Podcast.from_dict(radio_data) for radio_data in fixture_data]
async def trigger_subscription_callback(
hass: HomeAssistant,
client: MagicMock,
event: EventType = EventType.PLAYER_UPDATED,
object_id: str | None = None,
data: Any = None,
) -> None:
"""Trigger a subscription callback."""
# trigger callback on all subscribers
for sub in client.subscribe_events.call_args_list:
callback = sub.kwargs["callback"]
event_filter = sub.kwargs.get("event_filter")
if event_filter in (None, event):
callback(event, data)
for sub in client.subscribe.call_args_list:
cb_func = sub.kwargs.get("cb_func", sub.args[0])
event_filter = sub.kwargs.get(
"event_filter", sub.args[1] if len(sub.args) > 1 else None
)
id_filter = sub.kwargs.get(
"id_filter", sub.args[2] if len(sub.args) > 2 else None
)
if not (
event_filter is None
or event == event_filter
or (isinstance(event_filter, list) and event in event_filter)
):
continue
if not (
id_filter is None
or object_id == id_filter
or (isinstance(id_filter, list) and object_id in id_filter)
):
continue
event = MassEvent(
event=event,
object_id=object_id,
data=data,
)
if asyncio.iscoroutinefunction(cb_func):
await cb_func(event)
else:
cb_func(event)
await hass.async_block_till_done()

View File

@@ -0,0 +1,489 @@
{
"library_audiobooks": [
{
"item_id": "1",
"provider": "library",
"name": "Test Audiobook",
"version": "",
"sort_name": "test audiobook",
"uri": "library://audiobook/1",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "test-audiobook.mp3",
"provider_domain": "filesystem_smb",
"provider_instance": "filesystem_smb--7Kf8QySu",
"available": true,
"audio_format": {
"content_type": "mp3",
"codec_type": "?",
"sample_rate": 48000,
"bit_depth": 16,
"channels": 1,
"output_format_str": "mp3",
"bit_rate": 90304
},
"url": null,
"details": "1738502411"
}
],
"metadata": {
"description": "Cover (front)",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "test-audiobook.mp3",
"provider": "filesystem_smb--7Kf8QySu",
"remotely_accessible": false
}
],
"genres": [],
"mood": null,
"style": null,
"copyright": null,
"lyrics": "",
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": null,
"authors": ["TestWriter"],
"narrators": [],
"duration": 9,
"fully_played": true,
"resume_position_ms": 9000
},
{
"item_id": "11",
"provider": "library",
"name": "Test Audiobook 0",
"version": "",
"sort_name": "test audiobook 0",
"uri": "library://audiobook/11",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "0",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": "This is a description for Test Audiobook",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [
{
"position": 1,
"name": "Chapter 1",
"start": 10.0,
"end": 20.0
},
{
"position": 2,
"name": "Chapter 2",
"start": 20.0,
"end": 40.0
},
{
"position": 2,
"name": "Chapter 3",
"start": 40.0,
"end": null
}
],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"authors": ["AudioBook Author"],
"narrators": ["AudioBook Narrator"],
"duration": 60,
"fully_played": null,
"resume_position_ms": null
},
{
"item_id": "12",
"provider": "library",
"name": "Test Audiobook 1",
"version": "",
"sort_name": "test audiobook 1",
"uri": "library://audiobook/12",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "1",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": "This is a description for Test Audiobook",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [
{
"position": 1,
"name": "Chapter 1",
"start": 10.0,
"end": 20.0
},
{
"position": 2,
"name": "Chapter 2",
"start": 20.0,
"end": 40.0
},
{
"position": 2,
"name": "Chapter 3",
"start": 40.0,
"end": null
}
],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"authors": ["AudioBook Author"],
"narrators": ["AudioBook Narrator"],
"duration": 60,
"fully_played": null,
"resume_position_ms": null
},
{
"item_id": "13",
"provider": "library",
"name": "Test Audiobook 2",
"version": "",
"sort_name": "test audiobook 2",
"uri": "library://audiobook/13",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "2",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": "This is a description for Test Audiobook",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [
{
"position": 1,
"name": "Chapter 1",
"start": 10.0,
"end": 20.0
},
{
"position": 2,
"name": "Chapter 2",
"start": 20.0,
"end": 40.0
},
{
"position": 2,
"name": "Chapter 3",
"start": 40.0,
"end": null
}
],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"authors": ["AudioBook Author"],
"narrators": ["AudioBook Narrator"],
"duration": 60,
"fully_played": null,
"resume_position_ms": null
},
{
"item_id": "14",
"provider": "library",
"name": "Test Audiobook 3",
"version": "",
"sort_name": "test audiobook 3",
"uri": "library://audiobook/14",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "3",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": "This is a description for Test Audiobook",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [
{
"position": 1,
"name": "Chapter 1",
"start": 10.0,
"end": 20.0
},
{
"position": 2,
"name": "Chapter 2",
"start": 20.0,
"end": 40.0
},
{
"position": 2,
"name": "Chapter 3",
"start": 40.0,
"end": null
}
],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"authors": ["AudioBook Author"],
"narrators": ["AudioBook Narrator"],
"duration": 60,
"fully_played": null,
"resume_position_ms": null
},
{
"item_id": "15",
"provider": "library",
"name": "Test Audiobook 4",
"version": "",
"sort_name": "test audiobook 4",
"uri": "library://audiobook/15",
"external_ids": [],
"is_playable": true,
"media_type": "audiobook",
"provider_mappings": [
{
"item_id": "4",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": "This is a description for Test Audiobook",
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": [
{
"position": 1,
"name": "Chapter 1",
"start": 10.0,
"end": 20.0
},
{
"position": 2,
"name": "Chapter 2",
"start": 20.0,
"end": 40.0
},
{
"position": 2,
"name": "Chapter 3",
"start": 40.0,
"end": null
}
],
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"authors": ["AudioBook Author"],
"narrators": ["AudioBook Narrator"],
"duration": 60,
"fully_played": null,
"resume_position_ms": null
}
]
}

View File

@@ -0,0 +1,309 @@
{
"library_podcasts": [
{
"item_id": "6",
"provider": "library",
"name": "Test Podcast 0",
"version": "",
"sort_name": "test podcast 0",
"uri": "library://podcast/6",
"external_ids": [],
"is_playable": true,
"media_type": "podcast",
"provider_mappings": [
{
"item_id": "0",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": null,
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": null,
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"total_episodes": null
},
{
"item_id": "7",
"provider": "library",
"name": "Test Podcast 1",
"version": "",
"sort_name": "test podcast 1",
"uri": "library://podcast/7",
"external_ids": [],
"is_playable": true,
"media_type": "podcast",
"provider_mappings": [
{
"item_id": "1",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": null,
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": null,
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"total_episodes": null
},
{
"item_id": "8",
"provider": "library",
"name": "Test Podcast 2",
"version": "",
"sort_name": "test podcast 2",
"uri": "library://podcast/8",
"external_ids": [],
"is_playable": true,
"media_type": "podcast",
"provider_mappings": [
{
"item_id": "2",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": null,
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": null,
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"total_episodes": null
},
{
"item_id": "9",
"provider": "library",
"name": "Test Podcast 3",
"version": "",
"sort_name": "test podcast 3",
"uri": "library://podcast/9",
"external_ids": [],
"is_playable": true,
"media_type": "podcast",
"provider_mappings": [
{
"item_id": "3",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": null,
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": null,
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"total_episodes": null
},
{
"item_id": "10",
"provider": "library",
"name": "Test Podcast 4",
"version": "",
"sort_name": "test podcast 4",
"uri": "library://podcast/10",
"external_ids": [],
"is_playable": true,
"media_type": "podcast",
"provider_mappings": [
{
"item_id": "4",
"provider_domain": "test",
"provider_instance": "test",
"available": true,
"audio_format": {
"content_type": "?",
"codec_type": "?",
"sample_rate": 44100,
"bit_depth": 16,
"channels": 2,
"output_format_str": "?",
"bit_rate": 0
},
"url": null,
"details": null
}
],
"metadata": {
"description": null,
"review": null,
"explicit": null,
"images": [
{
"type": "thumb",
"path": "logo.png",
"provider": "builtin",
"remotely_accessible": false
}
],
"genres": null,
"mood": null,
"style": null,
"copyright": null,
"lyrics": null,
"label": null,
"links": null,
"performers": null,
"preview": null,
"popularity": null,
"release_date": null,
"languages": null,
"chapters": null,
"last_refresh": null
},
"favorite": false,
"position": null,
"publisher": "Test Publisher",
"total_episodes": null
}
]
}

View File

@@ -1,5 +1,195 @@
# serializer version: 1
# name: test_get_library_action
# name: test_get_library_action[album]
dict({
'items': list([
dict({
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'A Space Love Adventure',
'uri': 'library://artist/289',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Synth Punk EP',
'uri': 'library://album/396',
'version': '',
}),
dict({
'artists': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'Various Artists',
'uri': 'library://artist/96',
'version': '',
}),
]),
'image': None,
'media_type': <MediaType.ALBUM: 'album'>,
'name': 'Synthwave (The 80S Revival)',
'uri': 'library://album/95',
'version': 'The 80S Revival',
}),
]),
'limit': 25,
'media_type': <MediaType.ALBUM: 'album'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[artist]
dict({
'items': list([
dict({
'image': None,
'media_type': <MediaType.ARTIST: 'artist'>,
'name': 'W O L F C L U B',
'uri': 'library://artist/127',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.ARTIST: 'artist'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[audiobook]
dict({
'items': list([
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook',
'uri': 'library://audiobook/1',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook 0',
'uri': 'library://audiobook/11',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook 1',
'uri': 'library://audiobook/12',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook 2',
'uri': 'library://audiobook/13',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook 3',
'uri': 'library://audiobook/14',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'name': 'Test Audiobook 4',
'uri': 'library://audiobook/15',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.AUDIOBOOK: 'audiobook'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[playlist]
dict({
'items': list([
dict({
'image': None,
'media_type': <MediaType.PLAYLIST: 'playlist'>,
'name': '1970s Rock Hits',
'uri': 'library://playlist/40',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.PLAYLIST: 'playlist'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[podcast]
dict({
'items': list([
dict({
'image': None,
'media_type': <MediaType.PODCAST: 'podcast'>,
'name': 'Test Podcast 0',
'uri': 'library://podcast/6',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.PODCAST: 'podcast'>,
'name': 'Test Podcast 1',
'uri': 'library://podcast/7',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.PODCAST: 'podcast'>,
'name': 'Test Podcast 2',
'uri': 'library://podcast/8',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.PODCAST: 'podcast'>,
'name': 'Test Podcast 3',
'uri': 'library://podcast/9',
'version': '',
}),
dict({
'image': None,
'media_type': <MediaType.PODCAST: 'podcast'>,
'name': 'Test Podcast 4',
'uri': 'library://podcast/10',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.PODCAST: 'podcast'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[radio]
dict({
'items': list([
dict({
'image': None,
'media_type': <MediaType.RADIO: 'radio'>,
'name': 'fm4 | ORF | HQ',
'uri': 'library://radio/1',
'version': '',
}),
]),
'limit': 25,
'media_type': <MediaType.RADIO: 'radio'>,
'offset': 0,
'order_by': 'name',
})
# ---
# name: test_get_library_action[track]
dict({
'items': list([
dict({
@@ -192,8 +382,12 @@
]),
'artists': list([
]),
'audiobooks': list([
]),
'playlists': list([
]),
'podcasts': list([
]),
'radio': list([
]),
'tracks': list([

View File

@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, MagicMock
from music_assistant_models.media_items import SearchResults
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.music_assistant.actions import (
@@ -47,9 +48,22 @@ async def test_search_action(
assert response == snapshot
@pytest.mark.parametrize(
"media_type",
[
"artist",
"album",
"track",
"playlist",
"audiobook",
"podcast",
"radio",
],
)
async def test_get_library_action(
hass: HomeAssistant,
music_assistant_client: MagicMock,
media_type: str,
snapshot: SnapshotAssertion,
) -> None:
"""Test music assistant get_library action."""
@@ -60,7 +74,7 @@ async def test_get_library_action(
{
ATTR_CONFIG_ENTRY_ID: entry.entry_id,
ATTR_FAVORITE: False,
ATTR_MEDIA_TYPE: "track",
ATTR_MEDIA_TYPE: media_type,
},
blocking=True,
return_response=True,

View File

@@ -2,7 +2,13 @@
from unittest.mock import MagicMock, call
from music_assistant_models.enums import MediaType, QueueOption
from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
PlayerFeature,
QueueOption,
)
from music_assistant_models.media_items import Track
import pytest
from syrupy import SnapshotAssertion
@@ -20,6 +26,7 @@ from homeassistant.components.media_player import (
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_UNJOIN,
MediaPlayerEntityFeature,
)
from homeassistant.components.music_assistant.const import DOMAIN as MASS_DOMAIN
from homeassistant.components.music_assistant.media_player import (
@@ -59,7 +66,11 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities
from .common import (
setup_integration_from_fixtures,
snapshot_music_assistant_entities,
trigger_subscription_callback,
)
from tests.common import AsyncMock
@@ -607,3 +618,104 @@ async def test_media_player_get_queue_action(
# no call is made, this info comes from the cached queue data
assert music_assistant_client.send_command.call_count == 0
assert response == snapshot(exclude=paths(f"{entity_id}.elapsed_time"))
async def test_media_player_supported_features(
hass: HomeAssistant,
music_assistant_client: MagicMock,
) -> None:
"""Test if media_player entity supported features are cortrectly (re)mapped."""
await setup_integration_from_fixtures(hass, music_assistant_client)
entity_id = "media_player.test_player_1"
mass_player_id = "00:00:00:00:00:01"
state = hass.states.get(entity_id)
assert state
expected_features = (
MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.SEEK
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
)
assert state.attributes["supported_features"] == expected_features
# remove power control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].power_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.TURN_ON
expected_features &= ~MediaPlayerEntityFeature.TURN_OFF
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove volume control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].volume_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.VOLUME_SET
expected_features &= ~MediaPlayerEntityFeature.VOLUME_STEP
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove mute control capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[
mass_player_id
].mute_control = PLAYER_CONTROL_NONE
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.VOLUME_MUTE
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove pause capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[mass_player_id].supported_features.remove(
PlayerFeature.PAUSE
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.PAUSE
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features
# remove grouping capability from player, trigger subscription callback
# and check if the supported features got updated
music_assistant_client.players._players[mass_player_id].supported_features.remove(
PlayerFeature.SET_MEMBERS
)
await trigger_subscription_callback(
hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id
)
expected_features &= ~MediaPlayerEntityFeature.GROUPING
state = hass.states.get(entity_id)
assert state
assert state.attributes["supported_features"] == expected_features

View File

@@ -0,0 +1,31 @@
# serializer version: 1
# name: test_diagnostics
dict({
'config': dict({
'auth_implementation': 'onedrive',
'folder_id': 'my_folder_id',
'folder_name': 'name',
'token': '**REDACTED**',
}),
'drive': dict({
'drive_type': 'personal',
'id': 'mock_drive_id',
'name': 'My Drive',
'owner': dict({
'application': None,
'user': dict({
'display_name': '**REDACTED**',
'email': '**REDACTED**',
'id': 'id',
}),
}),
'quota': dict({
'deleted': 5,
'remaining': 805306368,
'state': 'nearing',
'total': 5368709120,
'used': 4250000000,
}),
}),
})
# ---

View File

@@ -0,0 +1,26 @@
"""Tests for the diagnostics data provided by the OneDrive integration."""
from syrupy import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -236,7 +236,6 @@ async def test_data_cap_issues(
async def test_1_1_to_1_2_migration(
hass: HomeAssistant,
mock_onedrive_client: MagicMock,
mock_config_entry: MockConfigEntry,
mock_folder: Folder,
) -> None:
@@ -251,12 +250,34 @@ async def test_1_1_to_1_2_migration(
},
)
await setup_integration(hass, old_config_entry)
assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id
assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name
assert old_config_entry.minor_version == 2
async def test_1_1_to_1_2_migration_failure(
hass: HomeAssistant,
mock_onedrive_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test migration from 1.1 to 1.2 failure."""
old_config_entry = MockConfigEntry(
unique_id="mock_drive_id",
title="John Doe's OneDrive",
domain=DOMAIN,
data={
"auth_implementation": mock_config_entry.data["auth_implementation"],
"token": mock_config_entry.data["token"],
},
)
# will always 404 after migration, because of dummy id
mock_onedrive_client.get_drive_item.side_effect = NotFoundError(404, "Not found")
await setup_integration(hass, old_config_entry)
assert old_config_entry.data[CONF_FOLDER_ID] == mock_folder.id
assert old_config_entry.data[CONF_FOLDER_NAME] == mock_folder.name
assert old_config_entry.state is ConfigEntryState.MIGRATION_ERROR
assert old_config_entry.minor_version == 1
async def test_migration_guard_against_major_downgrade(

View File

@@ -1,12 +1,13 @@
"""Test backup platform for the Recorder integration."""
from contextlib import AbstractContextManager, nullcontext as does_not_raise
from unittest.mock import patch
import pytest
from homeassistant.components.recorder import Recorder
from homeassistant.components.recorder.backup import async_post_backup, async_pre_backup
from homeassistant.core import HomeAssistant
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -19,6 +20,41 @@ async def test_async_pre_backup(recorder_mock: Recorder, hass: HomeAssistant) ->
assert lock_mock.called
RAISES_HASS_NOT_RUNNING = pytest.raises(
HomeAssistantError, match="Home Assistant is not running"
)
@pytest.mark.parametrize(
("core_state", "expected_result", "lock_calls"),
[
(CoreState.final_write, RAISES_HASS_NOT_RUNNING, 0),
(CoreState.not_running, RAISES_HASS_NOT_RUNNING, 0),
(CoreState.running, does_not_raise(), 1),
(CoreState.starting, RAISES_HASS_NOT_RUNNING, 0),
(CoreState.stopped, RAISES_HASS_NOT_RUNNING, 0),
(CoreState.stopping, RAISES_HASS_NOT_RUNNING, 0),
],
)
async def test_async_pre_backup_core_state(
recorder_mock: Recorder,
hass: HomeAssistant,
core_state: CoreState,
expected_result: AbstractContextManager,
lock_calls: int,
) -> None:
"""Test pre backup in different core states."""
hass.set_state(core_state)
with ( # pylint: disable=confusing-with-statement
patch(
"homeassistant.components.recorder.core.Recorder.lock_database"
) as lock_mock,
expected_result,
):
await async_pre_backup(hass)
assert len(lock_mock.mock_calls) == lock_calls
async def test_async_pre_backup_with_timeout(
recorder_mock: Recorder, hass: HomeAssistant
) -> None:

View File

@@ -57,19 +57,21 @@ async def trigger_update(
data: dict[str, Any] | None = None,
) -> None:
"""Trigger an update."""
event = DeviceEvent(
"abc",
"abc",
"abc",
device_id,
MAIN,
capability,
attribute,
value,
data,
)
for call in mock.add_device_event_listener.call_args_list:
if call[0][0] == device_id:
call[0][3](event)
for call in mock.add_device_capability_event_listener.call_args_list:
if call[0][0] == device_id and call[0][2] == capability:
call[0][3](
DeviceEvent(
"abc",
"abc",
"abc",
device_id,
MAIN,
capability,
attribute,
value,
data,
)
)
call[0][3](event)
await hass.async_block_till_done()

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
# serializer version: 1
# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-entry]
# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -14,7 +14,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.aeotec_energy_monitor_energy_2',
'entity_id': 'sensor.aeotec_energy_monitor_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -35,7 +35,7 @@
'unit_of_measurement': 'kWh',
})
# ---
# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy_2-state]
# name: test_all_entities[aeotec_home_energy_meter_gen5][sensor.aeotec_energy_monitor_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
@@ -44,7 +44,7 @@
'unit_of_measurement': 'kWh',
}),
'context': <ANY>,
'entity_id': 'sensor.aeotec_energy_monitor_energy_2',
'entity_id': 'sensor.aeotec_energy_monitor_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
@@ -545,6 +545,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -597,6 +600,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -649,6 +655,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -753,6 +762,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -807,6 +819,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -959,6 +974,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1011,6 +1029,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1063,6 +1084,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1167,6 +1191,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -1221,6 +1248,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1768,6 +1798,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1820,6 +1853,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1872,6 +1908,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -1924,6 +1963,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -1978,6 +2020,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2326,6 +2371,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2378,6 +2426,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2430,6 +2481,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2614,6 +2668,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -2668,6 +2725,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2768,6 +2828,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2820,6 +2883,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -2872,6 +2938,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -3066,6 +3135,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -3120,6 +3192,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -3220,6 +3295,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -3272,6 +3350,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -3324,6 +3405,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
@@ -3520,6 +3604,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
@@ -3574,6 +3661,9 @@
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,

View File

@@ -0,0 +1,49 @@
"""Test SmartThings diagnostics."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.smartthings.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_device
from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_device(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
device_registry: dr.DeviceRegistry,
devices: AsyncMock,
mock_smartthings: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test generating diagnostics for a device entry."""
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device(
identifiers={(DOMAIN, "96a5ef74-5832-a84b-f1f7-ca799957065d")}
)
mock_smartthings.get_device_status.reset_mock()
with patch("homeassistant.components.smartthings.diagnostics.EVENT_WAIT_TIME", 0.1):
diag = await get_diagnostics_for_device(
hass, hass_client, mock_config_entry, device
)
assert diag == snapshot(
exclude=props("last_changed", "last_reported", "last_updated")
)
mock_smartthings.get_device_status.assert_called_once_with(
"96a5ef74-5832-a84b-f1f7-ca799957065d"
)

View File

@@ -28,7 +28,7 @@ async def test_all_entities(
snapshot_smartthings_entities(hass, entity_registry, snapshot, Platform.SENSOR)
@pytest.mark.parametrize("device_fixture", ["aeotec_home_energy_meter_gen5"])
@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"])
async def test_state_update(
hass: HomeAssistant,
devices: AsyncMock,
@@ -37,15 +37,15 @@ async def test_state_update(
"""Test state update."""
await setup_integration(hass, mock_config_entry)
assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "19978.536"
assert hass.states.get("sensor.ac_office_granit_temperature").state == "25"
await trigger_update(
hass,
devices,
"f0af21a2-d5a1-437c-b10a-b34a87394b71",
Capability.ENERGY_METER,
Attribute.ENERGY,
20000.0,
"96a5ef74-5832-a84b-f1f7-ca799957065d",
Capability.TEMPERATURE_MEASUREMENT,
Attribute.TEMPERATURE,
20,
)
assert hass.states.get("sensor.aeotec_energy_monitor_energy_2").state == "20000.0"
assert hass.states.get("sensor.ac_office_granit_temperature").state == "20"

View File

@@ -70,10 +70,10 @@ def mock_stookwijzer() -> Generator[MagicMock]:
new=stookwijzer_mock,
),
):
stookwijzer_mock.async_transform_coordinates.return_value = (
450000.123456789,
200000.123456789,
)
stookwijzer_mock.async_transform_coordinates.return_value = {
"x": 450000.123456789,
"y": 200000.123456789,
}
client = stookwijzer_mock.return_value
client.lki = 2

View File

@@ -32,8 +32,8 @@ async def test_full_user_flow(
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Stookwijzer"
assert result["data"] == {
CONF_LATITUDE: 200000.123456789,
CONF_LONGITUDE: 450000.123456789,
CONF_LATITUDE: 450000.123456789,
CONF_LONGITUDE: 200000.123456789,
}
assert len(mock_setup_entry.mock_calls) == 1
@@ -47,7 +47,7 @@ async def test_connection_error(
) -> None:
"""Test user configuration flow while connection fails."""
original_return_value = mock_stookwijzer.async_transform_coordinates.return_value
mock_stookwijzer.async_transform_coordinates.return_value = (None, None)
mock_stookwijzer.async_transform_coordinates.return_value = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}

View File

@@ -66,8 +66,8 @@ async def test_migrate_entry(
assert mock_v1_config_entry.version == 2
assert mock_v1_config_entry.data == {
CONF_LATITUDE: 200000.123456789,
CONF_LONGITUDE: 450000.123456789,
CONF_LATITUDE: 450000.123456789,
CONF_LONGITUDE: 200000.123456789,
}
@@ -81,7 +81,7 @@ async def test_entry_migration_failure(
assert mock_v1_config_entry.version == 1
# Failed getting the transformed coordinates
mock_stookwijzer.async_transform_coordinates.return_value = (None, None)
mock_stookwijzer.async_transform_coordinates.return_value = None
mock_v1_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_v1_config_entry.entry_id)

View File

@@ -20,12 +20,12 @@ MOCK_LIST_WITH_PROPERTIES = {
"/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.tar": [],
"/Automatic_backup_2025.2.1_2025-02-10_18.31_30202686.metadata.json": [
Property(
namespace="homeassistant",
namespace="https://home-assistant.io",
name="backup_id",
value="23e64aec",
),
Property(
namespace="homeassistant",
namespace="https://home-assistant.io",
name="metadata_version",
value="1",
),

View File

@@ -179,16 +179,7 @@
}),
'0x0010': dict({
'attribute': "ZCLAttributeDef(id=0x0010, name='cie_addr', type=<class 'zigpy.types.named.EUI64'>, zcl_type=<DataTypeId.EUI64: 240>, access=<ZCLAttributeAccess.Read|Write: 3>, mandatory=True, is_manufacturer_specific=False)",
'value': list([
50,
79,
50,
2,
0,
141,
21,
0,
]),
'value': None,
}),
'0x0011': dict({
'attribute': "ZCLAttributeDef(id=0x0011, name='zone_id', type=<class 'zigpy.types.basic.uint8_t'>, zcl_type=<DataTypeId.uint8: 32>, access=<ZCLAttributeAccess.Read: 1>, mandatory=True, is_manufacturer_specific=False)",

View File

@@ -30,6 +30,7 @@ from homeassistant.core import HomeAssistant
from .common import send_attributes_report
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
ENTITY_ID_NO_PREFIX = "sensor.fakemanufacturer_fakemodel"
ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}"
@@ -335,7 +336,7 @@ async def async_test_pi_heating_demand(
"humidity",
async_test_humidity,
1,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -344,7 +345,7 @@ async def async_test_pi_heating_demand(
"temperature",
async_test_temperature,
1,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -353,7 +354,7 @@ async def async_test_pi_heating_demand(
"pressure",
async_test_pressure,
1,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -362,7 +363,7 @@ async def async_test_pi_heating_demand(
"illuminance",
async_test_illuminance,
1,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -492,7 +493,7 @@ async def async_test_pi_heating_demand(
"device_temperature",
async_test_device_temperature,
1,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -501,7 +502,7 @@ async def async_test_pi_heating_demand(
"setpoint_change_source",
async_test_setpoint_change_source,
10,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -510,7 +511,7 @@ async def async_test_pi_heating_demand(
"pi_heating_demand",
async_test_pi_heating_demand,
10,
None,
{},
None,
STATE_UNKNOWN,
),
@@ -558,7 +559,6 @@ async def test_sensor(
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
entity_id = ENTITY_ID_PREFIX.format(entity_suffix)
zigpy_device = zigpy_device_mock(
{
@@ -570,6 +570,11 @@ async def test_sensor(
}
)
if hass.states.get(ENTITY_ID_NO_PREFIX):
entity_id = ENTITY_ID_NO_PREFIX
else:
entity_id = ENTITY_ID_PREFIX.format(entity_suffix)
assert hass.states.get(entity_id).state == initial_sensor_state
# test sensor associated logic

View File

@@ -420,8 +420,11 @@ async def test_list_groupable_devices(
assert entity_reference[ATTR_NAME] is not None
assert entity_reference["entity_id"] is not None
for entity_reference in endpoint["entities"]:
assert entity_reference["original_name"] is not None
if len(endpoint["entities"]) == 1:
assert endpoint["entities"][0]["original_name"] is None
else:
for entity_reference in endpoint["entities"]:
assert entity_reference["original_name"] is not None
# Make sure there are no groupable devices when the device is unavailable
# Make device unavailable