mirror of
https://github.com/home-assistant/core.git
synced 2026-02-06 15:25:33 +01:00
Compare commits
32 Commits
2026.2.0b2
...
2026.2.0b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52fb0343e4 | ||
|
|
1050b4580a | ||
|
|
344c42172e | ||
|
|
93cc0fd7f1 | ||
|
|
05fe636b55 | ||
|
|
f22467d099 | ||
|
|
4bc3899b32 | ||
|
|
fc4d6bf5f1 | ||
|
|
8ed0672a8f | ||
|
|
282e347a1b | ||
|
|
1bfb02b440 | ||
|
|
71b03bd9ae | ||
|
|
cbd69822eb | ||
|
|
db900f4dd2 | ||
|
|
a707e695bc | ||
|
|
4feceac205 | ||
|
|
10c20faaca | ||
|
|
abcd512401 | ||
|
|
fdf8edf474 | ||
|
|
47e1a98bee | ||
|
|
2d8572b943 | ||
|
|
660cfdbd50 | ||
|
|
4208595da6 | ||
|
|
b6b2d2fc6f | ||
|
|
6c4c632848 | ||
|
|
78cf62176f | ||
|
|
df971c7a42 | ||
|
|
1fcabb7f2d | ||
|
|
9fb60c9ea2 | ||
|
|
9c11a4646f | ||
|
|
b036a78776 | ||
|
|
60bb3cb704 |
5
homeassistant/brands/heatit.json
Normal file
5
homeassistant/brands/heatit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heatit",
|
||||
"name": "Heatit",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
5
homeassistant/brands/heiman.json
Normal file
5
homeassistant/brands/heiman.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heiman",
|
||||
"name": "Heiman",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -105,6 +106,9 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Remove notification sensors from unsupported devices
|
||||
await async_remove_unsupported_notification_sensors(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -122,6 +126,7 @@ async def async_setup_entry(
|
||||
AmazonSensorEntity(coordinator, serial_num, notification_desc)
|
||||
for notification_desc in NOTIFICATIONS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].notifications_supported
|
||||
]
|
||||
async_add_entities(sensors_list + notifications_list)
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@ from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -81,3 +87,27 @@ async def async_remove_dnd_from_virtual_group(
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
) -> None:
|
||||
"""Remove notification sensors from unsupported devices."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
for notification_key in (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
if entity_id and is_unsupported:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
|
||||
@@ -14,10 +14,18 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -27,6 +35,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Anthropic."""
|
||||
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
await async_migrate_integration(hass)
|
||||
return True
|
||||
|
||||
@@ -50,6 +59,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
|
||||
tuple(DEPRECATED_MODELS)
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
learn_more_url="https://platform.claude.com/docs/en/about-claude/model-deprecations",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -62,6 +87,11 @@ async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||
DATA_REPAIR_DEFER_RELOAD, set()
|
||||
)
|
||||
if entry.entry_id in defer_reload_entries:
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,40 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
await client.models.list(timeout=10.0)
|
||||
|
||||
|
||||
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
@@ -401,38 +435,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
)
|
||||
return await get_model_list(client)
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
|
||||
@@ -22,8 +22,10 @@ CONF_WEB_SEARCH_REGION = "region"
|
||||
CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DATA_REPAIR_DEFER_RELOAD = "repair_defer_reload"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
@@ -46,3 +48,10 @@ WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3-5-haiku",
|
||||
"claude-3-7-sonnet",
|
||||
"claude-3-5-sonnet",
|
||||
"claude-3-opus",
|
||||
]
|
||||
|
||||
275
homeassistant/components/anthropic/repairs.py
Normal file
275
homeassistant/components/anthropic/repairs.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Issue repair flow for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
_subentry_iter: Iterator[tuple[str, str]] | None
|
||||
_current_entry_id: str | None
|
||||
_current_subentry_id: str | None
|
||||
_reload_pending: set[str]
|
||||
_pending_updates: dict[str, dict[str, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
super().__init__()
|
||||
self._subentry_iter = None
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
self._reload_pending = set()
|
||||
self._pending_updates = {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
previous_entry_id: str | None = None
|
||||
if user_input is not None:
|
||||
previous_entry_id = self._async_update_current_subentry(user_input)
|
||||
self._clear_current_target()
|
||||
|
||||
target = await self._async_next_target()
|
||||
next_entry_id = target[0].entry_id if target else None
|
||||
if previous_entry_id and previous_entry_id != next_entry_id:
|
||||
await self._async_apply_pending_updates(previous_entry_id)
|
||||
if target is None:
|
||||
await self._async_apply_all_pending_updates()
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
entry, subentry, model = target
|
||||
client = entry.runtime_data
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
|
||||
if "opus" in model:
|
||||
suggested_model = "claude-opus-4-5"
|
||||
elif "haiku" in model:
|
||||
suggested_model = "claude-haiku-4-5"
|
||||
elif "sonnet" in model:
|
||||
suggested_model = "claude-sonnet-4-5"
|
||||
else:
|
||||
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_CHAT_MODEL,
|
||||
default=suggested_model,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=model_list, custom_value=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"entry_name": entry.title,
|
||||
"model": model,
|
||||
"subentry_name": subentry.title,
|
||||
"subentry_type": self._format_subentry_type(subentry.subentry_type),
|
||||
},
|
||||
)
|
||||
|
||||
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
|
||||
"""Yield entry/subentry pairs that use deprecated models."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
continue
|
||||
for subentry in entry.subentries.values():
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if model and model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
yield entry.entry_id, subentry.subentry_id
|
||||
|
||||
async def _async_next_target(
|
||||
self,
|
||||
) -> tuple[ConfigEntry, ConfigSubentry, str] | None:
|
||||
"""Return the next deprecated subentry target."""
|
||||
if self._subentry_iter is None:
|
||||
self._subentry_iter = self._iter_deprecated_subentries()
|
||||
|
||||
while True:
|
||||
try:
|
||||
entry_id, subentry_id = next(self._subentry_iter)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
model = self._pending_model(entry_id, subentry_id)
|
||||
if model is None:
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
continue
|
||||
|
||||
self._current_entry_id = entry_id
|
||||
self._current_subentry_id = subentry_id
|
||||
return entry, subentry, model
|
||||
|
||||
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
|
||||
"""Update the currently selected subentry."""
|
||||
if not self._current_entry_id or not self._current_subentry_id:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
subentry = entry.subentries.get(self._current_subentry_id)
|
||||
if subentry is None:
|
||||
return None
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
return entry.entry_id
|
||||
self._queue_pending_update(
|
||||
entry.entry_id,
|
||||
subentry.subentry_id,
|
||||
updated_data[CONF_CHAT_MODEL],
|
||||
)
|
||||
return entry.entry_id
|
||||
|
||||
def _clear_current_target(self) -> None:
|
||||
"""Clear current target tracking."""
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
|
||||
def _format_subentry_type(self, subentry_type: str) -> str:
|
||||
"""Return a user-friendly subentry type label."""
|
||||
if subentry_type == "conversation":
|
||||
return "Conversation agent"
|
||||
if subentry_type in ("ai_task", "ai_task_data"):
|
||||
return "AI task"
|
||||
return subentry_type
|
||||
|
||||
def _queue_pending_update(
|
||||
self, entry_id: str, subentry_id: str, model: str
|
||||
) -> None:
|
||||
"""Store a pending model update for a subentry."""
|
||||
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
|
||||
|
||||
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
|
||||
"""Return a pending model update if one exists."""
|
||||
return self._pending_updates.get(entry_id, {}).get(subentry_id)
|
||||
|
||||
def _mark_entry_for_reload(self, entry_id: str) -> None:
|
||||
"""Prevent reload until repairs are complete for the entry."""
|
||||
self._reload_pending.add(entry_id)
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.add(entry_id)
|
||||
|
||||
async def _async_reload_entry(self, entry_id: str) -> None:
|
||||
"""Reload an entry once all repairs are completed."""
|
||||
if entry_id not in self._reload_pending:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is not None and entry.state is not ConfigEntryState.LOADED:
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
return
|
||||
|
||||
if entry is not None:
|
||||
await self.hass.config_entries.async_reload(entry_id)
|
||||
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
|
||||
def _clear_defer_reload(self, entry_id: str) -> None:
|
||||
"""Remove entry from the deferred reload set."""
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.discard(entry_id)
|
||||
|
||||
async def _async_apply_pending_updates(self, entry_id: str) -> None:
|
||||
"""Apply pending subentry updates for a single entry."""
|
||||
updates = self._pending_updates.pop(entry_id, None)
|
||||
if not updates:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
return
|
||||
|
||||
changed = False
|
||||
for subentry_id, model in updates.items():
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: model,
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
continue
|
||||
|
||||
if not changed:
|
||||
self._mark_entry_for_reload(entry_id)
|
||||
changed = True
|
||||
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry,
|
||||
subentry,
|
||||
data=updated_data,
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
await self._async_reload_entry(entry_id)
|
||||
|
||||
async def _async_apply_all_pending_updates(self) -> None:
|
||||
"""Apply all pending updates across entries."""
|
||||
for entry_id in list(self._pending_updates):
|
||||
await self._async_apply_pending_updates(entry_id)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
@@ -109,5 +109,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]"
|
||||
},
|
||||
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
|
||||
"title": "Update model"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Model deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ from .const import (
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
from .hub import AxisHub, get_axis_api
|
||||
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f", "e8:27:25"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
@@ -459,8 +459,17 @@ class BaseCloudLLMEntity(Entity):
|
||||
last_content: Any = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
files = await self._async_prepare_files_for_prompt(last_content.attachments)
|
||||
current_content = last_content.content
|
||||
last_content = [*(current_content or []), *files]
|
||||
|
||||
last_message = cast(dict[str, Any], messages[-1])
|
||||
assert (
|
||||
last_message["type"] == "message"
|
||||
and last_message["role"] == "user"
|
||||
and isinstance(last_message["content"], str)
|
||||
)
|
||||
last_message["content"] = [
|
||||
{"type": "input_text", "text": last_message["content"]},
|
||||
*files,
|
||||
]
|
||||
|
||||
tools: list[ToolParam] = []
|
||||
tool_choice: str | None = None
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.6.0"]
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.2.0"],
|
||||
"requirements": ["denonavr==1.3.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.5.0"
|
||||
"bleak-esphome==3.6.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.4"]
|
||||
"requirements": ["home-assistant-frontend==20260128.6"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"requirements": ["growattServer==1.7.1"]
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyhik"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["pyHik==0.4.1"]
|
||||
"requirements": ["pyHik==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -115,7 +115,6 @@ SENSORS = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="hot_water_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
|
||||
@@ -150,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
|
||||
self._attr_state = state
|
||||
self._attr_is_volume_muted = volume_muted
|
||||
self._attr_volume_level = volume_level
|
||||
# Only update volume_level if the API provides it, otherwise preserve current value
|
||||
if volume_level is not None:
|
||||
self._attr_volume_level = volume_level
|
||||
self._attr_media_content_type = media_content_type
|
||||
self._attr_media_content_id = media_content_id
|
||||
self._attr_media_title = media_title
|
||||
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
)
|
||||
features = MediaPlayerEntityFeature(0)
|
||||
|
||||
if "PlayMediaSource" in commands:
|
||||
if "PlayMediaSource" in commands or self.capabilities.get(
|
||||
"SupportsMediaControl", False
|
||||
):
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SEARCH_MEDIA
|
||||
)
|
||||
|
||||
if "Mute" in commands:
|
||||
if "Mute" in commands and "Unmute" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
if "VolumeSet" in commands:
|
||||
if "VolumeSet" in commands or "SetVolume" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
return features
|
||||
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send pause command."""
|
||||
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PAUSED
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play_pause(self) -> None:
|
||||
"""Send the PlayPause command to the session."""
|
||||
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send stop command."""
|
||||
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
|
||||
self._attr_state = MediaPlayerState.IDLE
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_set_volume(
|
||||
self.session_id, int(volume * 100)
|
||||
)
|
||||
self._attr_volume_level = volume
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
|
||||
else:
|
||||
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"yaml_mode_deprecated": {
|
||||
"description": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode deprecated"
|
||||
"description": "Your YAML dashboard configuration uses the legacy `mode: yaml` option, which will be removed in Home Assistant 2026.8. Your YAML dashboards will continue to work, you just need to update how they are defined.\n\nTo update your configuration:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Add a dashboard entry instead:\n\n ```yaml\n lovelace:\n resource_mode: yaml\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n3. Restart Home Assistant\n\nNote: `resource_mode: yaml` keeps loading resources from YAML. If you want to manage resources through the UI instead, you can remove this line and move your resources to Settings > Dashboards > Resources.",
|
||||
"title": "Lovelace YAML configuration needs update"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.12.08"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"name": "Browse media"
|
||||
},
|
||||
"clear_playlist": {
|
||||
"description": "Removes all items from the playlist.",
|
||||
"description": "Removes all items from a media player's playlist.",
|
||||
"name": "Clear playlist"
|
||||
},
|
||||
"join": {
|
||||
@@ -284,15 +284,15 @@
|
||||
"name": "Next"
|
||||
},
|
||||
"media_pause": {
|
||||
"description": "Pauses.",
|
||||
"description": "Pauses playback on a media player.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"media_play": {
|
||||
"description": "Starts playing.",
|
||||
"description": "Starts playback on a media player.",
|
||||
"name": "Play"
|
||||
},
|
||||
"media_play_pause": {
|
||||
"description": "Toggles play/pause.",
|
||||
"description": "Toggles play/pause on a media player.",
|
||||
"name": "Play/Pause"
|
||||
},
|
||||
"media_previous_track": {
|
||||
@@ -310,7 +310,7 @@
|
||||
"name": "Seek"
|
||||
},
|
||||
"media_stop": {
|
||||
"description": "Stops playing.",
|
||||
"description": "Stops playback on a media player.",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
},
|
||||
"play_media": {
|
||||
@@ -374,7 +374,7 @@
|
||||
"name": "Select sound mode"
|
||||
},
|
||||
"select_source": {
|
||||
"description": "Sends the media player the command to change input source.",
|
||||
"description": "Sends a media player the command to change the input source.",
|
||||
"fields": {
|
||||
"source": {
|
||||
"description": "Name of the source to switch to. Platform dependent.",
|
||||
@@ -398,23 +398,23 @@
|
||||
"name": "[%key:common::action::toggle%]"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off the power of the media player.",
|
||||
"description": "Turns off the power of a media player.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the power of the media player.",
|
||||
"description": "Turns on the power of a media player.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Removes the player from a group. Only works on platforms which support player groups.",
|
||||
"description": "Removes a media player from a group. Only works on platforms which support player groups.",
|
||||
"name": "Unjoin"
|
||||
},
|
||||
"volume_down": {
|
||||
"description": "Turns down the volume.",
|
||||
"description": "Turns down the volume of a media player.",
|
||||
"name": "Turn down volume"
|
||||
},
|
||||
"volume_mute": {
|
||||
"description": "Mutes or unmutes the media player.",
|
||||
"description": "Mutes or unmutes a media player.",
|
||||
"fields": {
|
||||
"is_volume_muted": {
|
||||
"description": "Defines whether or not it is muted.",
|
||||
@@ -424,7 +424,7 @@
|
||||
"name": "Mute/unmute volume"
|
||||
},
|
||||
"volume_set": {
|
||||
"description": "Sets the volume level.",
|
||||
"description": "Sets the volume level of a media player.",
|
||||
"fields": {
|
||||
"volume_level": {
|
||||
"description": "The volume. 0 is inaudible, 1 is the maximum volume.",
|
||||
@@ -434,7 +434,7 @@
|
||||
"name": "Set volume"
|
||||
},
|
||||
"volume_up": {
|
||||
"description": "Turns up the volume.",
|
||||
"description": "Turns up the volume of a media player.",
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -806,6 +806,8 @@ class CoffeeSystemProgramId(MieleEnum, missing_to_none=True):
|
||||
24813, # add profile
|
||||
)
|
||||
appliance_rinse = 24750, 24759, 24773, 24787, 24788
|
||||
intermediate_rinsing = 24758
|
||||
automatic_maintenance = 24778
|
||||
descaling = 24751
|
||||
brewing_unit_degrease = 24753
|
||||
milk_pipework_rinse = 24754
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"auto_roast": "Auto roast",
|
||||
"automatic": "Automatic",
|
||||
"automatic_maintenance": "Automatic maintenance",
|
||||
"automatic_plus": "Automatic plus",
|
||||
"baguettes": "Baguettes",
|
||||
"barista_assistant": "BaristaAssistant",
|
||||
@@ -551,6 +552,7 @@
|
||||
"hygiene": "Hygiene",
|
||||
"intensive": "Intensive",
|
||||
"intensive_bake": "Intensive bake",
|
||||
"intermediate_rinsing": "Intermediate rinsing",
|
||||
"iridescent_shark_fillet": "Iridescent shark (fillet)",
|
||||
"japanese_tea": "Japanese tea",
|
||||
"jasmine_rice_rapid_steam_cooking": "Jasmine rice (rapid steam cooking)",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1"]
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
@@ -81,6 +81,12 @@ class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
|
||||
serial_number=thermostat.serial_number,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._thermostat = self.coordinator.data[self._thermostat.serial_number]
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if the thermostat is available."""
|
||||
|
||||
@@ -1283,6 +1283,7 @@ RPC_SENSORS: Final = {
|
||||
key="voltmeter",
|
||||
sub_key="xvoltage",
|
||||
translation_key="voltmeter_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda _, status, key: (status[key].get("xvoltage") is None),
|
||||
unit=lambda config: config["xvoltage"]["unit"] or None,
|
||||
),
|
||||
@@ -1300,6 +1301,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xpercent",
|
||||
translation_key="analog_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "analog"
|
||||
or config[key]["enable"] is False
|
||||
@@ -1344,6 +1346,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xfreq",
|
||||
translation_key="pulse_counter_frequency_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "count"
|
||||
or config[key]["enable"] is False
|
||||
|
||||
@@ -284,6 +284,8 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
|
||||
try:
|
||||
battery = self._psutil.sensors_battery()
|
||||
_LOGGER.debug("battery: %s", battery)
|
||||
except (FileNotFoundError, PermissionError) as err:
|
||||
_LOGGER.debug("OS error when accessing battery sensors: %s", err)
|
||||
except (AttributeError, FileNotFoundError):
|
||||
_LOGGER.debug("OS does not provide battery sensors")
|
||||
|
||||
|
||||
@@ -670,23 +670,41 @@ class WeatherExtraStoredData(ExtraStoredData):
|
||||
@classmethod
|
||||
def from_dict(cls, restored: dict[str, Any]) -> Self | None:
|
||||
"""Initialize a stored event state from a dict."""
|
||||
try:
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
for key, vtypes in (
|
||||
("last_apparent_temperature", (float, int)),
|
||||
("last_cloud_coverage", (float, int)),
|
||||
("last_dew_point", (float, int)),
|
||||
("last_humidity", (float, int)),
|
||||
("last_ozone", (float, int)),
|
||||
("last_pressure", (float, int)),
|
||||
("last_temperature", (float, int)),
|
||||
("last_uv_index", (float, int)),
|
||||
("last_visibility", (float, int)),
|
||||
("last_wind_bearing", (float, int, str)),
|
||||
("last_wind_gust_speed", (float, int)),
|
||||
("last_wind_speed", (float, int)),
|
||||
):
|
||||
# This is needed to safeguard against previous restore data that has strings
|
||||
# instead of floats or ints.
|
||||
if key not in restored or (
|
||||
(value := restored[key]) is not None and not isinstance(value, vtypes)
|
||||
):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
|
||||
|
||||
class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity):
|
||||
@@ -824,18 +842,18 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
|
||||
def extra_restore_state_data(self) -> WeatherExtraStoredData:
|
||||
"""Return weather specific state data to be restored."""
|
||||
return WeatherExtraStoredData(
|
||||
last_apparent_temperature=self._rendered.get(CONF_APPARENT_TEMPERATURE),
|
||||
last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE),
|
||||
last_dew_point=self._rendered.get(CONF_DEW_POINT),
|
||||
last_humidity=self._rendered.get(CONF_HUMIDITY),
|
||||
last_ozone=self._rendered.get(CONF_OZONE),
|
||||
last_pressure=self._rendered.get(CONF_PRESSURE),
|
||||
last_temperature=self._rendered.get(CONF_TEMPERATURE),
|
||||
last_uv_index=self._rendered.get(CONF_UV_INDEX),
|
||||
last_visibility=self._rendered.get(CONF_VISIBILITY),
|
||||
last_wind_bearing=self._rendered.get(CONF_WIND_BEARING),
|
||||
last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED),
|
||||
last_wind_speed=self._rendered.get(CONF_WIND_SPEED),
|
||||
last_apparent_temperature=self.native_apparent_temperature,
|
||||
last_cloud_coverage=self._attr_cloud_coverage,
|
||||
last_dew_point=self.native_dew_point,
|
||||
last_humidity=self.humidity,
|
||||
last_ozone=self.ozone,
|
||||
last_pressure=self.native_pressure,
|
||||
last_temperature=self.native_temperature,
|
||||
last_uv_index=self.uv_index,
|
||||
last_visibility=self.native_visibility,
|
||||
last_wind_bearing=self.wind_bearing,
|
||||
last_wind_gust_speed=self.native_wind_gust_speed,
|
||||
last_wind_speed=self.native_wind_speed,
|
||||
)
|
||||
|
||||
async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None:
|
||||
|
||||
@@ -54,7 +54,7 @@ class DatasetEntry:
|
||||
return cast(tlv_parser.Channel, channel).channel
|
||||
|
||||
@cached_property
|
||||
def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]:
|
||||
def dataset(self) -> dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem]:
|
||||
"""Return the dataset in dict format."""
|
||||
return tlv_parser.parse_tlv(self.tlv)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.88", "serialx==0.6.2"],
|
||||
"requirements": ["zha==0.0.89", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
PATCH_VERSION: Final = "0b4"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
|
||||
@@ -2701,6 +2701,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"heatit": {
|
||||
"name": "Heatit",
|
||||
"iot_standards": [
|
||||
"zwave"
|
||||
]
|
||||
},
|
||||
"heatmiser": {
|
||||
"name": "Heatmiser",
|
||||
"integration_type": "hub",
|
||||
@@ -2712,6 +2718,13 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
},
|
||||
"heiman": {
|
||||
"name": "Heiman",
|
||||
"iot_standards": [
|
||||
"matter",
|
||||
"zigbee"
|
||||
]
|
||||
},
|
||||
"heiwa": {
|
||||
"name": "Heiwa",
|
||||
"integration_type": "virtual",
|
||||
|
||||
@@ -39,8 +39,8 @@ habluetooth==5.8.0
|
||||
hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260128.4
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-frontend==20260128.6
|
||||
home-assistant-intents==2026.2.3
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
@@ -230,3 +230,8 @@ pytest-rerunfailures==16.0.1
|
||||
|
||||
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
|
||||
aiomqtt>=2.5.0
|
||||
|
||||
# auth0-python v5.0 is a major rewrite with breaking changes
|
||||
# used by sharkiq==1.5.0
|
||||
# https://github.com/auth0/auth0-python/releases/tag/5.0.0
|
||||
auth0-python<5.0
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2026.2.0b2"
|
||||
version = "2026.2.0b4"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
22
requirements_all.txt
generated
22
requirements_all.txt
generated
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.0.2
|
||||
aioamazondevices==11.1.1
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -637,7 +637,7 @@ bimmer-connected[china]==0.17.3
|
||||
bizkaibus==0.1.1
|
||||
|
||||
# homeassistant.components.esphome
|
||||
bleak-esphome==3.5.0
|
||||
bleak-esphome==3.6.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.4.3
|
||||
@@ -740,7 +740,7 @@ colorlog==6.10.1
|
||||
colorthief==0.2.1
|
||||
|
||||
# homeassistant.components.compit
|
||||
compit-inext-api==0.6.0
|
||||
compit-inext-api==0.8.0
|
||||
|
||||
# homeassistant.components.concord232
|
||||
concord232==0.15.1
|
||||
@@ -797,7 +797,7 @@ deluge-client==1.10.2
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.2.0
|
||||
denonavr==1.3.1
|
||||
|
||||
# homeassistant.components.devialet
|
||||
devialet==1.5.7
|
||||
@@ -1142,7 +1142,7 @@ greenwavereality==0.5.1
|
||||
gridnet==5.0.1
|
||||
|
||||
# homeassistant.components.growatt_server
|
||||
growattServer==1.7.1
|
||||
growattServer==1.9.0
|
||||
|
||||
# homeassistant.components.google_sheets
|
||||
gspread==5.5.0
|
||||
@@ -1219,10 +1219,10 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.4
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -1865,7 +1865,7 @@ pyElectra==1.2.4
|
||||
pyEmby==1.10
|
||||
|
||||
# homeassistant.components.hikvision
|
||||
pyHik==0.4.1
|
||||
pyHik==0.4.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.3.8
|
||||
@@ -2576,7 +2576,7 @@ python-opensky==1.0.1
|
||||
|
||||
# homeassistant.components.otbr
|
||||
# homeassistant.components.thread
|
||||
python-otbr-api==2.7.1
|
||||
python-otbr-api==2.8.0
|
||||
|
||||
# homeassistant.components.overseerr
|
||||
python-overseerr==0.8.0
|
||||
@@ -3278,7 +3278,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.1.1
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.12.08
|
||||
yt-dlp[default]==2026.02.04
|
||||
|
||||
# homeassistant.components.zabbix
|
||||
zabbix-utils==2.0.3
|
||||
@@ -3296,7 +3296,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.88
|
||||
zha==0.0.89
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
22
requirements_test_all.txt
generated
22
requirements_test_all.txt
generated
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.5
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==11.0.2
|
||||
aioamazondevices==11.1.1
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -574,7 +574,7 @@ beautifulsoup4==4.13.3
|
||||
bimmer-connected[china]==0.17.3
|
||||
|
||||
# homeassistant.components.esphome
|
||||
bleak-esphome==3.5.0
|
||||
bleak-esphome==3.6.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==4.4.3
|
||||
@@ -655,7 +655,7 @@ colorlog==6.10.1
|
||||
colorthief==0.2.1
|
||||
|
||||
# homeassistant.components.compit
|
||||
compit-inext-api==0.6.0
|
||||
compit-inext-api==0.8.0
|
||||
|
||||
# homeassistant.components.concord232
|
||||
concord232==0.15.1
|
||||
@@ -706,7 +706,7 @@ deluge-client==1.10.2
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.2.0
|
||||
denonavr==1.3.1
|
||||
|
||||
# homeassistant.components.devialet
|
||||
devialet==1.5.7
|
||||
@@ -1012,7 +1012,7 @@ greenplanet-energy-api==0.1.4
|
||||
gridnet==5.0.1
|
||||
|
||||
# homeassistant.components.growatt_server
|
||||
growattServer==1.7.1
|
||||
growattServer==1.9.0
|
||||
|
||||
# homeassistant.components.google_sheets
|
||||
gspread==5.5.0
|
||||
@@ -1077,10 +1077,10 @@ hole==0.9.0
|
||||
holidays==0.84
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20260128.4
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
@@ -1602,7 +1602,7 @@ pyDuotecno==2024.10.1
|
||||
pyElectra==1.2.4
|
||||
|
||||
# homeassistant.components.hikvision
|
||||
pyHik==0.4.1
|
||||
pyHik==0.4.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.3.8
|
||||
@@ -2169,7 +2169,7 @@ python-opensky==1.0.1
|
||||
|
||||
# homeassistant.components.otbr
|
||||
# homeassistant.components.thread
|
||||
python-otbr-api==2.7.1
|
||||
python-otbr-api==2.8.0
|
||||
|
||||
# homeassistant.components.overseerr
|
||||
python-overseerr==0.8.0
|
||||
@@ -2748,7 +2748,7 @@ youless-api==2.2.0
|
||||
youtubeaio==2.1.1
|
||||
|
||||
# homeassistant.components.media_extractor
|
||||
yt-dlp[default]==2025.12.08
|
||||
yt-dlp[default]==2026.02.04
|
||||
|
||||
# homeassistant.components.zamg
|
||||
zamg==0.3.6
|
||||
@@ -2763,7 +2763,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.88
|
||||
zha==0.0.89
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.68.0
|
||||
|
||||
@@ -220,6 +220,11 @@ pytest-rerunfailures==16.0.1
|
||||
|
||||
# Fixes detected blocking call to load_default_certs https://github.com/home-assistant/core/issues/157475
|
||||
aiomqtt>=2.5.0
|
||||
|
||||
# auth0-python v5.0 is a major rewrite with breaking changes
|
||||
# used by sharkiq==1.5.0
|
||||
# https://github.com/auth0/auth0-python/releases/tag/5.0.0
|
||||
auth0-python<5.0
|
||||
"""
|
||||
|
||||
GENERATED_MESSAGE = (
|
||||
|
||||
@@ -46,6 +46,7 @@ TEST_DEVICE_1 = AmazonDevice(
|
||||
scale="CELSIUS",
|
||||
),
|
||||
},
|
||||
notifications_supported=True,
|
||||
notifications={
|
||||
NOTIFICATION_ALARM: AmazonSchedule(
|
||||
type=NOTIFICATION_ALARM,
|
||||
@@ -93,5 +94,6 @@ TEST_DEVICE_2 = AmazonDevice(
|
||||
scale="CELSIUS",
|
||||
)
|
||||
},
|
||||
notifications_supported=False,
|
||||
notifications={},
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
'type': 'Timer',
|
||||
}),
|
||||
}),
|
||||
'notifications_supported': True,
|
||||
'online': True,
|
||||
'sensors': dict({
|
||||
'dnd': dict({
|
||||
@@ -103,6 +104,7 @@
|
||||
'type': 'Timer',
|
||||
}),
|
||||
}),
|
||||
'notifications_supported': True,
|
||||
'online': True,
|
||||
'sensors': dict({
|
||||
'dnd': dict({
|
||||
|
||||
@@ -7,6 +7,7 @@ from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.alexa_devices.const import DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -134,3 +135,42 @@ async def test_alexa_dnd_group_removal(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not hass.states.get(entity.entity_id)
|
||||
|
||||
|
||||
async def test_alexa_unsupported_notification_sensor_removal(
|
||||
hass: HomeAssistant,
|
||||
mock_amazon_devices_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test notification sensors are removed from devices that do not support them."""
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)},
|
||||
name=mock_config_entry.title,
|
||||
manufacturer="Amazon",
|
||||
model=SPEAKER_GROUP_MODEL,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
entity = entity_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
SENSOR_DOMAIN,
|
||||
unique_id=f"{TEST_DEVICE_1_SN}-Timer",
|
||||
device_id=device.id,
|
||||
config_entry=mock_config_entry,
|
||||
has_entity_name=True,
|
||||
)
|
||||
|
||||
mock_amazon_devices_client.get_devices_data.return_value[
|
||||
TEST_DEVICE_1_SN
|
||||
].notifications_supported = False
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert not hass.states.get(entity.entity_id)
|
||||
|
||||
@@ -387,7 +387,7 @@ async def test_model_list(
|
||||
},
|
||||
{
|
||||
"label": "Claude Haiku 3.5",
|
||||
"value": "claude-3-5-haiku-latest",
|
||||
"value": "claude-3-5-haiku-20241022",
|
||||
},
|
||||
{
|
||||
"label": "Claude Haiku 3",
|
||||
@@ -500,7 +500,7 @@ async def test_model_list_error(
|
||||
CONF_LLM_HASS_API: [],
|
||||
},
|
||||
{
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-20241022",
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
},
|
||||
{
|
||||
@@ -513,7 +513,7 @@ async def test_model_list_error(
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_PROMPT: "Speak like a pirate",
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-20241022",
|
||||
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 10,
|
||||
@@ -581,6 +581,7 @@ async def test_model_list_error(
|
||||
CONF_TEMPERATURE: 0.3,
|
||||
CONF_CHAT_MODEL: DEFAULT[CONF_CHAT_MODEL],
|
||||
CONF_MAX_TOKENS: DEFAULT[CONF_MAX_TOKENS],
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
|
||||
301
tests/components/anthropic/test_repairs.py
Normal file
301
tests/components/anthropic/test_repairs.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Tests for the Anthropic repairs flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from homeassistant.components.anthropic.const import CONF_CHAT_MODEL, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.repairs import (
|
||||
async_process_repairs_platforms,
|
||||
process_repair_fix_flow,
|
||||
start_repair_fix_flow,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
def _make_entry(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
title: str,
|
||||
api_key: str,
|
||||
subentries_data: list[dict[str, Any]],
|
||||
) -> MockConfigEntry:
|
||||
"""Create a config entry with subentries and runtime data."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=title,
|
||||
data={"api_key": api_key},
|
||||
version=2,
|
||||
subentries_data=subentries_data,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
object.__setattr__(entry, "state", ConfigEntryState.LOADED)
|
||||
entry.runtime_data = MagicMock()
|
||||
return entry
|
||||
|
||||
|
||||
def _get_subentry(
|
||||
entry: MockConfigEntry,
|
||||
subentry_type: str,
|
||||
) -> ConfigSubentry:
|
||||
"""Return the first subentry of a type."""
|
||||
return next(
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == subentry_type
|
||||
)
|
||||
|
||||
|
||||
async def _setup_repairs(hass: HomeAssistant) -> None:
|
||||
hass.config.components.add(DOMAIN)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await async_process_repairs_platforms(hass)
|
||||
|
||||
|
||||
async def test_repair_flow_iterates_subentries(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test the repair flow iterates across deprecated subentries."""
|
||||
entry_one: MockConfigEntry = _make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-haiku-20241022"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-sonnet-20241022"},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "AI task One",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry_two: MockConfigEntry = _make_entry(
|
||||
hass,
|
||||
title="Entry Two",
|
||||
api_key="key-two",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-opus-20240229"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation Two",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
model_options: list[dict[str, str]] = [
|
||||
{"label": "Claude Haiku 4.5", "value": "claude-haiku-4-5"},
|
||||
{"label": "Claude Sonnet 4.5", "value": "claude-sonnet-4-5"},
|
||||
{"label": "Claude Opus 4.5", "value": "claude-opus-4-5"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.anthropic.repairs.get_model_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=model_options,
|
||||
):
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_one.title
|
||||
assert placeholders["subentry_name"] == "Conversation One"
|
||||
assert placeholders["subentry_type"] == "Conversation agent"
|
||||
|
||||
flow_id = result["flow_id"]
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-haiku-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert (
|
||||
_get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-3-5-haiku-20241022"
|
||||
)
|
||||
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_one.title
|
||||
assert placeholders["subentry_name"] == "AI task One"
|
||||
assert placeholders["subentry_type"] == "AI task"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert (
|
||||
_get_subentry(entry_one, "ai_task_data").data[CONF_CHAT_MODEL]
|
||||
== "claude-sonnet-4-5"
|
||||
)
|
||||
assert (
|
||||
_get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-haiku-4-5"
|
||||
)
|
||||
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_two.title
|
||||
assert placeholders["subentry_name"] == "Conversation Two"
|
||||
assert placeholders["subentry_type"] == "Conversation agent"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-opus-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
_get_subentry(entry_two, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-opus-4-5"
|
||||
)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None
|
||||
|
||||
|
||||
async def test_repair_flow_no_deprecated_models(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test the repair flow completes when everything was fixed."""
|
||||
_make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None
|
||||
|
||||
|
||||
async def test_repair_flow_defers_reload_until_entry_done(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test reload is deferred until all subentries for an entry are fixed."""
|
||||
entry = _make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-haiku-20241022"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-sonnet-20241022"},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "AI task One",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
model_options: list[dict[str, str]] = [
|
||||
{"label": "Claude Haiku 4.5", "value": "claude-haiku-4-5"},
|
||||
{"label": "Claude Sonnet 4.5", "value": "claude-sonnet-4-5"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.anthropic.repairs.get_model_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=model_options,
|
||||
),
|
||||
patch.object(
|
||||
hass.config_entries,
|
||||
"async_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as reload_mock,
|
||||
):
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
flow_id = result["flow_id"]
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-haiku-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert reload_mock.await_count == 0
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert reload_mock.await_count == 1
|
||||
assert reload_mock.call_args_list == [call(entry.entry_id)]
|
||||
@@ -206,6 +206,17 @@ async def test_prepare_chat_for_generation_appends_attachments(
|
||||
assert response["messages"] is messages
|
||||
mock_prepare_files_for_prompt.assert_awaited_once_with([attachment])
|
||||
|
||||
# Verify that files are actually added to the last user message
|
||||
last_message = messages[-1]
|
||||
assert last_message["type"] == "message"
|
||||
assert last_message["role"] == "user"
|
||||
assert isinstance(last_message["content"], list)
|
||||
assert last_message["content"][0] == {
|
||||
"type": "input_text",
|
||||
"text": "Describe the door",
|
||||
}
|
||||
assert last_message["content"][1] == files[0]
|
||||
|
||||
|
||||
async def test_prepare_chat_for_generation_passes_messages_through(
|
||||
hass: HomeAssistant, cloud_entity: BaseCloudLLMEntity
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -4548,5 +4548,133 @@
|
||||
"PlayMediaSource",
|
||||
"PlayTrailers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Mute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-FIVE",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Five",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-FIVE",
|
||||
"DeviceId": "DEVICE-UUID-FIVE",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-FIVE",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Unmute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-SIX",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Six",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-SIX",
|
||||
"DeviceId": "DEVICE-UUID-SIX",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-SIX",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
@@ -902,6 +903,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
'SupportsMediaControl': True,
|
||||
@@ -1785,6 +1787,122 @@
|
||||
}),
|
||||
'user_id': 'USER-UUID-TWO',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Mute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Five',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-FIVE',
|
||||
'device_name': 'JELLYFIN-DEVICE-FIVE',
|
||||
'id': 'SESSION-UUID-FIVE',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-FIVE',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Unmute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Six',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-SIX',
|
||||
'device_name': 'JELLYFIN-DEVICE-SIX',
|
||||
'id': 'SESSION-UUID-SIX',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-SIX',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
MediaClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
@@ -423,3 +424,98 @@ async def test_new_client_connected(
|
||||
|
||||
state = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state
|
||||
|
||||
|
||||
async def test_supports_media_control_fallback(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SupportsMediaControl enables controls without PlayMediaSource."""
|
||||
# SESSION-UUID-TWO has SupportsMediaControl: true but no PlayMediaSource command
|
||||
state = hass.states.get("media_player.jellyfin_device_two")
|
||||
|
||||
assert state
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have basic playback controls
|
||||
assert features & MediaPlayerEntityFeature.PLAY
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert features & MediaPlayerEntityFeature.STOP
|
||||
assert features & MediaPlayerEntityFeature.SEEK
|
||||
assert features & MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
assert features & MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
|
||||
# Should also have volume controls since it has VolumeSet, Mute, and Unmute
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
|
||||
async def test_set_volume_command_alternative(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SetVolume command (alternative to VolumeSet) enables volume control."""
|
||||
# SESSION-UUID-FOUR has SetVolume instead of VolumeSet
|
||||
state = hass.states.get("media_player.jellyfin_device_four")
|
||||
|
||||
assert state
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have volume control via SetVolume command
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
|
||||
async def test_mute_requires_both_commands(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that VOLUME_MUTE requires both Mute AND Unmute commands."""
|
||||
|
||||
# SESSION-UUID-FIVE has only Mute (no Unmute) - should NOT have VOLUME_MUTE
|
||||
state_five = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state_five
|
||||
|
||||
entity_five = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_five.entity_id
|
||||
)
|
||||
features_five = entity_five.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_five & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_five & MediaPlayerEntityFeature.PLAY
|
||||
assert features_five & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
# SESSION-UUID-SIX has only Unmute (no Mute) - should NOT have VOLUME_MUTE
|
||||
state_six = hass.states.get("media_player.jellyfin_device_six")
|
||||
assert state_six
|
||||
|
||||
entity_six = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_six.entity_id
|
||||
)
|
||||
features_six = entity_six.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_six & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_six & MediaPlayerEntityFeature.PLAY
|
||||
assert features_six & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_watching(
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER Active clients"
|
||||
assert state.attributes.get(ATTR_ICON) is None
|
||||
assert state.attributes.get(ATTR_STATE_CLASS) is None
|
||||
assert state.state == "3"
|
||||
assert state.state == "5"
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
'options': list([
|
||||
'appliance_rinse',
|
||||
'appliance_settings',
|
||||
'automatic_maintenance',
|
||||
'barista_assistant',
|
||||
'black_tea',
|
||||
'brewing_unit_degrease',
|
||||
@@ -121,6 +122,7 @@
|
||||
'herbal_tea',
|
||||
'hot_milk',
|
||||
'hot_water',
|
||||
'intermediate_rinsing',
|
||||
'japanese_tea',
|
||||
'latte_macchiato',
|
||||
'long_coffee',
|
||||
@@ -171,6 +173,7 @@
|
||||
'options': list([
|
||||
'appliance_rinse',
|
||||
'appliance_settings',
|
||||
'automatic_maintenance',
|
||||
'barista_assistant',
|
||||
'black_tea',
|
||||
'brewing_unit_degrease',
|
||||
@@ -190,6 +193,7 @@
|
||||
'herbal_tea',
|
||||
'hot_milk',
|
||||
'hot_water',
|
||||
'intermediate_rinsing',
|
||||
'japanese_tea',
|
||||
'latte_macchiato',
|
||||
'long_coffee',
|
||||
|
||||
@@ -389,6 +389,44 @@ async def test_exception_handling_disk_sensor(
|
||||
assert disk_sensor.attributes["unit_of_measurement"] == "%"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.freeze_time("2024-02-24 15:00:00", tz_offset=0)
|
||||
@pytest.mark.parametrize("exception_class", [FileNotFoundError, PermissionError])
|
||||
async def test_exception_handling_battery_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_psutil: Mock,
|
||||
mock_os: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
exception_class: type[Exception],
|
||||
) -> None:
|
||||
"""Test the battery failures."""
|
||||
mock_psutil.sensors_battery.side_effect = exception_class(
|
||||
"[Errno 2] No such file or directory: '/sys/class/power_supply'"
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery"))
|
||||
assert temp_entity.state == STATE_UNAVAILABLE
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery_empty"))
|
||||
assert temp_entity.state == STATE_UNAVAILABLE
|
||||
|
||||
assert "OS error when accessing battery sensors" in caplog.text
|
||||
|
||||
mock_psutil.sensors_battery.side_effect = None
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery"))
|
||||
assert temp_entity.state == "93"
|
||||
assert (temp_entity := hass.states.get("sensor.system_monitor_battery_empty"))
|
||||
assert temp_entity.state == "2024-02-24T19:38:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_cpu_percentage_is_zero_returns_unknown(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -269,10 +269,10 @@
|
||||
'last_apparent_temperature': None,
|
||||
'last_cloud_coverage': None,
|
||||
'last_dew_point': None,
|
||||
'last_humidity': '25.0',
|
||||
'last_humidity': 25.0,
|
||||
'last_ozone': None,
|
||||
'last_pressure': None,
|
||||
'last_temperature': '15.0',
|
||||
'last_temperature': 15.0,
|
||||
'last_uv_index': None,
|
||||
'last_visibility': None,
|
||||
'last_wind_bearing': None,
|
||||
|
||||
@@ -839,11 +839,27 @@ SAVED_EXTRA_DATA_MISSING_KEY = {
|
||||
"last_wind_speed": None,
|
||||
}
|
||||
|
||||
SAVED_EXTRA_DATA_STRING_HUMIDITY = {
|
||||
"last_apparent_temperature": None,
|
||||
"last_cloud_coverage": None,
|
||||
"last_dew_point": None,
|
||||
"last_humidity": "20.0",
|
||||
"last_ozone": None,
|
||||
"last_pressure": None,
|
||||
"last_temperature": 20.0,
|
||||
"last_uv_index": None,
|
||||
"last_visibility": None,
|
||||
"last_wind_bearing": None,
|
||||
"last_wind_gust_speed": None,
|
||||
"last_wind_speed": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("saved_attributes", "saved_extra_data"),
|
||||
[
|
||||
(SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY),
|
||||
(SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_STRING_HUMIDITY),
|
||||
(SAVED_ATTRIBUTES_1, None),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ DATASET_1_LARGER_TIMESTAMP = (
|
||||
|
||||
async def test_add_invalid_dataset(hass: HomeAssistant) -> None:
|
||||
"""Test adding an invalid dataset."""
|
||||
with pytest.raises(TLVError, match="unknown type 222"):
|
||||
with pytest.raises(TLVError, match="expected 173 bytes for tag 222, got 2"):
|
||||
await dataset_store.async_add_dataset(hass, "source", "DEADBEEF")
|
||||
|
||||
store = await dataset_store.async_get_store(hass)
|
||||
|
||||
@@ -57,7 +57,10 @@ async def test_add_invalid_dataset(
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert not msg["success"]
|
||||
assert msg["error"] == {"code": "invalid_format", "message": "unknown type 222"}
|
||||
assert msg["error"] == {
|
||||
"code": "invalid_format",
|
||||
"message": "expected 173 bytes for tag 222, got 2",
|
||||
}
|
||||
|
||||
|
||||
async def test_delete_dataset(
|
||||
|
||||
Reference in New Issue
Block a user