forked from home-assistant/core
Compare commits
37 Commits
mqtt-clien
...
2025.3.0b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178d509d56 | ||
|
|
09c129de40 | ||
|
|
07128ba063 | ||
|
|
a786ff53ff | ||
|
|
d2e19c829d | ||
|
|
94b342f26a | ||
|
|
9e3e6b3f43 | ||
|
|
4300900322 | ||
|
|
342e04974d | ||
|
|
fdb4c0a81f | ||
|
|
6de878ffe4 | ||
|
|
c63aaec09e | ||
|
|
d8bf47c101 | ||
|
|
736ff8828d | ||
|
|
b501999a4c | ||
|
|
3985f1c6c8 | ||
|
|
46ec3987a8 | ||
|
|
df4e5a54e3 | ||
|
|
d8a259044f | ||
|
|
0891669aee | ||
|
|
83c0351338 | ||
|
|
c5e5fe555d | ||
|
|
345ba73777 | ||
|
|
e4200a79a2 | ||
|
|
381fa65ba0 | ||
|
|
16314711b8 | ||
|
|
553abe4a4a | ||
|
|
6a1bbdb3a7 | ||
|
|
59d92c75bd | ||
|
|
7732e6878e | ||
|
|
2cde317d59 | ||
|
|
0c08430507 | ||
|
|
fa6d7d5e3c | ||
|
|
585b950a46 | ||
|
|
3effc2e182 | ||
|
|
0e1602ff71 | ||
|
|
693584ce29 |
40
.github/workflows/wheels.yml
vendored
40
.github/workflows/wheels.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -195,8 +195,11 @@
|
||||
"options": {
|
||||
"artist": "Artist",
|
||||
"album": "Album",
|
||||
"audiobook": "Audiobook",
|
||||
"folder": "Folder",
|
||||
"track": "Track",
|
||||
"playlist": "Playlist",
|
||||
"podcast": "Podcast",
|
||||
"radio": "Radio"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
33
homeassistant/components/onedrive/diagnostics.py
Normal file
33
homeassistant/components/onedrive/diagnostics.py
Normal 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)
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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()}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
49
homeassistant/components/smartthings/diagnostics.py
Normal file
49
homeassistant/components/smartthings/diagnostics.py
Normal 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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"requirements": ["aiounifi==82"],
|
||||
"requirements": ["aiounifi==83"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"zha",
|
||||
"universal_silabs_flasher"
|
||||
],
|
||||
"requirements": ["zha==0.0.50"],
|
||||
"requirements": ["zha==0.0.51"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "10C4",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
26
requirements_all.txt
generated
@@ -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
|
||||
|
||||
26
requirements_test_all.txt
generated
26
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
2
script/hassfest/docker/Dockerfile
generated
2
script/hassfest/docker/Dockerfile
generated
@@ -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>"
|
||||
|
||||
@@ -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(
|
||||
(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"},
|
||||
),
|
||||
(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
456
tests/components/intent/test_temperature.py
Normal file
456
tests/components/intent/test_temperature.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
309
tests/components/music_assistant/fixtures/library_podcasts.json
Normal file
309
tests/components/music_assistant/fixtures/library_podcasts.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
31
tests/components/onedrive/snapshots/test_diagnostics.ambr
Normal file
31
tests/components/onedrive/snapshots/test_diagnostics.ambr
Normal 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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
26
tests/components/onedrive/test_diagnostics.py
Normal file
26
tests/components/onedrive/test_diagnostics.py
Normal 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
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
1163
tests/components/smartthings/snapshots/test_diagnostics.ambr
Normal file
1163
tests/components/smartthings/snapshots/test_diagnostics.ambr
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
49
tests/components/smartthings/test_diagnostics.py
Normal file
49
tests/components/smartthings/test_diagnostics.py
Normal 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"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user