Compare commits

..

41 Commits

Author SHA1 Message Date
Liquidmasl
fe363f32ec Jellyfin native client controls (#161982) 2026-02-03 20:18:59 +01:00
Kamil Breguła
31562e7571 Remove duplicated exception handler in overkiz (#162171)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-02-03 20:16:06 +01:00
Kamil Breguła
0bdb51e4ca Remove duplicated exception handler in systemmonitor (#162170)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
2026-02-03 20:14:37 +01:00
epenet
67a5d7ac21 Move neato service registration (#162146) 2026-02-03 20:05:12 +01:00
epenet
5e7f06c476 Move sharkiq service registration (#162147) 2026-02-03 19:52:54 +01:00
epenet
9a69852296 Move xiaomi_miio service registration (#162148) 2026-02-03 19:48:39 +01:00
Bram Kragten
a722925b8e Update frontend to 20260128.5 (#162156) 2026-02-03 17:57:15 +01:00
Joost Lekkerkerker
419c5de50e Add Heiman virtual brand (#162152) 2026-02-03 17:20:25 +01:00
Joost Lekkerkerker
37faed565e Add Heatit virtual brand (#162155) 2026-02-03 17:19:50 +01:00
Paul Bottein
622953e61f Update title and description of YAML dashboard repair (#162138) 2026-02-03 17:09:23 +01:00
Steven Travers
17926c3f6a Modify Analytics text on feature labs (#162151) 2026-02-03 16:09:34 +01:00
victorigualada
48d85170c2 Handle chat log attachments in Cloud integration (#162121) 2026-02-03 15:54:56 +01:00
epenet
08d179c520 Move ecovacs service registration (#162145) 2026-02-03 15:50:17 +01:00
hanwg
5752387da8 Add entity_id parameter for Telegram bot actions (#159745)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-02-03 15:34:39 +01:00
Sebastiaan Speck
1ebde65f03 Add sound horn and flash lights buttons to Renault (#161976)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2026-02-03 15:18:55 +01:00
Denis Shulyaka
89f536e332 Anthropic: Switch default model to Haiku 4.5 (#162093) 2026-02-03 14:12:21 +01:00
Shay Levy
8784329333 Fix Shelly xpercent sensor state_class (#162107) 2026-02-03 14:11:55 +01:00
Marc Mueller
d73538722d Use Generator and AsyncGenerator for contextmanager typing (#162144) 2026-02-03 13:52:33 +01:00
Brett Adams
d49d3f0a2f Mark test-coverage as done for Teslemetry quality scale (#161958)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-03 12:57:14 +01:00
Blaine Cook
8466dd4c2b Add temperature sensor to Huum integration (#161405)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 12:38:39 +01:00
Brett Adams
6bb1e688c6 Mark reconfiguration-flow as done for Teslemetry (#162139)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 12:37:24 +01:00
epenet
9bc1c4c4f3 Simplify reolink method arguments (#162137) 2026-02-03 12:23:33 +01:00
jameson_uk
a554cb8211 Remove invalid notification sensors for Alexa devices (#160422)
Co-authored-by: Simone Chemelli <simone.chemelli@gmail.com>
2026-02-03 11:48:57 +01:00
Liquidmasl
145d38403e Add get_queue and get_movies service calls to Radarr (#160753)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 11:30:30 +01:00
Erwin Douna
10d4af5674 Use asyncio.gather pattern in portainer (#160888) 2026-02-03 11:23:34 +01:00
Brett Adams
ed3b4d2de3 Fix oauth debug log bug in Teslemetry (#161652) 2026-02-03 11:16:45 +01:00
epenet
e66d324877 Move openhome service registration (#162127) 2026-02-03 11:15:53 +01:00
epenet
f7f18627a2 Move squeezebox service registration (#162132) 2026-02-03 11:15:28 +01:00
epenet
d18630020f Move songpal service registration (#162131) 2026-02-03 11:15:05 +01:00
epenet
a715ec318c Move snapcast service registration (#162130) 2026-02-03 11:14:39 +01:00
epenet
0ef5a77dc9 Move roon service registration (#162129) 2026-02-03 11:14:06 +01:00
epenet
b43abf83b8 Move roku service registration (#162128) 2026-02-03 11:13:19 +01:00
epenet
84d28db3a7 Move linkplay service registration (#162126) 2026-02-03 11:12:32 +01:00
epenet
74d99fa0be Move denonavr service registration (#162123) 2026-02-03 11:12:04 +01:00
epenet
3ff0320ed8 Move vizio service registration (#162133) 2026-02-03 11:11:19 +01:00
epenet
16cb9e9785 Move kodi service registration (#162125) 2026-02-03 11:10:41 +01:00
epenet
d92279dfcb Move epson service registration (#162124) 2026-02-03 11:10:10 +01:00
Kamil Breguła
4b9d28d0e5 Handle missing battery stats in systemmonitor (#158287)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-03 10:54:31 +01:00
Wendelin
e6a60dfe50 Add option to use frontend PR artifact to frontend integration (#161291)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-02-03 10:23:25 +01:00
Tom
d219056e9d Add target_humidity_step attribute to climate (#160418) 2026-02-03 09:34:31 +02:00
epenet
6ff6b099b5 Move bring service registration (#162077) 2026-02-03 07:42:03 +01:00
188 changed files with 5656 additions and 1556 deletions

View File

@@ -0,0 +1,5 @@
{
"domain": "heatit",
"name": "Heatit",
"iot_standards": ["zwave"]
}

View File

@@ -0,0 +1,5 @@
{
"domain": "heiman",
"name": "Heiman",
"iot_standards": ["matter", "zigbee"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==11.0.2"]
"requirements": ["aioamazondevices==11.1.1"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ EXCLUDE_FROM_BACKUP = [
"tmp_backups/*.tar",
"OZW_Log.txt",
"tts/*",
".cache/*",
]
EXCLUDE_DATABASE_FROM_BACKUP = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
DOMAIN = "denonavr"
ATTR_DYNAMIC_EQ = "dynamic_eq"
CONF_SHOW_ALL_SOURCES = "show_all_sources"
CONF_ZONE2 = "zone2"

View File

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

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

View File

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

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
"""Constants for the epson integration."""
DOMAIN = "epson"
SERVICE_SELECT_CMODE = "select_cmode"
CONF_CONNECTION_TYPE = "connection_type"
ATTR_CMODE = "cmode"

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -150,7 +150,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self._attr_state = state
self._attr_is_volume_muted = volume_muted
self._attr_volume_level = volume_level
# Only update volume_level if the API provides it, otherwise preserve current value
if volume_level is not None:
self._attr_volume_level = volume_level
self._attr_media_content_type = media_content_type
self._attr_media_content_id = media_content_id
self._attr_media_title = media_title
@@ -190,7 +192,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
)
features = MediaPlayerEntityFeature(0)
if "PlayMediaSource" in commands:
if "PlayMediaSource" in commands or self.capabilities.get(
"SupportsMediaControl", False
):
features |= (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -201,10 +205,10 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
| MediaPlayerEntityFeature.SEARCH_MEDIA
)
if "Mute" in commands:
if "Mute" in commands and "Unmute" in commands:
features |= MediaPlayerEntityFeature.VOLUME_MUTE
if "VolumeSet" in commands:
if "VolumeSet" in commands or "SetVolume" in commands:
features |= MediaPlayerEntityFeature.VOLUME_SET
return features
@@ -219,11 +223,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
"""Send pause command."""
self.coordinator.api_client.jellyfin.remote_pause(self.session_id)
self._attr_state = MediaPlayerState.PAUSED
self.schedule_update_ha_state()
def media_play(self) -> None:
"""Send play command."""
self.coordinator.api_client.jellyfin.remote_unpause(self.session_id)
self._attr_state = MediaPlayerState.PLAYING
self.schedule_update_ha_state()
def media_play_pause(self) -> None:
"""Send the PlayPause command to the session."""
@@ -233,6 +239,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
"""Send stop command."""
self.coordinator.api_client.jellyfin.remote_stop(self.session_id)
self._attr_state = MediaPlayerState.IDLE
self.schedule_update_ha_state()
def play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
@@ -247,6 +254,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self.coordinator.api_client.jellyfin.remote_set_volume(
self.session_id, int(volume * 100)
)
self._attr_volume_level = volume
self.schedule_update_ha_state()
def mute_volume(self, mute: bool) -> None:
"""Mute the volume."""
@@ -254,6 +263,8 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
self.coordinator.api_client.jellyfin.remote_mute(self.session_id)
else:
self.coordinator.api_client.jellyfin.remote_unmute(self.session_id)
self._attr_is_volume_muted = mute
self.schedule_update_ha_state()
async def async_browse_media(
self,

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
"""Constants for Neato integration."""
NEATO_DOMAIN = "neato"
DOMAIN = "neato"
CONF_VENDOR = "vendor"
NEATO_LOGIN = "neato_login"

View File

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

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
"""Constants for the Openhome component."""
DOMAIN = "openhome"
SERVICE_INVOKE_PIN = "invoke_pin"
ATTR_PIN_INDEX = "pin"
DATA_OPENHOME = "openhome"

View File

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

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

View File

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

View File

@@ -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] = {}

View File

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

View File

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

View File

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

View 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

View File

@@ -8,5 +8,13 @@
"default": "mdi:download"
}
}
},
"services": {
"get_movies": {
"service": "mdi:filmstrip"
},
"get_queue": {
"service": "mdi:download"
}
}
}

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

View 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

View File

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

View File

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

View File

@@ -9,6 +9,12 @@
}
},
"button": {
"flash_lights": {
"default": "mdi:lightbulb-on"
},
"sound_horn": {
"default": "mdi:bugle"
},
"start_air_conditioner": {
"default": "mdi:air-conditioner"
},

View File

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

View File

@@ -73,6 +73,12 @@
}
},
"button": {
"flash_lights": {
"name": "Flash lights"
},
"sound_horn": {
"name": "Sound horn"
},
"start_air_conditioner": {
"name": "Start air conditioner"
},

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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, {})

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,7 +1,6 @@
"""Constants for the Songpal component."""
DOMAIN = "songpal"
SET_SOUND_SETTING = "set_sound_setting"
CONF_ENDPOINT = "endpoint"

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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