mirror of
https://github.com/home-assistant/core.git
synced 2026-02-05 14:55:35 +01:00
Compare commits
1 Commits
overkiz_su
...
whirlpool_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e4a6d9092 |
1
.github/workflows/builder.yml
vendored
1
.github/workflows/builder.yml
vendored
@@ -235,7 +235,6 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
|
||||
uses: github/codeql-action/init@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
|
||||
uses: github/codeql-action/analyze@b20883b0cd1f46c72ae0ba6d1090936928f9fa30 # v4.32.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -435,7 +435,6 @@ homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.redgtech.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.remote_calendar.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -1355,8 +1355,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/recorder/ @home-assistant/core
|
||||
/homeassistant/components/recovery_mode/ @home-assistant/core
|
||||
/tests/components/recovery_mode/ @home-assistant/core
|
||||
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
|
||||
/tests/components/redgtech/ @jonhsady @luan-nvg
|
||||
/homeassistant/components/refoss/ @ashionky
|
||||
/tests/components/refoss/ @ashionky
|
||||
/homeassistant/components/rehlko/ @bdraco @peterager
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "heatit",
|
||||
"name": "Heatit",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"domain": "heiman",
|
||||
"name": "Heiman",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ from homeassistant.helpers.typing import StateType
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -106,9 +105,6 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Remove notification sensors from unsupported devices
|
||||
await async_remove_unsupported_notification_sensors(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -126,7 +122,6 @@ async def async_setup_entry(
|
||||
AmazonSensorEntity(coordinator, serial_num, notification_desc)
|
||||
for notification_desc in NOTIFICATIONS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].notifications_supported
|
||||
]
|
||||
async_add_entities(sensors_list + notifications_list)
|
||||
|
||||
|
||||
@@ -59,15 +59,13 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# DND keys
|
||||
old_key = "do_not_disturb"
|
||||
new_key = "dnd"
|
||||
# Replace unique id for "DND" switch and remove from Speaker Group
|
||||
await async_update_unique_id(
|
||||
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Remove DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
|
||||
@@ -5,14 +5,8 @@ from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -54,7 +48,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
async def async_update_unique_id(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
platform: str,
|
||||
domain: str,
|
||||
old_key: str,
|
||||
new_key: str,
|
||||
) -> None:
|
||||
@@ -63,9 +57,7 @@ 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, platform, unique_id
|
||||
):
|
||||
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
@@ -76,13 +68,12 @@ 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}-{key}"
|
||||
unique_id = f"{serial_num}-do_not_disturb"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SWITCH_DOMAIN, unique_id
|
||||
)
|
||||
@@ -90,27 +81,3 @@ async def async_remove_dnd_from_virtual_group(
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
) -> None:
|
||||
"""Remove notification sensors from unsupported devices."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
for notification_key in (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
if entity_id and is_unsupported:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
@@ -202,7 +202,7 @@ class AmcrestChecker(ApiWrapper):
|
||||
@asynccontextmanager
|
||||
async def async_stream_command(
|
||||
self, *args: Any, **kwargs: Any
|
||||
) -> AsyncGenerator[httpx.Response]:
|
||||
) -> AsyncIterator[httpx.Response]:
|
||||
"""amcrest.ApiWrapper.command wrapper to catch errors."""
|
||||
async with (
|
||||
self._async_command_wrapper(),
|
||||
@@ -211,7 +211,7 @@ class AmcrestChecker(ApiWrapper):
|
||||
yield ret
|
||||
|
||||
@asynccontextmanager
|
||||
async def _async_command_wrapper(self) -> AsyncGenerator[None]:
|
||||
async def _async_command_wrapper(self) -> AsyncIterator[None]:
|
||||
try:
|
||||
yield
|
||||
except LoginError as ex:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
|
||||
@@ -14,18 +14,10 @@ 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 (
|
||||
CONF_CHAT_MODEL,
|
||||
DATA_REPAIR_DEFER_RELOAD,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEPRECATED_MODELS,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
)
|
||||
from .const import DEFAULT_CONVERSATION_NAME, DOMAIN, LOGGER
|
||||
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -35,7 +27,6 @@ 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
|
||||
|
||||
@@ -59,22 +50,6 @@ 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
|
||||
|
||||
|
||||
@@ -87,11 +62,6 @@ 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,40 +92,6 @@ 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."""
|
||||
|
||||
@@ -435,13 +401,38 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
async def _get_model_list(self) -> list[SelectOptionDict]:
|
||||
"""Get list of available models."""
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
try:
|
||||
client = await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
anthropic.AsyncAnthropic,
|
||||
api_key=self._get_entry().data[CONF_API_KEY],
|
||||
)
|
||||
)
|
||||
)
|
||||
return await get_model_list(client)
|
||||
models = (await client.models.list()).data
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
else model_info.id
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
if model_alias.endswith(("haiku", "opus", "sonnet")):
|
||||
model_alias += "-latest"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
|
||||
async def _get_location_data(self) -> dict[str, str]:
|
||||
"""Get approximate location data of the user."""
|
||||
|
||||
@@ -22,10 +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_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
@@ -48,10 +46,3 @@ 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",
|
||||
]
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
"""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,21 +109,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.16"]
|
||||
"requirements": ["py-aosmith==1.0.15"]
|
||||
}
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
|
||||
from . import AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
|
||||
@@ -22,22 +21,10 @@ 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
|
||||
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)
|
||||
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
|
||||
|
||||
|
||||
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", "e8:27:25"}
|
||||
AXIS_OUI = {"00:40:8c", "ac:cc:8e", "b8:a4:4f"}
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_PROTOCOL = "https"
|
||||
PROTOCOL_CHOICES = ["https", "http"]
|
||||
|
||||
@@ -26,7 +26,6 @@ EXCLUDE_FROM_BACKUP = [
|
||||
"tmp_backups/*.tar",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
".cache/*",
|
||||
]
|
||||
|
||||
EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
"""Constants for the Bring! integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN = "bring"
|
||||
|
||||
ATTR_SENDER: Final = "sender"
|
||||
ATTR_ITEM_NAME: Final = "item"
|
||||
ATTR_NOTIFICATION_TYPE: Final = "message"
|
||||
ATTR_REACTION: Final = "reaction"
|
||||
ATTR_ACTIVITY: Final = "uuid"
|
||||
ATTR_RECEIVER: Final = "publicUserUuid"
|
||||
SERVICE_PUSH_NOTIFICATION = "send_message"
|
||||
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Actions for Bring! integration."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bring_api import (
|
||||
@@ -12,28 +13,22 @@ from bring_api import (
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.event import ATTR_EVENT_TYPE
|
||||
from homeassistant.components.todo import DOMAIN as TODO_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
service,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
ATTR_ACTIVITY,
|
||||
ATTR_REACTION,
|
||||
ATTR_RECEIVER,
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
)
|
||||
from .coordinator import BringConfigEntry
|
||||
|
||||
ATTR_ACTIVITY = "uuid"
|
||||
ATTR_ITEM_NAME = "item"
|
||||
ATTR_NOTIFICATION_TYPE = "message"
|
||||
ATTR_REACTION = "reaction"
|
||||
ATTR_RECEIVER = "publicUserUuid"
|
||||
|
||||
SERVICE_PUSH_NOTIFICATION = "send_message"
|
||||
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -59,7 +54,6 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
|
||||
return entry
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services for Bring! integration."""
|
||||
|
||||
@@ -114,17 +108,3 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
async_send_activity_stream_reaction,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
|
||||
)
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
entity_domain=TODO_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
|
||||
vol.Upper, vol.Coerce(BringNotificationType)
|
||||
),
|
||||
vol.Optional(ATTR_ITEM_NAME): cv.string,
|
||||
},
|
||||
func="async_send_message",
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from bring_api import (
|
||||
BringNotificationType,
|
||||
BringRequestException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
@@ -22,9 +23,15 @@ from homeassistant.components.todo import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import (
|
||||
ATTR_ITEM_NAME,
|
||||
ATTR_NOTIFICATION_TYPE,
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
)
|
||||
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
|
||||
from .entity import BringBaseEntity
|
||||
|
||||
@@ -56,6 +63,19 @@ async def async_setup_entry(
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
{
|
||||
vol.Required(ATTR_NOTIFICATION_TYPE): vol.All(
|
||||
vol.Upper, vol.Coerce(BringNotificationType)
|
||||
),
|
||||
vol.Optional(ATTR_ITEM_NAME): cv.string,
|
||||
},
|
||||
"async_send_message",
|
||||
)
|
||||
|
||||
|
||||
class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
||||
"""A To-do List representation of the Bring! Shopping List."""
|
||||
|
||||
@@ -58,14 +58,14 @@
|
||||
"name": "Enable motion detection"
|
||||
},
|
||||
"play_stream": {
|
||||
"description": "Plays a camera stream on a supported media player.",
|
||||
"description": "Plays the camera stream on a supported media player.",
|
||||
"fields": {
|
||||
"format": {
|
||||
"description": "Stream format supported by the media player.",
|
||||
"name": "Format"
|
||||
},
|
||||
"media_player": {
|
||||
"description": "Media player to stream to.",
|
||||
"description": "Media players to stream to.",
|
||||
"name": "Media player"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,7 +49,6 @@ from .const import ( # noqa: F401
|
||||
ATTR_SWING_HORIZONTAL_MODES,
|
||||
ATTR_SWING_MODE,
|
||||
ATTR_SWING_MODES,
|
||||
ATTR_TARGET_HUMIDITY_STEP,
|
||||
ATTR_TARGET_TEMP_HIGH,
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_TARGET_TEMP_STEP,
|
||||
@@ -235,7 +234,6 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"max_temp",
|
||||
"min_humidity",
|
||||
"max_humidity",
|
||||
"target_humidity_step",
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +249,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
ATTR_MAX_TEMP,
|
||||
ATTR_MIN_HUMIDITY,
|
||||
ATTR_MAX_HUMIDITY,
|
||||
ATTR_TARGET_HUMIDITY_STEP,
|
||||
ATTR_TARGET_TEMP_STEP,
|
||||
ATTR_PRESET_MODES,
|
||||
}
|
||||
@@ -278,7 +275,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_swing_horizontal_mode: str | None
|
||||
_attr_swing_horizontal_modes: list[str] | None
|
||||
_attr_target_humidity: float | None = None
|
||||
_attr_target_humidity_step: int | None = None
|
||||
_attr_target_temperature_high: float | None
|
||||
_attr_target_temperature_low: float | None
|
||||
_attr_target_temperature_step: float | None = None
|
||||
@@ -327,9 +323,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
data[ATTR_MIN_HUMIDITY] = self.min_humidity
|
||||
data[ATTR_MAX_HUMIDITY] = self.max_humidity
|
||||
|
||||
if self.target_humidity_step is not None:
|
||||
data[ATTR_TARGET_HUMIDITY_STEP] = self.target_humidity_step
|
||||
|
||||
if ClimateEntityFeature.FAN_MODE in supported_features:
|
||||
data[ATTR_FAN_MODES] = self.fan_modes
|
||||
|
||||
@@ -735,11 +728,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the maximum humidity."""
|
||||
return self._attr_max_humidity
|
||||
|
||||
@cached_property
|
||||
def target_humidity_step(self) -> int | None:
|
||||
"""Return the supported step of humidity."""
|
||||
return self._attr_target_humidity_step
|
||||
|
||||
|
||||
async def async_service_humidity_set(
|
||||
entity: ClimateEntity, service_call: ServiceCall
|
||||
|
||||
@@ -114,7 +114,6 @@ ATTR_SWING_MODES = "swing_modes"
|
||||
ATTR_SWING_MODE = "swing_mode"
|
||||
ATTR_SWING_HORIZONTAL_MODE = "swing_horizontal_mode"
|
||||
ATTR_SWING_HORIZONTAL_MODES = "swing_horizontal_modes"
|
||||
ATTR_TARGET_HUMIDITY_STEP = "target_humidity_step"
|
||||
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
|
||||
ATTR_TARGET_TEMP_LOW = "target_temp_low"
|
||||
ATTR_TARGET_TEMP_STEP = "target_temp_step"
|
||||
|
||||
@@ -459,17 +459,8 @@ class BaseCloudLLMEntity(Entity):
|
||||
last_content: Any = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
files = await self._async_prepare_files_for_prompt(last_content.attachments)
|
||||
|
||||
last_message = cast(dict[str, Any], messages[-1])
|
||||
assert (
|
||||
last_message["type"] == "message"
|
||||
and last_message["role"] == "user"
|
||||
and isinstance(last_message["content"], str)
|
||||
)
|
||||
last_message["content"] = [
|
||||
{"type": "input_text", "text": last_message["content"]},
|
||||
*files,
|
||||
]
|
||||
current_content = last_content.content
|
||||
last_content = [*(current_content or []), *files]
|
||||
|
||||
tools: list[ToolParam] = []
|
||||
tool_choice: str | None = None
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.13.0", "openai==2.15.0"],
|
||||
"requirements": ["hass-nabucasa==1.12.0", "openai==2.15.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["compit"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["compit-inext-api==0.8.0"]
|
||||
"requirements": ["compit-inext-api==0.7.0"]
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ async def async_setup_entry(
|
||||
target_temp_high=None,
|
||||
target_temp_low=None,
|
||||
hvac_modes=[cls for cls in HVACMode if cls != HVACMode.HEAT_COOL],
|
||||
target_humidity_step=5,
|
||||
),
|
||||
DemoClimate(
|
||||
unique_id="climate_3",
|
||||
@@ -119,7 +118,6 @@ class DemoClimate(ClimateEntity):
|
||||
target_temp_low: float | None,
|
||||
hvac_modes: list[HVACMode],
|
||||
preset_modes: list[str] | None = None,
|
||||
target_humidity_step: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize the climate device."""
|
||||
self._unique_id = unique_id
|
||||
@@ -165,7 +163,6 @@ class DemoClimate(ClimateEntity):
|
||||
identifiers={(DOMAIN, unique_id)},
|
||||
name=device_name,
|
||||
)
|
||||
self._attr_target_humidity_step = target_humidity_step
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
|
||||
@@ -9,9 +9,8 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_SHOW_ALL_SOURCES,
|
||||
@@ -25,12 +24,9 @@ from .const import (
|
||||
DEFAULT_USE_TELNET,
|
||||
DEFAULT_ZONE2,
|
||||
DEFAULT_ZONE3,
|
||||
DOMAIN,
|
||||
)
|
||||
from .receiver import ConnectDenonAVR
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -38,12 +34,6 @@ _LOGGER = logging.getLogger(__name__)
|
||||
type DenonavrConfigEntry = ConfigEntry[DenonAVR]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> bool:
|
||||
"""Set up the denonavr components from a config entry."""
|
||||
# Connect to receiver
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
DOMAIN = "denonavr"
|
||||
|
||||
ATTR_DYNAMIC_EQ = "dynamic_eq"
|
||||
|
||||
CONF_SHOW_ALL_SOURCES = "show_all_sources"
|
||||
CONF_ZONE2 = "zone2"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denonavr"],
|
||||
"requirements": ["denonavr==1.3.1"],
|
||||
"requirements": ["denonavr==1.2.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -26,6 +26,7 @@ from denonavr.exceptions import (
|
||||
AvrTimoutError,
|
||||
DenonAvrError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
@@ -34,14 +35,14 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE
|
||||
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DenonavrConfigEntry
|
||||
from .const import (
|
||||
ATTR_DYNAMIC_EQ,
|
||||
CONF_MANUFACTURER,
|
||||
CONF_SERIAL_NUMBER,
|
||||
CONF_UPDATE_AUDYSSEY,
|
||||
@@ -52,6 +53,7 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_SOUND_MODE_RAW = "sound_mode_raw"
|
||||
ATTR_DYNAMIC_EQ = "dynamic_eq"
|
||||
|
||||
SUPPORT_DENON = (
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
@@ -74,6 +76,11 @@ SUPPORT_MEDIA_MODES = (
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Services
|
||||
SERVICE_GET_COMMAND = "get_command"
|
||||
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
|
||||
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
|
||||
|
||||
# HA Telnet events
|
||||
TELNET_EVENTS = {
|
||||
"HD",
|
||||
@@ -127,6 +134,24 @@ async def async_setup_entry(
|
||||
"%s receiver at host %s initialized", receiver.manufacturer, receiver.host
|
||||
)
|
||||
|
||||
# Register additional services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_GET_COMMAND,
|
||||
{vol.Required(ATTR_COMMAND): cv.string},
|
||||
f"async_{SERVICE_GET_COMMAND}",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_DYNAMIC_EQ,
|
||||
{vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
|
||||
f"async_{SERVICE_SET_DYNAMIC_EQ}",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_UPDATE_AUDYSSEY,
|
||||
None,
|
||||
f"async_{SERVICE_UPDATE_AUDYSSEY}",
|
||||
)
|
||||
|
||||
async_add_entities(entities, update_before_add=True)
|
||||
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Support for Denon AVR receivers using their HTTP interface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.const import ATTR_COMMAND
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import ATTR_DYNAMIC_EQ, DOMAIN
|
||||
|
||||
# Services
|
||||
SERVICE_GET_COMMAND = "get_command"
|
||||
SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq"
|
||||
SERVICE_UPDATE_AUDYSSEY = "update_audyssey"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_GET_COMMAND,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_COMMAND): cv.string},
|
||||
func=f"async_{SERVICE_GET_COMMAND}",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_DYNAMIC_EQ,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_DYNAMIC_EQ): cv.boolean},
|
||||
func=f"async_{SERVICE_SET_DYNAMIC_EQ}",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_UPDATE_AUDYSSEY,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func=f"async_{SERVICE_UPDATE_AUDYSSEY}",
|
||||
)
|
||||
@@ -5,12 +5,8 @@ from sucks import VacBot
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .controller import EcovacsController
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -26,14 +22,6 @@ PLATFORMS = [
|
||||
]
|
||||
type EcovacsConfigEntry = ConfigEntry[EcovacsController]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool:
|
||||
"""Set up this integration using UI."""
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Ecovacs services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse, callback
|
||||
from homeassistant.helpers import service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
# Vacuum Services
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RAW_GET_POSITIONS,
|
||||
entity_domain=VACUUM_DOMAIN,
|
||||
schema=None,
|
||||
func="async_raw_get_positions",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
@@ -18,8 +18,9 @@ from homeassistant.components.vacuum import (
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
@@ -31,6 +32,9 @@ from .util import get_name_key
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_ERROR = "error"
|
||||
ATTR_COMPONENT_PREFIX = "component_"
|
||||
|
||||
SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -52,6 +56,14 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Adding Ecovacs Vacuums to Home Assistant: %s", vacuums)
|
||||
async_add_entities(vacuums)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_RAW_GET_POSITIONS,
|
||||
None,
|
||||
"async_raw_get_positions",
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
|
||||
class EcovacsLegacyVacuum(EcovacsLegacyEntity, StateVacuumEntity):
|
||||
"""Legacy Ecovacs vacuums."""
|
||||
|
||||
@@ -11,15 +11,11 @@ from epson_projector.const import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_CONNECTION_TYPE, DOMAIN, HTTP
|
||||
from .const import CONF_CONNECTION_TYPE, HTTP
|
||||
from .exceptions import CannotConnect, PoweredOff
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -51,12 +47,6 @@ async def validate_projector(
|
||||
return epson_proj
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: EpsonConfigEntry) -> bool:
|
||||
"""Set up epson from a config entry."""
|
||||
projector = await validate_projector(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Constants for the epson integration."""
|
||||
|
||||
DOMAIN = "epson"
|
||||
|
||||
SERVICE_SELECT_CMODE = "select_cmode"
|
||||
CONF_CONNECTION_TYPE = "connection_type"
|
||||
|
||||
ATTR_CMODE = "cmode"
|
||||
|
||||
@@ -27,6 +27,7 @@ from epson_projector.const import (
|
||||
VOL_UP,
|
||||
VOLUME,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
@@ -35,12 +36,17 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import EpsonConfigEntry
|
||||
from .const import ATTR_CMODE, DOMAIN
|
||||
from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +63,12 @@ async def async_setup_entry(
|
||||
entry=config_entry,
|
||||
)
|
||||
async_add_entities([projector_entity], True)
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SELECT_CMODE,
|
||||
{vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
|
||||
SERVICE_SELECT_CMODE,
|
||||
)
|
||||
|
||||
|
||||
class EpsonProjectorMediaPlayer(MediaPlayerEntity):
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Support for Epson projector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from epson_projector.const import CMODE_LIST_SET
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import ATTR_CMODE, DOMAIN
|
||||
|
||||
SERVICE_SELECT_CMODE = "select_cmode"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SELECT_CMODE,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))},
|
||||
func=SERVICE_SELECT_CMODE,
|
||||
)
|
||||
@@ -19,7 +19,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.14.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.6.0"
|
||||
"bleak-esphome==3.5.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()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"codeowners": ["@zxdavb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohomeasync", "evohomeasync2"],
|
||||
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["evohome-async==1.1.3"]
|
||||
"requirements": ["evohome-async==1.0.6"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[Path]:
|
||||
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
|
||||
"""Get an uploaded file.
|
||||
|
||||
File is removed at the end of the context.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
@@ -132,7 +132,7 @@ class FjaraskupanCoordinator(DataUpdateCoordinator[State]):
|
||||
self.async_set_updated_data(self.device.state)
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_connect_and_update(self) -> AsyncGenerator[Device]:
|
||||
async def async_connect_and_update(self) -> AsyncIterator[Device]:
|
||||
"""Provide an up-to-date device for use during connections."""
|
||||
if (
|
||||
ble_device := async_ble_device_from_address(
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from fressnapftracker import (
|
||||
ApiClient,
|
||||
AuthClient,
|
||||
Device,
|
||||
FressnapfTrackerAuthenticationError,
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
Tracker,
|
||||
)
|
||||
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
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 (
|
||||
@@ -32,43 +21,6 @@ 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
|
||||
@@ -88,15 +40,12 @@ 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,7 +34,6 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
initial_data: Tracker,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -50,7 +49,6 @@ 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:
|
||||
|
||||
@@ -92,11 +92,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ from functools import lru_cache, partial
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from aiohttp import hdrs, web, web_urldispatcher
|
||||
@@ -37,7 +36,6 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_integration, bind_hass
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .pr_download import download_pr_artifact
|
||||
from .storage import (
|
||||
async_setup_frontend_storage,
|
||||
async_system_store as async_system_store,
|
||||
@@ -57,10 +55,6 @@ CONF_EXTRA_MODULE_URL = "extra_module_url"
|
||||
CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5"
|
||||
CONF_FRONTEND_REPO = "development_repo"
|
||||
CONF_JS_VERSION = "javascript_version"
|
||||
CONF_DEVELOPMENT_PR = "development_pr"
|
||||
CONF_GITHUB_TOKEN = "github_token"
|
||||
|
||||
DEV_ARTIFACTS_DIR = "development_artifacts"
|
||||
|
||||
DEFAULT_THEME_COLOR = "#2980b9"
|
||||
|
||||
@@ -139,8 +133,6 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
|
||||
vol.Inclusive(CONF_DEVELOPMENT_PR, "development_pr"): cv.positive_int,
|
||||
vol.Inclusive(CONF_GITHUB_TOKEN, "development_pr"): cv.string,
|
||||
vol.Optional(CONF_THEMES): vol.All(dict, _validate_themes),
|
||||
vol.Optional(CONF_EXTRA_MODULE_URL): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
@@ -433,49 +425,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
)
|
||||
|
||||
repo_path = conf.get(CONF_FRONTEND_REPO)
|
||||
dev_pr_number = conf.get(CONF_DEVELOPMENT_PR)
|
||||
|
||||
pr_cache_dir = pathlib.Path(hass.config.cache_path(DOMAIN, DEV_ARTIFACTS_DIR))
|
||||
if not dev_pr_number and pr_cache_dir.exists():
|
||||
try:
|
||||
await hass.async_add_executor_job(shutil.rmtree, pr_cache_dir)
|
||||
_LOGGER.debug("Cleaned up frontend development artifacts")
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not clean up frontend development artifacts: %s", err
|
||||
)
|
||||
|
||||
# Priority: development_repo > development_pr > integrated
|
||||
if repo_path and dev_pr_number:
|
||||
_LOGGER.warning(
|
||||
"Both development_repo and development_pr are specified for frontend. "
|
||||
"Using development_repo, remove development_repo to use "
|
||||
"automatic PR download"
|
||||
)
|
||||
dev_pr_number = None
|
||||
|
||||
if dev_pr_number:
|
||||
github_token: str = conf[CONF_GITHUB_TOKEN]
|
||||
|
||||
try:
|
||||
dev_pr_dir = await download_pr_artifact(
|
||||
hass, dev_pr_number, github_token, pr_cache_dir
|
||||
)
|
||||
repo_path = str(dev_pr_dir)
|
||||
_LOGGER.info("Using frontend from PR #%s", dev_pr_number)
|
||||
except HomeAssistantError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to download PR #%s: %s, falling back to the integrated frontend",
|
||||
dev_pr_number,
|
||||
err,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
_LOGGER.exception(
|
||||
"Unexpected error downloading PR #%s, "
|
||||
"falling back to the integrated frontend",
|
||||
dev_pr_number,
|
||||
)
|
||||
|
||||
is_dev = repo_path is not None
|
||||
root_path = _frontend_root(repo_path)
|
||||
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.6"]
|
||||
"requirements": ["home-assistant-frontend==20260128.4"]
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
"""GitHub PR artifact download functionality for frontend development."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import pathlib
|
||||
import shutil
|
||||
import zipfile
|
||||
|
||||
from aiogithubapi import (
|
||||
GitHubAPI,
|
||||
GitHubAuthenticationException,
|
||||
GitHubException,
|
||||
GitHubNotFoundException,
|
||||
GitHubPermissionException,
|
||||
GitHubRatelimitException,
|
||||
)
|
||||
from aiohttp import ClientError, ClientResponseError, ClientTimeout
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_REPO = "home-assistant/frontend"
|
||||
ARTIFACT_NAME = "frontend-build"
|
||||
|
||||
# Zip bomb protection limits (10x typical frontend build size)
|
||||
# Typical frontend build: ~4500 files, ~135MB uncompressed
|
||||
MAX_ZIP_FILES = 50000
|
||||
MAX_ZIP_SIZE = 1500 * 1024 * 1024 # 1.5GB
|
||||
|
||||
ERROR_INVALID_TOKEN = (
|
||||
"GitHub token is invalid or expired. "
|
||||
"Please check your github_token in the frontend configuration. "
|
||||
"Generate a new token at https://github.com/settings/tokens"
|
||||
)
|
||||
ERROR_RATE_LIMIT = (
|
||||
"GitHub API rate limit exceeded or token lacks permissions. "
|
||||
"Ensure your token has 'repo' or 'public_repo' scope"
|
||||
)
|
||||
|
||||
|
||||
async def _get_pr_head_sha(client: GitHubAPI, pr_number: int) -> str:
|
||||
"""Get the head SHA for the PR."""
|
||||
try:
|
||||
response = await client.generic(
|
||||
endpoint=f"/repos/home-assistant/frontend/pulls/{pr_number}",
|
||||
)
|
||||
return str(response.data["head"]["sha"])
|
||||
except GitHubAuthenticationException as err:
|
||||
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
|
||||
except (GitHubRatelimitException, GitHubPermissionException) as err:
|
||||
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
|
||||
except GitHubNotFoundException as err:
|
||||
raise HomeAssistantError(
|
||||
f"PR #{pr_number} does not exist in repository {GITHUB_REPO}"
|
||||
) from err
|
||||
except GitHubException as err:
|
||||
raise HomeAssistantError(f"GitHub API error: {err}") from err
|
||||
|
||||
|
||||
async def _find_pr_artifact(client: GitHubAPI, pr_number: int, head_sha: str) -> str:
|
||||
"""Find the build artifact for the given PR and commit SHA.
|
||||
|
||||
Returns the artifact download URL.
|
||||
"""
|
||||
try:
|
||||
response = await client.generic(
|
||||
endpoint="/repos/home-assistant/frontend/actions/workflows/ci.yaml/runs",
|
||||
params={"head_sha": head_sha, "per_page": 10},
|
||||
)
|
||||
|
||||
for run in response.data.get("workflow_runs", []):
|
||||
if run["status"] == "completed" and run["conclusion"] == "success":
|
||||
artifacts_response = await client.generic(
|
||||
endpoint=f"/repos/home-assistant/frontend/actions/runs/{run['id']}/artifacts",
|
||||
)
|
||||
|
||||
for artifact in artifacts_response.data.get("artifacts", []):
|
||||
if artifact["name"] == ARTIFACT_NAME:
|
||||
_LOGGER.info(
|
||||
"Found artifact '%s' from CI run #%s",
|
||||
ARTIFACT_NAME,
|
||||
run["id"],
|
||||
)
|
||||
return str(artifact["archive_download_url"])
|
||||
|
||||
raise HomeAssistantError(
|
||||
f"No '{ARTIFACT_NAME}' artifact found for PR #{pr_number}. "
|
||||
"Possible reasons: CI has not run yet or is running, "
|
||||
"or the build failed, or the PR artifact expired. "
|
||||
f"Check https://github.com/{GITHUB_REPO}/pull/{pr_number}/checks"
|
||||
)
|
||||
except GitHubAuthenticationException as err:
|
||||
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
|
||||
except (GitHubRatelimitException, GitHubPermissionException) as err:
|
||||
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
|
||||
except GitHubException as err:
|
||||
raise HomeAssistantError(f"GitHub API error: {err}") from err
|
||||
|
||||
|
||||
async def _download_artifact_data(
|
||||
hass: HomeAssistant, artifact_url: str, github_token: str
|
||||
) -> bytes:
|
||||
"""Download artifact data from GitHub."""
|
||||
session = async_get_clientsession(hass)
|
||||
headers = {
|
||||
"Authorization": f"token {github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await session.get(
|
||||
artifact_url, headers=headers, timeout=ClientTimeout(total=60)
|
||||
)
|
||||
response.raise_for_status()
|
||||
return await response.read()
|
||||
except ClientResponseError as err:
|
||||
if err.status == 401:
|
||||
raise HomeAssistantError(ERROR_INVALID_TOKEN) from err
|
||||
if err.status == 403:
|
||||
raise HomeAssistantError(ERROR_RATE_LIMIT) from err
|
||||
raise HomeAssistantError(
|
||||
f"Failed to download artifact: HTTP {err.status}"
|
||||
) from err
|
||||
except TimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
"Timeout downloading artifact (>60s). Check your network connection"
|
||||
) from err
|
||||
except ClientError as err:
|
||||
raise HomeAssistantError(f"Network error downloading artifact: {err}") from err
|
||||
|
||||
|
||||
def _extract_artifact(
|
||||
artifact_data: bytes,
|
||||
cache_dir: pathlib.Path,
|
||||
head_sha: str,
|
||||
) -> None:
|
||||
"""Extract artifact and save SHA (runs in executor)."""
|
||||
frontend_dir = cache_dir / "hass_frontend"
|
||||
|
||||
if cache_dir.exists():
|
||||
shutil.rmtree(cache_dir)
|
||||
frontend_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(artifact_data)) as zip_file:
|
||||
# Validate zip contents to protect against zip bombs
|
||||
# See: https://github.com/python/cpython/issues/80643
|
||||
total_size = 0
|
||||
for file_count, info in enumerate(zip_file.infolist(), start=1):
|
||||
total_size += info.file_size
|
||||
if file_count > MAX_ZIP_FILES:
|
||||
raise ValueError(
|
||||
f"Zip contains too many files (>{MAX_ZIP_FILES}), possible zip bomb"
|
||||
)
|
||||
if total_size > MAX_ZIP_SIZE:
|
||||
raise ValueError(
|
||||
f"Zip uncompressed size too large (>{MAX_ZIP_SIZE} bytes), "
|
||||
"possible zip bomb"
|
||||
)
|
||||
zip_file.extractall(str(frontend_dir))
|
||||
|
||||
# Save the commit SHA for cache validation
|
||||
sha_file = cache_dir / ".sha"
|
||||
sha_file.write_text(head_sha)
|
||||
|
||||
|
||||
async def download_pr_artifact(
|
||||
hass: HomeAssistant,
|
||||
pr_number: int,
|
||||
github_token: str,
|
||||
tmp_dir: pathlib.Path,
|
||||
) -> pathlib.Path:
|
||||
"""Download and extract frontend PR artifact from GitHub.
|
||||
|
||||
Returns the path to the tmp directory containing hass_frontend/.
|
||||
Raises HomeAssistantError on failure.
|
||||
"""
|
||||
try:
|
||||
session = async_get_clientsession(hass)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(f"Failed to get HTTP client session: {err}") from err
|
||||
|
||||
client = GitHubAPI(token=github_token, session=session)
|
||||
|
||||
head_sha = await _get_pr_head_sha(client, pr_number)
|
||||
|
||||
frontend_dir = tmp_dir / "hass_frontend"
|
||||
sha_file = tmp_dir / ".sha"
|
||||
|
||||
if frontend_dir.exists() and sha_file.exists():
|
||||
try:
|
||||
cached_sha = await hass.async_add_executor_job(sha_file.read_text)
|
||||
if cached_sha.strip() == head_sha:
|
||||
_LOGGER.info(
|
||||
"Using cached PR #%s (commit %s) from %s",
|
||||
pr_number,
|
||||
head_sha[:8],
|
||||
tmp_dir,
|
||||
)
|
||||
return tmp_dir
|
||||
_LOGGER.info(
|
||||
"PR #%s has new commits (cached: %s, current: %s), re-downloading",
|
||||
pr_number,
|
||||
cached_sha[:8],
|
||||
head_sha[:8],
|
||||
)
|
||||
except OSError as err:
|
||||
_LOGGER.debug("Failed to read cache SHA file: %s", err)
|
||||
|
||||
artifact_url = await _find_pr_artifact(client, pr_number, head_sha)
|
||||
|
||||
_LOGGER.info("Downloading frontend PR #%s artifact", pr_number)
|
||||
artifact_data = await _download_artifact_data(hass, artifact_url, github_token)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(
|
||||
_extract_artifact, artifact_data, tmp_dir, head_sha
|
||||
)
|
||||
except zipfile.BadZipFile as err:
|
||||
raise HomeAssistantError(
|
||||
f"Downloaded artifact for PR #{pr_number} is corrupted or invalid"
|
||||
) from err
|
||||
except ValueError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Downloaded artifact for PR #{pr_number} failed validation: {err}"
|
||||
) from err
|
||||
except OSError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Failed to extract artifact for PR #{pr_number}: {err}"
|
||||
) from err
|
||||
|
||||
_LOGGER.info(
|
||||
"Successfully downloaded and extracted PR #%s (commit %s) to %s",
|
||||
pr_number,
|
||||
head_sha[:8],
|
||||
tmp_dir,
|
||||
)
|
||||
return tmp_dir
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==3.0.1"]
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["growattServer"],
|
||||
"requirements": ["growattServer==1.9.0"]
|
||||
"requirements": ["growattServer==1.7.1"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Protocol, TypedDict
|
||||
@@ -281,7 +281,7 @@ def async_is_firmware_update_in_progress(hass: HomeAssistant, device: str) -> bo
|
||||
@asynccontextmanager
|
||||
async def async_firmware_update_context(
|
||||
hass: HomeAssistant, device: str, source_domain: str
|
||||
) -> AsyncGenerator[None]:
|
||||
) -> AsyncIterator[None]:
|
||||
"""Register a device as having its firmware being actively updated."""
|
||||
async_register_firmware_update_in_progress(hass, device, source_domain)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Callable, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
@@ -125,7 +125,7 @@ class OwningAddon:
|
||||
return addon_info.state == AddonState.RUNNING
|
||||
|
||||
@asynccontextmanager
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[None]:
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
|
||||
"""Temporarily stop the add-on, restarting it after completion."""
|
||||
addon_manager = self._get_addon_manager(hass)
|
||||
|
||||
@@ -165,7 +165,7 @@ class OwningIntegration:
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[None]:
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
|
||||
"""Temporarily stop the integration, restarting it after completion."""
|
||||
if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None:
|
||||
yield
|
||||
@@ -368,7 +368,7 @@ async def probe_silabs_firmware_type(
|
||||
@asynccontextmanager
|
||||
async def async_firmware_flashing_context(
|
||||
hass: HomeAssistant, device: str, source_domain: str
|
||||
) -> AsyncGenerator[None]:
|
||||
) -> AsyncIterator[None]:
|
||||
"""Register a device as having its firmware being actively interacted with."""
|
||||
async with async_firmware_update_context(hass, device, source_domain):
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
|
||||
@@ -4,13 +4,7 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "huum"
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER]
|
||||
|
||||
CONFIG_STEAMER = 1
|
||||
CONFIG_LIGHT = 2
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Sensor platform for Huum sauna integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
|
||||
from .entity import HuumBaseEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HuumConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Huum sensors from a config entry."""
|
||||
async_add_entities([HuumTemperatureSensor(config_entry.runtime_data)])
|
||||
|
||||
|
||||
class HuumTemperatureSensor(HuumBaseEntity, SensorEntity):
|
||||
"""Representation of a Huum temperature sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
|
||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||
|
||||
def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None:
|
||||
"""Initialize the temperature sensor."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_temperature"
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the current temperature."""
|
||||
return self.coordinator.data.temperature
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Callable, Coroutine
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
@@ -297,7 +297,7 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@asynccontextmanager
|
||||
async def _async_provision_context(
|
||||
self, ble_mac: str
|
||||
) -> AsyncGenerator[asyncio.Future[str]]:
|
||||
) -> AsyncIterator[asyncio.Future[str]]:
|
||||
"""Context manager to register and cleanup provisioning future."""
|
||||
future = self.hass.loop.create_future()
|
||||
provisioning_futures = async_get_provisioning_futures(self.hass)
|
||||
|
||||
@@ -150,9 +150,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
|
||||
self._attr_state = state
|
||||
self._attr_is_volume_muted = volume_muted
|
||||
# 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_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
|
||||
@@ -192,9 +190,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
)
|
||||
features = MediaPlayerEntityFeature(0)
|
||||
|
||||
if "PlayMediaSource" in commands or self.capabilities.get(
|
||||
"SupportsMediaControl", False
|
||||
):
|
||||
if "PlayMediaSource" in commands:
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -205,10 +201,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SEARCH_MEDIA
|
||||
)
|
||||
|
||||
if "Mute" in commands and "Unmute" in commands:
|
||||
if "Mute" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
if "VolumeSet" in commands or "SetVolume" in commands:
|
||||
if "VolumeSet" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
return features
|
||||
@@ -223,13 +219,11 @@ 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."""
|
||||
@@ -239,7 +233,6 @@ 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
|
||||
@@ -254,8 +247,6 @@ 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."""
|
||||
@@ -263,8 +254,6 @@ 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,
|
||||
|
||||
@@ -17,16 +17,11 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_WS_PORT, DOMAIN
|
||||
from .services import async_setup_services
|
||||
from .const import CONF_WS_PORT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
type KodiConfigEntry = ConfigEntry[KodiRuntimeData]
|
||||
@@ -40,12 +35,6 @@ class KodiRuntimeData:
|
||||
kodi: Kodi
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: KodiConfigEntry) -> bool:
|
||||
"""Set up Kodi from a config entry."""
|
||||
conn = get_kodi_connection(
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, Concatenate
|
||||
|
||||
from jsonrpc_base.jsonrpc import ProtocolError, TransportError
|
||||
from pykodi import CannotConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -30,11 +31,16 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.network import is_internal_request
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import KodiConfigEntry
|
||||
@@ -79,12 +85,42 @@ MAP_KODI_MEDIA_TYPES: dict[MediaType | str, str] = {
|
||||
}
|
||||
|
||||
|
||||
SERVICE_ADD_MEDIA = "add_to_playlist"
|
||||
SERVICE_CALL_METHOD = "call_method"
|
||||
|
||||
ATTR_MEDIA_TYPE = "media_type"
|
||||
ATTR_MEDIA_NAME = "media_name"
|
||||
ATTR_MEDIA_ARTIST_NAME = "artist_name"
|
||||
ATTR_MEDIA_ID = "media_id"
|
||||
ATTR_METHOD = "method"
|
||||
|
||||
|
||||
KODI_ADD_MEDIA_SCHEMA: VolDictType = {
|
||||
vol.Required(ATTR_MEDIA_TYPE): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_ID): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_NAME): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
|
||||
}
|
||||
|
||||
KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
|
||||
{vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: KodiConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Kodi media player platform."""
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_ADD_MEDIA, KODI_ADD_MEDIA_SCHEMA, "async_add_media_to_playlist"
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method"
|
||||
)
|
||||
|
||||
data = config_entry.runtime_data
|
||||
name = config_entry.data[CONF_NAME]
|
||||
if (uid := config_entry.unique_id) is None:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Support for interfacing with the XBMC/Kodi JSON-RPC API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SERVICE_ADD_MEDIA = "add_to_playlist"
|
||||
SERVICE_CALL_METHOD = "call_method"
|
||||
|
||||
ATTR_MEDIA_TYPE = "media_type"
|
||||
ATTR_MEDIA_NAME = "media_name"
|
||||
ATTR_MEDIA_ARTIST_NAME = "artist_name"
|
||||
ATTR_MEDIA_ID = "media_id"
|
||||
ATTR_METHOD = "method"
|
||||
|
||||
|
||||
KODI_ADD_MEDIA_SCHEMA: VolDictType = {
|
||||
vol.Required(ATTR_MEDIA_TYPE): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_ID): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_NAME): cv.string,
|
||||
vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
|
||||
}
|
||||
|
||||
KODI_CALL_METHOD_SCHEMA = cv.make_entity_service_schema(
|
||||
{vol.Required(ATTR_METHOD): cv.string}, extra=vol.ALLOW_EXTRA
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_ADD_MEDIA,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=KODI_ADD_MEDIA_SCHEMA,
|
||||
func="async_add_media_to_playlist",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CALL_METHOD,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=KODI_CALL_METHOD_SCHEMA,
|
||||
func="async_call_method",
|
||||
)
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyliebherrhomeapi"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyliebherrhomeapi==0.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable
|
||||
import csv
|
||||
import dataclasses
|
||||
from functools import partial
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Self, cast, final
|
||||
from typing import TYPE_CHECKING, Any, Final, Self, cast, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -22,6 +23,13 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.deprecation import (
|
||||
DeprecatedConstant,
|
||||
DeprecatedConstantEnum,
|
||||
all_with_deprecated_constants,
|
||||
check_if_deprecated_constant,
|
||||
dir_with_deprecated_constants,
|
||||
)
|
||||
from homeassistant.helpers.entity import ToggleEntity, ToggleEntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.frame import ReportBehavior, report_usage
|
||||
@@ -48,11 +56,52 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
|
||||
|
||||
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
|
||||
# Please use the LightEntityFeature enum instead.
|
||||
_DEPRECATED_SUPPORT_BRIGHTNESS: Final = DeprecatedConstant(
|
||||
1, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_COLOR_TEMP: Final = DeprecatedConstant(
|
||||
2, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_EFFECT: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.EFFECT, "2026.1"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_FLASH: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.FLASH, "2026.1"
|
||||
)
|
||||
_DEPRECATED_SUPPORT_COLOR: Final = DeprecatedConstant(
|
||||
16, "supported_color_modes", "2026.1"
|
||||
) # Deprecated, replaced by color modes
|
||||
_DEPRECATED_SUPPORT_TRANSITION: Final = DeprecatedConstantEnum(
|
||||
LightEntityFeature.TRANSITION, "2026.1"
|
||||
)
|
||||
|
||||
# Color mode of the light
|
||||
ATTR_COLOR_MODE = "color_mode"
|
||||
# List of color modes supported by the light
|
||||
ATTR_SUPPORTED_COLOR_MODES = "supported_color_modes"
|
||||
|
||||
# These COLOR_MODE_* constants are deprecated as of Home Assistant 2022.5.
|
||||
# Please use the LightEntityFeature enum instead.
|
||||
_DEPRECATED_COLOR_MODE_UNKNOWN: Final = DeprecatedConstantEnum(
|
||||
ColorMode.UNKNOWN, "2026.1"
|
||||
)
|
||||
_DEPRECATED_COLOR_MODE_ONOFF: Final = DeprecatedConstantEnum(ColorMode.ONOFF, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_BRIGHTNESS: Final = DeprecatedConstantEnum(
|
||||
ColorMode.BRIGHTNESS, "2026.1"
|
||||
)
|
||||
_DEPRECATED_COLOR_MODE_COLOR_TEMP: Final = DeprecatedConstantEnum(
|
||||
ColorMode.COLOR_TEMP, "2026.1"
|
||||
)
|
||||
_DEPRECATED_COLOR_MODE_HS: Final = DeprecatedConstantEnum(ColorMode.HS, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_XY: Final = DeprecatedConstantEnum(ColorMode.XY, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_RGB: Final = DeprecatedConstantEnum(ColorMode.RGB, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_RGBW: Final = DeprecatedConstantEnum(ColorMode.RGBW, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_RGBWW: Final = DeprecatedConstantEnum(ColorMode.RGBWW, "2026.1")
|
||||
_DEPRECATED_COLOR_MODE_WHITE: Final = DeprecatedConstantEnum(ColorMode.WHITE, "2026.1")
|
||||
|
||||
|
||||
# mypy: disallow-any-generics
|
||||
|
||||
|
||||
@@ -143,6 +192,20 @@ ATTR_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
|
||||
ATTR_COLOR_NAME = "color_name"
|
||||
ATTR_WHITE = "white"
|
||||
|
||||
# Deprecated in HA Core 2022.11
|
||||
_DEPRECATED_ATTR_COLOR_TEMP: Final = DeprecatedConstant(
|
||||
"color_temp", "kelvin equivalent (ATTR_COLOR_TEMP_KELVIN)", "2026.1"
|
||||
)
|
||||
_DEPRECATED_ATTR_KELVIN: Final = DeprecatedConstant(
|
||||
"kelvin", "ATTR_COLOR_TEMP_KELVIN", "2026.1"
|
||||
)
|
||||
_DEPRECATED_ATTR_MIN_MIREDS: Final = DeprecatedConstant(
|
||||
"min_mireds", "kelvin equivalent (ATTR_MAX_COLOR_TEMP_KELVIN)", "2026.1"
|
||||
)
|
||||
_DEPRECATED_ATTR_MAX_MIREDS: Final = DeprecatedConstant(
|
||||
"max_mireds", "kelvin equivalent (ATTR_MIN_COLOR_TEMP_KELVIN)", "2026.1"
|
||||
)
|
||||
|
||||
# Brightness of the light, 0..255 or percentage
|
||||
ATTR_BRIGHTNESS = "brightness"
|
||||
ATTR_BRIGHTNESS_PCT = "brightness_pct"
|
||||
@@ -187,7 +250,11 @@ LIGHT_TURN_ON_SCHEMA: VolDictType = {
|
||||
vol.Exclusive(ATTR_BRIGHTNESS_STEP, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP,
|
||||
vol.Exclusive(ATTR_BRIGHTNESS_STEP_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_STEP_PCT,
|
||||
vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string,
|
||||
vol.Exclusive(_DEPRECATED_ATTR_COLOR_TEMP.value, COLOR_GROUP): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=1)
|
||||
),
|
||||
vol.Exclusive(ATTR_COLOR_TEMP_KELVIN, COLOR_GROUP): cv.positive_int,
|
||||
vol.Exclusive(_DEPRECATED_ATTR_KELVIN.value, COLOR_GROUP): cv.positive_int,
|
||||
vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP): vol.All(
|
||||
vol.Coerce(tuple),
|
||||
vol.ExactSequence(
|
||||
@@ -250,6 +317,31 @@ def preprocess_turn_on_alternatives(
|
||||
_LOGGER.warning("Got unknown color %s, falling back to white", color_name)
|
||||
params[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
if (mired := params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)) is not None:
|
||||
_LOGGER.warning(
|
||||
"Got `color_temp` argument in `turn_on` service, which is deprecated "
|
||||
"and will break in Home Assistant 2026.1, please use "
|
||||
"`color_temp_kelvin` argument"
|
||||
)
|
||||
kelvin = color_util.color_temperature_mired_to_kelvin(mired)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
|
||||
|
||||
if (kelvin := params.pop(_DEPRECATED_ATTR_KELVIN.value, None)) is not None:
|
||||
_LOGGER.warning(
|
||||
"Got `kelvin` argument in `turn_on` service, which is deprecated "
|
||||
"and will break in Home Assistant 2026.1, please use "
|
||||
"`color_temp_kelvin` argument"
|
||||
)
|
||||
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
|
||||
|
||||
if (kelvin := params.pop(ATTR_COLOR_TEMP_KELVIN, None)) is not None:
|
||||
mired = color_util.color_temperature_kelvin_to_mired(kelvin)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = int(mired)
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = int(kelvin)
|
||||
|
||||
brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None)
|
||||
if brightness_pct is not None:
|
||||
params[ATTR_BRIGHTNESS] = round(255 * brightness_pct / 100)
|
||||
@@ -262,7 +354,7 @@ def filter_turn_off_params(
|
||||
if not params:
|
||||
return params
|
||||
|
||||
supported_features = light.supported_features
|
||||
supported_features = light.supported_features_compat
|
||||
|
||||
if LightEntityFeature.FLASH not in supported_features:
|
||||
params.pop(ATTR_FLASH, None)
|
||||
@@ -274,7 +366,7 @@ def filter_turn_off_params(
|
||||
|
||||
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter out params not supported by the light."""
|
||||
supported_features = light.supported_features
|
||||
supported_features = light.supported_features_compat
|
||||
|
||||
if LightEntityFeature.EFFECT not in supported_features:
|
||||
params.pop(ATTR_EFFECT, None)
|
||||
@@ -289,6 +381,7 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
|
||||
if not brightness_supported(supported_color_modes):
|
||||
params.pop(ATTR_BRIGHTNESS, None)
|
||||
if ColorMode.COLOR_TEMP not in supported_color_modes:
|
||||
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value, None)
|
||||
params.pop(ATTR_COLOR_TEMP_KELVIN, None)
|
||||
if ColorMode.HS not in supported_color_modes:
|
||||
params.pop(ATTR_HS_COLOR, None)
|
||||
@@ -373,6 +466,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
and ColorMode.COLOR_TEMP not in supported_color_modes
|
||||
and ColorMode.RGBWW in supported_color_modes
|
||||
):
|
||||
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
|
||||
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
|
||||
@@ -382,6 +476,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
light.max_color_temp_kelvin,
|
||||
)
|
||||
elif ColorMode.COLOR_TEMP not in legacy_supported_color_modes:
|
||||
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
|
||||
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
|
||||
if color_supported(legacy_supported_color_modes):
|
||||
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
|
||||
@@ -428,6 +523,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
params[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
)
|
||||
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
|
||||
rgb_color = params.pop(ATTR_RGB_COLOR)
|
||||
assert rgb_color is not None
|
||||
@@ -450,6 +550,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
params[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
)
|
||||
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
|
||||
xy_color = params.pop(ATTR_XY_COLOR)
|
||||
if ColorMode.HS in supported_color_modes:
|
||||
@@ -468,6 +573,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
params[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
)
|
||||
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
|
||||
rgbw_color = params.pop(ATTR_RGBW_COLOR)
|
||||
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
|
||||
@@ -486,6 +596,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
params[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
)
|
||||
elif (
|
||||
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
|
||||
):
|
||||
@@ -509,6 +624,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
|
||||
*xy_color
|
||||
)
|
||||
params[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(
|
||||
params[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
)
|
||||
|
||||
# If white is set to True, set it to the light's brightness
|
||||
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
|
||||
@@ -715,7 +835,7 @@ class Profiles:
|
||||
|
||||
color_attributes = (
|
||||
ATTR_COLOR_NAME,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
_DEPRECATED_ATTR_COLOR_TEMP.value,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
@@ -746,9 +866,9 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"rgb_color",
|
||||
"rgbw_color",
|
||||
"rgbww_color",
|
||||
"color_temp_kelvin",
|
||||
"min_color_temp_kelvin",
|
||||
"max_color_temp_kelvin",
|
||||
"color_temp",
|
||||
"min_mireds",
|
||||
"max_mireds",
|
||||
"effect_list",
|
||||
"effect",
|
||||
"supported_color_modes",
|
||||
@@ -763,10 +883,13 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
{
|
||||
ATTR_SUPPORTED_COLOR_MODES,
|
||||
ATTR_EFFECT_LIST,
|
||||
_DEPRECATED_ATTR_MIN_MIREDS.value,
|
||||
_DEPRECATED_ATTR_MAX_MIREDS.value,
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN,
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN,
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
_DEPRECATED_ATTR_COLOR_TEMP.value,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_HS_COLOR,
|
||||
@@ -784,8 +907,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_effect_list: list[str] | None = None
|
||||
_attr_effect: str | None = None
|
||||
_attr_hs_color: tuple[float, float] | None = None
|
||||
_attr_max_color_temp_kelvin: int = DEFAULT_MAX_KELVIN
|
||||
_attr_min_color_temp_kelvin: int = DEFAULT_MIN_KELVIN
|
||||
# We cannot set defaults without causing breaking changes until mireds
|
||||
# are fully removed. Until then, developers can explicitly
|
||||
# use DEFAULT_MIN_KELVIN and DEFAULT_MAX_KELVIN
|
||||
_attr_max_color_temp_kelvin: int | None = None
|
||||
_attr_min_color_temp_kelvin: int | None = None
|
||||
_attr_rgb_color: tuple[int, int, int] | None = None
|
||||
_attr_rgbw_color: tuple[int, int, int, int] | None = None
|
||||
_attr_rgbww_color: tuple[int, int, int, int, int] | None = None
|
||||
@@ -793,6 +919,11 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
_attr_supported_features: LightEntityFeature = LightEntityFeature(0)
|
||||
_attr_xy_color: tuple[float, float] | None = None
|
||||
|
||||
# Deprecated, see https://github.com/home-assistant/core/pull/79591
|
||||
_attr_color_temp: Final[int | None] = None
|
||||
_attr_max_mireds: Final[int] = 500 # = 2000 K
|
||||
_attr_min_mireds: Final[int] = 153 # = 6535.94 K (~ 6500 K)
|
||||
|
||||
__color_mode_reported = False
|
||||
|
||||
@cached_property
|
||||
@@ -868,49 +999,91 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Return the rgbww color value [int, int, int, int, int]."""
|
||||
return self._attr_rgbww_color
|
||||
|
||||
@final
|
||||
@cached_property
|
||||
def color_temp(self) -> int | None:
|
||||
"""Return the CT color value in mireds.
|
||||
|
||||
Deprecated, see https://github.com/home-assistant/core/pull/79591
|
||||
"""
|
||||
return self._attr_color_temp
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the CT color value in Kelvin."""
|
||||
return self._attr_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the warmest color_temp_kelvin that this light supports."""
|
||||
if self._attr_min_color_temp_kelvin is None:
|
||||
# For historical reason when both Mired and Kelvin were supported,
|
||||
# integrations may have set this explicitly to None.
|
||||
# Fallback to DEFAULT to ensure compatibility.
|
||||
report_usage( # type: ignore[unreachable]
|
||||
"is explicitly setting `_attr_min_color_temp_kelvin` to `None`, when "
|
||||
"it should be setting a valid integer, possibly DEFAULT_MIN_KELVIN ",
|
||||
breaks_in_ha_version="2026.8",
|
||||
if self._attr_color_temp_kelvin is None and (color_temp := self.color_temp):
|
||||
report_usage(
|
||||
"is using mireds for current light color temperature, when "
|
||||
"it should be adjusted to use the kelvin attribute "
|
||||
"`_attr_color_temp_kelvin` or override the kelvin property "
|
||||
"`color_temp_kelvin` (see "
|
||||
"https://github.com/home-assistant/core/pull/79591)",
|
||||
breaks_in_ha_version="2026.1",
|
||||
core_behavior=ReportBehavior.LOG,
|
||||
integration_domain=self.platform.platform_name
|
||||
if self.platform
|
||||
else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
return DEFAULT_MIN_KELVIN
|
||||
return color_util.color_temperature_mired_to_kelvin(color_temp)
|
||||
return self._attr_color_temp_kelvin
|
||||
|
||||
@final
|
||||
@cached_property
|
||||
def min_mireds(self) -> int:
|
||||
"""Return the coldest color_temp that this light supports.
|
||||
|
||||
Deprecated, see https://github.com/home-assistant/core/pull/79591
|
||||
"""
|
||||
return self._attr_min_mireds
|
||||
|
||||
@final
|
||||
@cached_property
|
||||
def max_mireds(self) -> int:
|
||||
"""Return the warmest color_temp that this light supports.
|
||||
|
||||
Deprecated, see https://github.com/home-assistant/core/pull/79591
|
||||
"""
|
||||
return self._attr_max_mireds
|
||||
|
||||
@property
|
||||
def min_color_temp_kelvin(self) -> int:
|
||||
"""Return the warmest color_temp_kelvin that this light supports."""
|
||||
if self._attr_min_color_temp_kelvin is None:
|
||||
report_usage(
|
||||
"is using mireds for warmest light color temperature, when "
|
||||
"it should be adjusted to use the kelvin attribute "
|
||||
"`_attr_min_color_temp_kelvin` or override the kelvin property "
|
||||
"`min_color_temp_kelvin`, possibly with default DEFAULT_MIN_KELVIN "
|
||||
"(see https://github.com/home-assistant/core/pull/79591)",
|
||||
breaks_in_ha_version="2026.1",
|
||||
core_behavior=ReportBehavior.LOG,
|
||||
integration_domain=self.platform.platform_name
|
||||
if self.platform
|
||||
else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
return color_util.color_temperature_mired_to_kelvin(self.max_mireds)
|
||||
return self._attr_min_color_temp_kelvin
|
||||
|
||||
@property
|
||||
def max_color_temp_kelvin(self) -> int:
|
||||
"""Return the coldest color_temp_kelvin that this light supports."""
|
||||
if self._attr_max_color_temp_kelvin is None:
|
||||
# For historical reason when both Mired and Kelvin were supported,
|
||||
# integrations may have set this explicitly to None.
|
||||
# Fallback to DEFAULT to ensure compatibility.
|
||||
report_usage( # type: ignore[unreachable]
|
||||
"is explicitly setting `_attr_max_color_temp_kelvin` to `None`, when "
|
||||
"it should be setting a valid integer, possibly DEFAULT_MAX_KELVIN ",
|
||||
breaks_in_ha_version="2026.8",
|
||||
report_usage(
|
||||
"is using mireds for coldest light color temperature, when "
|
||||
"it should be adjusted to use the kelvin attribute "
|
||||
"`_attr_max_color_temp_kelvin` or override the kelvin property "
|
||||
"`max_color_temp_kelvin`, possibly with default DEFAULT_MAX_KELVIN "
|
||||
"(see https://github.com/home-assistant/core/pull/79591)",
|
||||
breaks_in_ha_version="2026.1",
|
||||
core_behavior=ReportBehavior.LOG,
|
||||
integration_domain=self.platform.platform_name
|
||||
if self.platform
|
||||
else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
return DEFAULT_MAX_KELVIN
|
||||
return color_util.color_temperature_mired_to_kelvin(self.min_mireds)
|
||||
return self._attr_max_color_temp_kelvin
|
||||
|
||||
@cached_property
|
||||
@@ -927,7 +1100,7 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def capability_attributes(self) -> dict[str, Any]:
|
||||
"""Return capability attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
supported_features = self.supported_features
|
||||
supported_features = self.supported_features_compat
|
||||
supported_color_modes = self._light_internal_supported_color_modes
|
||||
|
||||
if ColorMode.COLOR_TEMP in supported_color_modes:
|
||||
@@ -935,6 +1108,18 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
max_color_temp_kelvin = self.max_color_temp_kelvin
|
||||
data[ATTR_MIN_COLOR_TEMP_KELVIN] = min_color_temp_kelvin
|
||||
data[ATTR_MAX_COLOR_TEMP_KELVIN] = max_color_temp_kelvin
|
||||
if not max_color_temp_kelvin:
|
||||
data[_DEPRECATED_ATTR_MIN_MIREDS.value] = None
|
||||
else:
|
||||
data[_DEPRECATED_ATTR_MIN_MIREDS.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(max_color_temp_kelvin)
|
||||
)
|
||||
if not min_color_temp_kelvin:
|
||||
data[_DEPRECATED_ATTR_MAX_MIREDS.value] = None
|
||||
else:
|
||||
data[_DEPRECATED_ATTR_MAX_MIREDS.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(min_color_temp_kelvin)
|
||||
)
|
||||
if LightEntityFeature.EFFECT in supported_features:
|
||||
data[ATTR_EFFECT_LIST] = self.effect_list
|
||||
|
||||
@@ -1077,12 +1262,12 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return state attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
supported_features = self.supported_features
|
||||
supported_features = self.supported_features_compat
|
||||
supported_color_modes = self.supported_color_modes
|
||||
legacy_supported_color_modes = (
|
||||
supported_color_modes or self._light_internal_supported_color_modes
|
||||
)
|
||||
|
||||
supported_features_value = supported_features.value
|
||||
_is_on = self.is_on
|
||||
color_mode = self._light_internal_color_mode if _is_on else None
|
||||
|
||||
@@ -1101,12 +1286,42 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
data[ATTR_BRIGHTNESS] = self.brightness
|
||||
else:
|
||||
data[ATTR_BRIGHTNESS] = None
|
||||
elif supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value:
|
||||
# Backwards compatibility for ambiguous / incomplete states
|
||||
# Warning is printed by supported_features_compat, remove in 2025.1
|
||||
if _is_on:
|
||||
data[ATTR_BRIGHTNESS] = self.brightness
|
||||
else:
|
||||
data[ATTR_BRIGHTNESS] = None
|
||||
|
||||
if color_temp_supported(supported_color_modes):
|
||||
if color_mode == ColorMode.COLOR_TEMP:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin
|
||||
color_temp_kelvin = self.color_temp_kelvin
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
|
||||
if color_temp_kelvin:
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
|
||||
)
|
||||
else:
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
|
||||
else:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = None
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
|
||||
elif supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
|
||||
# Backwards compatibility
|
||||
# Warning is printed by supported_features_compat, remove in 2025.1
|
||||
if _is_on:
|
||||
color_temp_kelvin = self.color_temp_kelvin
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = color_temp_kelvin
|
||||
if color_temp_kelvin:
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = (
|
||||
color_util.color_temperature_kelvin_to_mired(color_temp_kelvin)
|
||||
)
|
||||
else:
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
|
||||
else:
|
||||
data[ATTR_COLOR_TEMP_KELVIN] = None
|
||||
data[_DEPRECATED_ATTR_COLOR_TEMP.value] = None
|
||||
|
||||
if color_supported(legacy_supported_color_modes) or color_temp_supported(
|
||||
legacy_supported_color_modes
|
||||
@@ -1144,8 +1359,24 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
type(self),
|
||||
report_issue,
|
||||
)
|
||||
supported_features = self.supported_features_compat
|
||||
supported_features_value = supported_features.value
|
||||
supported_color_modes: set[ColorMode] = set()
|
||||
|
||||
return {ColorMode.ONOFF}
|
||||
if supported_features_value & _DEPRECATED_SUPPORT_COLOR_TEMP.value:
|
||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||
if supported_features_value & _DEPRECATED_SUPPORT_COLOR.value:
|
||||
supported_color_modes.add(ColorMode.HS)
|
||||
if (
|
||||
not supported_color_modes
|
||||
and supported_features_value & _DEPRECATED_SUPPORT_BRIGHTNESS.value
|
||||
):
|
||||
supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
|
||||
if not supported_color_modes:
|
||||
supported_color_modes = {ColorMode.ONOFF}
|
||||
|
||||
return supported_color_modes
|
||||
|
||||
@cached_property
|
||||
def supported_color_modes(self) -> set[ColorMode] | None:
|
||||
@@ -1157,9 +1388,48 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
"""Flag supported features."""
|
||||
return self._attr_supported_features
|
||||
|
||||
@property
|
||||
def supported_features_compat(self) -> LightEntityFeature:
|
||||
"""Return the supported features as LightEntityFeature.
|
||||
|
||||
Remove this compatibility shim in 2025.1 or later.
|
||||
"""
|
||||
features = self.supported_features
|
||||
if type(features) is not int:
|
||||
return features
|
||||
new_features = LightEntityFeature(features)
|
||||
if self._deprecated_supported_features_reported is True:
|
||||
return new_features
|
||||
self._deprecated_supported_features_reported = True
|
||||
report_issue = self._suggest_report_issue()
|
||||
report_issue += (
|
||||
" and reference "
|
||||
"https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation"
|
||||
)
|
||||
_LOGGER.warning(
|
||||
(
|
||||
"Entity %s (%s) is using deprecated supported features"
|
||||
" values which will be removed in HA Core 2025.1. Instead it should use"
|
||||
" %s and color modes, please %s"
|
||||
),
|
||||
self.entity_id,
|
||||
type(self),
|
||||
repr(new_features),
|
||||
report_issue,
|
||||
)
|
||||
return new_features
|
||||
|
||||
def __should_report_light_issue(self) -> bool:
|
||||
"""Return if light color mode issues should be reported."""
|
||||
if not self.platform:
|
||||
return True
|
||||
# philips_js has known issues, we don't need users to open issues
|
||||
return self.platform.platform_name not in {"philips_js"}
|
||||
|
||||
|
||||
# These can be removed if no deprecated constant are in this module anymore
|
||||
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
||||
__dir__ = partial(
|
||||
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
||||
)
|
||||
__all__ = all_with_deprecated_constants(globals())
|
||||
|
||||
@@ -15,8 +15,10 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import Context, HomeAssistant, State
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import (
|
||||
_DEPRECATED_ATTR_COLOR_TEMP,
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
@@ -39,6 +41,7 @@ ATTR_GROUP = [ATTR_BRIGHTNESS, ATTR_EFFECT]
|
||||
|
||||
COLOR_GROUP = [
|
||||
ATTR_HS_COLOR,
|
||||
_DEPRECATED_ATTR_COLOR_TEMP.value,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
@@ -124,13 +127,30 @@ async def _async_reproduce_state(
|
||||
color_mode = state.attributes[ATTR_COLOR_MODE]
|
||||
if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode):
|
||||
if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None:
|
||||
if (
|
||||
color_mode != ColorMode.COLOR_TEMP
|
||||
or (
|
||||
mireds := state.attributes.get(
|
||||
_DEPRECATED_ATTR_COLOR_TEMP.value
|
||||
)
|
||||
)
|
||||
is None
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Color mode %s specified but attribute %s missing for: %s",
|
||||
color_mode,
|
||||
cm_attr.state_attr,
|
||||
state.entity_id,
|
||||
)
|
||||
return
|
||||
_LOGGER.warning(
|
||||
"Color mode %s specified but attribute %s missing for: %s",
|
||||
"Color mode %s specified but attribute %s missing for: %s, "
|
||||
"using color_temp (mireds) as fallback",
|
||||
color_mode,
|
||||
cm_attr.state_attr,
|
||||
state.entity_id,
|
||||
)
|
||||
return
|
||||
cm_attr_state = color_util.color_temperature_mired_to_kelvin(mireds)
|
||||
service_data[cm_attr.parameter] = cm_attr_state
|
||||
else:
|
||||
# Fall back to Choosing the first color that is specified
|
||||
|
||||
@@ -255,6 +255,13 @@ turn_on:
|
||||
example: "[0.52, 0.43]"
|
||||
selector:
|
||||
object:
|
||||
color_temp: &color_temp
|
||||
filter: *color_temp_support
|
||||
selector:
|
||||
color_temp:
|
||||
unit: "mired"
|
||||
min: 153
|
||||
max: 500
|
||||
brightness: &brightness
|
||||
filter: *brightness_support
|
||||
selector:
|
||||
@@ -320,6 +327,7 @@ toggle:
|
||||
color_name: *color_name
|
||||
hs_color: *hs_color
|
||||
xy_color: *xy_color
|
||||
color_temp: *color_temp
|
||||
brightness: *brightness
|
||||
white: *white
|
||||
profile: *profile
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"field_brightness_step_pct_name": "Brightness step",
|
||||
"field_color_name_description": "A human-readable color name.",
|
||||
"field_color_name_name": "Color name",
|
||||
"field_color_temp_description": "Color temperature in mireds.",
|
||||
"field_color_temp_kelvin_description": "Color temperature in Kelvin.",
|
||||
"field_color_temp_kelvin_name": "Color temperature",
|
||||
"field_color_temp_name": "Color temperature",
|
||||
"field_effect_description": "Light effect.",
|
||||
"field_effect_name": "Effect",
|
||||
"field_flash_description": "Tell light to flash, can be either value short or long.",
|
||||
@@ -110,6 +112,9 @@
|
||||
"xy": "XY"
|
||||
}
|
||||
},
|
||||
"color_temp": {
|
||||
"name": "Color temperature (mireds)"
|
||||
},
|
||||
"color_temp_kelvin": {
|
||||
"name": "Color temperature (Kelvin)"
|
||||
},
|
||||
@@ -125,9 +130,15 @@
|
||||
"max_color_temp_kelvin": {
|
||||
"name": "Maximum color temperature (Kelvin)"
|
||||
},
|
||||
"max_mireds": {
|
||||
"name": "Maximum color temperature (mireds)"
|
||||
},
|
||||
"min_color_temp_kelvin": {
|
||||
"name": "Minimum color temperature (Kelvin)"
|
||||
},
|
||||
"min_mireds": {
|
||||
"name": "Minimum color temperature (mireds)"
|
||||
},
|
||||
"supported_color_modes": {
|
||||
"name": "Available color modes",
|
||||
"state": {
|
||||
@@ -355,6 +366,10 @@
|
||||
"description": "[%key:component::light::common::field_color_name_description%]",
|
||||
"name": "[%key:component::light::common::field_color_name_name%]"
|
||||
},
|
||||
"color_temp": {
|
||||
"description": "[%key:component::light::common::field_color_temp_description%]",
|
||||
"name": "[%key:component::light::common::field_color_temp_name%]"
|
||||
},
|
||||
"color_temp_kelvin": {
|
||||
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]",
|
||||
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]"
|
||||
@@ -449,6 +464,10 @@
|
||||
"description": "[%key:component::light::common::field_color_name_description%]",
|
||||
"name": "[%key:component::light::common::field_color_name_name%]"
|
||||
},
|
||||
"color_temp": {
|
||||
"description": "[%key:component::light::common::field_color_temp_description%]",
|
||||
"name": "[%key:component::light::common::field_color_temp_name%]"
|
||||
},
|
||||
"color_temp_kelvin": {
|
||||
"description": "[%key:component::light::common::field_color_temp_kelvin_description%]",
|
||||
"name": "[%key:component::light::common::field_color_temp_kelvin_name%]"
|
||||
|
||||
@@ -12,15 +12,10 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, PLATFORMS, SHARED_DATA, LinkPlaySharedData
|
||||
from .services import async_setup_services
|
||||
from .utils import async_get_client_session
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LinkPlayData:
|
||||
@@ -32,12 +27,6 @@ class LinkPlayData:
|
||||
type LinkPlayConfigEntry = ConfigEntry[LinkPlayData]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> bool:
|
||||
"""Async setup hass config entry. Called when an entry has been setup."""
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from linkplay.bridge import LinkPlayBridge
|
||||
from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus
|
||||
from linkplay.controller import LinkPlayController, LinkPlayMultiroom
|
||||
from linkplay.exceptions import LinkPlayRequestException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -24,6 +25,7 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
@@ -104,6 +106,15 @@ SEEKABLE_FEATURES: MediaPlayerEntityFeature = (
|
||||
| MediaPlayerEntityFeature.SEEK
|
||||
)
|
||||
|
||||
SERVICE_PLAY_PRESET = "play_preset"
|
||||
ATTR_PRESET_NUMBER = "preset_number"
|
||||
|
||||
SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_PRESET_NUMBER): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
RETRY_POLL_MAXIMUM = 3
|
||||
SCAN_INTERVAL = timedelta(seconds=5)
|
||||
PARALLEL_UPDATES = 1
|
||||
@@ -115,6 +126,14 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up a media player from a config entry."""
|
||||
|
||||
# register services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_PLAY_PRESET, SERVICE_PLAY_PRESET_SCHEMA, "async_play_preset"
|
||||
)
|
||||
|
||||
# add entities
|
||||
async_add_entities([LinkPlayMediaPlayerEntity(entry.runtime_data.bridge)])
|
||||
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Support for LinkPlay media players."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SERVICE_PLAY_PRESET = "play_preset"
|
||||
ATTR_PRESET_NUMBER = "preset_number"
|
||||
|
||||
SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Required(ATTR_PRESET_NUMBER): cv.positive_int,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_PLAY_PRESET,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=SERVICE_PLAY_PRESET_SCHEMA,
|
||||
func="async_play_preset",
|
||||
)
|
||||
@@ -6,8 +6,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"yaml_mode_deprecated": {
|
||||
"description": "Your YAML dashboard configuration uses the legacy `mode: yaml` option, which will be removed in Home Assistant 2026.8. Your YAML dashboards will continue to work, you just need to update how they are defined.\n\nTo update your configuration:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Add a dashboard entry instead:\n\n ```yaml\n lovelace:\n resource_mode: yaml\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n3. Restart Home Assistant\n\nNote: `resource_mode: yaml` keeps loading resources from YAML. If you want to manage resources through the UI instead, you can remove this line and move your resources to Settings > Dashboards > Resources.",
|
||||
"title": "Lovelace YAML configuration needs update"
|
||||
"description": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode deprecated"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "calculated",
|
||||
"loggers": ["yt_dlp"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["yt-dlp[default]==2026.02.04"],
|
||||
"requirements": ["yt-dlp[default]==2025.12.08"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"name": "Browse media"
|
||||
},
|
||||
"clear_playlist": {
|
||||
"description": "Removes all items from a media player's playlist.",
|
||||
"description": "Removes all items from the playlist.",
|
||||
"name": "Clear playlist"
|
||||
},
|
||||
"join": {
|
||||
@@ -284,15 +284,15 @@
|
||||
"name": "Next"
|
||||
},
|
||||
"media_pause": {
|
||||
"description": "Pauses playback on a media player.",
|
||||
"description": "Pauses.",
|
||||
"name": "[%key:common::action::pause%]"
|
||||
},
|
||||
"media_play": {
|
||||
"description": "Starts playback on a media player.",
|
||||
"description": "Starts playing.",
|
||||
"name": "Play"
|
||||
},
|
||||
"media_play_pause": {
|
||||
"description": "Toggles play/pause on a media player.",
|
||||
"description": "Toggles play/pause.",
|
||||
"name": "Play/Pause"
|
||||
},
|
||||
"media_previous_track": {
|
||||
@@ -310,7 +310,7 @@
|
||||
"name": "Seek"
|
||||
},
|
||||
"media_stop": {
|
||||
"description": "Stops playback on a media player.",
|
||||
"description": "Stops playing.",
|
||||
"name": "[%key:common::action::stop%]"
|
||||
},
|
||||
"play_media": {
|
||||
@@ -374,7 +374,7 @@
|
||||
"name": "Select sound mode"
|
||||
},
|
||||
"select_source": {
|
||||
"description": "Sends a media player the command to change the input source.",
|
||||
"description": "Sends the media player the command to change 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 a media player.",
|
||||
"description": "Turns off the power of the media player.",
|
||||
"name": "[%key:common::action::turn_off%]"
|
||||
},
|
||||
"turn_on": {
|
||||
"description": "Turns on the power of a media player.",
|
||||
"description": "Turns on the power of the media player.",
|
||||
"name": "[%key:common::action::turn_on%]"
|
||||
},
|
||||
"unjoin": {
|
||||
"description": "Removes a media player from a group. Only works on platforms which support player groups.",
|
||||
"description": "Removes the player from a group. Only works on platforms which support player groups.",
|
||||
"name": "Unjoin"
|
||||
},
|
||||
"volume_down": {
|
||||
"description": "Turns down the volume of a media player.",
|
||||
"description": "Turns down the volume.",
|
||||
"name": "Turn down volume"
|
||||
},
|
||||
"volume_mute": {
|
||||
"description": "Mutes or unmutes a media player.",
|
||||
"description": "Mutes or unmutes the 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 of a media player.",
|
||||
"description": "Sets the volume level.",
|
||||
"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 of a media player.",
|
||||
"description": "Turns up the volume.",
|
||||
"name": "Turn up volume"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -806,8 +806,6 @@ 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,7 +288,6 @@
|
||||
"auto": "[%key:common::state::auto%]",
|
||||
"auto_roast": "Auto roast",
|
||||
"automatic": "Automatic",
|
||||
"automatic_maintenance": "Automatic maintenance",
|
||||
"automatic_plus": "Automatic plus",
|
||||
"baguettes": "Baguettes",
|
||||
"barista_assistant": "BaristaAssistant",
|
||||
@@ -552,7 +551,6 @@
|
||||
"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)",
|
||||
|
||||
@@ -9,6 +9,9 @@ from typing import Any, cast
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.light import (
|
||||
_DEPRECATED_ATTR_COLOR_TEMP,
|
||||
_DEPRECATED_ATTR_MAX_MIREDS,
|
||||
_DEPRECATED_ATTR_MIN_MIREDS,
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
@@ -124,12 +127,15 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset(
|
||||
{
|
||||
ATTR_COLOR_MODE,
|
||||
ATTR_BRIGHTNESS,
|
||||
_DEPRECATED_ATTR_COLOR_TEMP.value,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_EFFECT,
|
||||
ATTR_EFFECT_LIST,
|
||||
ATTR_HS_COLOR,
|
||||
ATTR_MAX_COLOR_TEMP_KELVIN,
|
||||
_DEPRECATED_ATTR_MAX_MIREDS.value,
|
||||
ATTR_MIN_COLOR_TEMP_KELVIN,
|
||||
_DEPRECATED_ATTR_MIN_MIREDS.value,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ATTR_RGBWW_COLOR,
|
||||
|
||||
@@ -10,22 +10,18 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN, NEATO_LOGIN
|
||||
from .const import NEATO_DOMAIN, NEATO_LOGIN
|
||||
from .hub import NeatoHub
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.CAMERA,
|
||||
@@ -35,15 +31,9 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data.setdefault(NEATO_DOMAIN, {})
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
@@ -51,7 +41,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_domain=NEATO_DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
@@ -65,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
hass.data[DOMAIN][entry.entry_id] = neato_session
|
||||
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
|
||||
hub = NeatoHub(hass, Account(neato_session))
|
||||
|
||||
await hub.async_update_entry_unique_id(entry)
|
||||
@@ -87,6 +77,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
hass.data[NEATO_DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -9,15 +9,15 @@ from typing import Any
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import NEATO_DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN
|
||||
):
|
||||
"""Config flow to handle Neato Botvac OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
DOMAIN = NEATO_DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for Neato integration."""
|
||||
|
||||
DOMAIN = "neato"
|
||||
NEATO_DOMAIN = "neato"
|
||||
|
||||
CONF_VENDOR = "vendor"
|
||||
NEATO_LOGIN = "neato_login"
|
||||
|
||||
@@ -7,7 +7,7 @@ from pybotvac import Robot
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import NEATO_DOMAIN
|
||||
|
||||
|
||||
class NeatoEntity(Entity):
|
||||
@@ -19,6 +19,6 @@ class NeatoEntity(Entity):
|
||||
"""Initialize Neato entity."""
|
||||
self.robot = robot
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.robot.serial)},
|
||||
identifiers={(NEATO_DOMAIN, self.robot.serial)},
|
||||
name=self.robot.name,
|
||||
)
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Neato services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_NAVIGATION = "navigation"
|
||||
ATTR_CATEGORY = "category"
|
||||
ATTR_ZONE = "zone"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
# Vacuum Services
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"custom_cleaning",
|
||||
entity_domain=VACUUM_DOMAIN,
|
||||
schema={
|
||||
vol.Optional(ATTR_MODE, default=2): cv.positive_int,
|
||||
vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
|
||||
vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
|
||||
vol.Optional(ATTR_ZONE): cv.string,
|
||||
},
|
||||
func="neato_custom_cleaning",
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
|
||||
from pybotvac import Robot
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_STATUS,
|
||||
@@ -16,7 +17,9 @@ from homeassistant.components.vacuum import (
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -49,6 +52,10 @@ ATTR_CLEAN_PAUSE_TIME = "clean_pause_time"
|
||||
ATTR_CLEAN_ERROR_TIME = "clean_error_time"
|
||||
ATTR_LAUNCHED_FROM = "launched_from"
|
||||
|
||||
ATTR_NAVIGATION = "navigation"
|
||||
ATTR_CATEGORY = "category"
|
||||
ATTR_ZONE = "zone"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -70,6 +77,20 @@ async def async_setup_entry(
|
||||
_LOGGER.debug("Adding vacuums %s", dev)
|
||||
async_add_entities(dev, True)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
assert platform is not None
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"custom_cleaning",
|
||||
{
|
||||
vol.Optional(ATTR_MODE, default=2): cv.positive_int,
|
||||
vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
|
||||
vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
|
||||
vol.Optional(ATTR_ZONE): cv.string,
|
||||
},
|
||||
"neato_custom_cleaning",
|
||||
)
|
||||
|
||||
|
||||
class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
|
||||
"""Representation of a Neato Connected Vacuum."""
|
||||
|
||||
@@ -11,21 +11,24 @@ from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.UPDATE]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Cleanup before removing config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -49,13 +52,3 @@ async def async_setup_entry(
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
"""Cleanup before removing config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Constants for the Openhome component."""
|
||||
|
||||
DOMAIN = "openhome"
|
||||
|
||||
SERVICE_INVOKE_PIN = "invoke_pin"
|
||||
ATTR_PIN_INDEX = "pin"
|
||||
DATA_OPENHOME = "openhome"
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import Any, Concatenate
|
||||
|
||||
import aiohttp
|
||||
from async_upnp_client.client import UpnpError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -21,10 +22,11 @@ from homeassistant.components.media_player import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN
|
||||
|
||||
SUPPORT_OPENHOME = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
@@ -50,6 +52,14 @@ async def async_setup_entry(
|
||||
|
||||
async_add_entities([entity])
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_INVOKE_PIN,
|
||||
{vol.Required(ATTR_PIN_INDEX): cv.positive_int},
|
||||
"async_invoke_pin",
|
||||
)
|
||||
|
||||
|
||||
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
|
||||
type _ReturnFuncType[_T, **_P, _R] = Callable[
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Support for Openhome Devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
SERVICE_INVOKE_PIN = "invoke_pin"
|
||||
ATTR_PIN_INDEX = "pin"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_INVOKE_PIN,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_PIN_INDEX): cv.positive_int},
|
||||
func="async_invoke_pin",
|
||||
)
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/otbr",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.8.0"]
|
||||
"requirements": ["python-otbr-api==2.7.1"]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class OverkizDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
|
||||
except (TimeoutError, ClientConnectorError) as exception:
|
||||
LOGGER.debug("Failed to connect", exc_info=True)
|
||||
raise UpdateFailed("Failed to connect.") from exception
|
||||
except ServerDisconnectedError:
|
||||
except (ServerDisconnectedError, NotAuthenticatedException):
|
||||
self.executions = {}
|
||||
|
||||
# During the relogin, similar exceptions can be thrown.
|
||||
|
||||
@@ -58,95 +58,18 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
"""Return Overkiz device linked to this entity."""
|
||||
return self.coordinator.data[self.device_url]
|
||||
|
||||
def _get_sibling_devices(self) -> list[Device]:
|
||||
"""Return sibling devices sharing the same base device URL."""
|
||||
prefix = f"{self.base_device_url}#"
|
||||
return [
|
||||
device
|
||||
for device in self.coordinator.data.values()
|
||||
if device.device_url != self.device_url
|
||||
and device.device_url.startswith(prefix)
|
||||
]
|
||||
|
||||
def _has_siblings_with_different_place_oid(self) -> bool:
|
||||
"""Check if sibling devices have different placeOIDs.
|
||||
|
||||
Returns True if siblings have different place_oid values, indicating
|
||||
devices should be grouped by placeOID rather than by base URL.
|
||||
"""
|
||||
my_place_oid = self.device.place_oid
|
||||
if not my_place_oid:
|
||||
return False
|
||||
|
||||
return any(
|
||||
sibling.place_oid and sibling.place_oid != my_place_oid
|
||||
for sibling in self._get_sibling_devices()
|
||||
)
|
||||
|
||||
def _get_device_index(self, device_url: str) -> int | None:
|
||||
"""Extract numeric index from device URL (e.g., 'io://gw/123#4' -> 4)."""
|
||||
suffix = device_url.split("#")[-1]
|
||||
return int(suffix) if suffix.isdigit() else None
|
||||
|
||||
def _is_main_device_for_place_oid(self) -> bool:
|
||||
"""Check if this device is the main device for its placeOID group.
|
||||
|
||||
The device with the lowest URL index among siblings sharing the same
|
||||
placeOID is considered the main device and provides full device info.
|
||||
"""
|
||||
my_place_oid = self.device.place_oid
|
||||
if not my_place_oid:
|
||||
return True
|
||||
|
||||
my_index = self._get_device_index(self.device_url)
|
||||
if my_index is None:
|
||||
return True
|
||||
|
||||
return not any(
|
||||
(sibling_index := self._get_device_index(sibling.device_url)) is not None
|
||||
and sibling_index < my_index
|
||||
for sibling in self._get_sibling_devices()
|
||||
if sibling.place_oid == my_place_oid
|
||||
)
|
||||
|
||||
def _get_via_device_id(self, use_place_oid_grouping: bool) -> str:
|
||||
"""Return the via_device identifier for device registry hierarchy.
|
||||
|
||||
Sub-devices link to the main actuator (#1 device) when using placeOID
|
||||
grouping, otherwise they link directly to the gateway.
|
||||
"""
|
||||
gateway_id = self.executor.get_gateway_id()
|
||||
|
||||
if not use_place_oid_grouping or self.device_url.endswith("#1"):
|
||||
return gateway_id
|
||||
|
||||
main_device = self.coordinator.data.get(f"{self.base_device_url}#1")
|
||||
if main_device and main_device.place_oid:
|
||||
return f"{self.base_device_url}#{main_device.place_oid}"
|
||||
|
||||
return gateway_id
|
||||
|
||||
def generate_device_info(self) -> DeviceInfo:
|
||||
"""Return device registry information for this entity."""
|
||||
# Some devices, such as the Smart Thermostat, have several sub-devices
|
||||
# sharing the same base URL (terminated by '#' and a number).
|
||||
use_place_oid_grouping = self._has_siblings_with_different_place_oid()
|
||||
|
||||
# Sub-devices without placeOID grouping inherit info from parent device
|
||||
if self.is_sub_device and not use_place_oid_grouping:
|
||||
# Some devices, such as the Smart Thermostat have several devices
|
||||
# in one physical device, with same device url, terminated by '#' and a number.
|
||||
# In this case, we use the base device url as the device identifier.
|
||||
if self.is_sub_device:
|
||||
# Only return the url of the base device, to inherit device name
|
||||
# and model from parent device.
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.executor.base_device_url)},
|
||||
)
|
||||
|
||||
# Determine identifier based on grouping strategy
|
||||
if use_place_oid_grouping:
|
||||
identifier = f"{self.base_device_url}#{self.device.place_oid}"
|
||||
# Non-main devices only reference the identifier
|
||||
if not self._is_main_device_for_place_oid():
|
||||
return DeviceInfo(identifiers={(DOMAIN, identifier)})
|
||||
else:
|
||||
identifier = self.executor.base_device_url
|
||||
|
||||
manufacturer = (
|
||||
self.executor.select_attribute(OverkizAttribute.CORE_MANUFACTURER)
|
||||
or self.executor.select_state(OverkizState.CORE_MANUFACTURER_NAME)
|
||||
@@ -169,7 +92,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, identifier)},
|
||||
identifiers={(DOMAIN, self.executor.base_device_url)},
|
||||
name=self.device.label,
|
||||
manufacturer=str(manufacturer),
|
||||
model=str(model),
|
||||
@@ -179,7 +102,7 @@ class OverkizEntity(CoordinatorEntity[OverkizDataUpdateCoordinator]):
|
||||
),
|
||||
hw_version=self.device.controllable_name,
|
||||
suggested_area=suggested_area,
|
||||
via_device=(DOMAIN, self._get_via_device_id(use_place_oid_grouping)),
|
||||
via_device=(DOMAIN, self.executor.get_gateway_id()),
|
||||
configuration_url=self.coordinator.client.server.configuration_url,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,11 +143,9 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
continue
|
||||
|
||||
try:
|
||||
containers, docker_version, docker_info = await asyncio.gather(
|
||||
self.portainer.get_containers(endpoint.id),
|
||||
self.portainer.docker_version(endpoint.id),
|
||||
self.portainer.docker_info(endpoint.id),
|
||||
)
|
||||
containers = await self.portainer.get_containers(endpoint.id)
|
||||
docker_version = await self.portainer.docker_version(endpoint.id)
|
||||
docker_info = await self.portainer.docker_info(endpoint.id)
|
||||
|
||||
prev_endpoint = self.data.get(endpoint.id) if self.data else None
|
||||
container_map: dict[str, PortainerContainerData] = {}
|
||||
|
||||
@@ -9,11 +9,8 @@ from aiopyarr.radarr_client import RadarrClient
|
||||
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import (
|
||||
CalendarUpdateCoordinator,
|
||||
DiskSpaceDataUpdateCoordinator,
|
||||
@@ -25,18 +22,9 @@ from .coordinator import (
|
||||
RadarrDataUpdateCoordinator,
|
||||
StatusDataUpdateCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR]
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Radarr integration."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: RadarrConfigEntry) -> bool:
|
||||
"""Set up Radarr from a config entry."""
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Final
|
||||
DOMAIN: Final = "radarr"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_MAX_RECORDS = 20
|
||||
DEFAULT_NAME = "Radarr"
|
||||
DEFAULT_URL = "http://127.0.0.1:7878"
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER
|
||||
|
||||
|
||||
@dataclass(kw_only=True, slots=True)
|
||||
@@ -130,20 +130,21 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]):
|
||||
|
||||
|
||||
class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
|
||||
"""Movies count update coordinator."""
|
||||
"""Movies update coordinator."""
|
||||
|
||||
async def _fetch_data(self) -> int:
|
||||
"""Fetch the total count of movies in Radarr."""
|
||||
"""Fetch the movies data."""
|
||||
return len(cast(list[RadarrMovie], await self.api_client.async_get_movies()))
|
||||
|
||||
|
||||
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
|
||||
"""Queue count update coordinator."""
|
||||
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator):
|
||||
"""Queue update coordinator."""
|
||||
|
||||
async def _fetch_data(self) -> int:
|
||||
"""Fetch the number of movies in the download queue."""
|
||||
# page_size=1 is sufficient since we only need the totalRecords count
|
||||
return (await self.api_client.async_get_queue(page_size=1)).totalRecords
|
||||
"""Fetch the movies in queue."""
|
||||
return (
|
||||
await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS)
|
||||
).totalRecords
|
||||
|
||||
|
||||
class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
"""Helper functions for Radarr."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiopyarr import RadarrMovie, RadarrQueue
|
||||
|
||||
|
||||
def format_queue_item(item: Any, base_url: str | None = None) -> dict[str, Any]:
|
||||
"""Format a single queue item."""
|
||||
|
||||
remaining = 1 if item.size == 0 else item.sizeleft / item.size
|
||||
remaining_pct = 100 * (1 - remaining)
|
||||
|
||||
movie = item.movie
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"id": item.id,
|
||||
"movie_id": item.movieId,
|
||||
"title": movie["title"],
|
||||
"download_title": item.title,
|
||||
"progress": f"{remaining_pct:.2f}%",
|
||||
"size": item.size,
|
||||
"size_left": item.sizeleft,
|
||||
"status": item.status,
|
||||
"tracked_download_status": getattr(item, "trackedDownloadStatus", None),
|
||||
"tracked_download_state": getattr(item, "trackedDownloadState", None),
|
||||
"download_client": getattr(item, "downloadClient", None),
|
||||
"download_id": getattr(item, "downloadId", None),
|
||||
"indexer": getattr(item, "indexer", None),
|
||||
"protocol": str(getattr(item, "protocol", None)),
|
||||
"estimated_completion_time": str(
|
||||
getattr(item, "estimatedCompletionTime", None)
|
||||
),
|
||||
"time_left": str(getattr(item, "timeleft", None)),
|
||||
}
|
||||
|
||||
if quality := getattr(item, "quality", None):
|
||||
result["quality"] = quality.quality.name
|
||||
|
||||
if languages := getattr(item, "languages", None):
|
||||
result["languages"] = [lang.name for lang in languages]
|
||||
|
||||
if custom_format_score := getattr(item, "customFormatScore", None):
|
||||
result["custom_format_score"] = custom_format_score
|
||||
|
||||
# Add movie images if available
|
||||
# Note: item.movie is a dict (not object), so images are also dicts
|
||||
if images := movie.get("images"):
|
||||
result["images"] = {}
|
||||
for image in images:
|
||||
cover_type = image.get("coverType")
|
||||
# Prefer remoteUrl (public TMDB URL) over local path
|
||||
if remote_url := image.get("remoteUrl"):
|
||||
result["images"][cover_type] = remote_url
|
||||
elif base_url and (url := image.get("url")):
|
||||
result["images"][cover_type] = f"{base_url.rstrip('/')}{url}"
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_queue(
|
||||
queue: RadarrQueue, base_url: str | None = None
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Format queue for service response."""
|
||||
movies = {}
|
||||
|
||||
for item in queue.records:
|
||||
movies[item.title] = format_queue_item(item, base_url)
|
||||
|
||||
return movies
|
||||
|
||||
|
||||
def format_movie_item(
|
||||
movie: RadarrMovie, base_url: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Format a single movie item."""
|
||||
result: dict[str, Any] = {
|
||||
"id": movie.id,
|
||||
"title": movie.title,
|
||||
"year": movie.year,
|
||||
"tmdb_id": movie.tmdbId,
|
||||
"imdb_id": getattr(movie, "imdbId", None),
|
||||
"status": movie.status,
|
||||
"monitored": movie.monitored,
|
||||
"has_file": movie.hasFile,
|
||||
"size_on_disk": getattr(movie, "sizeOnDisk", None),
|
||||
}
|
||||
|
||||
# Add path if available
|
||||
if path := getattr(movie, "path", None):
|
||||
result["path"] = path
|
||||
|
||||
# Add movie statistics if available
|
||||
if statistics := getattr(movie, "statistics", None):
|
||||
result["movie_file_count"] = getattr(statistics, "movieFileCount", None)
|
||||
result["size_on_disk"] = getattr(statistics, "sizeOnDisk", None)
|
||||
|
||||
# Add movie images if available
|
||||
if images := getattr(movie, "images", None):
|
||||
images_dict: dict[str, str] = {}
|
||||
for image in images:
|
||||
cover_type = image.coverType
|
||||
# Prefer remoteUrl (public TMDB URL) over local path
|
||||
if remote_url := getattr(image, "remoteUrl", None):
|
||||
images_dict[cover_type] = remote_url
|
||||
elif base_url and (url := getattr(image, "url", None)):
|
||||
images_dict[cover_type] = f"{base_url.rstrip('/')}{url}"
|
||||
result["images"] = images_dict
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def format_movies(
|
||||
movies: list[RadarrMovie], base_url: str | None = None
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Format movies list for service response."""
|
||||
formatted_movies = {}
|
||||
|
||||
for movie in movies:
|
||||
formatted_movies[movie.title] = format_movie_item(movie, base_url)
|
||||
|
||||
return formatted_movies
|
||||
@@ -8,13 +8,5 @@
|
||||
"default": "mdi:download"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_movies": {
|
||||
"service": "mdi:filmstrip"
|
||||
},
|
||||
"get_queue": {
|
||||
"service": "mdi:download"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""Define services for the Radarr integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from aiopyarr import exceptions
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import RadarrConfigEntry
|
||||
from .helpers import format_movies, format_queue
|
||||
|
||||
# Service names
|
||||
SERVICE_GET_MOVIES: Final = "get_movies"
|
||||
SERVICE_GET_QUEUE: Final = "get_queue"
|
||||
|
||||
# Service attributes
|
||||
ATTR_MOVIES: Final = "movies"
|
||||
ATTR_ENTRY_ID: Final = "entry_id"
|
||||
|
||||
# Service parameter constants
|
||||
CONF_MAX_ITEMS = "max_items"
|
||||
|
||||
# Default values - 0 means no limit
|
||||
DEFAULT_MAX_ITEMS = 0
|
||||
|
||||
SERVICE_BASE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTRY_ID): selector.ConfigEntrySelector(
|
||||
{"integration": DOMAIN}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
SERVICE_GET_MOVIES_SCHEMA = SERVICE_BASE_SCHEMA
|
||||
|
||||
SERVICE_GET_QUEUE_SCHEMA = SERVICE_BASE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(CONF_MAX_ITEMS, default=DEFAULT_MAX_ITEMS): vol.All(
|
||||
vol.Coerce(int), vol.Range(min=0, max=500)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _get_config_entry_from_service_data(call: ServiceCall) -> RadarrConfigEntry:
|
||||
"""Return config entry for entry id."""
|
||||
config_entry_id: str = call.data[ATTR_ENTRY_ID]
|
||||
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="integration_not_found",
|
||||
translation_placeholders={"target": DOMAIN},
|
||||
)
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_loaded",
|
||||
translation_placeholders={"target": entry.title},
|
||||
)
|
||||
return cast(RadarrConfigEntry, entry)
|
||||
|
||||
|
||||
async def _handle_api_errors[_T](func: Callable[[], Awaitable[_T]]) -> _T:
|
||||
"""Handle API errors and raise HomeAssistantError with user-friendly messages."""
|
||||
try:
|
||||
return await func()
|
||||
except exceptions.ArrAuthenticationException as ex:
|
||||
raise HomeAssistantError("Authentication failed for Radarr") from ex
|
||||
except exceptions.ArrConnectionException as ex:
|
||||
raise HomeAssistantError("Failed to connect to Radarr") from ex
|
||||
except exceptions.ArrException as ex:
|
||||
raise HomeAssistantError(f"Radarr API error: {ex}") from ex
|
||||
|
||||
|
||||
async def _async_get_movies(service: ServiceCall) -> dict[str, Any]:
|
||||
"""Get all Radarr movies."""
|
||||
entry = _get_config_entry_from_service_data(service)
|
||||
|
||||
api_client = entry.runtime_data.status.api_client
|
||||
movies_list = await _handle_api_errors(api_client.async_get_movies)
|
||||
|
||||
# Get base URL from config entry for image URLs
|
||||
base_url = entry.data[CONF_URL]
|
||||
movies = format_movies(cast(list, movies_list), base_url)
|
||||
|
||||
return {
|
||||
ATTR_MOVIES: movies,
|
||||
}
|
||||
|
||||
|
||||
async def _async_get_queue(service: ServiceCall) -> dict[str, Any]:
|
||||
"""Get Radarr queue."""
|
||||
entry = _get_config_entry_from_service_data(service)
|
||||
max_items: int = service.data[CONF_MAX_ITEMS]
|
||||
|
||||
api_client = entry.runtime_data.status.api_client
|
||||
|
||||
if max_items > 0:
|
||||
page_size = max_items
|
||||
else:
|
||||
# Get total count first, then fetch all items
|
||||
queue_preview = await _handle_api_errors(
|
||||
lambda: api_client.async_get_queue(page_size=1)
|
||||
)
|
||||
total = queue_preview.totalRecords
|
||||
page_size = total if total > 0 else 1
|
||||
|
||||
queue = await _handle_api_errors(
|
||||
lambda: api_client.async_get_queue(page_size=page_size, include_movie=True)
|
||||
)
|
||||
|
||||
# Get base URL from config entry for image URLs
|
||||
base_url = entry.data[CONF_URL]
|
||||
|
||||
movies = format_queue(queue, base_url)
|
||||
|
||||
return {ATTR_MOVIES: movies}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for the Radarr integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_MOVIES,
|
||||
_async_get_movies,
|
||||
schema=SERVICE_GET_MOVIES_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_GET_QUEUE,
|
||||
_async_get_queue,
|
||||
schema=SERVICE_GET_QUEUE_SCHEMA,
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user