Compare commits

...

25 Commits

Author SHA1 Message Date
Franck Nijhof
12714c489f Bump version to 2026.2.0b5 2026-02-04 18:45:36 +00:00
Robert Resch
f788d61b4a Revert "Bump intents (#162205)" (#162226) 2026-02-04 18:36:12 +00:00
Simone Chemelli
5c726af00b Fix logic and tests for Alexa Devices utils module (#162223) 2026-02-04 18:36:10 +00:00
Joost Lekkerkerker
d1d207fbb2 Add guard for Apple TV text focus state (#162207) 2026-02-04 18:36:09 +00:00
David Bonnes
6c7f8df7f7 Fix evohome not updating scheduled setpoints in state attrs (#162043) 2026-02-04 18:36:07 +00:00
Kevin Stillhammer
6f8c9b1504 Bump fressnapftracker to 0.2.2 (#161913) 2026-02-04 18:36:06 +00:00
Kevin Stillhammer
4f9aedbc84 Filter out invalid trackers in fressnapf_tracker (#161670) 2026-02-04 18:36:04 +00:00
Franck Nijhof
52fb0343e4 Bump version to 2026.2.0b4 2026-02-04 16:14:23 +00:00
Bram Kragten
1050b4580a Update frontend to 20260128.6 (#162214) 2026-02-04 16:10:08 +00:00
Åke Strandberg
344c42172e Add missing codes for Miele coffe systems (#162206) 2026-02-04 16:10:06 +00:00
Michael Hansen
93cc0fd7f1 Bump intents (#162205) 2026-02-04 16:10:05 +00:00
andreimoraru
05fe636b55 Bump yt-dlp to 2026.02.04 (#162204)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-04 16:10:03 +00:00
Marc Mueller
f22467d099 Pin auth0-python to <5.0 (#162203) 2026-02-04 16:10:01 +00:00
TheJulianJES
4bc3899b32 Bump ZHA to 0.0.89 (#162195) 2026-02-04 16:10:00 +00:00
Oliver
fc4d6bf5f1 Bump denonavr to 1.3.1 (#162183) 2026-02-04 16:09:58 +00:00
johanzander
8ed0672a8f Bump growattServer to 1.9.0 (#162179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 16:09:57 +00:00
Norbert Rittel
282e347a1b Clarify action descriptions in media_player (#162172) 2026-02-04 16:09:55 +00:00
Erik Montnemery
1bfb02b440 Bump python-otbr-api to 2.8.0 (#162167) 2026-02-04 16:09:54 +00:00
Przemko92
71b03bd9ae Bump compit-inext-api to 0.8.0 (#162166) 2026-02-04 16:09:52 +00:00
Przemko92
cbd69822eb Update compit-inext-api to 0.7.0 (#162020) 2026-02-04 16:09:51 +00:00
Denis Shulyaka
db900f4dd2 Anthropic repair deprecated models (#162162)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-04 16:00:14 +00:00
Jonathan Bangert
a707e695bc Bump bleak-esphome to 3.6.0 (#162028) 2026-02-04 16:00:12 +00:00
Liquidmasl
4feceac205 Jellyfin native client controls (#161982) 2026-02-04 16:00:11 +00:00
Petro31
10c20faaca Fix template weather humidity (#161945) 2026-02-04 16:00:09 +00:00
Robert Svensson
abcd512401 Add missing OUI to Axis integration, discovery would abort with unsup… (#161943) 2026-02-04 16:00:07 +00:00
55 changed files with 2293 additions and 221 deletions

View File

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

View File

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

View File

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

View File

@@ -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,42 +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-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
)
return await get_model_list(client)
async def _get_location_data(self) -> dict[str, str]:
"""Get approximate location data of the user."""

View File

@@ -22,6 +22,8 @@ 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-haiku-4-5",
CONF_MAX_TOKENS: 3000,
@@ -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",
]

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260128.5"]
"requirements": ["home-assistant-frontend==20260128.6"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
"requirements": ["growattServer==1.7.1"]
"requirements": ["growattServer==1.9.0"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "0b3"
PATCH_VERSION: Final = "0b5"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@@ -39,7 +39,7 @@ habluetooth==5.8.0
hass-nabucasa==1.12.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20260128.5
home-assistant-frontend==20260128.6
home-assistant-intents==2026.1.28
httpx==0.28.1
ifaddr==0.2.0
@@ -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

View File

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

18
requirements_all.txt generated
View File

@@ -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
@@ -1011,7 +1011,7 @@ freebox-api==1.3.0
freesms==0.2.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -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,7 +1219,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260128.5
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.1.28
@@ -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

View File

@@ -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
@@ -890,7 +890,7 @@ forecast-solar==4.2.0
freebox-api==1.3.0
# homeassistant.components.fressnapf_tracker
fressnapftracker==0.2.1
fressnapftracker==0.2.2
# homeassistant.components.fritz
# homeassistant.components.fritzbox_callmonitor
@@ -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,7 +1077,7 @@ hole==0.9.0
holidays==0.84
# homeassistant.components.frontend
home-assistant-frontend==20260128.5
home-assistant-frontend==20260128.6
# homeassistant.components.conversation
home-assistant-intents==2026.1.28
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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