mirror of
https://github.com/home-assistant/core.git
synced 2026-02-04 06:15:47 +01:00
Compare commits
41 Commits
whirlpool_
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe363f32ec | ||
|
|
31562e7571 | ||
|
|
0bdb51e4ca | ||
|
|
67a5d7ac21 | ||
|
|
5e7f06c476 | ||
|
|
9a69852296 | ||
|
|
a722925b8e | ||
|
|
419c5de50e | ||
|
|
37faed565e | ||
|
|
622953e61f | ||
|
|
17926c3f6a | ||
|
|
48d85170c2 | ||
|
|
08d179c520 | ||
|
|
5752387da8 | ||
|
|
1ebde65f03 | ||
|
|
89f536e332 | ||
|
|
8784329333 | ||
|
|
d73538722d | ||
|
|
d49d3f0a2f | ||
|
|
8466dd4c2b | ||
|
|
6bb1e688c6 | ||
|
|
9bc1c4c4f3 | ||
|
|
a554cb8211 | ||
|
|
145d38403e | ||
|
|
10d4af5674 | ||
|
|
ed3b4d2de3 | ||
|
|
e66d324877 | ||
|
|
f7f18627a2 | ||
|
|
d18630020f | ||
|
|
a715ec318c | ||
|
|
0ef5a77dc9 | ||
|
|
b43abf83b8 | ||
|
|
84d28db3a7 | ||
|
|
74d99fa0be | ||
|
|
3ff0320ed8 | ||
|
|
16cb9e9785 | ||
|
|
d92279dfcb | ||
|
|
4b9d28d0e5 | ||
|
|
e6a60dfe50 | ||
|
|
d219056e9d | ||
|
|
6ff6b099b5 |
5
homeassistant/brands/heatit.json
Normal file
5
homeassistant/brands/heatit.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heatit",
|
||||
"name": "Heatit",
|
||||
"iot_standards": ["zwave"]
|
||||
}
|
||||
5
homeassistant/brands/heiman.json
Normal file
5
homeassistant/brands/heiman.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"domain": "heiman",
|
||||
"name": "Heiman",
|
||||
"iot_standards": ["matter", "zigbee"]
|
||||
}
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
"requirements": ["aioamazondevices==11.1.1"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from .const import CATEGORY_NOTIFICATIONS, CATEGORY_SENSORS
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import async_remove_unsupported_notification_sensors
|
||||
|
||||
# Coordinator is used to centralize the data updates
|
||||
PARALLEL_UPDATES = 0
|
||||
@@ -105,6 +106,9 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# Remove notification sensors from unsupported devices
|
||||
await async_remove_unsupported_notification_sensors(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
def _check_device() -> None:
|
||||
@@ -122,6 +126,7 @@ async def async_setup_entry(
|
||||
AmazonSensorEntity(coordinator, serial_num, notification_desc)
|
||||
for notification_desc in NOTIFICATIONS
|
||||
for serial_num in new_devices
|
||||
if coordinator.data[serial_num].notifications_supported
|
||||
]
|
||||
async_add_entities(sensors_list + notifications_list)
|
||||
|
||||
|
||||
@@ -5,8 +5,14 @@ from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -81,3 +87,27 @@ async def async_remove_dnd_from_virtual_group(
|
||||
if entity_id and is_group:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed DND switch from virtual group %s", entity_id)
|
||||
|
||||
|
||||
async def async_remove_unsupported_notification_sensors(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
) -> None:
|
||||
"""Remove notification sensors from unsupported devices."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
for notification_key in (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
if entity_id and is_unsupported:
|
||||
entity_registry.async_remove(entity_id)
|
||||
_LOGGER.debug("Removed unsupported notification sensor %s", entity_id)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from collections.abc import AsyncGenerator, 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
|
||||
) -> AsyncIterator[httpx.Response]:
|
||||
) -> AsyncGenerator[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) -> AsyncIterator[None]:
|
||||
async def _async_command_wrapper(self) -> AsyncGenerator[None]:
|
||||
try:
|
||||
yield
|
||||
except LoginError as ex:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"preview_features": {
|
||||
"snapshots": {
|
||||
"description": "This free, open source device database of the Open Home Foundation helps users find useful information about smart home devices used in real installations.\n\nYou can help build it by anonymously sharing data about your devices. Only device-specific details (like model or manufacturer) are shared — never personally identifying information (like the names you assign).\n\nLearn more about the device database and how we process your data in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement), which you accept by opting in.",
|
||||
"description": "We're creating the [Open Home Foundation Device Database](https://www.home-assistant.io/blog/2026/02/02/about-device-database/): a free, open source community-powered resource to help users find practical information about how smart home devices perform in real installations.\n\nYou can help us build it by opting in to share anonymized data about your devices. This data will only ever include device-specific details (like model or manufacturer) – never personally identifying information (like the names you assign).\n\nFind out how we process your data (should you choose to contribute) in our [Data Use Statement](https://www.openhomefoundation.org/device-database-data-use-statement).",
|
||||
"disable_confirmation": "Your data will no longer be shared with the Open Home Foundation's device database.",
|
||||
"enable_confirmation": "This feature is still in development and may change. The device database is being refined based on user feedback and is not yet complete.",
|
||||
"name": "Device database"
|
||||
|
||||
@@ -419,7 +419,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id
|
||||
not in ("claude-3-haiku-20240307", "claude-3-opus-20240229")
|
||||
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):
|
||||
|
||||
@@ -23,7 +23,7 @@ CONF_WEB_SEARCH_COUNTRY = "country"
|
||||
CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-3-5-haiku-latest",
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
|
||||
@@ -26,6 +26,7 @@ EXCLUDE_FROM_BACKUP = [
|
||||
"tmp_backups/*.tar",
|
||||
"OZW_Log.txt",
|
||||
"tts/*",
|
||||
".cache/*",
|
||||
]
|
||||
|
||||
EXCLUDE_DATABASE_FROM_BACKUP = [
|
||||
|
||||
@@ -1,14 +1,3 @@
|
||||
"""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,6 +1,5 @@
|
||||
"""Actions for Bring! integration."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bring_api import (
|
||||
@@ -13,22 +12,28 @@ 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
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
|
||||
from .const import (
|
||||
ATTR_ACTIVITY,
|
||||
ATTR_REACTION,
|
||||
ATTR_RECEIVER,
|
||||
DOMAIN,
|
||||
SERVICE_ACTIVITY_STREAM_REACTION,
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
service,
|
||||
)
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BringConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
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"
|
||||
|
||||
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
@@ -54,6 +59,7 @@ 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."""
|
||||
|
||||
@@ -108,3 +114,17 @@ 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,7 +13,6 @@ from bring_api import (
|
||||
BringNotificationType,
|
||||
BringRequestException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
TodoItem,
|
||||
@@ -23,15 +22,9 @@ 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 (
|
||||
ATTR_ITEM_NAME,
|
||||
ATTR_NOTIFICATION_TYPE,
|
||||
DOMAIN,
|
||||
SERVICE_PUSH_NOTIFICATION,
|
||||
)
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
|
||||
from .entity import BringBaseEntity
|
||||
|
||||
@@ -63,19 +56,6 @@ 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."""
|
||||
|
||||
@@ -49,6 +49,7 @@ 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,
|
||||
@@ -234,6 +235,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
|
||||
"max_temp",
|
||||
"min_humidity",
|
||||
"max_humidity",
|
||||
"target_humidity_step",
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +251,7 @@ 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,
|
||||
}
|
||||
@@ -275,6 +278,7 @@ 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
|
||||
@@ -323,6 +327,9 @@ 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
|
||||
|
||||
@@ -728,6 +735,11 @@ 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,6 +114,7 @@ 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,8 +459,17 @@ class BaseCloudLLMEntity(Entity):
|
||||
last_content: Any = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
files = await self._async_prepare_files_for_prompt(last_content.attachments)
|
||||
current_content = last_content.content
|
||||
last_content = [*(current_content or []), *files]
|
||||
|
||||
last_message = cast(dict[str, Any], messages[-1])
|
||||
assert (
|
||||
last_message["type"] == "message"
|
||||
and last_message["role"] == "user"
|
||||
and isinstance(last_message["content"], str)
|
||||
)
|
||||
last_message["content"] = [
|
||||
{"type": "input_text", "text": last_message["content"]},
|
||||
*files,
|
||||
]
|
||||
|
||||
tools: list[ToolParam] = []
|
||||
tool_choice: str | None = None
|
||||
|
||||
@@ -67,6 +67,7 @@ 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",
|
||||
@@ -118,6 +119,7 @@ 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
|
||||
@@ -163,6 +165,7 @@ 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,8 +9,9 @@ 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 entity_registry as er
|
||||
from homeassistant.helpers import config_validation as cv, 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,
|
||||
@@ -24,9 +25,12 @@ 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__)
|
||||
@@ -34,6 +38,12 @@ _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,6 +2,7 @@
|
||||
|
||||
DOMAIN = "denonavr"
|
||||
|
||||
ATTR_DYNAMIC_EQ = "dynamic_eq"
|
||||
|
||||
CONF_SHOW_ALL_SOURCES = "show_all_sources"
|
||||
CONF_ZONE2 = "zone2"
|
||||
|
||||
@@ -26,7 +26,6 @@ from denonavr.exceptions import (
|
||||
AvrTimoutError,
|
||||
DenonAvrError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
@@ -35,14 +34,14 @@ from homeassistant.components.media_player import (
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
)
|
||||
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
|
||||
from homeassistant.const import 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,
|
||||
@@ -53,7 +52,6 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_SOUND_MODE_RAW = "sound_mode_raw"
|
||||
ATTR_DYNAMIC_EQ = "dynamic_eq"
|
||||
|
||||
SUPPORT_DENON = (
|
||||
MediaPlayerEntityFeature.VOLUME_STEP
|
||||
@@ -76,11 +74,6 @@ 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",
|
||||
@@ -134,24 +127,6 @@ 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)
|
||||
|
||||
|
||||
|
||||
47
homeassistant/components/denonavr/services.py
Normal file
47
homeassistant/components/denonavr/services.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""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,8 +5,12 @@ 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,
|
||||
@@ -22,6 +26,14 @@ 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."""
|
||||
|
||||
27
homeassistant/components/ecovacs/services.py
Normal file
27
homeassistant/components/ecovacs/services.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""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,9 +18,8 @@ from homeassistant.components.vacuum import (
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import slugify
|
||||
|
||||
@@ -32,9 +31,6 @@ 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(
|
||||
@@ -56,14 +52,6 @@ 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,11 +11,15 @@ 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, HTTP
|
||||
from .const import CONF_CONNECTION_TYPE, DOMAIN, 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__)
|
||||
@@ -47,6 +51,12 @@ 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,7 +27,6 @@ from epson_projector.const import (
|
||||
VOL_UP,
|
||||
VOLUME,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerEntity,
|
||||
@@ -36,17 +35,12 @@ 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,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr, 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, SERVICE_SELECT_CMODE
|
||||
from .const import ATTR_CMODE, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -63,12 +57,6 @@ 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):
|
||||
|
||||
27
homeassistant/components/epson/services.py
Normal file
27
homeassistant/components/epson/services.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator
|
||||
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) -> Iterator[Path]:
|
||||
def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Generator[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 AsyncIterator
|
||||
from collections.abc import AsyncGenerator
|
||||
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) -> AsyncIterator[Device]:
|
||||
async def async_connect_and_update(self) -> AsyncGenerator[Device]:
|
||||
"""Provide an up-to-date device for use during connections."""
|
||||
if (
|
||||
ble_device := async_ble_device_from_address(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -36,6 +37,7 @@ 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,
|
||||
@@ -55,6 +57,10 @@ 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"
|
||||
|
||||
@@ -133,6 +139,8 @@ 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]
|
||||
@@ -425,6 +433,49 @@ 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.4"]
|
||||
"requirements": ["home-assistant-frontend==20260128.5"]
|
||||
}
|
||||
|
||||
242
homeassistant/components/frontend/pr_download.py
Normal file
242
homeassistant/components/frontend/pr_download.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""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
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from collections.abc import AsyncGenerator, 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
|
||||
) -> AsyncIterator[None]:
|
||||
) -> AsyncGenerator[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 AsyncIterator, Callable, Sequence
|
||||
from collections.abc import AsyncGenerator, 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) -> AsyncIterator[None]:
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[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) -> AsyncIterator[None]:
|
||||
async def temporarily_stop(self, hass: HomeAssistant) -> AsyncGenerator[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
|
||||
) -> AsyncIterator[None]:
|
||||
) -> AsyncGenerator[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,7 +4,13 @@ from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "huum"
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER]
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CLIMATE,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
CONFIG_STEAMER = 1
|
||||
CONFIG_LIGHT = 2
|
||||
|
||||
42
homeassistant/components/huum/sensor.py
Normal file
42
homeassistant/components/huum/sensor.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""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 AsyncIterator, Callable, Coroutine
|
||||
from collections.abc import AsyncGenerator, 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
|
||||
) -> AsyncIterator[asyncio.Future[str]]:
|
||||
) -> AsyncGenerator[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,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
|
||||
self._attr_state = state
|
||||
self._attr_is_volume_muted = volume_muted
|
||||
self._attr_volume_level = volume_level
|
||||
# Only update volume_level if the API provides it, otherwise preserve current value
|
||||
if volume_level is not None:
|
||||
self._attr_volume_level = volume_level
|
||||
self._attr_media_content_type = media_content_type
|
||||
self._attr_media_content_id = media_content_id
|
||||
self._attr_media_title = media_title
|
||||
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
)
|
||||
features = MediaPlayerEntityFeature(0)
|
||||
|
||||
if "PlayMediaSource" in commands:
|
||||
if "PlayMediaSource" in commands or self.capabilities.get(
|
||||
"SupportsMediaControl", False
|
||||
):
|
||||
features |= (
|
||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
| MediaPlayerEntityFeature.SEARCH_MEDIA
|
||||
)
|
||||
|
||||
if "Mute" in commands:
|
||||
if "Mute" in commands and "Unmute" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
|
||||
if "VolumeSet" in commands:
|
||||
if "VolumeSet" in commands or "SetVolume" in commands:
|
||||
features |= MediaPlayerEntityFeature.VOLUME_SET
|
||||
|
||||
return features
|
||||
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send pause command."""
|
||||
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PAUSED
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
|
||||
self._attr_state = MediaPlayerState.PLAYING
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def media_play_pause(self) -> None:
|
||||
"""Send the PlayPause command to the session."""
|
||||
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
"""Send stop command."""
|
||||
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
|
||||
self._attr_state = MediaPlayerState.IDLE
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_set_volume(
|
||||
self.session_id, int(volume * 100)
|
||||
)
|
||||
self._attr_volume_level = volume
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def mute_volume(self, mute: bool) -> None:
|
||||
"""Mute the volume."""
|
||||
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
||||
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
|
||||
else:
|
||||
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
|
||||
self._attr_is_volume_muted = mute
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_browse_media(
|
||||
self,
|
||||
|
||||
@@ -17,11 +17,16 @@ 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
|
||||
from .const import CONF_WS_PORT, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
type KodiConfigEntry = ConfigEntry[KodiRuntimeData]
|
||||
@@ -35,6 +40,12 @@ 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,7 +11,6 @@ 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 (
|
||||
@@ -31,16 +30,11 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STARTED,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
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
|
||||
@@ -85,42 +79,12 @@ 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:
|
||||
|
||||
54
homeassistant/components/kodi/services.py
Normal file
54
homeassistant/components/kodi/services.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -12,10 +12,15 @@ 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:
|
||||
@@ -27,6 +32,12 @@ 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,7 +10,6 @@ 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 (
|
||||
@@ -25,7 +24,6 @@ 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
|
||||
|
||||
@@ -106,15 +104,6 @@ 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
|
||||
@@ -126,14 +115,6 @@ 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)])
|
||||
|
||||
|
||||
|
||||
33
homeassistant/components/linkplay/services.py
Normal file
33
homeassistant/components/linkplay/services.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""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": "The `mode` option in `lovelace:` configuration is deprecated and will be removed in Home Assistant 2026.8.\n\nTo migrate:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. If you have `resources:` declared in your lovelace configuration, add `resource_mode: yaml` to keep loading resources from YAML\n3. Add a dashboard entry in your `configuration.yaml`:\n\n ```yaml\n lovelace:\n resource_mode: yaml # Add this if you have resources declared\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n4. Restart Home Assistant",
|
||||
"title": "Lovelace YAML mode deprecated"
|
||||
"description": "Your YAML dashboard configuration uses the legacy `mode: yaml` option, which will be removed in Home Assistant 2026.8. Your YAML dashboards will continue to work, you just need to update how they are defined.\n\nTo update your configuration:\n\n1. Remove `mode: yaml` from `lovelace:` in your `configuration.yaml`\n2. Add a dashboard entry instead:\n\n ```yaml\n lovelace:\n resource_mode: yaml\n dashboards:\n lovelace:\n mode: yaml\n filename: {config_file}\n title: Overview\n icon: mdi:view-dashboard\n show_in_sidebar: true\n ```\n\n3. Restart Home Assistant\n\nNote: `resource_mode: yaml` keeps loading resources from YAML. If you want to manage resources through the UI instead, you can remove this line and move your resources to Settings > Dashboards > Resources.",
|
||||
"title": "Lovelace YAML configuration needs update"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -10,18 +10,22 @@ 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 NEATO_DOMAIN, NEATO_LOGIN
|
||||
from .const import 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,
|
||||
@@ -31,9 +35,15 @@ 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(NEATO_DOMAIN, {})
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
@@ -41,7 +51,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=NEATO_DOMAIN,
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
|
||||
@@ -55,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
|
||||
hass.data[DOMAIN][entry.entry_id] = neato_session
|
||||
hub = NeatoHub(hass, Account(neato_session))
|
||||
|
||||
await hub.async_update_entry_unique_id(entry)
|
||||
@@ -77,6 +87,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[NEATO_DOMAIN].pop(entry.entry_id)
|
||||
hass.data[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 NEATO_DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
|
||||
):
|
||||
"""Config flow to handle Neato Botvac OAuth2 authentication."""
|
||||
|
||||
DOMAIN = NEATO_DOMAIN
|
||||
DOMAIN = DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Constants for Neato integration."""
|
||||
|
||||
NEATO_DOMAIN = "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 NEATO_DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
class NeatoEntity(Entity):
|
||||
@@ -19,6 +19,6 @@ class NeatoEntity(Entity):
|
||||
"""Initialize Neato entity."""
|
||||
self.robot = robot
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(NEATO_DOMAIN, self.robot.serial)},
|
||||
identifiers={(DOMAIN, self.robot.serial)},
|
||||
name=self.robot.name,
|
||||
)
|
||||
|
||||
36
homeassistant/components/neato/services.py
Normal file
36
homeassistant/components/neato/services.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""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,7 +8,6 @@ from typing import Any
|
||||
|
||||
from pybotvac import Robot
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_STATUS,
|
||||
@@ -17,9 +16,7 @@ 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
|
||||
|
||||
@@ -52,10 +49,6 @@ 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,
|
||||
@@ -77,20 +70,6 @@ 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,24 +11,21 @@ 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_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(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the component."""
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -52,3 +49,13 @@ 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,6 +1,5 @@
|
||||
"""Constants for the Openhome component."""
|
||||
|
||||
DOMAIN = "openhome"
|
||||
SERVICE_INVOKE_PIN = "invoke_pin"
|
||||
ATTR_PIN_INDEX = "pin"
|
||||
|
||||
DATA_OPENHOME = "openhome"
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 (
|
||||
@@ -22,11 +21,10 @@ 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 ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN
|
||||
from .const import DOMAIN
|
||||
|
||||
SUPPORT_OPENHOME = (
|
||||
MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
@@ -52,14 +50,6 @@ 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[
|
||||
|
||||
27
homeassistant/components/openhome/services.py
Normal file
27
homeassistant/components/openhome/services.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -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, NotAuthenticatedException):
|
||||
except ServerDisconnectedError:
|
||||
self.executions = {}
|
||||
|
||||
# During the relogin, similar exceptions can be thrown.
|
||||
|
||||
@@ -143,9 +143,11 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
continue
|
||||
|
||||
try:
|
||||
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)
|
||||
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),
|
||||
)
|
||||
|
||||
prev_endpoint = self.data.get(endpoint.id) if self.data else None
|
||||
container_map: dict[str, PortainerContainerData] = {}
|
||||
|
||||
@@ -9,8 +9,11 @@ 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,
|
||||
@@ -22,9 +25,18 @@ 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,7 +6,6 @@ from typing import Final
|
||||
DOMAIN: Final = "radarr"
|
||||
|
||||
# Defaults
|
||||
DEFAULT_MAX_RECORDS = 20
|
||||
DEFAULT_NAME = "Radarr"
|
||||
DEFAULT_URL = "http://127.0.0.1:7878"
|
||||
|
||||
@@ -18,3 +17,11 @@ HEALTH_ISSUES = (
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -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 DEFAULT_MAX_RECORDS, DOMAIN, LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
|
||||
@dataclass(kw_only=True, slots=True)
|
||||
@@ -130,21 +130,20 @@ class HealthDataUpdateCoordinator(RadarrDataUpdateCoordinator[list[Health]]):
|
||||
|
||||
|
||||
class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
|
||||
"""Movies update coordinator."""
|
||||
"""Movies count update coordinator."""
|
||||
|
||||
async def _fetch_data(self) -> int:
|
||||
"""Fetch the movies data."""
|
||||
"""Fetch the total count of movies in Radarr."""
|
||||
return len(cast(list[RadarrMovie], await self.api_client.async_get_movies()))
|
||||
|
||||
|
||||
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator):
|
||||
"""Queue update coordinator."""
|
||||
class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]):
|
||||
"""Queue count update coordinator."""
|
||||
|
||||
async def _fetch_data(self) -> int:
|
||||
"""Fetch the movies in queue."""
|
||||
return (
|
||||
await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS)
|
||||
).totalRecords
|
||||
"""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
|
||||
|
||||
|
||||
class CalendarUpdateCoordinator(RadarrDataUpdateCoordinator[None]):
|
||||
|
||||
122
homeassistant/components/radarr/helpers.py
Normal file
122
homeassistant/components/radarr/helpers.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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,5 +8,13 @@
|
||||
"default": "mdi:download"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_movies": {
|
||||
"service": "mdi:filmstrip"
|
||||
},
|
||||
"get_queue": {
|
||||
"service": "mdi:download"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
143
homeassistant/components/radarr/services.py
Normal file
143
homeassistant/components/radarr/services.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Define services for the Radarr integration."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, 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 (
|
||||
ATTR_ENTRY_ID,
|
||||
ATTR_MOVIES,
|
||||
DOMAIN,
|
||||
SERVICE_GET_MOVIES,
|
||||
SERVICE_GET_QUEUE,
|
||||
)
|
||||
from .coordinator import RadarrConfigEntry
|
||||
from .helpers import format_movies, format_queue
|
||||
|
||||
# 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,
|
||||
)
|
||||
23
homeassistant/components/radarr/services.yaml
Normal file
23
homeassistant/components/radarr/services.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
get_movies:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: radarr
|
||||
|
||||
get_queue:
|
||||
fields:
|
||||
entry_id:
|
||||
required: true
|
||||
selector:
|
||||
config_entry:
|
||||
integration: radarr
|
||||
max_items:
|
||||
required: false
|
||||
default: 0
|
||||
selector:
|
||||
number:
|
||||
min: 0
|
||||
max: 500
|
||||
mode: box
|
||||
@@ -46,6 +46,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"integration_not_found": {
|
||||
"message": "Config entry for integration \"{target}\" not found."
|
||||
},
|
||||
"not_loaded": {
|
||||
"message": "Config entry \"{target}\" is not loaded."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
@@ -54,5 +62,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"get_movies": {
|
||||
"description": "Get all movies in Radarr with their details and status.",
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"description": "ID of the config entry to use.",
|
||||
"name": "Radarr entry"
|
||||
}
|
||||
},
|
||||
"name": "Get movies"
|
||||
},
|
||||
"get_queue": {
|
||||
"description": "Get all movies currently in the download queue with their progress and details.",
|
||||
"fields": {
|
||||
"entry_id": {
|
||||
"description": "[%key:component::radarr::services::get_movies::fields::entry_id::description%]",
|
||||
"name": "[%key:component::radarr::services::get_movies::fields::entry_id::name%]"
|
||||
},
|
||||
"max_items": {
|
||||
"description": "Maximum number of queue items to return (0 = no limit).",
|
||||
"name": "Max items"
|
||||
}
|
||||
},
|
||||
"name": "Get queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,20 @@ BUTTON_TYPES: tuple[RenaultButtonEntityDescription, ...] = (
|
||||
),
|
||||
translation_key="stop_charge",
|
||||
),
|
||||
RenaultButtonEntityDescription(
|
||||
async_press=lambda x: x.vehicle.sound_horn(),
|
||||
key="sound_horn",
|
||||
is_supported=lambda vehicle: (
|
||||
vehicle.details.supports_endpoint("actions/horn-start")
|
||||
),
|
||||
translation_key="sound_horn",
|
||||
),
|
||||
RenaultButtonEntityDescription(
|
||||
async_press=lambda x: x.vehicle.flash_lights(),
|
||||
key="flash_lights",
|
||||
is_supported=lambda vehicle: (
|
||||
vehicle.details.supports_endpoint("actions/lights-start")
|
||||
),
|
||||
translation_key="flash_lights",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"flash_lights": {
|
||||
"default": "mdi:lightbulb-on"
|
||||
},
|
||||
"sound_horn": {
|
||||
"default": "mdi:bugle"
|
||||
},
|
||||
"start_air_conditioner": {
|
||||
"default": "mdi:air-conditioner"
|
||||
},
|
||||
|
||||
@@ -221,6 +221,16 @@ class RenaultVehicleProxy:
|
||||
"""Set vehicle charge schedules."""
|
||||
return await self._vehicle.set_charge_schedules(schedules)
|
||||
|
||||
@with_error_wrapping
|
||||
async def sound_horn(self) -> None:
|
||||
"""Start vehicle horn."""
|
||||
await self._vehicle.start_horn()
|
||||
|
||||
@with_error_wrapping
|
||||
async def flash_lights(self) -> None:
|
||||
"""Start vehicle lights."""
|
||||
await self._vehicle.start_lights()
|
||||
|
||||
|
||||
COORDINATORS: tuple[RenaultCoordinatorDescription, ...] = (
|
||||
RenaultCoordinatorDescription(
|
||||
|
||||
@@ -73,6 +73,12 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"flash_lights": {
|
||||
"name": "Flash lights"
|
||||
},
|
||||
"sound_horn": {
|
||||
"name": "Sound horn"
|
||||
},
|
||||
"start_air_conditioner": {
|
||||
"name": "Start air conditioner"
|
||||
},
|
||||
|
||||
@@ -203,7 +203,7 @@ class ReolinkButtonEntity(ReolinkChannelCoordinatorEntity, ButtonEntity):
|
||||
await self.entity_description.method(self._host.api, self._channel)
|
||||
|
||||
@raise_translated_error
|
||||
async def async_ptz_move(self, *, speed: int) -> None:
|
||||
async def async_ptz_move(self, speed: int) -> None:
|
||||
"""PTZ move with speed."""
|
||||
await self._host.api.set_ptz_command(
|
||||
self._channel, command=self.entity_description.ptz_cmd, speed=speed
|
||||
|
||||
@@ -4,9 +4,14 @@ from __future__ import annotations
|
||||
|
||||
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 .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.MEDIA_PLAYER,
|
||||
@@ -16,6 +21,12 @@ 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: RokuConfigEntry) -> bool:
|
||||
"""Set up Roku from a config entry."""
|
||||
coordinator = RokuDataUpdateCoordinator(hass, entry)
|
||||
|
||||
@@ -6,15 +6,12 @@ DOMAIN = "roku"
|
||||
ATTR_ARTIST_NAME = "artist_name"
|
||||
ATTR_CONTENT_ID = "content_id"
|
||||
ATTR_FORMAT = "format"
|
||||
ATTR_KEYWORD = "keyword"
|
||||
ATTR_MEDIA_TYPE = "media_type"
|
||||
ATTR_THUMBNAIL = "thumbnail"
|
||||
|
||||
# Default Values
|
||||
DEFAULT_PORT = 8060
|
||||
|
||||
# Services
|
||||
SERVICE_SEARCH = "search"
|
||||
|
||||
# Config
|
||||
CONF_PLAY_MEDIA_APP_ID = "play_media_app_id"
|
||||
|
||||
@@ -8,7 +8,6 @@ import mimetypes
|
||||
from typing import Any
|
||||
|
||||
from rokuecp.helpers import guess_stream_format
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
from homeassistant.components import media_source
|
||||
@@ -25,19 +24,15 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
|
||||
from homeassistant.const import ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .browse_media import async_browse_media
|
||||
from .const import (
|
||||
ATTR_ARTIST_NAME,
|
||||
ATTR_CONTENT_ID,
|
||||
ATTR_FORMAT,
|
||||
ATTR_KEYWORD,
|
||||
ATTR_MEDIA_TYPE,
|
||||
ATTR_THUMBNAIL,
|
||||
SERVICE_SEARCH,
|
||||
)
|
||||
from .coordinator import RokuConfigEntry, RokuDataUpdateCoordinator
|
||||
from .entity import RokuEntity
|
||||
@@ -76,8 +71,6 @@ ATTRS_TO_PLAY_ON_ROKU_AUDIO_PARAMS = {
|
||||
ATTR_THUMBNAIL: "albumArtUrl",
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str}
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
@@ -96,14 +89,6 @@ async def async_setup_entry(
|
||||
True,
|
||||
)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SEARCH,
|
||||
SEARCH_SCHEMA,
|
||||
"search",
|
||||
)
|
||||
|
||||
|
||||
class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
||||
"""Representation of a Roku media player on the network."""
|
||||
|
||||
31
homeassistant/components/roku/services.py
Normal file
31
homeassistant/components/roku/services.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Support for the Roku media player."""
|
||||
|
||||
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 service
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_KEYWORD = "keyword"
|
||||
|
||||
SERVICE_SEARCH = "search"
|
||||
|
||||
SEARCH_SCHEMA: VolDictType = {vol.Required(ATTR_KEYWORD): str}
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SEARCH,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=SEARCH_SCHEMA,
|
||||
func="search",
|
||||
)
|
||||
@@ -3,14 +3,23 @@
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_ROON_NAME, DOMAIN
|
||||
from .server import RoonServer
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [Platform.EVENT, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
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 a roonserver from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from roonapi import split_media_path
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
BrowseMedia,
|
||||
@@ -19,7 +18,6 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import DEVICE_DEFAULT_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
@@ -34,10 +32,6 @@ from .media_browser import browse_media
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SERVICE_TRANSFER = "transfer"
|
||||
|
||||
ATTR_TRANSFER = "transfer_id"
|
||||
|
||||
REPEAT_MODE_MAPPING_TO_HA = {
|
||||
"loop": RepeatMode.ALL,
|
||||
"disabled": RepeatMode.OFF,
|
||||
@@ -58,14 +52,6 @@ async def async_setup_entry(
|
||||
roon_server = hass.data[DOMAIN][config_entry.entry_id]
|
||||
media_players = set()
|
||||
|
||||
# Register entity services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_TRANSFER,
|
||||
{vol.Required(ATTR_TRANSFER): cv.entity_id},
|
||||
"async_transfer",
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_update_media_player(player_data):
|
||||
"""Add or update Roon MediaPlayer."""
|
||||
|
||||
28
homeassistant/components/roon/services.py
Normal file
28
homeassistant/components/roon/services.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""MediaPlayer platform for Roon integration."""
|
||||
|
||||
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_TRANSFER = "transfer"
|
||||
|
||||
ATTR_TRANSFER = "transfer_id"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_TRANSFER,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_TRANSFER): cv.entity_id},
|
||||
func="async_transfer",
|
||||
)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
@@ -30,7 +30,7 @@ def trace_script(
|
||||
blueprint_inputs: dict[str, Any] | None,
|
||||
context: Context,
|
||||
trace_config: dict[str, Any],
|
||||
) -> Iterator[ScriptTrace]:
|
||||
) -> Generator[ScriptTrace]:
|
||||
"""Trace execution of a script."""
|
||||
trace = ScriptTrace(item_id, config, blueprint_inputs, context)
|
||||
async_store_trace(hass, trace, trace_config[CONF_STORED_TRACES])
|
||||
|
||||
@@ -16,7 +16,9 @@ from homeassistant import exceptions
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
API_TIMEOUT,
|
||||
@@ -27,6 +29,9 @@ from .const import (
|
||||
SHARKIQ_REGION_EUROPE,
|
||||
)
|
||||
from .coordinator import SharkIqUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
@@ -49,6 +54,12 @@ async def async_connect_or_timeout(ayla_api: AylaApi) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
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, config_entry: ConfigEntry) -> bool:
|
||||
"""Initialize the sharkiq platform via config entry."""
|
||||
if CONF_REGION not in config_entry.data:
|
||||
|
||||
@@ -12,7 +12,8 @@ PLATFORMS = [Platform.VACUUM]
|
||||
DOMAIN = "sharkiq"
|
||||
SHARK = "Shark"
|
||||
UPDATE_INTERVAL = timedelta(seconds=30)
|
||||
SERVICE_CLEAN_ROOM = "clean_room"
|
||||
|
||||
ATTR_ROOMS = "rooms"
|
||||
|
||||
SHARKIQ_REGION_EUROPE = "europe"
|
||||
SHARKIQ_REGION_ELSEWHERE = "elsewhere"
|
||||
|
||||
32
homeassistant/components/sharkiq/services.py
Normal file
32
homeassistant/components/sharkiq/services.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Shark IQ services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, service
|
||||
|
||||
from .const import ATTR_ROOMS, DOMAIN
|
||||
|
||||
SERVICE_CLEAN_ROOM = "clean_room"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
|
||||
# Vacuum Services
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CLEAN_ROOM,
|
||||
entity_domain=VACUUM_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_ROOMS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
func="async_clean_room",
|
||||
)
|
||||
@@ -6,7 +6,6 @@ from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sharkiq import OperatingModes, PowerModes, Properties, SharkIqVacuum
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
StateVacuumEntity,
|
||||
@@ -16,12 +15,11 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
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 homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK
|
||||
from .const import ATTR_ROOMS, DOMAIN, LOGGER, SHARK
|
||||
from .coordinator import SharkIqUpdateCoordinator
|
||||
|
||||
OPERATING_STATE_MAP = {
|
||||
@@ -44,7 +42,6 @@ ATTR_ERROR_CODE = "last_error_code"
|
||||
ATTR_ERROR_MSG = "last_error_message"
|
||||
ATTR_LOW_LIGHT = "low_light"
|
||||
ATTR_RECHARGE_RESUME = "recharge_and_resume"
|
||||
ATTR_ROOMS = "rooms"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -63,17 +60,6 @@ async def async_setup_entry(
|
||||
)
|
||||
async_add_entities([SharkVacuumEntity(d, coordinator) for d in devices])
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CLEAN_ROOM,
|
||||
{
|
||||
vol.Required(ATTR_ROOMS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
"async_clean_room",
|
||||
)
|
||||
|
||||
|
||||
class SharkVacuumEntity(CoordinatorEntity[SharkIqUpdateCoordinator], StateVacuumEntity):
|
||||
"""Shark IQ vacuum entity."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
@@ -844,7 +844,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@asynccontextmanager
|
||||
async def _async_provision_context(
|
||||
self, mac: str
|
||||
) -> AsyncIterator[ProvisioningState]:
|
||||
) -> AsyncGenerator[ProvisioningState]:
|
||||
"""Context manager to register and cleanup provisioning state."""
|
||||
state = ProvisioningState()
|
||||
provisioning_registry = async_get_provisioning_registry(self.hass)
|
||||
|
||||
@@ -1283,6 +1283,7 @@ RPC_SENSORS: Final = {
|
||||
key="voltmeter",
|
||||
sub_key="xvoltage",
|
||||
translation_key="voltmeter_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda _, status, key: (status[key].get("xvoltage") is None),
|
||||
unit=lambda config: config["xvoltage"]["unit"] or None,
|
||||
),
|
||||
@@ -1300,6 +1301,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xpercent",
|
||||
translation_key="analog_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "analog"
|
||||
or config[key]["enable"] is False
|
||||
@@ -1344,6 +1346,7 @@ RPC_SENSORS: Final = {
|
||||
key="input",
|
||||
sub_key="xfreq",
|
||||
translation_key="pulse_counter_frequency_value",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
removal_condition=lambda config, status, key: (
|
||||
config[key]["type"] != "count"
|
||||
or config[key]["enable"] is False
|
||||
|
||||
@@ -4,9 +4,20 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
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
|
||||
from .coordinator import SnapcastUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
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: ConfigEntry) -> bool:
|
||||
|
||||
@@ -7,11 +7,5 @@ PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
|
||||
CLIENT_PREFIX = "snapcast_client_"
|
||||
CLIENT_SUFFIX = "Snapcast Client"
|
||||
|
||||
SERVICE_SNAPSHOT = "snapshot"
|
||||
SERVICE_RESTORE = "restore"
|
||||
SERVICE_SET_LATENCY = "set_latency"
|
||||
|
||||
ATTR_LATENCY = "latency"
|
||||
|
||||
DOMAIN = "snapcast"
|
||||
DEFAULT_TITLE = "Snapcast"
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
|
||||
from snapcast.control.client import Snapclient
|
||||
from snapcast.control.group import Snapgroup
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
@@ -21,22 +20,10 @@ from homeassistant.components.media_player import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
ATTR_LATENCY,
|
||||
CLIENT_PREFIX,
|
||||
CLIENT_SUFFIX,
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE,
|
||||
SERVICE_SET_LATENCY,
|
||||
SERVICE_SNAPSHOT,
|
||||
)
|
||||
from .const import CLIENT_PREFIX, CLIENT_SUFFIX, DOMAIN
|
||||
from .coordinator import SnapcastUpdateCoordinator
|
||||
from .entity import SnapcastCoordinatorEntity
|
||||
|
||||
@@ -49,19 +36,6 @@ STREAM_STATUS = {
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_services() -> None:
|
||||
"""Register snapcast services."""
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
platform.async_register_entity_service(SERVICE_SNAPSHOT, None, "async_snapshot")
|
||||
platform.async_register_entity_service(SERVICE_RESTORE, None, "async_restore")
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_SET_LATENCY,
|
||||
{vol.Required(ATTR_LATENCY): cv.positive_int},
|
||||
"async_set_latency",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
@@ -72,8 +46,6 @@ async def async_setup_entry(
|
||||
# Fetch coordinator from global data
|
||||
coordinator: SnapcastUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
register_services()
|
||||
|
||||
_known_client_ids: set[str] = set()
|
||||
|
||||
@callback
|
||||
|
||||
46
homeassistant/components/snapcast/services.py
Normal file
46
homeassistant/components/snapcast/services.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Support for interacting with Snapcast clients."""
|
||||
|
||||
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_SNAPSHOT = "snapshot"
|
||||
SERVICE_RESTORE = "restore"
|
||||
SERVICE_SET_LATENCY = "set_latency"
|
||||
|
||||
ATTR_LATENCY = "latency"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SNAPSHOT,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_snapshot",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_RESTORE,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema=None,
|
||||
func="async_restore",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_SET_LATENCY,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={vol.Required(ATTR_LATENCY): cv.positive_int},
|
||||
func="async_set_latency",
|
||||
)
|
||||
@@ -9,6 +9,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_ENDPOINT, DOMAIN
|
||||
from .services import async_setup_services
|
||||
|
||||
SONGPAL_CONFIG_SCHEMA = vol.Schema(
|
||||
{vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_ENDPOINT): cv.string}
|
||||
@@ -24,6 +25,8 @@ PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up songpal environment."""
|
||||
async_setup_services(hass)
|
||||
|
||||
if (conf := config.get(DOMAIN)) is None:
|
||||
return True
|
||||
for config_entry in conf:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Constants for the Songpal component."""
|
||||
|
||||
DOMAIN = "songpal"
|
||||
SET_SOUND_SETTING = "set_sound_setting"
|
||||
|
||||
CONF_ENDPOINT = "endpoint"
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from songpal import (
|
||||
VolumeChange,
|
||||
)
|
||||
from songpal.containers import Setting
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
@@ -28,11 +27,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import (
|
||||
AddConfigEntryEntitiesCallback,
|
||||
@@ -40,7 +35,7 @@ from homeassistant.helpers.entity_platform import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING
|
||||
from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -86,13 +81,6 @@ async def async_setup_entry(
|
||||
songpal_entity = SongpalEntity(name, device)
|
||||
async_add_entities([songpal_entity], True)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SET_SOUND_SETTING,
|
||||
{vol.Required(PARAM_NAME): cv.string, vol.Required(PARAM_VALUE): cv.string},
|
||||
"async_set_sound_setting",
|
||||
)
|
||||
|
||||
|
||||
class SongpalEntity(MediaPlayerEntity):
|
||||
"""Class representing a Songpal device."""
|
||||
|
||||
32
homeassistant/components/songpal/services.py
Normal file
32
homeassistant/components/songpal/services.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Support for Songpal-enabled (Sony) media 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
|
||||
|
||||
PARAM_NAME = "name"
|
||||
PARAM_VALUE = "value"
|
||||
|
||||
SET_SOUND_SETTING = "set_sound_setting"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SET_SOUND_SETTING,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(PARAM_NAME): cv.string,
|
||||
vol.Required(PARAM_VALUE): cv.string,
|
||||
},
|
||||
func="async_set_sound_setting",
|
||||
)
|
||||
@@ -23,7 +23,7 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import (
|
||||
CONNECTION_NETWORK_MAC,
|
||||
@@ -32,6 +32,7 @@ from homeassistant.helpers.device_registry import (
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -53,9 +54,11 @@ from .coordinator import (
|
||||
LMSStatusDataUpdateCoordinator,
|
||||
SqueezeBoxPlayerUpdateCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
@@ -80,6 +83,12 @@ class SqueezeboxData:
|
||||
type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData]
|
||||
|
||||
|
||||
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: SqueezeboxConfigEntry) -> bool:
|
||||
"""Set up an LMS Server from a config entry."""
|
||||
config = entry.data
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from lru import LRU
|
||||
from pysqueezebox import Server, async_discover
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -29,14 +28,12 @@ from homeassistant.components.media_player import (
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY
|
||||
from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
@@ -75,16 +72,12 @@ from .util import safe_library_call
|
||||
if TYPE_CHECKING:
|
||||
from . import SqueezeboxConfigEntry
|
||||
|
||||
SERVICE_CALL_METHOD = "call_method"
|
||||
SERVICE_CALL_QUERY = "call_query"
|
||||
|
||||
ATTR_QUERY_RESULT = "query_result"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
ATTR_PARAMETERS = "parameters"
|
||||
ATTR_OTHER_PLAYER = "other_player"
|
||||
|
||||
ATTR_TO_PROPERTY = [
|
||||
@@ -181,29 +174,6 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
# Register entity services
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALL_METHOD,
|
||||
{
|
||||
vol.Required(ATTR_COMMAND): cv.string,
|
||||
vol.Optional(ATTR_PARAMETERS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
"async_call_method",
|
||||
)
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_CALL_QUERY,
|
||||
{
|
||||
vol.Required(ATTR_COMMAND): cv.string,
|
||||
vol.Optional(ATTR_PARAMETERS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
"async_call_query",
|
||||
)
|
||||
|
||||
# Start server discovery task if not already running
|
||||
entry.async_on_unload(async_at_start(hass, start_server_discovery))
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration only has entity_actions, which are setup in the entity async_setup_entry.
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
|
||||
48
homeassistant/components/squeezebox/services.py
Normal file
48
homeassistant/components/squeezebox/services.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Support for interfacing to the SqueezeBox API."""
|
||||
|
||||
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 DOMAIN
|
||||
|
||||
SERVICE_CALL_METHOD = "call_method"
|
||||
SERVICE_CALL_QUERY = "call_query"
|
||||
|
||||
ATTR_PARAMETERS = "parameters"
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up services."""
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CALL_METHOD,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_COMMAND): cv.string,
|
||||
vol.Optional(ATTR_PARAMETERS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
func="async_call_method",
|
||||
)
|
||||
service.async_register_platform_entity_service(
|
||||
hass,
|
||||
DOMAIN,
|
||||
SERVICE_CALL_QUERY,
|
||||
entity_domain=MEDIA_PLAYER_DOMAIN,
|
||||
schema={
|
||||
vol.Required(ATTR_COMMAND): cv.string,
|
||||
vol.Optional(ATTR_PARAMETERS): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
},
|
||||
func="async_call_query",
|
||||
)
|
||||
@@ -284,7 +284,9 @@ class SystemMonitorCoordinator(TimestampDataUpdateCoordinator[SensorData]):
|
||||
try:
|
||||
battery = self._psutil.sensors_battery()
|
||||
_LOGGER.debug("battery: %s", battery)
|
||||
except (AttributeError, FileNotFoundError):
|
||||
except (FileNotFoundError, PermissionError) as err:
|
||||
_LOGGER.debug("OS error when accessing battery sensors: %s", err)
|
||||
except AttributeError:
|
||||
_LOGGER.debug("OS does not provide battery sensors")
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,8 +32,17 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
issue_registry as ir,
|
||||
)
|
||||
from homeassistant.helpers.target import (
|
||||
TargetSelection,
|
||||
async_extract_referenced_entity_ids,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, VolSchemaType
|
||||
from homeassistant.util.json import JsonValueType
|
||||
|
||||
from . import broadcast, polling, webhooks
|
||||
from .bot import TelegramBotConfigEntry, TelegramNotificationService, initialize_bot
|
||||
@@ -130,8 +139,10 @@ ATTR_PARSER_SCHEMA = vol.All(
|
||||
|
||||
BASE_SERVICE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
|
||||
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
|
||||
@@ -160,8 +171,10 @@ SERVICE_SCHEMA_SEND_CHAT_ACTION = vol.All(
|
||||
cv.deprecated(ATTR_TIMEOUT),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Required(ATTR_CHAT_ACTION): vol.In(
|
||||
(
|
||||
CHAT_ACTION_TYPING,
|
||||
@@ -196,7 +209,8 @@ SERVICE_SCHEMA_BASE_SEND_FILE = BASE_SERVICE_SCHEMA.extend(
|
||||
)
|
||||
|
||||
SERVICE_SCHEMA_SEND_FILE = vol.All(
|
||||
cv.deprecated(ATTR_TIMEOUT), SERVICE_SCHEMA_BASE_SEND_FILE
|
||||
cv.deprecated(ATTR_TIMEOUT),
|
||||
SERVICE_SCHEMA_BASE_SEND_FILE,
|
||||
)
|
||||
|
||||
|
||||
@@ -220,7 +234,9 @@ SERVICE_SCHEMA_SEND_POLL = vol.All(
|
||||
cv.deprecated(ATTR_TIMEOUT),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_CHAT_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
|
||||
vol.Required(ATTR_QUESTION): cv.string,
|
||||
vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]),
|
||||
@@ -238,13 +254,14 @@ SERVICE_SCHEMA_EDIT_MESSAGE = vol.All(
|
||||
cv.deprecated(ATTR_TIMEOUT),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Optional(ATTR_TITLE): cv.string,
|
||||
vol.Required(ATTR_MESSAGE): cv.string,
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
|
||||
vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
|
||||
@@ -256,11 +273,12 @@ SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All(
|
||||
cv.deprecated(ATTR_TIMEOUT),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CAPTION): cv.string,
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Required(ATTR_MEDIA_TYPE): vol.In(
|
||||
@@ -285,12 +303,13 @@ SERVICE_SCHEMA_EDIT_MESSAGE_MEDIA = vol.All(
|
||||
|
||||
SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_PARSER): ATTR_PARSER_SCHEMA,
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Required(ATTR_CAPTION): cv.string,
|
||||
vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
|
||||
}
|
||||
@@ -298,11 +317,12 @@ SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema(
|
||||
|
||||
SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list,
|
||||
}
|
||||
)
|
||||
@@ -318,8 +338,9 @@ SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema(
|
||||
|
||||
SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
@@ -328,8 +349,9 @@ SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema(
|
||||
|
||||
SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema(
|
||||
{
|
||||
vol.Optional(ATTR_ENTITY_ID): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string,
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -339,7 +361,7 @@ SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema(
|
||||
vol.Required(ATTR_MESSAGEID): vol.Any(
|
||||
cv.positive_int, vol.All(cv.string, "last")
|
||||
),
|
||||
vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Optional(ATTR_CHAT_ID): vol.Coerce(int),
|
||||
vol.Required(ATTR_REACTION): cv.string,
|
||||
vol.Optional(ATTR_IS_BIG, default=False): cv.boolean,
|
||||
}
|
||||
@@ -389,118 +411,6 @@ PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY]
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Telegram bot component."""
|
||||
|
||||
async def async_send_telegram_message(service: ServiceCall) -> ServiceResponse:
|
||||
"""Handle sending Telegram Bot message service calls."""
|
||||
|
||||
msgtype = service.service
|
||||
kwargs = dict(service.data)
|
||||
_LOGGER.debug("New telegram message %s: %s", msgtype, kwargs)
|
||||
|
||||
if ATTR_TIMEOUT in service.data:
|
||||
_deprecate_timeout(hass, service)
|
||||
|
||||
config_entry_id: str | None = service.data.get(CONF_CONFIG_ENTRY_ID)
|
||||
config_entry: TelegramBotConfigEntry | None = None
|
||||
if config_entry_id:
|
||||
config_entry = hass.config_entries.async_get_known_entry(config_entry_id)
|
||||
|
||||
else:
|
||||
config_entries: list[TelegramBotConfigEntry] = (
|
||||
service.hass.config_entries.async_entries(DOMAIN)
|
||||
)
|
||||
|
||||
if len(config_entries) == 1:
|
||||
config_entry = config_entries[0]
|
||||
|
||||
if len(config_entries) > 1:
|
||||
raise ServiceValidationError(
|
||||
"Multiple config entries found. Please specify the Telegram bot to use.",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="multiple_config_entry",
|
||||
)
|
||||
|
||||
if not config_entry or not hasattr(config_entry, "runtime_data"):
|
||||
raise ServiceValidationError(
|
||||
"No config entries found or setup failed. Please set up the Telegram Bot first.",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_config_entry",
|
||||
)
|
||||
|
||||
notify_service = config_entry.runtime_data
|
||||
|
||||
messages = None
|
||||
if msgtype == SERVICE_SEND_MESSAGE:
|
||||
messages = await notify_service.send_message(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype == SERVICE_SEND_CHAT_ACTION:
|
||||
messages = await notify_service.send_chat_action(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype in [
|
||||
SERVICE_SEND_PHOTO,
|
||||
SERVICE_SEND_ANIMATION,
|
||||
SERVICE_SEND_VIDEO,
|
||||
SERVICE_SEND_VOICE,
|
||||
SERVICE_SEND_DOCUMENT,
|
||||
]:
|
||||
messages = await notify_service.send_file(
|
||||
msgtype, context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype == SERVICE_SEND_STICKER:
|
||||
messages = await notify_service.send_sticker(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype == SERVICE_SEND_LOCATION:
|
||||
messages = await notify_service.send_location(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype == SERVICE_SEND_POLL:
|
||||
messages = await notify_service.send_poll(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY:
|
||||
await notify_service.answer_callback_query(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif msgtype == SERVICE_DELETE_MESSAGE:
|
||||
await notify_service.delete_message(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_LEAVE_CHAT:
|
||||
await notify_service.leave_chat(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_SET_MESSAGE_REACTION:
|
||||
await notify_service.set_message_reaction(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_EDIT_MESSAGE_MEDIA:
|
||||
await notify_service.edit_message_media(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_DOWNLOAD_FILE:
|
||||
return await notify_service.download_file(context=service.context, **kwargs)
|
||||
else:
|
||||
await notify_service.edit_message(
|
||||
msgtype, context=service.context, **kwargs
|
||||
)
|
||||
|
||||
if service.return_response and messages is not None:
|
||||
target: list[int] | None = service.data.get(ATTR_TARGET)
|
||||
if not target:
|
||||
target = notify_service.get_target_chat_ids(None)
|
||||
|
||||
failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages]
|
||||
if failed_chat_ids:
|
||||
raise HomeAssistantError(
|
||||
f"Failed targets: {failed_chat_ids}",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_chat_ids",
|
||||
translation_placeholders={
|
||||
"chat_ids": ", ".join([str(i) for i in failed_chat_ids]),
|
||||
"bot_name": config_entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"chats": [
|
||||
{"chat_id": cid, "message_id": mid} for cid, mid in messages.items()
|
||||
]
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# Register notification services
|
||||
for service_notif, schema in SERVICE_MAP.items():
|
||||
supports_response = SupportsResponse.NONE
|
||||
@@ -523,7 +433,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
service_notif,
|
||||
async_send_telegram_message,
|
||||
_async_send_telegram_message,
|
||||
schema=schema,
|
||||
supports_response=supports_response,
|
||||
description_placeholders={
|
||||
@@ -534,7 +444,131 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _deprecate_timeout(hass: HomeAssistant, service: ServiceCall) -> None:
|
||||
async def _async_send_telegram_message(service: ServiceCall) -> ServiceResponse:
|
||||
"""Handle sending Telegram Bot message service calls."""
|
||||
|
||||
_deprecate_timeout(service)
|
||||
|
||||
# this is the list of targets to send the message to
|
||||
targets = _build_targets(service)
|
||||
|
||||
service_responses: JsonValueType = []
|
||||
errors: list[tuple[HomeAssistantError, str]] = []
|
||||
|
||||
# invoke the service for each target
|
||||
for target_config_entry, target_chat_id, target_notify_entity_id in targets:
|
||||
try:
|
||||
service_response = await _call_service(
|
||||
service, target_config_entry.runtime_data, target_chat_id
|
||||
)
|
||||
|
||||
if service.service == SERVICE_DOWNLOAD_FILE:
|
||||
return service_response
|
||||
|
||||
if service_response is not None:
|
||||
formatted_responses: list[JsonValueType] = []
|
||||
for chat_id, message_id in service_response.items():
|
||||
formatted_response = {
|
||||
ATTR_CHAT_ID: int(chat_id),
|
||||
ATTR_MESSAGEID: message_id,
|
||||
}
|
||||
|
||||
if target_notify_entity_id:
|
||||
formatted_response[ATTR_ENTITY_ID] = target_notify_entity_id
|
||||
|
||||
formatted_responses.append(formatted_response)
|
||||
|
||||
assert isinstance(service_responses, list)
|
||||
service_responses.extend(formatted_responses)
|
||||
except HomeAssistantError as ex:
|
||||
target = (
|
||||
target_notify_entity_id
|
||||
if target_notify_entity_id
|
||||
else str(target_chat_id)
|
||||
)
|
||||
errors.append((ex, target))
|
||||
|
||||
if len(errors) == 1:
|
||||
raise errors[0][0]
|
||||
|
||||
if len(errors) > 1:
|
||||
error_messages: list[str] = []
|
||||
for error, target in errors:
|
||||
target_type = ATTR_CHAT_ID if target.isdigit() else ATTR_ENTITY_ID
|
||||
error_messages.append(f"`{target_type}` {target}: {error}")
|
||||
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="multiple_errors",
|
||||
translation_placeholders={"errors": "\n".join(error_messages)},
|
||||
)
|
||||
|
||||
if service.return_response:
|
||||
return {"chats": service_responses}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _call_service(
|
||||
service: ServiceCall, notify_service: TelegramNotificationService, chat_id: int
|
||||
) -> dict[str, JsonValueType] | None:
|
||||
"""Calls a Telegram bot service using the specified bot and chat_id."""
|
||||
|
||||
service_name = service.service
|
||||
|
||||
kwargs = dict(service.data)
|
||||
kwargs[ATTR_TARGET] = chat_id
|
||||
|
||||
messages: dict[str, JsonValueType] | None = None
|
||||
if service_name == SERVICE_SEND_MESSAGE:
|
||||
messages = await notify_service.send_message(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_SEND_CHAT_ACTION:
|
||||
messages = await notify_service.send_chat_action(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
elif service_name in [
|
||||
SERVICE_SEND_PHOTO,
|
||||
SERVICE_SEND_ANIMATION,
|
||||
SERVICE_SEND_VIDEO,
|
||||
SERVICE_SEND_VOICE,
|
||||
SERVICE_SEND_DOCUMENT,
|
||||
]:
|
||||
messages = await notify_service.send_file(
|
||||
service_name, context=service.context, **kwargs
|
||||
)
|
||||
elif service_name == SERVICE_SEND_STICKER:
|
||||
messages = await notify_service.send_sticker(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_SEND_LOCATION:
|
||||
messages = await notify_service.send_location(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_SEND_POLL:
|
||||
messages = await notify_service.send_poll(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_ANSWER_CALLBACK_QUERY:
|
||||
await notify_service.answer_callback_query(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_DELETE_MESSAGE:
|
||||
await notify_service.delete_message(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_LEAVE_CHAT:
|
||||
await notify_service.leave_chat(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_SET_MESSAGE_REACTION:
|
||||
await notify_service.set_message_reaction(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_EDIT_MESSAGE_MEDIA:
|
||||
await notify_service.edit_message_media(context=service.context, **kwargs)
|
||||
elif service_name == SERVICE_DOWNLOAD_FILE:
|
||||
return await notify_service.download_file(context=service.context, **kwargs)
|
||||
else:
|
||||
await notify_service.edit_message(
|
||||
service_name, context=service.context, **kwargs
|
||||
)
|
||||
|
||||
if service.return_response and messages is not None:
|
||||
return messages
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _deprecate_timeout(service: ServiceCall) -> None:
|
||||
if ATTR_TIMEOUT not in service.data:
|
||||
return
|
||||
|
||||
# default: service was called using frontend such as developer tools or automation editor
|
||||
service_call_origin = "call_service"
|
||||
|
||||
@@ -547,7 +581,7 @@ def _deprecate_timeout(hass: HomeAssistant, service: ServiceCall) -> None:
|
||||
service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}"
|
||||
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
service.hass,
|
||||
DOMAIN,
|
||||
"deprecated_timeout_parameter",
|
||||
breaks_in_ha_version="2026.7.0",
|
||||
@@ -598,6 +632,187 @@ async def async_migrate_entry(
|
||||
return True
|
||||
|
||||
|
||||
def _build_targets(
|
||||
service: ServiceCall,
|
||||
) -> list[tuple[TelegramBotConfigEntry, int, str]]:
|
||||
"""Builds a list of targets from the service parameters.
|
||||
|
||||
Each target is a tuple of (config_entry, chat_id, notify_entity_id).
|
||||
The config_entry identifies the bot to use for the service call.
|
||||
The chat_id or notify_entity_id identifies the recipient of the message.
|
||||
"""
|
||||
|
||||
migrate_chat_ids = _warn_chat_id_migration(service)
|
||||
|
||||
targets: list[tuple[TelegramBotConfigEntry, int, str]] = []
|
||||
|
||||
# build target list from notify entities using service data: `entity_id`
|
||||
|
||||
referenced = async_extract_referenced_entity_ids(
|
||||
service.hass, TargetSelection(service.data)
|
||||
)
|
||||
notify_entity_ids = referenced.referenced | referenced.indirectly_referenced
|
||||
|
||||
# parse entity IDs
|
||||
entity_registry = er.async_get(service.hass)
|
||||
for notify_entity_id in notify_entity_ids:
|
||||
# get config entry from notify entity
|
||||
entity_entry = entity_registry.async_get(notify_entity_id)
|
||||
if not entity_entry:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_notify_entity",
|
||||
translation_placeholders={ATTR_ENTITY_ID: notify_entity_id},
|
||||
)
|
||||
assert entity_entry.config_entry_id is not None
|
||||
notify_config_entry = service.hass.config_entries.async_get_known_entry(
|
||||
entity_entry.config_entry_id
|
||||
)
|
||||
|
||||
# get chat id from subentry
|
||||
assert entity_entry.config_subentry_id is not None
|
||||
notify_config_subentry = notify_config_entry.subentries[
|
||||
entity_entry.config_subentry_id
|
||||
]
|
||||
notify_chat_id: int = notify_config_subentry.data[ATTR_CHAT_ID]
|
||||
|
||||
targets.append((notify_config_entry, notify_chat_id, notify_entity_id))
|
||||
|
||||
# build target list using service data: `config_entry_id` and `chat_id`
|
||||
|
||||
config_entry: TelegramBotConfigEntry | None = None
|
||||
if CONF_CONFIG_ENTRY_ID in service.data:
|
||||
# parse config entry from service data
|
||||
config_entry_id: str = service.data[CONF_CONFIG_ENTRY_ID]
|
||||
config_entry = service.hass.config_entries.async_get_known_entry(
|
||||
config_entry_id
|
||||
)
|
||||
else:
|
||||
# config entry not provided so we try to determine the default
|
||||
config_entries: list[TelegramBotConfigEntry] = (
|
||||
service.hass.config_entries.async_entries(DOMAIN)
|
||||
)
|
||||
if len(config_entries) == 1:
|
||||
config_entry = config_entries[0]
|
||||
|
||||
# parse chat IDs from service data: `chat_id`
|
||||
if config_entry is not None:
|
||||
chat_ids: set[int] = migrate_chat_ids
|
||||
if ATTR_CHAT_ID in service.data:
|
||||
chat_ids = chat_ids | set(
|
||||
[service.data[ATTR_CHAT_ID]]
|
||||
if isinstance(service.data[ATTR_CHAT_ID], int)
|
||||
else service.data[ATTR_CHAT_ID]
|
||||
)
|
||||
|
||||
if not chat_ids and not targets:
|
||||
# no targets from service data, so we default to the first allowed chat IDs of the config entry
|
||||
subentries = list(config_entry.subentries.values())
|
||||
if not subentries:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_allowed_chat_ids",
|
||||
translation_placeholders={
|
||||
"bot_name": config_entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
default_chat_id: int = subentries[0].data[ATTR_CHAT_ID]
|
||||
_LOGGER.debug(
|
||||
"Defaulting to chat ID %s for bot %s",
|
||||
default_chat_id,
|
||||
config_entry.title,
|
||||
)
|
||||
chat_ids = {default_chat_id}
|
||||
|
||||
invalid_chat_ids: set[int] = set()
|
||||
for chat_id in chat_ids:
|
||||
# map chat_id to notify entity ID
|
||||
|
||||
if not hasattr(config_entry, "runtime_data"):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_config_entry",
|
||||
)
|
||||
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
"notify",
|
||||
DOMAIN,
|
||||
f"{config_entry.runtime_data.bot.id}_{chat_id}",
|
||||
)
|
||||
|
||||
if not entity_id:
|
||||
invalid_chat_ids.add(chat_id)
|
||||
else:
|
||||
targets.append((config_entry, chat_id, entity_id))
|
||||
|
||||
if invalid_chat_ids:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_chat_ids",
|
||||
translation_placeholders={
|
||||
"chat_ids": ", ".join(str(chat_id) for chat_id in invalid_chat_ids),
|
||||
"bot_name": config_entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
# we're done building targets from service data
|
||||
if targets:
|
||||
return targets
|
||||
|
||||
# can't determine default since multiple config entries exist
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_notify_entities",
|
||||
)
|
||||
|
||||
|
||||
def _warn_chat_id_migration(service: ServiceCall) -> set[int]:
|
||||
if not service.data.get(ATTR_TARGET):
|
||||
return set()
|
||||
|
||||
chat_ids: set[int] = set(
|
||||
[service.data[ATTR_TARGET]]
|
||||
if isinstance(service.data[ATTR_TARGET], int)
|
||||
else service.data[ATTR_TARGET]
|
||||
)
|
||||
|
||||
# default: service was called using frontend such as developer tools or automation editor
|
||||
service_call_origin = "call_service"
|
||||
|
||||
origin = service.context.origin_event
|
||||
if origin and ATTR_ENTITY_ID in origin.data:
|
||||
# automation
|
||||
service_call_origin = origin.data[ATTR_ENTITY_ID]
|
||||
elif origin and origin.data.get(ATTR_DOMAIN) == SCRIPT_DOMAIN:
|
||||
# script
|
||||
service_call_origin = f"{origin.data[ATTR_DOMAIN]}.{origin.data[ATTR_SERVICE]}"
|
||||
|
||||
ir.async_create_issue(
|
||||
service.hass,
|
||||
DOMAIN,
|
||||
f"migrate_chat_ids_in_target_{service_call_origin}_{service.service}",
|
||||
breaks_in_ha_version="2026.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="migrate_chat_ids_in_target",
|
||||
translation_placeholders={
|
||||
"integration_title": "Telegram Bot",
|
||||
"action": f"{DOMAIN}.{service.service}",
|
||||
"chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids),
|
||||
"action_origin": service_call_origin,
|
||||
"telegram_bot_entities_url": "/config/entities?domain=telegram_bot",
|
||||
"example_old": f"```yaml\naction: {service.service}\ndata:\n target: # to be updated\n - 1234567890\n...\n```",
|
||||
"example_new_entity_id": f"```yaml\naction: {service.service}\ndata:\n entity_id:\n - notify.telegram_bot_1234567890_1234567890 # replace with your notify entity\n...\n```",
|
||||
"example_new_chat_id": f"```yaml\naction: {service.service}\ndata:\n chat_id:\n - 1234567890 # replace with your chat_id\n...\n```",
|
||||
},
|
||||
learn_more_url="https://github.com/home-assistant/core/pull/154868",
|
||||
)
|
||||
|
||||
return chat_ids
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TelegramBotConfigEntry) -> bool:
|
||||
"""Create the Telegram bot from config entry."""
|
||||
bot: Bot = await hass.async_add_executor_job(initialize_bot, hass, entry.data)
|
||||
|
||||
@@ -307,24 +307,6 @@ class TelegramNotificationService:
|
||||
self.hass = hass
|
||||
self._last_message_id: dict[int, int] = {}
|
||||
|
||||
def _get_allowed_chat_ids(self) -> list[int]:
|
||||
allowed_chat_ids: list[int] = [
|
||||
subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values()
|
||||
]
|
||||
|
||||
if not allowed_chat_ids:
|
||||
bot_name: str = self.config.title
|
||||
raise ServiceValidationError(
|
||||
"No allowed chat IDs found for bot",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="missing_allowed_chat_ids",
|
||||
translation_placeholders={
|
||||
"bot_name": bot_name,
|
||||
},
|
||||
)
|
||||
|
||||
return allowed_chat_ids
|
||||
|
||||
def _get_msg_ids(
|
||||
self, msg_data: dict[str, Any], chat_id: int
|
||||
) -> tuple[Any | None, int | None]:
|
||||
@@ -355,7 +337,9 @@ class TelegramNotificationService:
|
||||
:param target: optional list of integers ([12234, -12345])
|
||||
:return list of chat_id targets (integers)
|
||||
"""
|
||||
allowed_chat_ids: list[int] = self._get_allowed_chat_ids()
|
||||
allowed_chat_ids: list[int] = [
|
||||
subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values()
|
||||
]
|
||||
|
||||
if target is None:
|
||||
return [allowed_chat_ids[0]]
|
||||
@@ -483,7 +467,7 @@ class TelegramNotificationService:
|
||||
*args_msg: Any,
|
||||
context: Context | None = None,
|
||||
**kwargs_msg: Any,
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Sends a message to each of the targets.
|
||||
|
||||
If there is only 1 targtet, an error is raised if the send fails.
|
||||
@@ -492,7 +476,7 @@ class TelegramNotificationService:
|
||||
:return: dict with chat_id keys and message_id values for successful sends
|
||||
"""
|
||||
chat_ids = self.get_target_chat_ids(kwargs_msg.pop(ATTR_TARGET, None))
|
||||
msg_ids = {}
|
||||
msg_ids: dict[str, JsonValueType] = {}
|
||||
for chat_id in chat_ids:
|
||||
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
|
||||
|
||||
@@ -513,7 +497,7 @@ class TelegramNotificationService:
|
||||
**kwargs_msg,
|
||||
)
|
||||
if response:
|
||||
msg_ids[chat_id] = response.id
|
||||
msg_ids[str(chat_id)] = response.id
|
||||
|
||||
return msg_ids
|
||||
|
||||
@@ -580,7 +564,7 @@ class TelegramNotificationService:
|
||||
target: Any = None,
|
||||
context: Context | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a message to one or multiple pre-allowed chat IDs."""
|
||||
title = kwargs.get(ATTR_TITLE)
|
||||
text = f"{title}\n{message}" if title else message
|
||||
@@ -780,9 +764,9 @@ class TelegramNotificationService:
|
||||
target: Any = None,
|
||||
context: Context | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a chat action to pre-allowed chat IDs."""
|
||||
result = {}
|
||||
result: dict[str, JsonValueType] = {}
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
_LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id)
|
||||
is_successful = await self._send_msg(
|
||||
@@ -794,7 +778,7 @@ class TelegramNotificationService:
|
||||
message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID),
|
||||
context=context,
|
||||
)
|
||||
result[chat_id] = is_successful
|
||||
result[str(chat_id)] = is_successful
|
||||
return result
|
||||
|
||||
async def send_file(
|
||||
@@ -802,7 +786,7 @@ class TelegramNotificationService:
|
||||
file_type: str,
|
||||
context: Context | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a photo, sticker, video, or document."""
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
file_content = await load_data(
|
||||
@@ -922,7 +906,7 @@ class TelegramNotificationService:
|
||||
self,
|
||||
context: Context | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a sticker from a telegram sticker pack."""
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
stickerid = kwargs.get(ATTR_STICKER_ID)
|
||||
@@ -950,7 +934,7 @@ class TelegramNotificationService:
|
||||
target: Any = None,
|
||||
context: Context | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a location."""
|
||||
latitude = float(latitude)
|
||||
longitude = float(longitude)
|
||||
@@ -978,7 +962,7 @@ class TelegramNotificationService:
|
||||
target: Any = None,
|
||||
context: Context | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> dict[int, int]:
|
||||
) -> dict[str, JsonValueType]:
|
||||
"""Send a poll."""
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user