mirror of
https://github.com/home-assistant/core.git
synced 2026-02-05 23:05:25 +01:00
Compare commits
25 Commits
2026.2.0b3
...
2026.2.0b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12714c489f | ||
|
|
f788d61b4a | ||
|
|
5c726af00b | ||
|
|
d1d207fbb2 | ||
|
|
6c7f8df7f7 | ||
|
|
6f8c9b1504 | ||
|
|
4f9aedbc84 | ||
|
|
52fb0343e4 | ||
|
|
1050b4580a | ||
|
|
344c42172e | ||
|
|
93cc0fd7f1 | ||
|
|
05fe636b55 | ||
|
|
f22467d099 | ||
|
|
4bc3899b32 | ||
|
|
fc4d6bf5f1 | ||
|
|
8ed0672a8f | ||
|
|
282e347a1b | ||
|
|
1bfb02b440 | ||
|
|
71b03bd9ae | ||
|
|
cbd69822eb | ||
|
|
db900f4dd2 | ||
|
|
a707e695bc | ||
|
|
4feceac205 | ||
|
|
10c20faaca | ||
|
|
abcd512401 |
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,10 +14,18 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -27,6 +35,7 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Anthropic."""
|
||||
hass.data.setdefault(DOMAIN, {}).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
await async_migrate_integration(hass)
|
||||
return True
|
||||
|
||||
@@ -50,6 +59,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
for subentry in entry.subentries.values():
|
||||
if (model := subentry.data.get(CONF_CHAT_MODEL)) and model.startswith(
|
||||
tuple(DEPRECATED_MODELS)
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
learn_more_url="https://platform.claude.com/docs/en/about-claude/model-deprecations",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -62,6 +87,11 @@ async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
defer_reload_entries: set[str] = hass.data.setdefault(DOMAIN, {}).setdefault(
|
||||
DATA_REPAIR_DEFER_RELOAD, set()
|
||||
)
|
||||
if entry.entry_id in defer_reload_entries:
|
||||
return
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,40 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
await client.models.list(timeout=10.0)
|
||||
|
||||
|
||||
async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
try:
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in (
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
)
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
@@ -401,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."""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
275
homeassistant/components/anthropic/repairs.py
Normal file
275
homeassistant/components/anthropic/repairs.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Issue repair flow for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
from homeassistant.components.repairs import RepairsFlow
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
|
||||
|
||||
from .config_flow import get_model_list
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
class ModelDeprecatedRepairFlow(RepairsFlow):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
_subentry_iter: Iterator[tuple[str, str]] | None
|
||||
_current_entry_id: str | None
|
||||
_current_subentry_id: str | None
|
||||
_reload_pending: set[str]
|
||||
_pending_updates: dict[str, dict[str, str]]
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
super().__init__()
|
||||
self._subentry_iter = None
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
self._reload_pending = set()
|
||||
self._pending_updates = {}
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
previous_entry_id: str | None = None
|
||||
if user_input is not None:
|
||||
previous_entry_id = self._async_update_current_subentry(user_input)
|
||||
self._clear_current_target()
|
||||
|
||||
target = await self._async_next_target()
|
||||
next_entry_id = target[0].entry_id if target else None
|
||||
if previous_entry_id and previous_entry_id != next_entry_id:
|
||||
await self._async_apply_pending_updates(previous_entry_id)
|
||||
if target is None:
|
||||
await self._async_apply_all_pending_updates()
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
entry, subentry, model = target
|
||||
client = entry.runtime_data
|
||||
model_list = [
|
||||
model_option
|
||||
for model_option in await get_model_list(client)
|
||||
if not model_option["value"].startswith(tuple(DEPRECATED_MODELS))
|
||||
]
|
||||
|
||||
if "opus" in model:
|
||||
suggested_model = "claude-opus-4-5"
|
||||
elif "haiku" in model:
|
||||
suggested_model = "claude-haiku-4-5"
|
||||
elif "sonnet" in model:
|
||||
suggested_model = "claude-sonnet-4-5"
|
||||
else:
|
||||
suggested_model = cast(str, DEFAULT[CONF_CHAT_MODEL])
|
||||
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_CHAT_MODEL,
|
||||
default=suggested_model,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=model_list, custom_value=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=schema,
|
||||
description_placeholders={
|
||||
"entry_name": entry.title,
|
||||
"model": model,
|
||||
"subentry_name": subentry.title,
|
||||
"subentry_type": self._format_subentry_type(subentry.subentry_type),
|
||||
},
|
||||
)
|
||||
|
||||
def _iter_deprecated_subentries(self) -> Iterator[tuple[str, str]]:
|
||||
"""Yield entry/subentry pairs that use deprecated models."""
|
||||
for entry in self.hass.config_entries.async_entries(DOMAIN):
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
continue
|
||||
for subentry in entry.subentries.values():
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if model and model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
yield entry.entry_id, subentry.subentry_id
|
||||
|
||||
async def _async_next_target(
|
||||
self,
|
||||
) -> tuple[ConfigEntry, ConfigSubentry, str] | None:
|
||||
"""Return the next deprecated subentry target."""
|
||||
if self._subentry_iter is None:
|
||||
self._subentry_iter = self._iter_deprecated_subentries()
|
||||
|
||||
while True:
|
||||
try:
|
||||
entry_id, subentry_id = next(self._subentry_iter)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
model = self._pending_model(entry_id, subentry_id)
|
||||
if model is None:
|
||||
model = subentry.data.get(CONF_CHAT_MODEL)
|
||||
if not model or not model.startswith(tuple(DEPRECATED_MODELS)):
|
||||
continue
|
||||
|
||||
self._current_entry_id = entry_id
|
||||
self._current_subentry_id = subentry_id
|
||||
return entry, subentry, model
|
||||
|
||||
def _async_update_current_subentry(self, user_input: dict[str, str]) -> str | None:
|
||||
"""Update the currently selected subentry."""
|
||||
if not self._current_entry_id or not self._current_subentry_id:
|
||||
return None
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(self._current_entry_id)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
subentry = entry.subentries.get(self._current_subentry_id)
|
||||
if subentry is None:
|
||||
return None
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: user_input[CONF_CHAT_MODEL],
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
return entry.entry_id
|
||||
self._queue_pending_update(
|
||||
entry.entry_id,
|
||||
subentry.subentry_id,
|
||||
updated_data[CONF_CHAT_MODEL],
|
||||
)
|
||||
return entry.entry_id
|
||||
|
||||
def _clear_current_target(self) -> None:
|
||||
"""Clear current target tracking."""
|
||||
self._current_entry_id = None
|
||||
self._current_subentry_id = None
|
||||
|
||||
def _format_subentry_type(self, subentry_type: str) -> str:
|
||||
"""Return a user-friendly subentry type label."""
|
||||
if subentry_type == "conversation":
|
||||
return "Conversation agent"
|
||||
if subentry_type in ("ai_task", "ai_task_data"):
|
||||
return "AI task"
|
||||
return subentry_type
|
||||
|
||||
def _queue_pending_update(
|
||||
self, entry_id: str, subentry_id: str, model: str
|
||||
) -> None:
|
||||
"""Store a pending model update for a subentry."""
|
||||
self._pending_updates.setdefault(entry_id, {})[subentry_id] = model
|
||||
|
||||
def _pending_model(self, entry_id: str, subentry_id: str) -> str | None:
|
||||
"""Return a pending model update if one exists."""
|
||||
return self._pending_updates.get(entry_id, {}).get(subentry_id)
|
||||
|
||||
def _mark_entry_for_reload(self, entry_id: str) -> None:
|
||||
"""Prevent reload until repairs are complete for the entry."""
|
||||
self._reload_pending.add(entry_id)
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.add(entry_id)
|
||||
|
||||
async def _async_reload_entry(self, entry_id: str) -> None:
|
||||
"""Reload an entry once all repairs are completed."""
|
||||
if entry_id not in self._reload_pending:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is not None and entry.state is not ConfigEntryState.LOADED:
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
return
|
||||
|
||||
if entry is not None:
|
||||
await self.hass.config_entries.async_reload(entry_id)
|
||||
|
||||
self._clear_defer_reload(entry_id)
|
||||
self._reload_pending.discard(entry_id)
|
||||
|
||||
def _clear_defer_reload(self, entry_id: str) -> None:
|
||||
"""Remove entry from the deferred reload set."""
|
||||
defer_reload_entries: set[str] = self.hass.data.setdefault(
|
||||
DOMAIN, {}
|
||||
).setdefault(DATA_REPAIR_DEFER_RELOAD, set())
|
||||
defer_reload_entries.discard(entry_id)
|
||||
|
||||
async def _async_apply_pending_updates(self, entry_id: str) -> None:
|
||||
"""Apply pending subentry updates for a single entry."""
|
||||
updates = self._pending_updates.pop(entry_id, None)
|
||||
if not updates:
|
||||
return
|
||||
|
||||
entry = self.hass.config_entries.async_get_entry(entry_id)
|
||||
if entry is None or entry.state is not ConfigEntryState.LOADED:
|
||||
return
|
||||
|
||||
changed = False
|
||||
for subentry_id, model in updates.items():
|
||||
subentry = entry.subentries.get(subentry_id)
|
||||
if subentry is None:
|
||||
continue
|
||||
|
||||
updated_data = {
|
||||
**subentry.data,
|
||||
CONF_CHAT_MODEL: model,
|
||||
}
|
||||
if updated_data == subentry.data:
|
||||
continue
|
||||
|
||||
if not changed:
|
||||
self._mark_entry_for_reload(entry_id)
|
||||
changed = True
|
||||
|
||||
self.hass.config_entries.async_update_subentry(
|
||||
entry,
|
||||
subentry,
|
||||
data=updated_data,
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return
|
||||
|
||||
await self._async_reload_entry(entry_id)
|
||||
|
||||
async def _async_apply_all_pending_updates(self) -> None:
|
||||
"""Apply all pending updates across entries."""
|
||||
for entry_id in list(self._pending_updates):
|
||||
await self._async_apply_pending_updates(entry_id)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
data: dict[str, str | int | float | None] | None,
|
||||
) -> RepairsFlow:
|
||||
"""Create flow."""
|
||||
if issue_id == "model_deprecated":
|
||||
return ModelDeprecatedRepairFlow()
|
||||
raise HomeAssistantError("Unknown issue ID")
|
||||
@@ -109,5 +109,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"model_deprecated": {
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]"
|
||||
},
|
||||
"description": "You are updating {subentry_name} ({subentry_type}) in {entry_name}. The current model {model} is deprecated. Select a supported model to continue.",
|
||||
"title": "Update model"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Model deprecated"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.2.0"],
|
||||
"requirements": ["denonavr==1.3.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.5.0"
|
||||
"bleak-esphome==3.6.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["fressnapftracker==0.2.1"]
|
||||
"requirements": ["fressnapftracker==0.2.2"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"requirements": ["growattServer==1.7.1"]
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2025.12.08"],
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"name": "Browse media"
|
||||
},
|
||||
"clear_playlist": {
|
||||
"description": "Removes all items from the playlist.",
|
||||
"description": "Removes all items from a media player's playlist.",
|
||||
"name": "Clear playlist"
|
||||
},
|
||||
"join": {
|
||||
@@ -284,15 +284,15 @@
|
||||
"name": "Next"
|
||||
},
|
||||
"media_pause": {
|
||||
"description": "Pauses.",
|
||||
"description": "Pauses playback on a media player.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"media_play": {
|
||||
"description": "Starts playing.",
|
||||
"description": "Starts playback on a media player.",
|
||||
"name": "Play"
|
||||
},
|
||||
"media_play_pause": {
|
||||
"description": "Toggles play/pause.",
|
||||
"description": "Toggles play/pause on a media player.",
|
||||
"name": "Play/Pause"
|
||||
},
|
||||
"media_previous_track": {
|
||||
@@ -310,7 +310,7 @@
|
||||
"name": "Seek"
|
||||
},
|
||||
"media_stop": {
|
||||
"description": "Stops playing.",
|
||||
"description": "Stops playback on a media player.",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
},
|
||||
"play_media": {
|
||||
@@ -374,7 +374,7 @@
|
||||
"name": "Select sound mode"
|
||||
},
|
||||
"select_source": {
|
||||
"description": "Sends the media player the command to change input source.",
|
||||
"description": "Sends a media player the command to change the input source.",
|
||||
"fields": {
|
||||
"source": {
|
||||
"description": "Name of the source to switch to. Platform dependent.",
|
||||
@@ -398,23 +398,23 @@
|
||||
"name": "[%key:common::action::toggle%]"
|
||||
},
|
||||
"turn_off": {
|
||||
"description": "Turns off the power of the media player.",
|
||||
"description": "Turns off the power of a media player.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the power of the media player.",
|
||||
"description": "Turns on the power of a media player.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Removes the player from a group. Only works on platforms which support player groups.",
|
||||
"description": "Removes a media player from a group. Only works on platforms which support player groups.",
|
||||
"name": "Unjoin"
|
||||
},
|
||||
"volume_down": {
|
||||
"description": "Turns down the volume.",
|
||||
"description": "Turns down the volume of a media player.",
|
||||
"name": "Turn down volume"
|
||||
},
|
||||
"volume_mute": {
|
||||
"description": "Mutes or unmutes the media player.",
|
||||
"description": "Mutes or unmutes a media player.",
|
||||
"fields": {
|
||||
"is_volume_muted": {
|
||||
"description": "Defines whether or not it is muted.",
|
||||
@@ -424,7 +424,7 @@
|
||||
"name": "Mute/unmute volume"
|
||||
},
|
||||
"volume_set": {
|
||||
"description": "Sets the volume level.",
|
||||
"description": "Sets the volume level of a media player.",
|
||||
"fields": {
|
||||
"volume_level": {
|
||||
"description": "The volume. 0 is inaudible, 1 is the maximum volume.",
|
||||
@@ -434,7 +434,7 @@
|
||||
"name": "Set volume"
|
||||
},
|
||||
"volume_up": {
|
||||
"description": "Turns up the volume.",
|
||||
"description": "Turns up the volume of a media player.",
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -806,6 +806,8 @@ class CoffeeSystemProgramId(MieleEnum, missing_to_none=True):
|
||||
24813, # add profile
|
||||
)
|
||||
appliance_rinse = 24750, 24759, 24773, 24787, 24788
|
||||
intermediate_rinsing = 24758
|
||||
automatic_maintenance = 24778
|
||||
descaling = 24751
|
||||
brewing_unit_degrease = 24753
|
||||
milk_pipework_rinse = 24754
|
||||
|
||||
@@ -288,6 +288,7 @@
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"auto_roast": "Auto roast",
|
||||
"automatic": "Automatic",
|
||||
"automatic_maintenance": "Automatic maintenance",
|
||||
"automatic_plus": "Automatic plus",
|
||||
"baguettes": "Baguettes",
|
||||
"barista_assistant": "BaristaAssistant",
|
||||
@@ -551,6 +552,7 @@
|
||||
"hygiene": "Hygiene",
|
||||
"intensive": "Intensive",
|
||||
"intensive_bake": "Intensive bake",
|
||||
"intermediate_rinsing": "Intermediate rinsing",
|
||||
"iridescent_shark_fillet": "Iridescent shark (fillet)",
|
||||
"japanese_tea": "Japanese tea",
|
||||
"jasmine_rice_rapid_steam_cooking": "Jasmine rice (rapid steam cooking)",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1"]
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
}
|
||||
|
||||
@@ -670,23 +670,41 @@ class WeatherExtraStoredData(ExtraStoredData):
|
||||
@classmethod
|
||||
def from_dict(cls, restored: dict[str, Any]) -> Self | None:
|
||||
"""Initialize a stored event state from a dict."""
|
||||
try:
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
except KeyError:
|
||||
return None
|
||||
for key, vtypes in (
|
||||
("last_apparent_temperature", (float, int)),
|
||||
("last_cloud_coverage", (float, int)),
|
||||
("last_dew_point", (float, int)),
|
||||
("last_humidity", (float, int)),
|
||||
("last_ozone", (float, int)),
|
||||
("last_pressure", (float, int)),
|
||||
("last_temperature", (float, int)),
|
||||
("last_uv_index", (float, int)),
|
||||
("last_visibility", (float, int)),
|
||||
("last_wind_bearing", (float, int, str)),
|
||||
("last_wind_gust_speed", (float, int)),
|
||||
("last_wind_speed", (float, int)),
|
||||
):
|
||||
# This is needed to safeguard against previous restore data that has strings
|
||||
# instead of floats or ints.
|
||||
if key not in restored or (
|
||||
(value := restored[key]) is not None and not isinstance(value, vtypes)
|
||||
):
|
||||
return None
|
||||
|
||||
return cls(
|
||||
last_apparent_temperature=restored["last_apparent_temperature"],
|
||||
last_cloud_coverage=restored["last_cloud_coverage"],
|
||||
last_dew_point=restored["last_dew_point"],
|
||||
last_humidity=restored["last_humidity"],
|
||||
last_ozone=restored["last_ozone"],
|
||||
last_pressure=restored["last_pressure"],
|
||||
last_temperature=restored["last_temperature"],
|
||||
last_uv_index=restored["last_uv_index"],
|
||||
last_visibility=restored["last_visibility"],
|
||||
last_wind_bearing=restored["last_wind_bearing"],
|
||||
last_wind_gust_speed=restored["last_wind_gust_speed"],
|
||||
last_wind_speed=restored["last_wind_speed"],
|
||||
)
|
||||
|
||||
|
||||
class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity):
|
||||
@@ -824,18 +842,18 @@ class TriggerWeatherEntity(TriggerEntity, AbstractTemplateWeather, RestoreEntity
|
||||
def extra_restore_state_data(self) -> WeatherExtraStoredData:
|
||||
"""Return weather specific state data to be restored."""
|
||||
return WeatherExtraStoredData(
|
||||
last_apparent_temperature=self._rendered.get(CONF_APPARENT_TEMPERATURE),
|
||||
last_cloud_coverage=self._rendered.get(CONF_CLOUD_COVERAGE),
|
||||
last_dew_point=self._rendered.get(CONF_DEW_POINT),
|
||||
last_humidity=self._rendered.get(CONF_HUMIDITY),
|
||||
last_ozone=self._rendered.get(CONF_OZONE),
|
||||
last_pressure=self._rendered.get(CONF_PRESSURE),
|
||||
last_temperature=self._rendered.get(CONF_TEMPERATURE),
|
||||
last_uv_index=self._rendered.get(CONF_UV_INDEX),
|
||||
last_visibility=self._rendered.get(CONF_VISIBILITY),
|
||||
last_wind_bearing=self._rendered.get(CONF_WIND_BEARING),
|
||||
last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED),
|
||||
last_wind_speed=self._rendered.get(CONF_WIND_SPEED),
|
||||
last_apparent_temperature=self.native_apparent_temperature,
|
||||
last_cloud_coverage=self._attr_cloud_coverage,
|
||||
last_dew_point=self.native_dew_point,
|
||||
last_humidity=self.humidity,
|
||||
last_ozone=self.ozone,
|
||||
last_pressure=self.native_pressure,
|
||||
last_temperature=self.native_temperature,
|
||||
last_uv_index=self.uv_index,
|
||||
last_visibility=self.native_visibility,
|
||||
last_wind_bearing=self.wind_bearing,
|
||||
last_wind_gust_speed=self.native_wind_gust_speed,
|
||||
last_wind_speed=self.native_wind_speed,
|
||||
)
|
||||
|
||||
async def async_get_last_weather_data(self) -> WeatherExtraStoredData | None:
|
||||
|
||||
@@ -54,7 +54,7 @@ class DatasetEntry:
|
||||
return cast(tlv_parser.Channel, channel).channel
|
||||
|
||||
@cached_property
|
||||
def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]:
|
||||
def dataset(self) -> dict[MeshcopTLVType | int, tlv_parser.MeshcopTLVItem]:
|
||||
"""Return the dataset in dict format."""
|
||||
return tlv_parser.parse_tlv(self.tlv)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.7.1", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.8.0", "pyroute2==0.7.5"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==0.0.88", "serialx==0.6.2"],
|
||||
"requirements": ["zha==0.0.89", "serialx==0.6.2"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2026
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
18
requirements_all.txt
generated
@@ -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
|
||||
|
||||
18
requirements_test_all.txt
generated
18
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
301
tests/components/anthropic/test_repairs.py
Normal file
301
tests/components/anthropic/test_repairs.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Tests for the Anthropic repairs flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
|
||||
from homeassistant.components.anthropic.const import CONF_CHAT_MODEL, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.repairs import (
|
||||
async_process_repairs_platforms,
|
||||
process_repair_fix_flow,
|
||||
start_repair_fix_flow,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
def _make_entry(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
title: str,
|
||||
api_key: str,
|
||||
subentries_data: list[dict[str, Any]],
|
||||
) -> MockConfigEntry:
|
||||
"""Create a config entry with subentries and runtime data."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=title,
|
||||
data={"api_key": api_key},
|
||||
version=2,
|
||||
subentries_data=subentries_data,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
object.__setattr__(entry, "state", ConfigEntryState.LOADED)
|
||||
entry.runtime_data = MagicMock()
|
||||
return entry
|
||||
|
||||
|
||||
def _get_subentry(
|
||||
entry: MockConfigEntry,
|
||||
subentry_type: str,
|
||||
) -> ConfigSubentry:
|
||||
"""Return the first subentry of a type."""
|
||||
return next(
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == subentry_type
|
||||
)
|
||||
|
||||
|
||||
async def _setup_repairs(hass: HomeAssistant) -> None:
|
||||
hass.config.components.add(DOMAIN)
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
await async_process_repairs_platforms(hass)
|
||||
|
||||
|
||||
async def test_repair_flow_iterates_subentries(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test the repair flow iterates across deprecated subentries."""
|
||||
entry_one: MockConfigEntry = _make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-haiku-20241022"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-sonnet-20241022"},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "AI task One",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry_two: MockConfigEntry = _make_entry(
|
||||
hass,
|
||||
title="Entry Two",
|
||||
api_key="key-two",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-opus-20240229"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation Two",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
model_options: list[dict[str, str]] = [
|
||||
{"label": "Claude Haiku 4.5", "value": "claude-haiku-4-5"},
|
||||
{"label": "Claude Sonnet 4.5", "value": "claude-sonnet-4-5"},
|
||||
{"label": "Claude Opus 4.5", "value": "claude-opus-4-5"},
|
||||
]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.anthropic.repairs.get_model_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=model_options,
|
||||
):
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_one.title
|
||||
assert placeholders["subentry_name"] == "Conversation One"
|
||||
assert placeholders["subentry_type"] == "Conversation agent"
|
||||
|
||||
flow_id = result["flow_id"]
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-haiku-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert (
|
||||
_get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-3-5-haiku-20241022"
|
||||
)
|
||||
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_one.title
|
||||
assert placeholders["subentry_name"] == "AI task One"
|
||||
assert placeholders["subentry_type"] == "AI task"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert (
|
||||
_get_subentry(entry_one, "ai_task_data").data[CONF_CHAT_MODEL]
|
||||
== "claude-sonnet-4-5"
|
||||
)
|
||||
assert (
|
||||
_get_subentry(entry_one, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-haiku-4-5"
|
||||
)
|
||||
|
||||
placeholders = result["description_placeholders"]
|
||||
assert placeholders["entry_name"] == entry_two.title
|
||||
assert placeholders["subentry_name"] == "Conversation Two"
|
||||
assert placeholders["subentry_type"] == "Conversation agent"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-opus-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert (
|
||||
_get_subentry(entry_two, "conversation").data[CONF_CHAT_MODEL]
|
||||
== "claude-opus-4-5"
|
||||
)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None
|
||||
|
||||
|
||||
async def test_repair_flow_no_deprecated_models(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test the repair flow completes when everything was fixed."""
|
||||
_make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert issue_registry.async_get_issue(DOMAIN, "model_deprecated") is None
|
||||
|
||||
|
||||
async def test_repair_flow_defers_reload_until_entry_done(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test reload is deferred until all subentries for an entry are fixed."""
|
||||
entry = _make_entry(
|
||||
hass,
|
||||
title="Entry One",
|
||||
api_key="key-one",
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-haiku-20241022"},
|
||||
"subentry_type": "conversation",
|
||||
"title": "Conversation One",
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {CONF_CHAT_MODEL: "claude-3-5-sonnet-20241022"},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": "AI task One",
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"model_deprecated",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="model_deprecated",
|
||||
)
|
||||
|
||||
await _setup_repairs(hass)
|
||||
client = await hass_client()
|
||||
|
||||
model_options: list[dict[str, str]] = [
|
||||
{"label": "Claude Haiku 4.5", "value": "claude-haiku-4-5"},
|
||||
{"label": "Claude Sonnet 4.5", "value": "claude-sonnet-4-5"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.anthropic.repairs.get_model_list",
|
||||
new_callable=AsyncMock,
|
||||
return_value=model_options,
|
||||
),
|
||||
patch.object(
|
||||
hass.config_entries,
|
||||
"async_reload",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
) as reload_mock,
|
||||
):
|
||||
result = await start_repair_fix_flow(client, DOMAIN, "model_deprecated")
|
||||
flow_id = result["flow_id"]
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-haiku-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert reload_mock.await_count == 0
|
||||
|
||||
result = await process_repair_fix_flow(
|
||||
client,
|
||||
flow_id,
|
||||
json={CONF_CHAT_MODEL: "claude-sonnet-4-5"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert reload_mock.await_count == 1
|
||||
assert reload_mock.call_args_list == [call(entry.entry_id)]
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -1781,7 +1781,7 @@
|
||||
],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute"],
|
||||
"SupportedCommands": ["VolumeSet", "Mute", "Unmute"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": true,
|
||||
"MessageCallbackUrl": "string",
|
||||
@@ -4548,5 +4548,133 @@
|
||||
"PlayMediaSource",
|
||||
"PlayTrailers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Mute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-FIVE",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Five",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-FIVE",
|
||||
"DeviceId": "DEVICE-UUID-FIVE",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-FIVE",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"PlayState": {
|
||||
"PositionTicks": 100000000,
|
||||
"CanSeek": true,
|
||||
"IsPaused": false,
|
||||
"IsMuted": false,
|
||||
"VolumeLevel": 50,
|
||||
"AudioStreamIndex": 0,
|
||||
"SubtitleStreamIndex": 0,
|
||||
"MediaSourceId": "string",
|
||||
"PlayMethod": "Transcode",
|
||||
"RepeatMode": "RepeatNone",
|
||||
"LiveStreamId": "string"
|
||||
},
|
||||
"AdditionalUsers": [],
|
||||
"Capabilities": {
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"SupportedCommands": ["Unmute", "VolumeSet", "PlayMediaSource"],
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsContentUploading": false,
|
||||
"MessageCallbackUrl": "string",
|
||||
"SupportsPersistentIdentifier": false,
|
||||
"SupportsSync": false,
|
||||
"DeviceProfile": null
|
||||
},
|
||||
"RemoteEndPoint": "192.168.1.1",
|
||||
"PlayableMediaTypes": ["Video"],
|
||||
"Id": "SESSION-UUID-SIX",
|
||||
"UserId": "USER-UUID",
|
||||
"UserName": "USER",
|
||||
"Client": "Test Client Six",
|
||||
"LastActivityDate": "2021-05-21T06:09:06.919Z",
|
||||
"LastPlaybackCheckIn": "2021-05-21T06:09:06.919Z",
|
||||
"DeviceName": "JELLYFIN-DEVICE-SIX",
|
||||
"DeviceId": "DEVICE-UUID-SIX",
|
||||
"ApplicationVersion": "1.0.0",
|
||||
"IsActive": true,
|
||||
"SupportsMediaControl": true,
|
||||
"SupportsRemoteControl": true,
|
||||
"HasCustomDeviceName": false,
|
||||
"ServerId": "SERVER-UUID",
|
||||
"SupportedCommands": ["MoveUp"],
|
||||
"NowPlayingItem": {
|
||||
"Name": "TEST VIDEO",
|
||||
"ServerId": "SERVER-UUID",
|
||||
"Id": "VIDEO-UUID-SIX",
|
||||
"RunTimeTicks": 600000000,
|
||||
"Type": "Episode",
|
||||
"UserData": {
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"Key": "string"
|
||||
},
|
||||
"PrimaryImageAspectRatio": 1,
|
||||
"SeriesName": "TEST SERIES",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": 1,
|
||||
"ImageTags": {
|
||||
"Primary": "tag"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
@@ -902,6 +903,7 @@
|
||||
'SupportedCommands': list([
|
||||
'VolumeSet',
|
||||
'Mute',
|
||||
'Unmute',
|
||||
]),
|
||||
'SupportsContentUploading': True,
|
||||
'SupportsMediaControl': True,
|
||||
@@ -1785,6 +1787,122 @@
|
||||
}),
|
||||
'user_id': 'USER-UUID-TWO',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Mute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Five',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-FIVE',
|
||||
'device_name': 'JELLYFIN-DEVICE-FIVE',
|
||||
'id': 'SESSION-UUID-FIVE',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-FIVE',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
dict({
|
||||
'capabilities': dict({
|
||||
'DeviceProfile': None,
|
||||
'MessageCallbackUrl': 'string',
|
||||
'PlayableMediaTypes': list([
|
||||
'Video',
|
||||
]),
|
||||
'SupportedCommands': list([
|
||||
'Unmute',
|
||||
'VolumeSet',
|
||||
'PlayMediaSource',
|
||||
]),
|
||||
'SupportsContentUploading': False,
|
||||
'SupportsMediaControl': True,
|
||||
'SupportsPersistentIdentifier': False,
|
||||
'SupportsSync': False,
|
||||
}),
|
||||
'client_name': 'Test Client Six',
|
||||
'client_version': '1.0.0',
|
||||
'device_id': 'DEVICE-UUID-SIX',
|
||||
'device_name': 'JELLYFIN-DEVICE-SIX',
|
||||
'id': 'SESSION-UUID-SIX',
|
||||
'now_playing': dict({
|
||||
'Id': 'VIDEO-UUID-SIX',
|
||||
'ImageTags': dict({
|
||||
'Primary': 'tag',
|
||||
}),
|
||||
'IndexNumber': 1,
|
||||
'Name': 'TEST VIDEO',
|
||||
'ParentIndexNumber': 1,
|
||||
'PrimaryImageAspectRatio': 1,
|
||||
'RunTimeTicks': 600000000,
|
||||
'SeriesName': 'TEST SERIES',
|
||||
'ServerId': 'SERVER-UUID',
|
||||
'Type': 'Episode',
|
||||
'UserData': dict({
|
||||
'IsFavorite': False,
|
||||
'Key': 'string',
|
||||
'PlayCount': 0,
|
||||
'PlaybackPositionTicks': 0,
|
||||
'Played': False,
|
||||
}),
|
||||
}),
|
||||
'play_state': dict({
|
||||
'AudioStreamIndex': 0,
|
||||
'CanSeek': True,
|
||||
'IsMuted': False,
|
||||
'IsPaused': False,
|
||||
'LiveStreamId': 'string',
|
||||
'MediaSourceId': 'string',
|
||||
'PlayMethod': 'Transcode',
|
||||
'PositionTicks': 100000000,
|
||||
'RepeatMode': 'RepeatNone',
|
||||
'SubtitleStreamIndex': 0,
|
||||
'VolumeLevel': 50,
|
||||
}),
|
||||
'user_id': 'USER-UUID',
|
||||
}),
|
||||
]),
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.components.media_player import (
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
MediaClass,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
@@ -423,3 +424,98 @@ async def test_new_client_connected(
|
||||
|
||||
state = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state
|
||||
|
||||
|
||||
async def test_supports_media_control_fallback(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SupportsMediaControl enables controls without PlayMediaSource."""
|
||||
# SESSION-UUID-TWO has SupportsMediaControl: true but no PlayMediaSource command
|
||||
state = hass.states.get("media_player.jellyfin_device_two")
|
||||
|
||||
assert state
|
||||
assert state.state == MediaPlayerState.PLAYING
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have basic playback controls
|
||||
assert features & MediaPlayerEntityFeature.PLAY
|
||||
assert features & MediaPlayerEntityFeature.PAUSE
|
||||
assert features & MediaPlayerEntityFeature.STOP
|
||||
assert features & MediaPlayerEntityFeature.SEEK
|
||||
assert features & MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
assert features & MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
|
||||
# Should also have volume controls since it has VolumeSet, Mute, and Unmute
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
|
||||
async def test_set_volume_command_alternative(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that SetVolume command (alternative to VolumeSet) enables volume control."""
|
||||
# SESSION-UUID-FOUR has SetVolume instead of VolumeSet
|
||||
state = hass.states.get("media_player.jellyfin_device_four")
|
||||
|
||||
assert state
|
||||
|
||||
# Get the entity to check supported features
|
||||
entity = hass.data["entity_components"]["media_player"].get_entity(state.entity_id)
|
||||
features = entity.supported_features
|
||||
|
||||
# Should have volume control via SetVolume command
|
||||
assert features & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
|
||||
async def test_mute_requires_both_commands(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_jellyfin: MagicMock,
|
||||
mock_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test that VOLUME_MUTE requires both Mute AND Unmute commands."""
|
||||
|
||||
# SESSION-UUID-FIVE has only Mute (no Unmute) - should NOT have VOLUME_MUTE
|
||||
state_five = hass.states.get("media_player.jellyfin_device_five")
|
||||
assert state_five
|
||||
|
||||
entity_five = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_five.entity_id
|
||||
)
|
||||
features_five = entity_five.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_five & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_five & MediaPlayerEntityFeature.PLAY
|
||||
assert features_five & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
# SESSION-UUID-SIX has only Unmute (no Mute) - should NOT have VOLUME_MUTE
|
||||
state_six = hass.states.get("media_player.jellyfin_device_six")
|
||||
assert state_six
|
||||
|
||||
entity_six = hass.data["entity_components"]["media_player"].get_entity(
|
||||
state_six.entity_id
|
||||
)
|
||||
features_six = entity_six.supported_features
|
||||
|
||||
# Should NOT have mute feature
|
||||
assert not (features_six & MediaPlayerEntityFeature.VOLUME_MUTE)
|
||||
# But should still have other features
|
||||
assert features_six & MediaPlayerEntityFeature.PLAY
|
||||
assert features_six & MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
@@ -25,7 +25,7 @@ async def test_watching(
|
||||
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "JELLYFIN-SERVER Active clients"
|
||||
assert state.attributes.get(ATTR_ICON) is None
|
||||
assert state.attributes.get(ATTR_STATE_CLASS) is None
|
||||
assert state.state == "3"
|
||||
assert state.state == "5"
|
||||
|
||||
entry = entity_registry.async_get(state.entity_id)
|
||||
assert entry
|
||||
|
||||
@@ -102,6 +102,7 @@
|
||||
'options': list([
|
||||
'appliance_rinse',
|
||||
'appliance_settings',
|
||||
'automatic_maintenance',
|
||||
'barista_assistant',
|
||||
'black_tea',
|
||||
'brewing_unit_degrease',
|
||||
@@ -121,6 +122,7 @@
|
||||
'herbal_tea',
|
||||
'hot_milk',
|
||||
'hot_water',
|
||||
'intermediate_rinsing',
|
||||
'japanese_tea',
|
||||
'latte_macchiato',
|
||||
'long_coffee',
|
||||
@@ -171,6 +173,7 @@
|
||||
'options': list([
|
||||
'appliance_rinse',
|
||||
'appliance_settings',
|
||||
'automatic_maintenance',
|
||||
'barista_assistant',
|
||||
'black_tea',
|
||||
'brewing_unit_degrease',
|
||||
@@ -190,6 +193,7 @@
|
||||
'herbal_tea',
|
||||
'hot_milk',
|
||||
'hot_water',
|
||||
'intermediate_rinsing',
|
||||
'japanese_tea',
|
||||
'latte_macchiato',
|
||||
'long_coffee',
|
||||
|
||||
@@ -269,10 +269,10 @@
|
||||
'last_apparent_temperature': None,
|
||||
'last_cloud_coverage': None,
|
||||
'last_dew_point': None,
|
||||
'last_humidity': '25.0',
|
||||
'last_humidity': 25.0,
|
||||
'last_ozone': None,
|
||||
'last_pressure': None,
|
||||
'last_temperature': '15.0',
|
||||
'last_temperature': 15.0,
|
||||
'last_uv_index': None,
|
||||
'last_visibility': None,
|
||||
'last_wind_bearing': None,
|
||||
|
||||
@@ -839,11 +839,27 @@ SAVED_EXTRA_DATA_MISSING_KEY = {
|
||||
"last_wind_speed": None,
|
||||
}
|
||||
|
||||
SAVED_EXTRA_DATA_STRING_HUMIDITY = {
|
||||
"last_apparent_temperature": None,
|
||||
"last_cloud_coverage": None,
|
||||
"last_dew_point": None,
|
||||
"last_humidity": "20.0",
|
||||
"last_ozone": None,
|
||||
"last_pressure": None,
|
||||
"last_temperature": 20.0,
|
||||
"last_uv_index": None,
|
||||
"last_visibility": None,
|
||||
"last_wind_bearing": None,
|
||||
"last_wind_gust_speed": None,
|
||||
"last_wind_speed": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("saved_attributes", "saved_extra_data"),
|
||||
[
|
||||
(SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_MISSING_KEY),
|
||||
(SAVED_ATTRIBUTES_1, SAVED_EXTRA_DATA_STRING_HUMIDITY),
|
||||
(SAVED_ATTRIBUTES_1, None),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ DATASET_1_LARGER_TIMESTAMP = (
|
||||
|
||||
async def test_add_invalid_dataset(hass: HomeAssistant) -> None:
|
||||
"""Test adding an invalid dataset."""
|
||||
with pytest.raises(TLVError, match="unknown type 222"):
|
||||
with pytest.raises(TLVError, match="expected 173 bytes for tag 222, got 2"):
|
||||
await dataset_store.async_add_dataset(hass, "source", "DEADBEEF")
|
||||
|
||||
store = await dataset_store.async_get_store(hass)
|
||||
|
||||
@@ -57,7 +57,10 @@ async def test_add_invalid_dataset(
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert not msg["success"]
|
||||
assert msg["error"] == {"code": "invalid_format", "message": "unknown type 222"}
|
||||
assert msg["error"] == {
|
||||
"code": "invalid_format",
|
||||
"message": "expected 173 bytes for tag 222, got 2",
|
||||
}
|
||||
|
||||
|
||||
async def test_delete_dataset(
|
||||
|
||||
Reference in New Issue
Block a user