Merge branch 'dev' into mqtt-subentry-export

This commit is contained in:
Jan Bouwhuis 2025-07-15 21:59:13 +02:00 committed by GitHub
commit a00af59d15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
157 changed files with 9944 additions and 1826 deletions

4
CODEOWNERS generated
View File

@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy
/tests/components/wiz/ @sbidy
/homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k

View File

@ -13,7 +13,7 @@ from homeassistant.components.conversation import (
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.helpers import llm
from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.helpers.chat_session import ChatSession
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity):
@contextlib.asynccontextmanager
async def _async_get_ai_task_chat_log(
self,
session: ChatSession,
task: GenDataTask,
) -> AsyncGenerator[ChatLog]:
"""Context manager used to manage the ChatLog used during an AI Task."""
# pylint: disable-next=contextmanager-generator-missing-cleanup
with (
async_get_chat_session(self.hass) as session,
async_get_chat_log(
self.hass,
session,
@ -88,12 +88,13 @@ class AITaskEntity(RestoreEntity):
@final
async def internal_async_generate_data(
self,
session: ChatSession,
task: GenDataTask,
) -> GenDataTaskResult:
"""Run a gen data task."""
self.__last_activity = dt_util.utcnow().isoformat()
self.async_write_ha_state()
async with self._async_get_ai_task_chat_log(task) as chat_log:
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
return await self._async_generate_data(task, chat_log)
async def _async_generate_data(

View File

@ -1,6 +1,7 @@
{
"domain": "ai_task",
"name": "AI Task",
"after_dependencies": ["camera"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["conversation", "media_source"],
"documentation": "https://www.home-assistant.io/integrations/ai_task",

View File

@ -15,9 +15,10 @@ generate_data:
required: false
selector:
entity:
domain: ai_task
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA
filter:
domain: ai_task
supported_features:
- ai_task.AITaskEntityFeature.GENERATE_DATA
structure:
advanced: true
required: false

View File

@ -3,17 +3,32 @@
from __future__ import annotations
from dataclasses import dataclass
import mimetypes
from pathlib import Path
import tempfile
from typing import Any
import voluptuous as vol
from homeassistant.components import conversation, media_source
from homeassistant.core import HomeAssistant
from homeassistant.components import camera, conversation, media_source
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.chat_session import async_get_chat_session
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
def _save_camera_snapshot(image: camera.Image) -> Path:
"""Save camera snapshot to temp file."""
with tempfile.NamedTemporaryFile(
mode="wb",
suffix=mimetypes.guess_extension(image.content_type, False),
delete=False,
) as temp_file:
temp_file.write(image.content)
return Path(temp_file.name)
async def async_generate_data(
hass: HomeAssistant,
*,
@ -40,41 +55,79 @@ async def async_generate_data(
)
# Resolve attachments
resolved_attachments: list[conversation.Attachment] | None = None
resolved_attachments: list[conversation.Attachment] = []
created_files: list[Path] = []
if attachments:
if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features:
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support attachments"
if (
attachments
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
):
raise HomeAssistantError(
f"AI Task entity {entity_id} does not support attachments"
)
for attachment in attachments or []:
media_content_id = attachment["media_content_id"]
# Special case for camera media sources
if media_content_id.startswith("media-source://camera/"):
# Extract entity_id from the media content ID
entity_id = media_content_id.removeprefix("media-source://camera/")
# Get snapshot from camera
image = await camera.async_get_image(hass, entity_id)
temp_filename = await hass.async_add_executor_job(
_save_camera_snapshot, image
)
created_files.append(temp_filename)
resolved_attachments = []
for attachment in attachments:
media = await media_source.async_resolve_media(
hass, attachment["media_content_id"], None
resolved_attachments.append(
conversation.Attachment(
media_content_id=media_content_id,
mime_type=image.content_type,
path=temp_filename,
)
)
else:
# Handle regular media sources
media = await media_source.async_resolve_media(hass, media_content_id, None)
if media.path is None:
raise HomeAssistantError(
"Only local attachments are currently supported"
)
resolved_attachments.append(
conversation.Attachment(
media_content_id=attachment["media_content_id"],
url=media.url,
media_content_id=media_content_id,
mime_type=media.mime_type,
path=media.path,
)
)
return await entity.internal_async_generate_data(
GenDataTask(
name=task_name,
instructions=instructions,
structure=structure,
attachments=resolved_attachments,
with async_get_chat_session(hass) as session:
if created_files:
def cleanup_files() -> None:
"""Cleanup temporary files."""
for file in created_files:
file.unlink(missing_ok=True)
@callback
def cleanup_files_callback() -> None:
"""Cleanup temporary files."""
hass.async_add_executor_job(cleanup_files)
session.async_on_cleanup(cleanup_files_callback)
return await entity.internal_async_generate_data(
session,
GenDataTask(
name=task_name,
instructions=instructions,
structure=structure,
attachments=resolved_attachments or None,
),
)
)
@dataclass(slots=True)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
"requirements": ["aioairzone-cloud==0.6.12"]
"requirements": ["aioairzone-cloud==0.6.13"]
}

View File

@ -2,11 +2,22 @@
import amberelectric
from homeassistant.components.sensor import ConfigType
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import CONF_SITE_ID, PLATFORMS
from .const import CONF_SITE_ID, DOMAIN, PLATFORMS
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
from .services import setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Amber component."""
setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool:

View File

@ -1,14 +1,24 @@
"""Amber Electric Constants."""
import logging
from typing import Final
from homeassistant.const import Platform
DOMAIN = "amberelectric"
DOMAIN: Final = "amberelectric"
CONF_SITE_NAME = "site_name"
CONF_SITE_ID = "site_id"
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_CHANNEL_TYPE = "channel_type"
ATTRIBUTION = "Data provided by Amber Electric"
LOGGER = logging.getLogger(__package__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
SERVICE_GET_FORECASTS = "get_forecasts"
GENERAL_CHANNEL = "general"
CONTROLLED_LOAD_CHANNEL = "controlled_load"
FEED_IN_CHANNEL = "feed_in"

View File

@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval
from amberelectric.models.channel import ChannelType
from amberelectric.models.current_interval import CurrentInterval
from amberelectric.models.forecast_interval import ForecastInterval
from amberelectric.models.price_descriptor import PriceDescriptor
from amberelectric.rest import ApiException
from homeassistant.config_entries import ConfigEntry
@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
from .helpers import normalize_descriptor
type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator]
@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) ->
return interval.channel_type == ChannelType.FEEDIN
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor is None:
return None
if descriptor.value == "spike":
return "spike"
if descriptor.value == "high":
return "high"
if descriptor.value == "neutral":
return "neutral"
if descriptor.value == "low":
return "low"
if descriptor.value == "veryLow":
return "very_low"
if descriptor.value == "extremelyLow":
return "extremely_low"
if descriptor.value == "negative":
return "negative"
return None
class AmberUpdateCoordinator(DataUpdateCoordinator):
"""AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read."""
@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator):
"grid": {},
}
try:
data = self._api.get_current_prices(self.site_id, next=48)
data = self._api.get_current_prices(self.site_id, next=288)
intervals = [interval.actual_instance for interval in data]
except ApiException as api_exception:
raise UpdateFailed("Missing price data, skipping update") from api_exception

View File

@ -0,0 +1,25 @@
"""Formatting helpers used to convert things."""
from amberelectric.models.price_descriptor import PriceDescriptor
DESCRIPTOR_MAP: dict[str, str] = {
PriceDescriptor.SPIKE: "spike",
PriceDescriptor.HIGH: "high",
PriceDescriptor.NEUTRAL: "neutral",
PriceDescriptor.LOW: "low",
PriceDescriptor.VERYLOW: "very_low",
PriceDescriptor.EXTREMELYLOW: "extremely_low",
PriceDescriptor.NEGATIVE: "negative",
}
def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None:
"""Return the snake case versions of descriptor names. Returns None if the name is not recognized."""
if descriptor in DESCRIPTOR_MAP:
return DESCRIPTOR_MAP[descriptor]
return None
def format_cents_to_dollars(cents: float) -> float:
"""Return a formatted conversion from cents to dollars."""
return round(cents / 100, 2)

View File

@ -22,5 +22,10 @@
}
}
}
},
"services": {
"get_forecasts": {
"service": "mdi:transmission-tower"
}
}
}

View File

@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor
from .coordinator import AmberConfigEntry, AmberUpdateCoordinator
from .helpers import format_cents_to_dollars, normalize_descriptor
UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}"
def format_cents_to_dollars(cents: float) -> float:
"""Return a formatted conversion from cents to dollars."""
return round(cents / 100, 2)
def friendly_channel_type(channel_type: str) -> str:
"""Return a human readable version of the channel type."""
if channel_type == "controlled_load":

View File

@ -0,0 +1,121 @@
"""Amber Electric Service class."""
from amberelectric.models.channel import ChannelType
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.selector import ConfigEntrySelector
from homeassistant.util.json import JsonValueType
from .const import (
ATTR_CHANNEL_TYPE,
ATTR_CONFIG_ENTRY_ID,
CONTROLLED_LOAD_CHANNEL,
DOMAIN,
FEED_IN_CHANNEL,
GENERAL_CHANNEL,
SERVICE_GET_FORECASTS,
)
from .coordinator import AmberConfigEntry
from .helpers import format_cents_to_dollars, normalize_descriptor
GET_FORECASTS_SCHEMA = vol.Schema(
{
ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}),
ATTR_CHANNEL_TYPE: vol.In(
[GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL]
),
}
)
def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry:
"""Get the Amber config entry."""
if not (entry := hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": config_entry_id},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return entry
def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]:
"""Return an array of forecasts."""
results: list[JsonValueType] = []
if channel_type not in data["forecasts"]:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="channel_not_found",
translation_placeholders={"channel_type": channel_type},
)
intervals = data["forecasts"][channel_type]
for interval in intervals:
datum = {}
datum["duration"] = interval.duration
datum["date"] = interval.var_date.isoformat()
datum["nem_date"] = interval.nem_time.isoformat()
datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh)
if interval.channel_type == ChannelType.FEEDIN:
datum["per_kwh"] = datum["per_kwh"] * -1
datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh)
datum["start_time"] = interval.start_time.isoformat()
datum["end_time"] = interval.end_time.isoformat()
datum["renewables"] = round(interval.renewables)
datum["spike_status"] = interval.spike_status.value
datum["descriptor"] = normalize_descriptor(interval.descriptor)
if interval.range is not None:
datum["range_min"] = format_cents_to_dollars(interval.range.min)
datum["range_max"] = format_cents_to_dollars(interval.range.max)
if interval.advanced_price is not None:
multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1
datum["advanced_price_low"] = multiplier * format_cents_to_dollars(
interval.advanced_price.low
)
datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars(
interval.advanced_price.predicted
)
datum["advanced_price_high"] = multiplier * format_cents_to_dollars(
interval.advanced_price.high
)
results.append(datum)
return results
def setup_services(hass: HomeAssistant) -> None:
"""Set up the services for the Amber integration."""
async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse:
channel_type = call.data[ATTR_CHANNEL_TYPE]
entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID])
coordinator = entry.runtime_data
forecasts = get_forecasts(channel_type, coordinator.data)
return {"forecasts": forecasts}
hass.services.async_register(
DOMAIN,
SERVICE_GET_FORECASTS,
handle_get_forecasts,
GET_FORECASTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)

View File

@ -0,0 +1,16 @@
get_forecasts:
fields:
config_entry_id:
required: true
selector:
config_entry:
integration: amberelectric
channel_type:
required: true
selector:
select:
options:
- general
- controlled_load
- feed_in
translation_key: channel_type

View File

@ -1,25 +1,61 @@
{
"config": {
"error": {
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
"no_site": "No site provided",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"site": {
"data": {
"site_id": "Site NMI",
"site_name": "Site name"
},
"description": "Select the NMI of the site you would like to add"
},
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"site_id": "Site ID"
},
"description": "Go to {api_url} to generate an API key"
},
"site": {
"data": {
"site_id": "Site NMI",
"site_name": "Site Name"
},
"description": "Select the NMI of the site you would like to add"
}
}
},
"services": {
"get_forecasts": {
"name": "Get price forecasts",
"description": "Retrieves price forecasts from Amber Electric for a site.",
"fields": {
"config_entry_id": {
"description": "The config entry of the site to get forecasts for.",
"name": "Config entry"
},
"channel_type": {
"name": "Channel type",
"description": "The channel to get forecasts for."
}
}
}
},
"exceptions": {
"integration_not_found": {
"message": "Config entry \"{target}\" not found in registry."
},
"error": {
"invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]",
"no_site": "No site provided",
"unknown_error": "[%key:common::config_flow::error::unknown%]"
"not_loaded": {
"message": "{target} is not loaded."
},
"channel_not_found": {
"message": "There is no {channel_type} channel at this site."
}
},
"selector": {
"channel_type": {
"options": {
"general": "General",
"controlled_load": "Controlled load",
"feed_in": "Feed-in"
}
}
}
}

View File

@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["amcrest"],
"quality_scale": "legacy",
"requirements": ["amcrest==1.9.8"]
"requirements": ["amcrest==1.9.9"]
}

View File

@ -68,9 +68,10 @@ ask_question:
required: true
selector:
entity:
domain: assist_satellite
supported_features:
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
filter:
domain: assist_satellite
supported_features:
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
question:
required: false
example: "What kind of music would you like to play?"

View File

@ -147,9 +147,6 @@ class Attachment:
media_content_id: str
"""Media content ID of the attachment."""
url: str
"""URL of the attachment."""
mime_type: str
"""MIME type of the attachment."""

View File

@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS]
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
"""Get ElevenLabs model from their API by the model_id."""
models = await client.models.get_all()
models = await client.models.list()
for maybe_model in models:
if maybe_model.model_id == model_id:
return maybe_model

View File

@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry
from .const import (
CONF_CONFIGURE_VOICE,
CONF_MODEL,
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
CONF_STABILITY,
CONF_STYLE,
CONF_USE_SPEAKER_BOOST,
CONF_VOICE,
DEFAULT_MODEL,
DEFAULT_OPTIMIZE_LATENCY,
DEFAULT_SIMILARITY,
DEFAULT_STABILITY,
DEFAULT_STYLE,
@ -51,7 +49,8 @@ async def get_voices_models(
httpx_client = get_async_client(hass)
client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client)
voices = (await client.voices.get_all()).voices
models = await client.models.get_all()
models = await client.models.list()
voices_dict = {
voice.voice_id: voice.name
for voice in sorted(voices, key=lambda v: v.name or "")
@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY])
except ApiError:
errors["base"] = "invalid_api_key"
except ApiError as exc:
errors["base"] = "unknown"
details = getattr(exc, "body", {}).get("detail", {})
if details:
status = details.get("status")
if status == "invalid_api_key":
errors["base"] = "invalid_api_key"
else:
return self.async_create_entry(
title="ElevenLabs",
@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
vol.Coerce(float),
vol.Range(min=0, max=1),
),
vol.Optional(
CONF_OPTIMIZE_LATENCY,
default=self.config_entry.options.get(
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
),
): vol.All(int, vol.Range(min=0, max=4)),
vol.Optional(
CONF_STYLE,
default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE),

View File

@ -7,7 +7,6 @@ CONF_MODEL = "model"
CONF_CONFIGURE_VOICE = "configure_voice"
CONF_STABILITY = "stability"
CONF_SIMILARITY = "similarity"
CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency"
CONF_STYLE = "style"
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
DOMAIN = "elevenlabs"
@ -15,6 +14,5 @@ DOMAIN = "elevenlabs"
DEFAULT_MODEL = "eleven_multilingual_v2"
DEFAULT_STABILITY = 0.5
DEFAULT_SIMILARITY = 0.75
DEFAULT_OPTIMIZE_LATENCY = 0
DEFAULT_STYLE = 0
DEFAULT_USE_SPEAKER_BOOST = True

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["elevenlabs"],
"requirements": ["elevenlabs==1.9.0"]
"requirements": ["elevenlabs==2.3.0"]
}

View File

@ -11,7 +11,8 @@
}
},
"error": {
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
"options": {
@ -32,14 +33,12 @@
"data": {
"stability": "Stability",
"similarity": "Similarity",
"optimize_streaming_latency": "Latency",
"style": "Style",
"use_speaker_boost": "Speaker boost"
},
"data_description": {
"stability": "Stability of the generated audio. Higher values lead to less emotional audio.",
"similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.",
"optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.",
"style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.",
"use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice."
}

View File

@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
ATTR_MODEL,
CONF_OPTIMIZE_LATENCY,
CONF_SIMILARITY,
CONF_STABILITY,
CONF_STYLE,
CONF_USE_SPEAKER_BOOST,
CONF_VOICE,
DEFAULT_OPTIMIZE_LATENCY,
DEFAULT_SIMILARITY,
DEFAULT_STABILITY,
DEFAULT_STYLE,
@ -75,9 +73,6 @@ async def async_setup_entry(
config_entry.entry_id,
config_entry.title,
voice_settings,
config_entry.options.get(
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
),
)
]
)
@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
entry_id: str,
title: str,
voice_settings: VoiceSettings,
latency: int = 0,
) -> None:
"""Init ElevenLabs TTS service."""
self._client = client
@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
if voice_indices:
self._voices.insert(0, self._voices.pop(voice_indices[0]))
self._voice_settings = voice_settings
self._latency = latency
# Entity attributes
self._attr_unique_id = entry_id
@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
voice_id = options.get(ATTR_VOICE, self._default_voice_id)
model = options.get(ATTR_MODEL, self._model.model_id)
try:
audio = await self._client.generate(
audio = self._client.text_to_speech.convert(
text=message,
voice=voice_id,
optimize_streaming_latency=self._latency,
voice_id=voice_id,
voice_settings=self._voice_settings,
model=model,
model_id=model,
)
bytes_combined = b"".join([byte_seg async for byte_seg in audio])
except ApiError as exc:
_LOGGER.warning(
"Error during processing of TTS request %s", exc, exc_info=True

View File

@ -26,6 +26,7 @@ from homeassistant.const import (
EVENT_THEMES_UPDATED,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.icon import async_get_icons
from homeassistant.helpers.json import json_dumps_sorted
@ -543,6 +544,12 @@ async def _async_setup_themes(
"""Reload themes."""
config = await async_hass_config_yaml(hass)
new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {})
try:
THEME_SCHEMA(new_themes)
except vol.Invalid as err:
raise HomeAssistantError(f"Failed to reload themes: {err}") from err
hass.data[DATA_THEMES] = new_themes
if hass.data[DATA_DEFAULT_THEME] not in new_themes:
hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME

View File

@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider):
await self.teardown()
raise HomeAssistantError("Camera has no stream source")
if camera.platform.platform_name == "generic":
# This is a workaround to use ffmpeg for generic cameras
# A proper fix will be added in the future together with supporting multiple streams per camera
stream_source = "ffmpeg:" + stream_source
if not self.async_is_supported(stream_source):
await self.teardown()
raise HomeAssistantError("Stream source is not supported by go2rtc")

View File

@ -113,9 +113,7 @@ class HomematicipHAP:
self._ws_close_requested = False
self._ws_connection_closed = asyncio.Event()
self._retry_task: asyncio.Task | None = None
self._tries = 0
self._accesspoint_connected = True
self._get_state_task: asyncio.Task | None = None
self.hmip_device_by_entity_id: dict[str, Any] = {}
self.reset_connection_listener: Callable | None = None
@ -161,17 +159,8 @@ class HomematicipHAP:
"""
if not self.home.connected:
_LOGGER.error("HMIP access point has lost connection with the cloud")
self._accesspoint_connected = False
self._ws_connection_closed.set()
self.set_all_to_unavailable()
elif not self._accesspoint_connected:
# Now the HOME_CHANGED event has fired indicating the access
# point has reconnected to the cloud again.
# Explicitly getting an update as entity states might have
# changed during access point disconnect."""
job = self.hass.async_create_task(self.get_state())
job.add_done_callback(self.get_state_finished)
self._accesspoint_connected = True
@callback
def async_create_entity(self, *args, **kwargs) -> None:
@ -185,20 +174,43 @@ class HomematicipHAP:
await asyncio.sleep(30)
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
async def _try_get_state(self) -> None:
"""Call get_state in a loop until no error occurs, using exponential backoff on error."""
# Wait until WebSocket connection is established.
while not self.home.websocket_is_connected():
await asyncio.sleep(2)
delay = 8
max_delay = 1500
while True:
try:
await self.get_state()
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err
)
await asyncio.sleep(delay)
delay = min(delay * 2, max_delay)
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
await self.home.get_current_state_async()
self.update_all()
def get_state_finished(self, future) -> None:
"""Execute when get_state coroutine has finished."""
"""Execute when try_get_state coroutine has finished."""
try:
future.result()
except HmipConnectionError:
# Somehow connection could not recover. Will disconnect and
# so reconnect loop is taking over.
_LOGGER.error("Updating state after HMIP access point reconnect failed")
self.hass.async_create_task(self.home.disable_events())
except Exception as err: # noqa: BLE001
_LOGGER.error(
"Error updating state after HMIP access point reconnect: %s", err
)
else:
_LOGGER.info(
"Updating state after HMIP access point reconnect finished successfully",
)
def set_all_to_unavailable(self) -> None:
"""Set all devices to unavailable and tell Home Assistant."""
@ -222,8 +234,8 @@ class HomematicipHAP:
async def async_reset(self) -> bool:
"""Close the websocket connection."""
self._ws_close_requested = True
if self._retry_task is not None:
self._retry_task.cancel()
if self._get_state_task is not None:
self._get_state_task.cancel()
await self.home.disable_events_async()
_LOGGER.debug("Closed connection to HomematicIP cloud server")
await self.hass.config_entries.async_unload_platforms(
@ -247,7 +259,9 @@ class HomematicipHAP:
"""Handle websocket connected."""
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
if self._ws_connection_closed.is_set():
await self.get_state()
self._get_state_task = self.hass.async_create_task(self._try_get_state())
self._get_state_task.add_done_callback(self.get_state_finished)
self._ws_connection_closed.clear()
async def ws_disconnected_handler(self) -> None:
@ -256,11 +270,12 @@ class HomematicipHAP:
self._ws_connection_closed.set()
async def ws_reconnected_handler(self, reason: str) -> None:
"""Handle websocket reconnection."""
"""Handle websocket reconnection. Is called when Websocket tries to reconnect."""
_LOGGER.info(
"Websocket connection to HomematicIP Cloud re-established due to reason: %s",
"Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s",
reason,
)
self._ws_connection_closed.set()
async def get_hap(

View File

@ -2,13 +2,20 @@
from __future__ import annotations
import logging
from typing import Any
from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState
from homematicip.base.enums import (
DeviceType,
FunctionalChannelType,
OpticalSignalBehaviour,
RGBColorState,
)
from homematicip.base.functionalChannels import NotificationLightChannel
from homematicip.device import (
BrandDimmer,
BrandSwitchNotificationLight,
Device,
Dimmer,
DinRailDimmer3,
FullFlushDimmer,
@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry, HomematicipHAP
_logger = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
@ -43,6 +52,14 @@ async def async_setup_entry(
"""Set up the HomematicIP Cloud lights from a config entry."""
hap = config_entry.runtime_data
entities: list[HomematicipGenericEntity] = []
entities.extend(
HomematicipLightHS(hap, d, ch.index)
for d in hap.home.devices
for ch in d.functionalChannels
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
)
for device in hap.home.devices:
if (
isinstance(device, SwitchMeasuring)
@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
await self._device.turn_off_async()
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP light with HS color mode."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
"""Initialize the light entity."""
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
@property
def is_on(self) -> bool:
"""Return true if light is on."""
return self.functional_channel.on
@property
def brightness(self) -> int | None:
"""Return the current brightness."""
return int(self.functional_channel.dimLevel * 255.0)
@property
def hs_color(self) -> tuple[float, float] | None:
"""Return the hue and saturation color value [float, float]."""
if (
self.functional_channel.hue is None
or self.functional_channel.saturationLevel is None
):
return None
return (
self.functional_channel.hue,
self.functional_channel.saturationLevel * 100.0,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
if ATTR_HS_COLOR not in kwargs:
hue = self.functional_channel.hue
saturation = self.functional_channel.saturationLevel
if ATTR_BRIGHTNESS not in kwargs:
# If no brightness is set, use the current brightness
dim_level = self.functional_channel.dimLevel or 1.0
await self.functional_channel.set_hue_saturation_dim_level_async(
hue=hue, saturation_level=saturation, dim_level=dim_level
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self.functional_channel.set_switch_state_async(on=False)
class HomematicipLightMeasuring(HomematicipLight):
"""Representation of the HomematicIP measuring light."""

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
"requirements": ["homematicip==2.0.6"]
"requirements": ["homematicip==2.0.7"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["imgw_pib==1.2.0"]
"requirements": ["imgw_pib==1.4.0"]
}

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.media_player import (
@ -21,6 +22,8 @@ from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .entity import JellyfinClientEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
@ -177,10 +180,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
commands: list[str] = self.capabilities.get("SupportedCommands", [])
controllable = self.capabilities.get("SupportsMediaControl", False)
_LOGGER.debug(
"Supported commands for device %s, client %s, %s",
self.device_name,
self.client_name,
commands,
)
features = MediaPlayerEntityFeature(0)
if controllable:
if "PlayMediaSource" in commands:
features |= (
MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.PLAY_MEDIA

View File

@ -2,6 +2,9 @@
"entity_component": {
"_": {
"default": "mdi:lightbulb",
"state": {
"off": "mdi:lightbulb-off"
},
"state_attributes": {
"effect": {
"default": "mdi:circle-medium",

View File

@ -16,6 +16,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
_attr_has_entity_name = True
@staticmethod
def get_unique_id(device_id: str, description: EntityDescription) -> str:
"""Generate a unique ID for the entity."""
return f"{device_id}-{description.key}"
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
@ -26,7 +31,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
super().__init__(coordinator)
self._device_id = device_id
self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
self._attr_unique_id = MieleEntity.get_unique_id(device_id, description)
device = self.device
appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type))

View File

@ -7,7 +7,7 @@ from dataclasses import dataclass
import logging
from typing import Final, cast
from pymiele import MieleDevice
from pymiele import MieleDevice, MieleTemperature
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -25,10 +25,13 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
DISABLED_TEMP_ENTITIES,
DOMAIN,
STATE_PROGRAM_ID,
STATE_PROGRAM_PHASE,
STATE_STATUS_TAGS,
@ -45,8 +48,6 @@ PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
DISABLED_TEMPERATURE = -32768
DEFAULT_PLATE_COUNT = 4
PLATE_COUNT = {
@ -75,12 +76,25 @@ def _convert_duration(value_list: list[int]) -> int | None:
return value_list[0] * 60 + value_list[1] if value_list else None
def _convert_temperature(
value_list: list[MieleTemperature], index: int
) -> float | None:
"""Convert temperature object to readable value."""
if index >= len(value_list):
return None
raw_value = cast(int, value_list[index].temperature) / 100.0
if raw_value in DISABLED_TEMP_ENTITIES:
return None
return raw_value
@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""
value_fn: Callable[[MieleDevice], StateType]
zone: int = 1
zone: int | None = None
unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None
@dataclass
@ -404,32 +418,20 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
),
description=MieleSensorDescription(
key="state_temperature_1",
zone=1,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: cast(int, value.state_temperatures[0].temperature)
/ 100.0,
value_fn=lambda value: _convert_temperature(value.state_temperatures, 0),
),
),
MieleSensorDefinition(
types=(
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.DISH_WARMER,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.WINE_CABINET_FREEZER,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_temperature_2",
@ -438,7 +440,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
translation_key="temperature_zone_2",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator]
value_fn=lambda value: _convert_temperature(value.state_temperatures, 1),
),
),
MieleSensorDefinition(
types=(
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.WINE_CABINET_FREEZER,
),
description=MieleSensorDescription(
key="state_temperature_3",
zone=3,
device_class=SensorDeviceClass.TEMPERATURE,
translation_key="temperature_zone_3",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: _convert_temperature(value.state_temperatures, 2),
),
),
MieleSensorDefinition(
@ -454,11 +473,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(
int, value.state_core_target_temperature[0].temperature
)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_core_target_temperature, 0
),
),
),
@ -479,9 +495,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(int, value.state_target_temperature[0].temperature)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_target_temperature, 0
),
),
),
@ -497,9 +512,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda value: cast(int, value.state_core_temperature[0].temperature)
/ 100.0
value_fn=lambda value: _convert_temperature(
value.state_core_temperature, 0
),
),
),
@ -518,6 +532,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
device_class=SensorDeviceClass.ENUM,
options=sorted(PlatePowerStep.keys()),
value_fn=lambda value: None,
unique_id_fn=lambda device_id,
description: f"{device_id}-{description.key}-{description.zone}",
),
)
for i in range(1, 7)
@ -539,6 +555,16 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
options=sorted(StateDryingStep.keys()),
),
),
MieleSensorDefinition(
types=(MieleAppliance.ROBOT_VACUUM_CLEANER,),
description=MieleSensorDescription(
key="state_battery",
value_fn=lambda value: value.state_battery_level,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.BATTERY,
),
),
)
@ -549,10 +575,52 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
added_devices: set[str] = set()
added_devices: set[str] = set() # device_id
added_entities: set[str] = set() # unique_id
def _async_add_new_devices() -> None:
nonlocal added_devices
def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]:
"""Get the entity class for the sensor."""
return {
"state_status": MieleStatusSensor,
"state_program_id": MieleProgramIdSensor,
"state_program_phase": MielePhaseSensor,
"state_plate_step": MielePlateSensor,
}.get(definition.description.key, MieleSensor)
def _is_entity_registered(unique_id: str) -> bool:
"""Check if the entity is already registered."""
entity_registry = er.async_get(hass)
return any(
entry.platform == DOMAIN and entry.unique_id == unique_id
for entry in entity_registry.entities.values()
)
def _is_sensor_enabled(
definition: MieleSensorDefinition,
device: MieleDevice,
unique_id: str,
) -> bool:
"""Check if the sensor is enabled."""
if (
definition.description.device_class == SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device) is None
and definition.description.zone != 1
):
# all appliances supporting temperature have at least zone 1, for other zones
# don't create entity if API signals that datapoint is disabled, unless the sensor
# already appeared in the past (= it provided a valid value)
return _is_entity_registered(unique_id)
if (
definition.description.key == "state_plate_step"
and definition.description.zone is not None
and definition.description.zone > _get_plate_count(device.tech_type)
):
# don't create plate entity if not expected by the appliance tech type
return False
return True
def _async_add_devices() -> None:
nonlocal added_devices, added_entities
entities: list = []
entity_class: type[MieleSensor]
new_devices_set, current_devices = coordinator.async_add_devices(added_devices)
@ -560,40 +628,35 @@ async def async_setup_entry(
for device_id, device in coordinator.data.devices.items():
for definition in SENSOR_TYPES:
if (
device_id in new_devices_set
and device.device_type in definition.types
):
match definition.description.key:
case "state_status":
entity_class = MieleStatusSensor
case "state_program_id":
entity_class = MieleProgramIdSensor
case "state_program_phase":
entity_class = MielePhaseSensor
case "state_plate_step":
entity_class = MielePlateSensor
case _:
entity_class = MieleSensor
if (
definition.description.device_class
== SensorDeviceClass.TEMPERATURE
and definition.description.value_fn(device)
== DISABLED_TEMPERATURE / 100
) or (
definition.description.key == "state_plate_step"
and definition.description.zone
> _get_plate_count(device.tech_type)
):
# Don't create entity if API signals that datapoint is disabled
continue
entities.append(
entity_class(coordinator, device_id, definition.description)
# device is not supported, skip
if device.device_type not in definition.types:
continue
entity_class = _get_entity_class(definition)
unique_id = (
definition.description.unique_id_fn(
device_id, definition.description
)
if definition.description.unique_id_fn is not None
else MieleEntity.get_unique_id(device_id, definition.description)
)
# entity was already added, skip
if device_id not in new_devices_set and unique_id in added_entities:
continue
# sensors is not enabled, skip
if not _is_sensor_enabled(definition, device, unique_id):
continue
added_entities.add(unique_id)
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices))
_async_add_new_devices()
config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices))
_async_add_devices()
APPLIANCE_ICONS = {
@ -631,6 +694,17 @@ class MieleSensor(MieleEntity, SensorEntity):
entity_description: MieleSensorDescription
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id, description)
if description.unique_id_fn is not None:
self._attr_unique_id = description.unique_id_fn(device_id, description)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
@ -642,16 +716,6 @@ class MielePlateSensor(MieleSensor):
entity_description: MieleSensorDescription
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the plate sensor."""
super().__init__(coordinator, device_id, description)
self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}"
@property
def native_value(self) -> StateType:
"""Return the state of the plate sensor."""
@ -662,7 +726,7 @@ class MielePlateSensor(MieleSensor):
cast(
int,
self.device.state_plate_step[
self.entity_description.zone - 1
cast(int, self.entity_description.zone) - 1
].value_raw,
)
).name

View File

@ -87,7 +87,6 @@ class MieleVacuumStateCode(MieleEnum):
SUPPORTED_FEATURES = (
VacuumEntityFeature.STATE
| VacuumEntityFeature.BATTERY
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.START
| VacuumEntityFeature.STOP
@ -174,11 +173,6 @@ class MieleVacuum(MieleEntity, StateVacuumEntity):
MieleVacuumStateCode(self.device.state_program_phase).value
)
@property
def battery_level(self) -> int | None:
"""Return the battery level."""
return self.device.state_battery_level
@property
def fan_speed(self) -> str | None:
"""Return the fan speed."""

View File

@ -19,7 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL
from .coordinator import NASwebCoordinator
from .nasweb_data import NASwebData
PLATFORMS: list[Platform] = [Platform.SWITCH]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
NASWEB_CONFIG_URL = "https://{host}/page"

View File

@ -1,6 +1,7 @@
"""Constants for the NASweb integration."""
DOMAIN = "nasweb"
KEY_TEMP_SENSOR = "temp_sensor"
MANUFACTURER = "chomtech.pl"
STATUS_UPDATE_MAX_TIME_INTERVAL = 60
SUPPORT_EMAIL = "support@chomtech.eu"

View File

@ -11,16 +11,19 @@ from typing import Any
from aiohttp.web import Request, Response
from webio_api import WebioAPI
from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE
from webio_api.const import KEY_DEVICE_SERIAL, KEY_TYPE, TYPE_STATUS_UPDATE
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers import event
from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol
from .const import STATUS_UPDATE_MAX_TIME_INTERVAL
from .const import KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL
_LOGGER = logging.getLogger(__name__)
KEY_INPUTS = "inputs"
KEY_OUTPUTS = "outputs"
class NotificationCoordinator:
"""Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator."""
@ -96,8 +99,11 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
self._job = HassJob(self._handle_max_update_interval, job_name)
self._unsub_last_update_check: CALLBACK_TYPE | None = None
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
data: dict[str, Any] = {}
data[KEY_OUTPUTS] = self.webio_api.outputs
data: dict[str, Any] = {
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
}
self.async_set_updated_data(data)
def is_connection_confirmed(self) -> bool:
@ -187,5 +193,9 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
async def process_status_update(self, new_status: dict) -> None:
"""Process status update from NASweb."""
self.webio_api.update_device_status(new_status)
new_data = {KEY_OUTPUTS: self.webio_api.outputs}
new_data = {
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
}
self.async_set_updated_data(new_data)

View File

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"sensor_input": {
"default": "mdi:help-circle-outline",
"state": {
"tamper": "mdi:lock-alert",
"active": "mdi:alert",
"normal": "mdi:shield-check-outline",
"problem": "mdi:alert-circle"
}
}
}
}
}

View File

@ -0,0 +1,189 @@
"""Platform for NASweb sensors."""
from __future__ import annotations
import logging
import time
from webio_api import Input as NASwebInput, TempSensor
from homeassistant.components.sensor import (
DOMAIN as DOMAIN_SENSOR,
SensorDeviceClass,
SensorEntity,
SensorStateClass,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
BaseDataUpdateCoordinatorProtocol,
)
from . import NASwebConfigEntry
from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL
SENSOR_INPUT_TRANSLATION_KEY = "sensor_input"
STATE_UNDEFINED = "undefined"
STATE_TAMPER = "tamper"
STATE_ACTIVE = "active"
STATE_NORMAL = "normal"
STATE_PROBLEM = "problem"
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config: NASwebConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up Sensor platform."""
coordinator = config.runtime_data
current_inputs: set[int] = set()
@callback
def _check_entities() -> None:
received_inputs: dict[int, NASwebInput] = {
entry.index: entry for entry in coordinator.webio_api.inputs
}
added = {i for i in received_inputs if i not in current_inputs}
removed = {i for i in current_inputs if i not in received_inputs}
entities_to_add: list[InputStateSensor] = []
for index in added:
webio_input = received_inputs[index]
if not isinstance(webio_input, NASwebInput):
_LOGGER.error("Cannot create InputStateSensor without NASwebInput")
continue
new_input = InputStateSensor(coordinator, webio_input)
entities_to_add.append(new_input)
current_inputs.add(index)
async_add_entities(entities_to_add)
entity_registry = er.async_get(hass)
for index in removed:
unique_id = f"{DOMAIN}.{config.unique_id}.input.{index}"
if entity_id := entity_registry.async_get_entity_id(
DOMAIN_SENSOR, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
current_inputs.remove(index)
else:
_LOGGER.warning("Failed to remove old input: no entity_id")
coordinator.async_add_listener(_check_entities)
_check_entities()
nasweb_temp_sensor = coordinator.data[KEY_TEMP_SENSOR]
temp_sensor = TemperatureSensor(coordinator, nasweb_temp_sensor)
async_add_entities([temp_sensor])
class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity):
"""Base class providing common functionality."""
def __init__(self, coordinator: BaseDataUpdateCoordinatorProtocol) -> None:
"""Initialize base sensor."""
super().__init__(coordinator)
self._attr_available = False
self._attr_has_entity_name = True
self._attr_should_poll = False
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
def _set_attr_available(
self, entity_last_update: float, available: bool | None
) -> None:
if (
self.coordinator.last_update is None
or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL
):
self._attr_available = False
else:
self._attr_available = available if available is not None else False
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
Scheduling updates is not necessary, the coordinator takes care of updates via push notifications.
"""
class InputStateSensor(BaseSensorEntity):
"""Entity representing NASweb input."""
_attr_device_class = SensorDeviceClass.ENUM
_attr_options: list[str] = [
STATE_UNDEFINED,
STATE_TAMPER,
STATE_ACTIVE,
STATE_NORMAL,
STATE_PROBLEM,
]
_attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY
def __init__(
self,
coordinator: BaseDataUpdateCoordinatorProtocol,
nasweb_input: NASwebInput,
) -> None:
"""Initialize InputStateSensor entity."""
super().__init__(coordinator)
self._input = nasweb_input
self._attr_native_value: str | None = None
self._attr_translation_placeholders = {"index": f"{nasweb_input.index:2d}"}
self._attr_unique_id = (
f"{DOMAIN}.{self._input.webio_serial}.input.{self._input.index}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._input.webio_serial)},
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if self._input.state is None or self._input.state in self._attr_options:
self._attr_native_value = self._input.state
else:
_LOGGER.warning("Received unrecognized input state: %s", self._input.state)
self._attr_native_value = None
self._set_attr_available(self._input.last_update, self._input.available)
self.async_write_ha_state()
class TemperatureSensor(BaseSensorEntity):
"""Entity representing NASweb temperature sensor."""
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
def __init__(
self,
coordinator: BaseDataUpdateCoordinatorProtocol,
nasweb_temp_sensor: TempSensor,
) -> None:
"""Initialize TemperatureSensor entity."""
super().__init__(coordinator)
self._temp_sensor = nasweb_temp_sensor
self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._temp_sensor.webio_serial)}
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_native_value = self._temp_sensor.value
self._set_attr_available(
self._temp_sensor.last_update, self._temp_sensor.available
)
self.async_write_ha_state()

View File

@ -45,6 +45,18 @@
"switch_output": {
"name": "Relay Switch {index}"
}
},
"sensor": {
"sensor_input": {
"name": "Input {index}",
"state": {
"undefined": "Undefined",
"tamper": "Tamper",
"active": "Active",
"normal": "Normal",
"problem": "Problem"
}
}
}
}
}

View File

@ -38,6 +38,7 @@ from .const import (
ATTR_HEATING_POWER_REQUEST,
ATTR_SCHEDULE_NAME,
ATTR_SELECTED_SCHEDULE,
ATTR_SELECTED_SCHEDULE_ID,
ATTR_TARGET_TEMPERATURE,
ATTR_TIME_PERIOD,
DATA_SCHEDULES,
@ -251,16 +252,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
if data["event_type"] == EVENT_TYPE_SCHEDULE:
# handle schedule change
if "schedule_id" in data:
selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][
self.home.entity_id
].get(data["schedule_id"])
self._selected_schedule = getattr(
self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get(
data["schedule_id"]
),
selected_schedule,
"name",
None,
)
self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = (
self._selected_schedule
)
self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr(
selected_schedule, "entity_id", None
)
self.async_write_ha_state()
self.data_handler.async_force_update(self._signal_name)
# ignore other schedule events
@ -420,12 +427,14 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity):
self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode]
self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY]
self._selected_schedule = getattr(
self.home.get_selected_schedule(), "name", None
)
selected_schedule = self.home.get_selected_schedule()
self._selected_schedule = getattr(selected_schedule, "name", None)
self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = (
self._selected_schedule
)
self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr(
selected_schedule, "entity_id", None
)
if self.device_type == NA_VALVE:
self._attr_extra_state_attributes[ATTR_HEATING_POWER_REQUEST] = (

View File

@ -95,6 +95,7 @@ ATTR_PSEUDO = "pseudo"
ATTR_SCHEDULE_ID = "schedule_id"
ATTR_SCHEDULE_NAME = "schedule_name"
ATTR_SELECTED_SCHEDULE = "selected_schedule"
ATTR_SELECTED_SCHEDULE_ID = "selected_schedule_id"
ATTR_TARGET_TEMPERATURE = "target_temperature"
ATTR_TIME_PERIOD = "time_period"

View File

@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -84,6 +84,8 @@ async def async_setup_entry(
OctoPrintJobPercentageSensor(coordinator, device_id),
OctoPrintEstimatedFinishTimeSensor(coordinator, device_id),
OctoPrintStartTimeSensor(coordinator, device_id),
OctoPrintFileNameSensor(coordinator, device_id),
OctoPrintFileSizeSensor(coordinator, device_id),
]
async_add_entities(entities)
@ -262,3 +264,61 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase):
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success and self.coordinator.data["printer"]
class OctoPrintFileNameSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
def __init__(
self,
coordinator: OctoprintDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize a new OctoPrint sensor."""
super().__init__(coordinator, "Current File", device_id)
@property
def native_value(self) -> str | None:
"""Return sensor state."""
job: OctoprintJobInfo = self.coordinator.data["job"]
return job.job.file.name or None
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.last_update_success:
return False
job: OctoprintJobInfo = self.coordinator.data["job"]
return job and job.job.file.name
class OctoPrintFileSizeSensor(OctoPrintSensorBase):
"""Representation of an OctoPrint sensor."""
_attr_device_class = SensorDeviceClass.DATA_SIZE
_attr_native_unit_of_measurement = UnitOfInformation.BYTES
_attr_suggested_unit_of_measurement = UnitOfInformation.MEGABYTES
def __init__(
self,
coordinator: OctoprintDataUpdateCoordinator,
device_id: str,
) -> None:
"""Initialize a new OctoPrint sensor."""
super().__init__(coordinator, "Current File Size", device_id)
@property
def native_value(self) -> int | None:
"""Return sensor state."""
job: OctoprintJobInfo = self.coordinator.data["job"]
return job.job.file.size or None
@property
def available(self) -> bool:
"""Return if entity is available."""
if not self.coordinator.last_update_success:
return False
job: OctoprintJobInfo = self.coordinator.data["job"]
return job and job.job.file.size

View File

@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.FAN,
Platform.LOCK,
Platform.SENSOR,
Platform.SWITCH,
@ -51,6 +52,7 @@ class SwitchbotDevices:
sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list)
@dataclass
@ -96,7 +98,6 @@ async def make_switchbot_devices(
for device in devices
]
)
return devices_data
@ -177,6 +178,16 @@ async def make_device_data(
else:
devices_data.switches.append((device, coordinator))
if isinstance(device, Device) and device.device_type in [
"Battery Circulator Fan",
"Circulator Fan",
]:
coordinator = await coordinator_for_device(
hass, entry, api, device, coordinators_by_id
)
devices_data.fans.append((device, coordinator))
devices_data.sensors.append((device, coordinator))
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SwitchBot via API from a config entry."""

View File

@ -0,0 +1,120 @@
"""Support for the Switchbot Battery Circulator fan."""
import asyncio
from typing import Any
from switchbot_api import (
BatteryCirculatorFanCommands,
BatteryCirculatorFanMode,
CommonCommands,
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
async_add_entities(
SwitchBotCloudFan(data.api, device, coordinator)
for device, coordinator in data.devices.fans
)
class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity):
"""Representation of a SwitchBot Battery Circulator Fan."""
_attr_name = None
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
_attr_preset_modes = list(BatteryCirculatorFanMode)
_attr_is_on: bool | None = None
@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
return self._attr_is_on
def _set_attributes(self) -> None:
"""Set attributes from coordinator data."""
if self.coordinator.data is None:
return
power: str = self.coordinator.data["power"]
mode: str = self.coordinator.data["mode"]
fan_speed: str = self.coordinator.data["fanSpeed"]
self._attr_is_on = power == "on"
self._attr_preset_mode = mode
self._attr_percentage = int(fan_speed)
self._attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_OFF
| FanEntityFeature.TURN_ON
)
if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
self._attr_supported_features |= FanEntityFeature.SET_SPEED
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
await self.send_api_command(CommonCommands.ON)
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=str(self.preset_mode),
)
if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value:
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(self.percentage),
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self.send_api_command(CommonCommands.OFF)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=str(BatteryCirculatorFanMode.DIRECT.value),
)
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_SPEED,
parameters=str(percentage),
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self.send_api_command(
command=BatteryCirculatorFanCommands.SET_WIND_MODE,
parameters=preset_mode,
)
await asyncio.sleep(5)
await self.coordinator.async_request_refresh()

View File

@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription(
SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
"Bot": (BATTERY_DESCRIPTION,),
"Battery Circulator Fan": (BATTERY_DESCRIPTION,),
"Meter": (
TEMPERATURE_DESCRIPTION,
HUMIDITY_DESCRIPTION,

View File

@ -32,8 +32,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -42,15 +40,11 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .helpers import async_setup_template_platform
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
@ -88,7 +82,7 @@ class TemplateCodeFormat(Enum):
text = CodeFormat.TEXT
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
}
@ -161,54 +155,6 @@ ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy alarm control panel configuration definitions to modern ones."""
alarm_control_panels = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
alarm_control_panels.append(entity_conf)
return alarm_control_panels
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template alarm control panels."""
alarm_control_panels = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
alarm_control_panels.append(
AlarmControlPanelTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(alarm_control_panels)
def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]:
"""Rewrite option configuration to modern configuration."""
option_config = {**option_config}
@ -231,7 +177,7 @@ async def async_setup_entry(
validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options)
async_add_entities(
[
AlarmControlPanelTemplate(
StateAlarmControlPanelEntity(
hass,
validated_config,
config_entry.entry_id,
@ -247,27 +193,16 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Template cover."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
ALARM_CONTROL_PANEL_DOMAIN,
config,
StateAlarmControlPanelEntity,
TriggerAlarmControlPanelEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_ALARM_CONTROL_PANELS,
)
@ -276,6 +211,8 @@ class AbstractTemplateAlarmControlPanel(
):
"""Representation of a templated Alarm Control Panel features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -414,7 +351,7 @@ class AbstractTemplateAlarmControlPanel(
)
class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel):
class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlPanel):
"""Representation of a templated Alarm Control Panel."""
_attr_should_poll = False
@ -426,12 +363,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
unique_id: str | None,
) -> None:
"""Initialize the panel."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateAlarmControlPanel.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
@ -442,11 +375,6 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
@ -497,11 +425,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()

View File

@ -24,9 +24,7 @@ from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_ENTITY_PICTURE_TEMPLATE,
CONF_FRIENDLY_NAME,
CONF_FRIENDLY_NAME_TEMPLATE,
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
CONF_SENSORS,
@ -41,8 +39,6 @@ from homeassistant.const import (
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -53,18 +49,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import (
CONF_ATTRIBUTES,
CONF_AVAILABILITY,
CONF_AVAILABILITY_TEMPLATE,
CONF_OBJECT_ID,
CONF_PICTURE,
)
from .template_entity import (
TEMPLATE_ENTITY_COMMON_SCHEMA,
TemplateEntity,
rewrite_common_legacy_to_modern_conf,
)
from .const import CONF_AVAILABILITY_TEMPLATE
from .helpers import async_setup_template_platform
from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity
from .trigger_entity import TriggerEntity
CONF_DELAY_ON = "delay_on"
@ -73,12 +60,7 @@ CONF_AUTO_OFF = "auto_off"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
LEGACY_FIELDS = {
CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY,
CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES,
CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME,
CONF_FRIENDLY_NAME: CONF_NAME,
CONF_VALUE_TEMPLATE: CONF_STATE,
}
@ -121,27 +103,6 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, cfg: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy binary sensor definitions to modern ones."""
sensors = []
for object_id, entity_cfg in cfg.items():
entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id}
entity_cfg = rewrite_common_legacy_to_modern_conf(
hass, entity_cfg, LEGACY_FIELDS
)
if CONF_NAME not in entity_cfg:
entity_cfg[CONF_NAME] = template.Template(object_id, hass)
sensors.append(entity_cfg)
return sensors
PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(
@ -151,33 +112,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
)
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template binary sensors."""
sensors = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
sensors.append(
BinarySensorTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(sensors)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -185,27 +119,16 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template binary sensors."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerBinarySensorEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
BINARY_SENSOR_DOMAIN,
config,
StateBinarySensorEntity,
TriggerBinarySensorEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_SENSORS,
)
@ -219,23 +142,24 @@ async def async_setup_entry(
_options.pop("template_type")
validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options)
async_add_entities(
[BinarySensorTemplate(hass, validated_config, config_entry.entry_id)]
[StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)]
)
@callback
def async_create_preview_binary_sensor(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> BinarySensorTemplate:
) -> StateBinarySensorEntity:
"""Create a preview sensor."""
validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name})
return BinarySensorTemplate(hass, validated_config, None)
return StateBinarySensorEntity(hass, validated_config, None)
class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity):
class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity):
"""A virtual binary sensor that triggers from another sensor."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -244,11 +168,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity):
unique_id: str | None,
) -> None:
"""Initialize the Template binary sensor."""
super().__init__(hass, config=config, unique_id=unique_id)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
TemplateEntity.__init__(self, hass, config, unique_id)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._template = config[CONF_STATE]
@ -257,10 +177,6 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity):
self._delay_on_raw = config.get(CONF_DELAY_ON)
self._delay_off = None
self._delay_off_raw = config.get(CONF_DELAY_OFF)
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore state."""
@ -333,6 +249,7 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity):
class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity):
"""Sensor entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = BINARY_SENSOR_DOMAIN
extra_template_keys = (CONF_STATE,)

View File

@ -3,22 +3,20 @@
from __future__ import annotations
import logging
from typing import Any
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_DEVICE_ID,
CONF_NAME,
CONF_UNIQUE_ID,
from homeassistant.components.button import (
DEVICE_CLASSES_SCHEMA,
DOMAIN as BUTTON_DOMAIN,
ENTITY_ID_FORMAT,
ButtonEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -26,6 +24,7 @@ from homeassistant.helpers.entity_platform import (
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PRESS, DOMAIN
from .helpers import async_setup_template_platform
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
_LOGGER = logging.getLogger(__name__)
@ -50,19 +49,6 @@ CONFIG_BUTTON_SCHEMA = vol.Schema(
)
async def _async_create_entities(
hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None
) -> list[TemplateButtonEntity]:
"""Create the Template button."""
entities = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(TemplateButtonEntity(hass, definition, unique_id))
return entities
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -70,15 +56,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template button."""
if not discovery_info or "coordinator" in discovery_info:
raise PlatformNotReady(
"The template button platform doesn't support trigger entities"
)
async_add_entities(
await _async_create_entities(
hass, discovery_info["entities"], discovery_info["unique_id"]
)
await async_setup_template_platform(
hass,
BUTTON_DOMAIN,
config,
StateButtonEntity,
None,
async_add_entities,
discovery_info,
)
@ -92,14 +77,15 @@ async def async_setup_entry(
_options.pop("template_type")
validated_config = CONFIG_BUTTON_SCHEMA(_options)
async_add_entities(
[TemplateButtonEntity(hass, validated_config, config_entry.entry_id)]
[StateButtonEntity(hass, validated_config, config_entry.entry_id)]
)
class TemplateButtonEntity(TemplateEntity, ButtonEntity):
class StateButtonEntity(TemplateEntity, ButtonEntity):
"""Representation of a template button."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -108,17 +94,16 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity):
unique_id: str | None,
) -> None:
"""Initialize the button."""
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
TemplateEntity.__init__(self, hass, config, unique_id)
if TYPE_CHECKING:
assert self._attr_name is not None
# Scripts can be an empty list, therefore we need to check for None
if (action := config.get(CONF_PRESS)) is not None:
self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state = None
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_press(self) -> None:
"""Press the button."""

View File

@ -65,7 +65,7 @@ from . import (
weather as weather_platform,
)
from .const import DOMAIN, PLATFORMS, TemplateConfig
from .helpers import async_get_blueprints
from .helpers import async_get_blueprints, rewrite_legacy_to_modern_configs
PACKAGE_MERGE_HINT = "list"
@ -249,16 +249,16 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf
legacy_warn_printed = False
for old_key, new_key, transform in (
for old_key, new_key, legacy_fields in (
(
CONF_SENSORS,
DOMAIN_SENSOR,
sensor_platform.rewrite_legacy_to_modern_conf,
sensor_platform.LEGACY_FIELDS,
),
(
CONF_BINARY_SENSORS,
DOMAIN_BINARY_SENSOR,
binary_sensor_platform.rewrite_legacy_to_modern_conf,
binary_sensor_platform.LEGACY_FIELDS,
),
):
if old_key not in template_config:
@ -276,7 +276,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf
definitions = (
list(template_config[new_key]) if new_key in template_config else []
)
definitions.extend(transform(hass, template_config[old_key]))
definitions.extend(
rewrite_legacy_to_modern_configs(
hass, template_config[old_key], legacy_fields
)
)
template_config = TemplateConfig({**template_config, new_key: definitions})
config_sections.append(template_config)

View File

@ -32,19 +32,17 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, DOMAIN
from .const import DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@ -85,7 +83,7 @@ TILT_FEATURES = (
| CoverEntityFeature.SET_TILT_POSITION
)
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
CONF_POSITION_TEMPLATE: CONF_POSITION,
CONF_TILT_TEMPLATE: CONF_TILT,
@ -140,54 +138,6 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy switch configuration definitions to modern ones."""
covers = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
covers.append(entity_conf)
return covers
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template switches."""
covers = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
covers.append(
CoverTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(covers)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -195,33 +145,24 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Template cover."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerCoverEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
COVER_DOMAIN,
config,
StateCoverEntity,
TriggerCoverEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_COVERS,
)
class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
"""Representation of a template cover features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -445,7 +386,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
self.async_write_ha_state()
class CoverTemplate(TemplateEntity, AbstractTemplateCover):
class StateCoverEntity(TemplateEntity, AbstractTemplateCover):
"""Representation of a Template cover."""
_attr_should_poll = False
@ -457,12 +398,8 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover):
unique_id,
) -> None:
"""Initialize the Template cover."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateCover.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None

View File

@ -1,32 +1,50 @@
"""Template entity base class."""
from abc import abstractmethod
from collections.abc import Sequence
from typing import Any
from homeassistant.const import CONF_DEVICE_ID
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import Entity, async_generate_entity_id
from homeassistant.helpers.script import Script, _VarsType
from homeassistant.helpers.template import TemplateStateFromEntityId
from homeassistant.helpers.typing import ConfigType
from .const import CONF_OBJECT_ID
class AbstractTemplateEntity(Entity):
"""Actions linked to a template entity."""
def __init__(self, hass: HomeAssistant) -> None:
_entity_id_format: str
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Initialize the entity."""
self.hass = hass
self._action_scripts: dict[str, Script] = {}
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
self._entity_id_format, object_id, hass=self.hass
)
self._attr_device_info = async_device_info_to_link_from_device_id(
self.hass,
config.get(CONF_DEVICE_ID),
)
@property
@abstractmethod
def referenced_blueprint(self) -> str | None:
"""Return referenced blueprint or None."""
raise NotImplementedError
@callback
@abstractmethod
def _render_script_variables(self) -> dict:
"""Render configured variables."""
raise NotImplementedError
def add_script(
self,

View File

@ -34,19 +34,17 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@ -73,7 +71,7 @@ CONF_OSCILLATING = "oscillating"
CONF_PERCENTAGE = "percentage"
CONF_PRESET_MODE = "preset_mode"
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_DIRECTION_TEMPLATE: CONF_DIRECTION,
CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING,
CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE,
@ -132,54 +130,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy fan configuration definitions to modern ones."""
fans = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
fans.append(entity_conf)
return fans
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template fans."""
fans = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
fans.append(
TemplateFan(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(fans)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -187,33 +137,24 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template fans."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerFanEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
FAN_DOMAIN,
config,
StateFanEntity,
TriggerFanEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_FANS,
)
class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
"""Representation of a template fan features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -484,7 +425,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
)
class TemplateFan(TemplateEntity, AbstractTemplateFan):
class StateFanEntity(TemplateEntity, AbstractTemplateFan):
"""A template fan component."""
_attr_should_poll = False
@ -496,12 +437,8 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan):
unique_id,
) -> None:
"""Initialize the fan."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateFan.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None

View File

@ -1,19 +1,60 @@
"""Helpers for template integration."""
from collections.abc import Callable
import itertools
import logging
from typing import Any
from homeassistant.components import blueprint
from homeassistant.const import SERVICE_RELOAD
from homeassistant.const import (
CONF_ENTITY_PICTURE_TEMPLATE,
CONF_FRIENDLY_NAME,
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import template
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import (
AddEntitiesCallback,
async_get_platforms,
)
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .const import (
CONF_ATTRIBUTE_TEMPLATES,
CONF_ATTRIBUTES,
CONF_AVAILABILITY,
CONF_AVAILABILITY_TEMPLATE,
CONF_OBJECT_ID,
CONF_PICTURE,
DOMAIN,
)
from .entity import AbstractTemplateEntity
from .template_entity import TemplateEntity
from .trigger_entity import TriggerEntity
DATA_BLUEPRINTS = "template_blueprints"
LOGGER = logging.getLogger(__name__)
LEGACY_FIELDS = {
CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY,
CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES,
CONF_FRIENDLY_NAME: CONF_NAME,
}
_LOGGER = logging.getLogger(__name__)
type CreateTemplateEntitiesCallback = Callable[
[type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None],
None,
]
@callback
@ -59,8 +100,131 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints:
return blueprint.DomainBlueprints(
hass,
DOMAIN,
LOGGER,
_LOGGER,
_blueprint_in_use,
_reload_blueprint_templates,
TEMPLATE_BLUEPRINT_SCHEMA,
)
def rewrite_legacy_to_modern_config(
hass: HomeAssistant,
entity_cfg: dict[str, Any],
extra_legacy_fields: dict[str, str],
) -> dict[str, Any]:
"""Rewrite legacy config."""
entity_cfg = {**entity_cfg}
for from_key, to_key in itertools.chain(
LEGACY_FIELDS.items(), extra_legacy_fields.items()
):
if from_key not in entity_cfg or to_key in entity_cfg:
continue
val = entity_cfg.pop(from_key)
if isinstance(val, str):
val = template.Template(val, hass)
entity_cfg[to_key] = val
if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str):
entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass)
return entity_cfg
def rewrite_legacy_to_modern_configs(
hass: HomeAssistant,
entity_cfg: dict[str, dict],
extra_legacy_fields: dict[str, str],
) -> list[dict]:
"""Rewrite legacy configuration definitions to modern ones."""
entities = []
for object_id, entity_conf in entity_cfg.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_legacy_to_modern_config(
hass, entity_conf, extra_legacy_fields
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
entities.append(entity_conf)
return entities
@callback
def async_create_template_tracking_entities(
entity_cls: type[Entity],
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template tracking entities."""
entities: list[Entity] = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg]
async_add_entities(entities)
async def async_setup_template_platform(
hass: HomeAssistant,
domain: str,
config: ConfigType,
state_entity_cls: type[TemplateEntity],
trigger_entity_cls: type[TriggerEntity] | None,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None,
legacy_fields: dict[str, str] | None = None,
legacy_key: str | None = None,
) -> None:
"""Set up the Template platform."""
if discovery_info is None:
# Legacy Configuration
if legacy_fields is not None:
if legacy_key:
configs = rewrite_legacy_to_modern_configs(
hass, config[legacy_key], legacy_fields
)
else:
configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)]
async_create_template_tracking_entities(
state_entity_cls,
async_add_entities,
hass,
configs,
None,
)
else:
_LOGGER.warning(
"Template %s entities can only be configured under template:", domain
)
return
# Trigger Configuration
if "coordinator" in discovery_info:
if trigger_entity_cls:
entities = [
trigger_entity_cls(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
]
async_add_entities(entities)
else:
raise PlatformNotReady(
f"The template {domain} platform doesn't support trigger entities"
)
return
# Modern Configuration
async_create_template_tracking_entities(
state_entity_cls,
async_add_entities,
hass,
discovery_info["entities"],
discovery_info["unique_id"],
)

View File

@ -7,19 +7,16 @@ from typing import Any
import voluptuous as vol
from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_NAME,
CONF_UNIQUE_ID,
CONF_URL,
CONF_VERIFY_SSL,
from homeassistant.components.image import (
DOMAIN as IMAGE_DOMAIN,
ENTITY_ID_FORMAT,
ImageEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -29,6 +26,7 @@ from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import CONF_PICTURE
from .helpers import async_setup_template_platform
from .template_entity import (
TemplateEntity,
make_template_entity_common_modern_attributes_schema,
@ -59,19 +57,6 @@ IMAGE_CONFIG_SCHEMA = vol.Schema(
)
async def _async_create_entities(
hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None
) -> list[StateImageEntity]:
"""Create the template image."""
entities = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(StateImageEntity(hass, definition, unique_id))
return entities
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -79,23 +64,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template image."""
if discovery_info is None:
_LOGGER.warning(
"Template image entities can only be configured under template:"
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerImageEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
async_add_entities(
await _async_create_entities(
hass, discovery_info["entities"], discovery_info["unique_id"]
)
await async_setup_template_platform(
hass,
IMAGE_DOMAIN,
config,
StateImageEntity,
TriggerImageEntity,
async_add_entities,
discovery_info,
)
@ -118,6 +94,7 @@ class StateImageEntity(TemplateEntity, ImageEntity):
_attr_should_poll = False
_attr_image_url: str | None = None
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -126,13 +103,9 @@ class StateImageEntity(TemplateEntity, ImageEntity):
unique_id: str | None,
) -> None:
"""Initialize the image."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL])
self._url_template = config[CONF_URL]
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
@property
def entity_picture(self) -> str | None:
@ -162,6 +135,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity):
"""Image entity based on trigger data."""
_attr_image_url: str | None = None
_entity_id_format = ENTITY_ID_FORMAT
domain = IMAGE_DOMAIN
extra_template_keys = (CONF_URL,)

View File

@ -43,20 +43,18 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, DOMAIN
from .const import DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@ -103,7 +101,7 @@ CONF_WHITE_VALUE_TEMPLATE = "white_value_template"
DEFAULT_MIN_MIREDS = 153
DEFAULT_MAX_MIREDS = 500
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_COLOR_ACTION: CONF_HS_ACTION,
CONF_COLOR_TEMPLATE: CONF_HS,
CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST,
@ -193,47 +191,6 @@ PLATFORM_SCHEMA = vol.All(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy switch configuration definitions to modern ones."""
lights = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
lights.append(entity_conf)
return lights
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the Template Lights."""
lights = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
lights.append(LightTemplate(hass, entity_conf, unique_id))
async_add_entities(lights)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -241,33 +198,24 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template lights."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerLightEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
LIGHT_DOMAIN,
config,
StateLightEntity,
TriggerLightEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_LIGHTS,
)
class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
"""Representation of a template lights features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__( # pylint: disable=super-init-not-called
@ -934,7 +882,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
self._attr_supported_features |= LightEntityFeature.TRANSITION
class LightTemplate(TemplateEntity, AbstractTemplateLight):
class StateLightEntity(TemplateEntity, AbstractTemplateLight):
"""Representation of a templated Light, including dimmable."""
_attr_should_poll = False
@ -946,12 +894,8 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight):
unique_id: str | None,
) -> None:
"""Initialize the light."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateLight.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None

View File

@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components.lock import (
DOMAIN as LOCK_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA,
LockEntity,
LockEntityFeature,
@ -31,12 +32,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PICTURE, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@ -49,7 +49,7 @@ CONF_OPEN = "open"
DEFAULT_NAME = "Template Lock"
DEFAULT_OPTIMISTIC = False
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT,
CONF_VALUE_TEMPLATE: CONF_STATE,
}
@ -83,33 +83,6 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend(
).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template fans."""
fans = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
fans.append(
TemplateLock(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(fans)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -117,33 +90,23 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template fans."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
[rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)],
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerLockEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
LOCK_DOMAIN,
config,
StateLockEntity,
TriggerLockEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
)
class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
"""Representation of a template lock features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -311,7 +274,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
)
class TemplateLock(TemplateEntity, AbstractTemplateLock):
class StateLockEntity(TemplateEntity, AbstractTemplateLock):
"""Representation of a template lock."""
_attr_should_poll = False
@ -323,7 +286,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock):
unique_id: str | None,
) -> None:
"""Initialize the lock."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateLock.__init__(self, config)
name = self._attr_name
if TYPE_CHECKING:

View File

@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
from typing import TYPE_CHECKING, Any
import voluptuous as vol
@ -13,6 +13,7 @@ from homeassistant.components.number import (
DEFAULT_MIN_VALUE,
DEFAULT_STEP,
DOMAIN as NUMBER_DOMAIN,
ENTITY_ID_FORMAT,
NumberEntity,
)
from homeassistant.config_entries import ConfigEntry
@ -21,12 +22,10 @@ from homeassistant.const import (
CONF_NAME,
CONF_OPTIMISTIC,
CONF_STATE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -35,6 +34,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN
from .helpers import async_setup_template_platform
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
@ -70,19 +70,6 @@ NUMBER_CONFIG_SCHEMA = vol.Schema(
)
async def _async_create_entities(
hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None
) -> list[TemplateNumber]:
"""Create the Template number."""
entities = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(TemplateNumber(hass, definition, unique_id))
return entities
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -90,23 +77,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template number."""
if discovery_info is None:
_LOGGER.warning(
"Template number entities can only be configured under template:"
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerNumberEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
async_add_entities(
await _async_create_entities(
hass, discovery_info["entities"], discovery_info["unique_id"]
)
await async_setup_template_platform(
hass,
NUMBER_DOMAIN,
config,
StateNumberEntity,
TriggerNumberEntity,
async_add_entities,
discovery_info,
)
@ -119,22 +97,25 @@ async def async_setup_entry(
_options = dict(config_entry.options)
_options.pop("template_type")
validated_config = NUMBER_CONFIG_SCHEMA(_options)
async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)])
async_add_entities(
[StateNumberEntity(hass, validated_config, config_entry.entry_id)]
)
@callback
def async_create_preview_number(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> TemplateNumber:
) -> StateNumberEntity:
"""Create a preview number."""
validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name})
return TemplateNumber(hass, validated_config, None)
return StateNumberEntity(hass, validated_config, None)
class TemplateNumber(TemplateEntity, NumberEntity):
class StateNumberEntity(TemplateEntity, NumberEntity):
"""Representation of a template number."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -143,8 +124,10 @@ class TemplateNumber(TemplateEntity, NumberEntity):
unique_id: str | None,
) -> None:
"""Initialize the number."""
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
TemplateEntity.__init__(self, hass, config, unique_id)
if TYPE_CHECKING:
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN)
@ -156,10 +139,6 @@ class TemplateNumber(TemplateEntity, NumberEntity):
self._attr_native_step = DEFAULT_STEP
self._attr_native_min_value = DEFAULT_MIN_VALUE
self._attr_native_max_value = DEFAULT_MAX_VALUE
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
@callback
def _async_setup_templates(self) -> None:
@ -208,6 +187,7 @@ class TemplateNumber(TemplateEntity, NumberEntity):
class TriggerNumberEntity(TriggerEntity, NumberEntity):
"""Number entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = NUMBER_DOMAIN
extra_template_keys = (
CONF_STATE,

View File

@ -11,19 +11,13 @@ from homeassistant.components.select import (
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN,
ENTITY_ID_FORMAT,
SelectEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_NAME,
CONF_OPTIMISTIC,
CONF_STATE,
CONF_UNIQUE_ID,
)
from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -33,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import DOMAIN
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
@ -65,19 +60,6 @@ SELECT_CONFIG_SCHEMA = vol.Schema(
)
async def _async_create_entities(
hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None
) -> list[TemplateSelect]:
"""Create the Template select."""
entities = []
for definition in definitions:
unique_id = definition.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(TemplateSelect(hass, definition, unique_id))
return entities
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -85,23 +67,14 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template select."""
if discovery_info is None:
_LOGGER.warning(
"Template select entities can only be configured under template:"
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerSelectEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
async_add_entities(
await _async_create_entities(
hass, discovery_info["entities"], discovery_info["unique_id"]
)
await async_setup_template_platform(
hass,
SELECT_DOMAIN,
config,
TemplateSelect,
TriggerSelectEntity,
async_add_entities,
discovery_info,
)
@ -120,6 +93,8 @@ async def async_setup_entry(
class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
"""Representation of a template select features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -159,7 +134,7 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect):
unique_id: str | None,
) -> None:
"""Initialize the select."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateSelect.__init__(self, config)
name = self._attr_name
@ -169,11 +144,6 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect):
if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN)
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
@callback
def _async_setup_templates(self) -> None:
"""Set up templates."""

View File

@ -44,8 +44,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -55,17 +53,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
from . import TriggerUpdateCoordinator
from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID
from .template_entity import (
TEMPLATE_ENTITY_COMMON_SCHEMA,
TemplateEntity,
rewrite_common_legacy_to_modern_conf,
)
from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE
from .helpers import async_setup_template_platform
from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity
from .trigger_entity import TriggerEntity
LEGACY_FIELDS = {
CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME,
CONF_FRIENDLY_NAME: CONF_NAME,
CONF_VALUE_TEMPLATE: CONF_STATE,
}
@ -142,27 +136,6 @@ def extra_validation_checks(val):
return val
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, cfg: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy sensor definitions to modern ones."""
sensors = []
for object_id, entity_cfg in cfg.items():
entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id}
entity_cfg = rewrite_common_legacy_to_modern_conf(
hass, entity_cfg, LEGACY_FIELDS
)
if CONF_NAME not in entity_cfg:
entity_cfg[CONF_NAME] = template.Template(object_id, hass)
sensors.append(entity_cfg)
return sensors
PLATFORM_SCHEMA = vol.All(
SENSOR_PLATFORM_SCHEMA.extend(
{
@ -177,33 +150,6 @@ PLATFORM_SCHEMA = vol.All(
_LOGGER = logging.getLogger(__name__)
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template sensors."""
sensors = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
sensors.append(
SensorTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(sensors)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -211,27 +157,16 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template sensors."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerSensorEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
SENSOR_DOMAIN,
config,
StateSensorEntity,
TriggerSensorEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_SENSORS,
)
@ -244,22 +179,25 @@ async def async_setup_entry(
_options = dict(config_entry.options)
_options.pop("template_type")
validated_config = SENSOR_CONFIG_SCHEMA(_options)
async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)])
async_add_entities(
[StateSensorEntity(hass, validated_config, config_entry.entry_id)]
)
@callback
def async_create_preview_sensor(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> SensorTemplate:
) -> StateSensorEntity:
"""Create a preview sensor."""
validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name})
return SensorTemplate(hass, validated_config, None)
return StateSensorEntity(hass, validated_config, None)
class SensorTemplate(TemplateEntity, SensorEntity):
class StateSensorEntity(TemplateEntity, SensorEntity):
"""Representation of a Template Sensor."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -268,7 +206,7 @@ class SensorTemplate(TemplateEntity, SensorEntity):
unique_id: str | None,
) -> None:
"""Initialize the sensor."""
super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id)
super().__init__(hass, config, unique_id)
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state_class = config.get(CONF_STATE_CLASS)
@ -276,14 +214,6 @@ class SensorTemplate(TemplateEntity, SensorEntity):
self._attr_last_reset_template: template.Template | None = config.get(
ATTR_LAST_RESET
)
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
@callback
def _async_setup_templates(self) -> None:
@ -327,6 +257,7 @@ class SensorTemplate(TemplateEntity, SensorEntity):
class TriggerSensorEntity(TriggerEntity, RestoreSensor):
"""Sensor entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = SENSOR_DOMAIN
extra_template_keys = (CONF_STATE,)

View File

@ -30,8 +30,6 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
@ -40,19 +38,18 @@ from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
_VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
}
@ -96,27 +93,6 @@ SWITCH_CONFIG_SCHEMA = vol.Schema(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy switch configuration definitions to modern ones."""
switches = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
switches.append(entity_conf)
return switches
def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]:
"""Rewrite option configuration to modern configuration."""
option_config = {**option_config}
@ -127,33 +103,6 @@ def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str,
return option_config
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template switches."""
switches = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
switches.append(
SwitchTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(switches)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -161,27 +110,16 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template switches."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerSwitchEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
SWITCH_DOMAIN,
config,
StateSwitchEntity,
TriggerSwitchEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_SWITCHES,
)
@ -195,23 +133,26 @@ async def async_setup_entry(
_options.pop("template_type")
_options = rewrite_options_to_modern_conf(_options)
validated_config = SWITCH_CONFIG_SCHEMA(_options)
async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)])
async_add_entities(
[StateSwitchEntity(hass, validated_config, config_entry.entry_id)]
)
@callback
def async_create_preview_switch(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> SwitchTemplate:
) -> StateSwitchEntity:
"""Create a preview switch."""
updated_config = rewrite_options_to_modern_conf(config)
validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name})
return SwitchTemplate(hass, validated_config, None)
return StateSwitchEntity(hass, validated_config, None)
class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity):
"""Representation of a Template switch."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -220,11 +161,8 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
unique_id: str | None,
) -> None:
"""Initialize the Template switch."""
super().__init__(hass, config=config, unique_id=unique_id)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
super().__init__(hass, config, unique_id)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None
@ -238,10 +176,6 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
self._state: bool | None = False
self._attr_assumed_state = self._template is None
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
@callback
def _update_state(self, result):
@ -304,6 +238,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity):
"""Switch entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = SWITCH_DOMAIN
def __init__(
@ -314,6 +249,7 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity):
) -> None:
"""Initialize the entity."""
super().__init__(hass, coordinator, config)
name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
self._template = config.get(CONF_STATE)
if on_action := config.get(CONF_TURN_ON):
@ -326,11 +262,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()

View File

@ -4,7 +4,6 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
import contextlib
import itertools
import logging
from typing import Any, cast
@ -14,7 +13,6 @@ import voluptuous as vol
from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
from homeassistant.const import (
CONF_ENTITY_PICTURE_TEMPLATE,
CONF_FRIENDLY_NAME,
CONF_ICON,
CONF_ICON_TEMPLATE,
CONF_NAME,
@ -137,42 +135,6 @@ TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema(
).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema)
LEGACY_FIELDS = {
CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY,
CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES,
CONF_FRIENDLY_NAME: CONF_NAME,
}
def rewrite_common_legacy_to_modern_conf(
hass: HomeAssistant,
entity_cfg: dict[str, Any],
extra_legacy_fields: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Rewrite legacy config."""
entity_cfg = {**entity_cfg}
if extra_legacy_fields is None:
extra_legacy_fields = {}
for from_key, to_key in itertools.chain(
LEGACY_FIELDS.items(), extra_legacy_fields.items()
):
if from_key not in entity_cfg or to_key in entity_cfg:
continue
val = entity_cfg.pop(from_key)
if isinstance(val, str):
val = Template(val, hass)
entity_cfg[to_key] = val
if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str):
entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass)
return entity_cfg
class _TemplateAttribute:
"""Attribute value linked to template result."""
@ -278,17 +240,11 @@ class TemplateEntity(AbstractTemplateEntity):
def __init__(
self,
hass: HomeAssistant,
*,
availability_template: Template | None = None,
icon_template: Template | None = None,
entity_picture_template: Template | None = None,
attribute_templates: dict[str, Template] | None = None,
config: ConfigType | None = None,
fallback_name: str | None = None,
unique_id: str | None = None,
config: ConfigType,
unique_id: str | None,
) -> None:
"""Template Entity."""
AbstractTemplateEntity.__init__(self, hass)
AbstractTemplateEntity.__init__(self, hass, config)
self._template_attrs: dict[Template, list[_TemplateAttribute]] = {}
self._template_result_info: TrackTemplateResultInfo | None = None
self._attr_extra_state_attributes = {}
@ -307,22 +263,13 @@ class TemplateEntity(AbstractTemplateEntity):
| None
) = None
self._run_variables: ScriptVariables | dict
if config is None:
self._attribute_templates = attribute_templates
self._availability_template = availability_template
self._icon_template = icon_template
self._entity_picture_template = entity_picture_template
self._friendly_name_template = None
self._run_variables = {}
self._blueprint_inputs = None
else:
self._attribute_templates = config.get(CONF_ATTRIBUTES)
self._availability_template = config.get(CONF_AVAILABILITY)
self._icon_template = config.get(CONF_ICON)
self._entity_picture_template = config.get(CONF_PICTURE)
self._friendly_name_template = config.get(CONF_NAME)
self._run_variables = config.get(CONF_VARIABLES, {})
self._blueprint_inputs = config.get("raw_blueprint_inputs")
self._attribute_templates = config.get(CONF_ATTRIBUTES)
self._availability_template = config.get(CONF_AVAILABILITY)
self._icon_template = config.get(CONF_ICON)
self._entity_picture_template = config.get(CONF_PICTURE)
self._friendly_name_template = config.get(CONF_NAME)
self._run_variables = config.get(CONF_VARIABLES, {})
self._blueprint_inputs = config.get("raw_blueprint_inputs")
class DummyState(State):
"""None-state for template entities not yet added to the state machine."""
@ -340,7 +287,7 @@ class TemplateEntity(AbstractTemplateEntity):
variables = {"this": DummyState()}
# Try to render the name as it can influence the entity ID
self._attr_name = fallback_name
self._attr_name = None
if self._friendly_name_template:
with contextlib.suppress(TemplateError):
self._attr_name = self._friendly_name_template.async_render(

View File

@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
"""Initialize the entity."""
CoordinatorEntity.__init__(self, coordinator)
TriggerBaseEntity.__init__(self, hass, config)
AbstractTemplateEntity.__init__(self, hass)
AbstractTemplateEntity.__init__(self, hass, config)
self._state_render_error = False

View File

@ -34,20 +34,18 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .const import DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .helpers import async_setup_template_platform
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY,
TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY,
TemplateEntity,
make_template_entity_common_modern_attributes_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
@ -72,7 +70,7 @@ _VALID_STATES = [
VacuumActivity.ERROR,
]
LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
LEGACY_FIELDS = {
CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL,
CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED,
CONF_VALUE_TEMPLATE: CONF_STATE,
@ -125,88 +123,31 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
def rewrite_legacy_to_modern_conf(
hass: HomeAssistant, config: dict[str, dict]
) -> list[dict]:
"""Rewrite legacy switch configuration definitions to modern ones."""
vacuums = []
for object_id, entity_conf in config.items():
entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
entity_conf = rewrite_common_legacy_to_modern_conf(
hass, entity_conf, LEGACY_FIELDS
)
if CONF_NAME not in entity_conf:
entity_conf[CONF_NAME] = template.Template(object_id, hass)
vacuums.append(entity_conf)
return vacuums
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the template switches."""
vacuums = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
vacuums.append(
TemplateVacuum(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(vacuums)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Template cover."""
if discovery_info is None:
_async_create_template_tracking_entities(
async_add_entities,
hass,
rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]),
None,
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerVacuumEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
"""Set up the Template vacuum."""
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
VACUUM_DOMAIN,
config,
TemplateStateVacuumEntity,
TriggerVacuumEntity,
async_add_entities,
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_VACUUMS,
)
class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
"""Representation of a template vacuum features."""
_entity_id_format = ENTITY_ID_FORMAT
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
@ -350,7 +291,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
self._attr_fan_speed = None
class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum):
class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum):
"""A template vacuum component."""
_attr_should_poll = False
@ -362,12 +303,8 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum):
unique_id,
) -> None:
"""Initialize the vacuum."""
TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id)
TemplateEntity.__init__(self, hass, config, unique_id)
AbstractTemplateVacuum.__init__(self, config)
if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
name = self._attr_name
if TYPE_CHECKING:
assert name is not None

View File

@ -31,16 +31,10 @@ from homeassistant.components.weather import (
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.const import (
CONF_TEMPERATURE_UNIT,
CONF_UNIQUE_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@ -52,11 +46,8 @@ from homeassistant.util.unit_conversion import (
)
from .coordinator import TriggerUpdateCoordinator
from .template_entity import (
TemplateEntity,
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .helpers import async_setup_template_platform
from .template_entity import TemplateEntity, make_template_entity_common_modern_schema
from .trigger_entity import TriggerEntity
CHECK_FORECAST_KEYS = (
@ -138,33 +129,6 @@ WEATHER_SCHEMA = vol.Schema(
PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema)
@callback
def _async_create_template_tracking_entities(
async_add_entities: AddEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
) -> None:
"""Create the weather entities."""
entities = []
for entity_conf in definitions:
unique_id = entity_conf.get(CONF_UNIQUE_ID)
if unique_id and unique_id_prefix:
unique_id = f"{unique_id_prefix}-{unique_id}"
entities.append(
WeatherTemplate(
hass,
entity_conf,
unique_id,
)
)
async_add_entities(entities)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -172,39 +136,23 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Template weather."""
if discovery_info is None:
config = rewrite_common_legacy_to_modern_conf(hass, config)
unique_id = config.get(CONF_UNIQUE_ID)
async_add_entities(
[
WeatherTemplate(
hass,
config,
unique_id,
)
]
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerWeatherEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
await async_setup_template_platform(
hass,
discovery_info["entities"],
discovery_info["unique_id"],
WEATHER_DOMAIN,
config,
StateWeatherEntity,
TriggerWeatherEntity,
async_add_entities,
discovery_info,
{},
)
class WeatherTemplate(TemplateEntity, WeatherEntity):
class StateWeatherEntity(TemplateEntity, WeatherEntity):
"""Representation of a weather condition."""
_attr_should_poll = False
_entity_id_format = ENTITY_ID_FORMAT
def __init__(
self,
@ -213,9 +161,8 @@ class WeatherTemplate(TemplateEntity, WeatherEntity):
unique_id: str | None,
) -> None:
"""Initialize the Template weather."""
super().__init__(hass, config=config, unique_id=unique_id)
super().__init__(hass, config, unique_id)
name = self._attr_name
self._condition_template = config[CONF_CONDITION_TEMPLATE]
self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE]
self._humidity_template = config[CONF_HUMIDITY_TEMPLATE]
@ -243,8 +190,6 @@ class WeatherTemplate(TemplateEntity, WeatherEntity):
self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT)
self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT)
self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass)
self._condition = None
self._temperature = None
self._humidity = None
@ -538,6 +483,7 @@ class WeatherExtraStoredData(ExtraStoredData):
class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity):
"""Sensor entity based on trigger data."""
_entity_id_format = ENTITY_ID_FORMAT
domain = WEATHER_DOMAIN
extra_template_keys = (
CONF_CONDITION_TEMPLATE,
@ -553,6 +499,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity):
) -> None:
"""Initialize."""
super().__init__(hass, coordinator, config)
self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT)
self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT)
self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT)

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.2.0"]
"requirements": ["tesla-fleet-api==1.2.2"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"]
"requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"]
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/touchline_sl",
"integration_type": "hub",
"iot_class": "cloud_polling",
"requirements": ["pytouchlinesl==0.3.0"]
"requirements": ["pytouchlinesl==0.4.0"]
}

View File

@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import StrEnum
import logging
@ -417,8 +416,6 @@ class UnitOfMeasurement:
device_classes: set[str]
aliases: set[str] = field(default_factory=set)
conversion_unit: str | None = None
conversion_fn: Callable[[float], float] | None = None
# A tuple of available units of measurements we can work with.
@ -458,8 +455,6 @@ UNITS = (
SensorDeviceClass.CO,
SensorDeviceClass.CO2,
},
conversion_unit=CONCENTRATION_PARTS_PER_MILLION,
conversion_fn=lambda x: x / 1000,
),
UnitOfMeasurement(
unit=UnitOfElectricCurrent.AMPERE,
@ -470,8 +465,6 @@ UNITS = (
unit=UnitOfElectricCurrent.MILLIAMPERE,
aliases={"ma", "milliampere"},
device_classes={SensorDeviceClass.CURRENT},
conversion_unit=UnitOfElectricCurrent.AMPERE,
conversion_fn=lambda x: x / 1000,
),
UnitOfMeasurement(
unit=UnitOfEnergy.WATT_HOUR,
@ -527,8 +520,6 @@ UNITS = (
SensorDeviceClass.SULPHUR_DIOXIDE,
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
},
conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
conversion_fn=lambda x: x * 1000,
),
UnitOfMeasurement(
unit=UnitOfPower.WATT,
@ -596,8 +587,6 @@ UNITS = (
unit=UnitOfElectricPotential.MILLIVOLT,
aliases={"mv", "millivolt"},
device_classes={SensorDeviceClass.VOLTAGE},
conversion_unit=UnitOfElectricPotential.VOLT,
conversion_fn=lambda x: x / 1000,
),
)

View File

@ -382,20 +382,18 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
return
uoms = DEVICE_CLASS_UNITS[self.device_class]
self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
self.native_unit_of_measurement.lower()
)
# Unknown unit of measurement, device class should not be used.
if self._uom is None:
if uom is None:
self._attr_device_class = None
return
# Found unit of measurement, use the standardized Unit
# Use the target conversion unit (if set)
self._attr_native_unit_of_measurement = (
self._uom.conversion_unit or self._uom.unit
)
self._attr_native_unit_of_measurement = uom.unit
@property
def native_value(self) -> float | None:

View File

@ -14,6 +14,8 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
@ -98,6 +100,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -112,6 +115,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
),
@ -164,6 +168,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
@ -181,6 +186,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
*BATTERY_SENSORS,
),
@ -192,6 +198,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_monoxide",
device_class=SensorDeviceClass.CO,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
*BATTERY_SENSORS,
),
@ -278,18 +285,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.CO_VALUE,
translation_key="carbon_monoxide",
device_class=SensorDeviceClass.CO,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.CO2_VALUE,
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
@ -418,6 +428,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -432,6 +443,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
),
@ -472,6 +484,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
@ -489,12 +502,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.PM10,
translation_key="pm10",
device_class=SensorDeviceClass.PM10,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
*BATTERY_SENSORS,
),
@ -506,6 +521,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.VOC_VALUE,
@ -518,6 +534,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.VA_HUMIDITY,
@ -583,6 +600,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -597,6 +615,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
),
@ -613,6 +632,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.TEMP,
@ -637,6 +657,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="concentration_carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.TOTAL_TIME,
@ -685,6 +706,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
*BATTERY_SENSORS,
),
@ -724,6 +746,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
@ -747,6 +770,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.HUMIDITY_VALUE,
@ -759,12 +783,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="pm1",
device_class=SensorDeviceClass.PM1,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.PM10,
translation_key="pm10",
device_class=SensorDeviceClass.PM10,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
*BATTERY_SENSORS,
),
@ -945,6 +971,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -959,6 +986,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -1004,12 +1032,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="carbon_dioxide",
device_class=SensorDeviceClass.CO2,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
),
TuyaSensorEntityDescription(
key=DPCode.PM25_VALUE,
translation_key="pm25",
device_class=SensorDeviceClass.PM25,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
TuyaSensorEntityDescription(
key=DPCode.CH2O_VALUE,
@ -1057,6 +1087,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_registry_enabled_default=False,
),
TuyaSensorEntityDescription(
@ -1071,6 +1102,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_registry_enabled_default=False,
),
),
@ -1097,6 +1129,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@ -1113,6 +1146,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="voltage",
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_unit_of_measurement=UnitOfElectricPotential.VOLT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@ -1415,20 +1449,18 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
return
uoms = DEVICE_CLASS_UNITS[self.device_class]
self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
uom = uoms.get(self.native_unit_of_measurement) or uoms.get(
self.native_unit_of_measurement.lower()
)
# Unknown unit of measurement, device class should not be used.
if self._uom is None:
if uom is None:
self._attr_device_class = None
return
# Found unit of measurement, use the standardized Unit
# Use the target conversion unit (if set)
self._attr_native_unit_of_measurement = (
self._uom.conversion_unit or self._uom.unit
)
self._attr_native_unit_of_measurement = uom.unit
@property
def native_value(self) -> StateType:
@ -1450,10 +1482,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
# Scale integer/float value
if isinstance(self._type_data, IntegerTypeData):
scaled_value = self._type_data.scale_value(value)
if self._uom and self._uom.conversion_fn is not None:
return self._uom.conversion_fn(scaled_value)
return scaled_value
return self._type_data.scale_value(value)
# Unexpected enum value
if (

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@ -38,6 +39,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
vol.Optional(CONF_API_KEY, default=""): str,
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str})
class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN):
@ -77,3 +79,48 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
entry = self._get_reauth_entry()
if user_input is not None:
session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL])
uptime_kuma = UptimeKuma(
session,
entry.data[CONF_URL],
user_input[CONF_API_KEY],
)
try:
await uptime_kuma.metrics()
except UptimeKumaAuthenticationException:
errors["base"] = "invalid_auth"
except UptimeKumaException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input
),
errors=errors,
)

View File

@ -16,7 +16,7 @@ from pythonkuma import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -59,7 +59,7 @@ class UptimeKumaDataUpdateCoordinator(
try:
metrics = await self.api.metrics()
except UptimeKumaAuthenticationException as e:
raise ConfigEntryError(
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed_exception",
) from e

View File

@ -0,0 +1,23 @@
"""Diagnostics platform for Uptime Kuma."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import UptimeKumaConfigEntry
TO_REDACT = {"monitor_url", "monitor_hostname"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: UptimeKumaConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT
)

View File

@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pythonkuma"],
"quality_scale": "bronze",
"requirements": ["pythonkuma==0.3.0"]
"requirements": ["pythonkuma==0.3.1"]
}

View File

@ -38,12 +38,12 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: is not locally discoverable

View File

@ -13,6 +13,16 @@
"verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address",
"api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**"
}
},
"reauth_confirm": {
"title": "Re-authenticate with Uptime Kuma: {name}",
"description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]"
}
}
},
"error": {
@ -21,7 +31,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {

View File

@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.FAN,
Platform.LIGHT,
Platform.NUMBER,
Platform.SENSOR,

View File

@ -0,0 +1,139 @@
"""WiZ integration fan platform."""
from __future__ import annotations
import math
from typing import Any, ClassVar
from pywizlight.bulblibrary import BulbType, Features
from homeassistant.components.fan import (
DIRECTION_FORWARD,
DIRECTION_REVERSE,
FanEntity,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from . import WizConfigEntry
from .entity import WizEntity
from .models import WizData
PRESET_MODE_BREEZE = "breeze"
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WiZ Platform from config_flow."""
if entry.runtime_data.bulb.bulbtype.features.fan:
async_add_entities([WizFanEntity(entry.runtime_data, entry.title)])
class WizFanEntity(WizEntity, FanEntity):
"""Representation of WiZ Light bulb."""
_attr_name = None
# We want the implementation of is_on to be the same as in ToggleEntity,
# but it is being overridden in FanEntity, so we need to restore it here.
is_on: ClassVar = ToggleEntity.is_on
def __init__(self, wiz_data: WizData, name: str) -> None:
"""Initialize a WiZ fan."""
super().__init__(wiz_data, name)
bulb_type: BulbType = self._device.bulbtype
features: Features = bulb_type.features
supported_features = (
FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.SET_SPEED
)
if features.fan_reverse:
supported_features |= FanEntityFeature.DIRECTION
if features.fan_breeze_mode:
supported_features |= FanEntityFeature.PRESET_MODE
self._attr_preset_modes = [PRESET_MODE_BREEZE]
self._attr_supported_features = supported_features
self._attr_speed_count = bulb_type.fan_speed_range
self._async_update_attrs()
@callback
def _async_update_attrs(self) -> None:
"""Handle updating _attr values."""
state = self._device.state
self._attr_is_on = state.get_fan_state() > 0
self._attr_percentage = ranged_value_to_percentage(
(1, self.speed_count), state.get_fan_speed()
)
if FanEntityFeature.PRESET_MODE in self.supported_features:
fan_mode = state.get_fan_mode()
self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None
if FanEntityFeature.DIRECTION in self.supported_features:
fan_reverse = state.get_fan_reverse()
self._attr_current_direction = None
if fan_reverse == 0:
self._attr_current_direction = DIRECTION_FORWARD
elif fan_reverse == 1:
self._attr_current_direction = DIRECTION_REVERSE
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode of the fan."""
# preset_mode == PRESET_MODE_BREEZE
await self._device.fan_set_state(mode=2)
await self.coordinator.async_request_refresh()
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
if percentage == 0:
await self.async_turn_off()
return
speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage))
await self._device.fan_set_state(mode=1, speed=speed)
await self.coordinator.async_request_refresh()
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn on the fan."""
mode: int | None = None
speed: int | None = None
if preset_mode is not None:
self._valid_preset_mode_or_raise(preset_mode)
if preset_mode == PRESET_MODE_BREEZE:
mode = 2
if percentage is not None:
speed = math.ceil(
percentage_to_ranged_value((1, self.speed_count), percentage)
)
if mode is None:
mode = 1
await self._device.fan_turn_on(mode=mode, speed=speed)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
await self._device.fan_turn_off(**kwargs)
await self.coordinator.async_request_refresh()
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
reverse = 1 if direction == DIRECTION_REVERSE else 0
await self._device.fan_set_state(reverse=reverse)
await self.coordinator.async_request_refresh()

View File

@ -1,7 +1,7 @@
{
"domain": "wiz",
"name": "WiZ",
"codeowners": ["@sbidy"],
"codeowners": ["@sbidy", "@arturpragacz"],
"config_flow": true,
"dependencies": ["network"],
"dhcp": [

View File

@ -165,6 +165,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore(
yolink_home, device_coordinators
)
# Clean up yolink devices which are not associated to the account anymore.
device_registry = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
for device_entry in device_entries:
for identifier in device_entry.identifiers:
if (
identifier[0] == DOMAIN
and device_coordinators.get(identifier[1]) is None
):
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=entry.entry_id
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def async_yolink_unload(event) -> None:

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/youtube",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["youtubeaio==1.1.5"]
"requirements": ["youtubeaio==2.0.0"]
}

View File

@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int:
raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc
def _validate_supported_features(supported_features: int | list[str]) -> int:
"""Validate a supported feature and resolve an enum string to its value."""
if isinstance(supported_features, int):
return supported_features
def _validate_supported_features(supported_features: list[str]) -> int:
"""Validate supported features and resolve enum strings to their value."""
feature_mask = 0
@ -160,6 +157,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
)
# Legacy entity selector config schema used directly under entity selectors
# is provided for backwards compatibility and remains feature frozen.
# New filtering features should be added under the `filter` key instead.
# https://github.com/home-assistant/frontend/pull/15302
LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema(
{
# Integration that provided the entity
vol.Optional("integration"): str,
# Domain the entity belongs to
vol.Optional("domain"): vol.All(cv.ensure_list, [str]),
# Device class of the entity
vol.Optional("device_class"): vol.All(cv.ensure_list, [str]),
}
)
class EntityFilterSelectorConfig(TypedDict, total=False):
"""Class to represent a single entity selector config."""
@ -179,10 +192,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
vol.Optional("model"): str,
# Model ID of device
vol.Optional("model_id"): str,
# Device has to contain entities matching this selector
vol.Optional("entity"): vol.All(
cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA]
),
}
)
# Legacy device selector config schema used directly under device selectors
# is provided for backwards compatibility and remains feature frozen.
# New filtering features should be added under the `filter` key instead.
# https://github.com/home-assistant/frontend/pull/15302
LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema(
{
# Integration linked to it with a config entry
vol.Optional("integration"): str,
# Manufacturer of device
vol.Optional("manufacturer"): str,
# Model of device
vol.Optional("model"): str,
}
)
@ -714,9 +739,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]):
selector_type = "device"
CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema
LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema
).extend(
{
# Device has to contain entities matching this selector
vol.Optional("entity"): vol.All(
cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA]
),
vol.Optional("multiple", default=False): cv.boolean,
vol.Optional("filter"): vol.All(
cv.ensure_list,
@ -794,7 +823,7 @@ class EntitySelector(Selector[EntitySelectorConfig]):
selector_type = "entity"
CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend(
ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema
LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema
).extend(
{
vol.Optional("exclude_entities"): [str],

View File

@ -45,7 +45,7 @@ ifaddr==0.2.0
Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.10.18
orjson==3.11.0
packaging>=23.1
paho-mqtt==2.1.0
Pillow==11.3.0

View File

@ -61,7 +61,7 @@ dependencies = [
"Pillow==11.3.0",
"propcache==0.3.2",
"pyOpenSSL==25.1.0",
"orjson==3.10.18",
"orjson==3.11.0",
"packaging>=23.1",
"psutil-home-assistant==0.0.1",
"python-slugify==8.0.4",

2
requirements.txt generated
View File

@ -33,7 +33,7 @@ cryptography==45.0.3
Pillow==11.3.0
propcache==0.3.2
pyOpenSSL==25.1.0
orjson==3.10.18
orjson==3.11.0
packaging>=23.1
psutil-home-assistant==0.0.1
python-slugify==8.0.4

18
requirements_all.txt generated
View File

@ -179,7 +179,7 @@ aioacaia==0.1.14
aioairq==0.4.6
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.12
aioairzone-cloud==0.6.13
# homeassistant.components.airzone
aioairzone==1.0.0
@ -471,7 +471,7 @@ altruistclient==0.1.1
amberelectric==2.0.12
# homeassistant.components.amcrest
amcrest==1.9.8
amcrest==1.9.9
# homeassistant.components.androidtv
androidtv[async]==0.0.75
@ -845,7 +845,7 @@ eheimdigital==1.3.0
electrickiwi-api==0.9.14
# homeassistant.components.elevenlabs
elevenlabs==1.9.0
elevenlabs==2.3.0
# homeassistant.components.elgato
elgato==5.1.2
@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2
home-assistant-intents==2025.6.23
# homeassistant.components.homematicip_cloud
homematicip==2.0.6
homematicip==2.0.7
# homeassistant.components.horizon
horimote==0.4.1
@ -1234,7 +1234,7 @@ ihcsdk==2.8.5
imeon_inverter_api==0.3.12
# homeassistant.components.imgw_pib
imgw_pib==1.2.0
imgw_pib==1.4.0
# homeassistant.components.incomfort
incomfort-client==0.6.9
@ -2526,7 +2526,7 @@ python-vlc==3.0.18122
pythonegardia==1.0.52
# homeassistant.components.uptime_kuma
pythonkuma==0.3.0
pythonkuma==0.3.1
# homeassistant.components.tile
pytile==2024.12.0
@ -2538,7 +2538,7 @@ pytomorrowio==0.3.6
pytouchline_extended==0.4.5
# homeassistant.components.touchline_sl
pytouchlinesl==0.3.0
pytouchlinesl==0.4.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@ -2907,7 +2907,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.2.0
tesla-fleet-api==1.2.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@ -3172,7 +3172,7 @@ yolink-api==0.5.7
youless-api==2.2.0
# homeassistant.components.youtube
youtubeaio==1.1.5
youtubeaio==2.0.0
# homeassistant.components.media_extractor
yt-dlp[default]==2025.06.09

View File

@ -167,7 +167,7 @@ aioacaia==0.1.14
aioairq==0.4.6
# homeassistant.components.airzone_cloud
aioairzone-cloud==0.6.12
aioairzone-cloud==0.6.13
# homeassistant.components.airzone
aioairzone==1.0.0
@ -736,7 +736,7 @@ eheimdigital==1.3.0
electrickiwi-api==0.9.14
# homeassistant.components.elevenlabs
elevenlabs==1.9.0
elevenlabs==2.3.0
# homeassistant.components.elgato
elgato==5.1.2
@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2
home-assistant-intents==2025.6.23
# homeassistant.components.homematicip_cloud
homematicip==2.0.6
homematicip==2.0.7
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@ -1068,7 +1068,7 @@ igloohome-api==0.1.1
imeon_inverter_api==0.3.12
# homeassistant.components.imgw_pib
imgw_pib==1.2.0
imgw_pib==1.4.0
# homeassistant.components.incomfort
incomfort-client==0.6.9
@ -2090,7 +2090,7 @@ python-technove==2.0.0
python-telegram-bot[socks]==21.5
# homeassistant.components.uptime_kuma
pythonkuma==0.3.0
pythonkuma==0.3.1
# homeassistant.components.tile
pytile==2024.12.0
@ -2099,7 +2099,7 @@ pytile==2024.12.0
pytomorrowio==0.3.6
# homeassistant.components.touchline_sl
pytouchlinesl==0.3.0
pytouchlinesl==0.4.0
# homeassistant.components.traccar
# homeassistant.components.traccar_server
@ -2393,7 +2393,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
tesla-fleet-api==1.2.0
tesla-fleet-api==1.2.2
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@ -2619,7 +2619,7 @@ yolink-api==0.5.7
youless-api==2.2.0
# homeassistant.components.youtube
youtubeaio==1.1.5
youtubeaio==2.0.0
# homeassistant.components.media_extractor
yt-dlp[default]==2025.06.09

View File

@ -117,7 +117,6 @@ async def test_generate_data_service(
for msg_attachment, attachment in zip(
msg_attachments, task.attachments or [], strict=False
):
assert attachment.url == "http://example.com/media.mp4"
assert attachment.mime_type == "video/mp4"
assert attachment.media_content_id == msg_attachment["media_content_id"]
assert attachment.path == Path("media.mp4")

View File

@ -1,18 +1,26 @@
"""Test tasks for the AI Task integration."""
from datetime import timedelta
from pathlib import Path
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components import media_source
from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data
from homeassistant.components.camera import Image
from homeassistant.components.conversation import async_get_chat_log
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session
from homeassistant.util import dt as dt_util
from .conftest import TEST_ENTITY_ID, MockAITaskEntity
from tests.common import async_fire_time_changed
from tests.typing import WebSocketGenerator
@ -154,3 +162,83 @@ async def test_generate_data_attachments_not_supported(
}
],
)
async def test_generate_data_mixed_attachments(
hass: HomeAssistant,
init_components: None,
mock_ai_task_entity: MockAITaskEntity,
) -> None:
"""Test generating data with both camera and regular media source attachments."""
with (
patch(
"homeassistant.components.camera.async_get_image",
return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"),
) as mock_get_image,
patch(
"homeassistant.components.media_source.async_resolve_media",
return_value=media_source.PlayMedia(
url="http://example.com/test.mp4",
mime_type="video/mp4",
path=Path("/media/test.mp4"),
),
) as mock_resolve_media,
):
await async_generate_data(
hass,
task_name="Test Task",
entity_id=TEST_ENTITY_ID,
instructions="Analyze these files",
attachments=[
{
"media_content_id": "media-source://camera/camera.front_door",
"media_content_type": "image/jpeg",
},
{
"media_content_id": "media-source://media_player/video.mp4",
"media_content_type": "video/mp4",
},
],
)
# Verify both methods were called
mock_get_image.assert_called_once_with(hass, "camera.front_door")
mock_resolve_media.assert_called_once_with(
hass, "media-source://media_player/video.mp4", None
)
# Check attachments
assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1
task = mock_ai_task_entity.mock_generate_data_tasks[0]
assert task.attachments is not None
assert len(task.attachments) == 2
# Check camera attachment
camera_attachment = task.attachments[0]
assert (
camera_attachment.media_content_id == "media-source://camera/camera.front_door"
)
assert camera_attachment.mime_type == "image/jpeg"
assert isinstance(camera_attachment.path, Path)
assert camera_attachment.path.suffix == ".jpg"
# Verify camera snapshot content
assert camera_attachment.path.exists()
content = await hass.async_add_executor_job(camera_attachment.path.read_bytes)
assert content == b"fake_camera_jpeg"
# Trigger clean up
async_fire_time_changed(
hass,
dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1),
)
await hass.async_block_till_done()
# Verify the temporary file cleaned up
assert not camera_attachment.path.exists()
# Check regular media attachment
media_attachment = task.attachments[1]
assert media_attachment.media_content_id == "media-source://media_player/video.mp4"
assert media_attachment.mime_type == "video/mp4"
assert media_attachment.path == Path("/media/test.mp4")

View File

@ -1 +1,13 @@
"""Tests for the amberelectric integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -1,10 +1,59 @@
"""Provide common Amber fixtures."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from collections.abc import AsyncGenerator, Generator
from unittest.mock import AsyncMock, Mock, patch
from amberelectric.models.interval import Interval
import pytest
from homeassistant.components.amberelectric.const import (
CONF_SITE_ID,
CONF_SITE_NAME,
DOMAIN,
)
from homeassistant.const import CONF_API_TOKEN
from .helpers import (
CONTROLLED_LOAD_CHANNEL,
FEED_IN_CHANNEL,
FORECASTS,
GENERAL_AND_CONTROLLED_SITE_ID,
GENERAL_AND_FEED_IN_SITE_ID,
GENERAL_CHANNEL,
GENERAL_CHANNEL_WITH_RANGE,
GENERAL_FORECASTS,
GENERAL_ONLY_SITE_ID,
)
from tests.common import MockConfigEntry
MOCK_API_TOKEN = "psk_0000000000000000"
def create_amber_config_entry(
site_id: str, entry_id: str, name: str
) -> MockConfigEntry:
"""Create an Amber config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_SITE_NAME: name,
CONF_SITE_ID: site_id,
},
entry_id=entry_id,
)
@pytest.fixture
def mock_amber_client() -> Generator[AsyncMock]:
"""Mock the Amber API client."""
with patch(
"homeassistant.components.amberelectric.amberelectric.AmberApi",
autospec=True,
) as mock_client:
yield mock_client
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.amberelectric.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
async def general_channel_config_entry():
"""Generate the default Amber config entry."""
return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home")
@pytest.fixture
async def general_channel_and_controlled_load_config_entry():
"""Generate the default Amber config entry for site with controlled load."""
return create_amber_config_entry(
GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home"
)
@pytest.fixture
async def general_channel_and_feed_in_config_entry():
"""Generate the default Amber config entry for site with feed in."""
return create_amber_config_entry(
GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home"
)
@pytest.fixture
def general_channel_prices() -> list[Interval]:
"""List containing general channel prices."""
return GENERAL_CHANNEL
@pytest.fixture
def general_channel_prices_with_range() -> list[Interval]:
"""List containing general channel prices."""
return GENERAL_CHANNEL_WITH_RANGE
@pytest.fixture
def controlled_load_channel_prices() -> list[Interval]:
"""List containing controlled load channel prices."""
return CONTROLLED_LOAD_CHANNEL
@pytest.fixture
def feed_in_channel_prices() -> list[Interval]:
"""List containing feed in channel prices."""
return FEED_IN_CHANNEL
@pytest.fixture
def forecast_prices() -> list[Interval]:
"""List containing forecasts with advanced prices."""
return FORECASTS
@pytest.fixture
def general_forecast_prices() -> list[Interval]:
"""List containing forecasts with advanced prices."""
return GENERAL_FORECASTS
@pytest.fixture
def mock_amber_client_general_channel(
mock_amber_client: AsyncMock, general_channel_prices: list[Interval]
) -> Generator[AsyncMock]:
"""Fake general channel prices."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = general_channel_prices
return mock_amber_client
@pytest.fixture
def mock_amber_client_general_channel_with_range(
mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval]
) -> Generator[AsyncMock]:
"""Fake general channel prices with a range."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = general_channel_prices_with_range
return mock_amber_client
@pytest.fixture
def mock_amber_client_general_and_controlled_load(
mock_amber_client: AsyncMock,
general_channel_prices: list[Interval],
controlled_load_channel_prices: list[Interval],
) -> Generator[AsyncMock]:
"""Fake general channel and controlled load channel prices."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = (
general_channel_prices + controlled_load_channel_prices
)
return mock_amber_client
@pytest.fixture
async def mock_amber_client_general_and_feed_in(
mock_amber_client: AsyncMock,
general_channel_prices: list[Interval],
feed_in_channel_prices: list[Interval],
) -> AsyncGenerator[Mock]:
"""Set up general channel and feed in channel."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = (
general_channel_prices + feed_in_channel_prices
)
return mock_amber_client
@pytest.fixture
async def mock_amber_client_forecasts(
mock_amber_client: AsyncMock, forecast_prices: list[Interval]
) -> AsyncGenerator[Mock]:
"""Set up general channel, controlled load and feed in channel."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = forecast_prices
return mock_amber_client
@pytest.fixture
async def mock_amber_client_general_forecasts(
mock_amber_client: AsyncMock, general_forecast_prices: list[Interval]
) -> AsyncGenerator[Mock]:
"""Set up general channel only."""
client = mock_amber_client.return_value
client.get_current_prices.return_value = general_forecast_prices
return mock_amber_client

View File

@ -3,11 +3,13 @@
from datetime import datetime, timedelta
from amberelectric.models.actual_interval import ActualInterval
from amberelectric.models.advanced_price import AdvancedPrice
from amberelectric.models.channel import ChannelType
from amberelectric.models.current_interval import CurrentInterval
from amberelectric.models.forecast_interval import ForecastInterval
from amberelectric.models.interval import Interval
from amberelectric.models.price_descriptor import PriceDescriptor
from amberelectric.models.range import Range
from amberelectric.models.spike_status import SpikeStatus
from dateutil import parser
@ -15,12 +17,16 @@ from dateutil import parser
def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval:
"""Generate a mock actual interval."""
start_time = end_time - timedelta(minutes=30)
if channel_type == ChannelType.CONTROLLEDLOAD:
per_kwh = 4.4
if channel_type == ChannelType.FEEDIN:
per_kwh = 1.1
return Interval(
ActualInterval(
type="ActualInterval",
duration=30,
spot_per_kwh=1.0,
per_kwh=8.0,
per_kwh=per_kwh,
date=start_time.date(),
nem_time=end_time,
start_time=start_time,
@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I
def generate_current_interval(
channel_type: ChannelType, end_time: datetime
channel_type: ChannelType,
end_time: datetime,
range=False,
) -> Interval:
"""Generate a mock current price."""
start_time = end_time - timedelta(minutes=30)
return Interval(
per_kwh = 8.8
if channel_type == ChannelType.CONTROLLEDLOAD:
per_kwh = 4.4
if channel_type == ChannelType.FEEDIN:
per_kwh = 1.1
interval = Interval(
CurrentInterval(
type="CurrentInterval",
duration=30,
spot_per_kwh=1.0,
per_kwh=8.0,
per_kwh=per_kwh,
date=start_time.date(),
nem_time=end_time,
start_time=start_time,
@ -56,18 +69,28 @@ def generate_current_interval(
)
)
if range:
interval.actual_instance.range = Range(min=6.7, max=9.1)
return interval
def generate_forecast_interval(
channel_type: ChannelType, end_time: datetime
channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False
) -> Interval:
"""Generate a mock forecast interval."""
start_time = end_time - timedelta(minutes=30)
return Interval(
per_kwh = 8.8
if channel_type == ChannelType.CONTROLLEDLOAD:
per_kwh = 4.4
if channel_type == ChannelType.FEEDIN:
per_kwh = 1.1
interval = Interval(
ForecastInterval(
type="ForecastInterval",
duration=30,
spot_per_kwh=1.1,
per_kwh=8.8,
per_kwh=per_kwh,
date=start_time.date(),
nem_time=end_time,
start_time=start_time,
@ -79,12 +102,20 @@ def generate_forecast_interval(
estimate=True,
)
)
if range:
interval.actual_instance.range = Range(min=6.7, max=9.1)
if advanced_price:
interval.actual_instance.advanced_price = AdvancedPrice(
low=6.7, predicted=9.0, high=10.2
)
return interval
GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ"
GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162"
GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S"
GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S"
GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG"
GENERAL_CHANNEL = [
generate_current_interval(
@ -101,6 +132,21 @@ GENERAL_CHANNEL = [
),
]
GENERAL_CHANNEL_WITH_RANGE = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True
),
generate_forecast_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True
),
generate_forecast_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True
),
generate_forecast_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True
),
]
CONTROLLED_LOAD_CHANNEL = [
generate_current_interval(
ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00")
@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [
ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00")
),
]
GENERAL_FORECASTS = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T09:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T09:30:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T10:00:00+10:00"),
range=True,
advanced_price=True,
),
]
FORECASTS = [
generate_current_interval(
ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_current_interval(
ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_current_interval(
ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00")
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T09:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T09:30:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.GENERAL,
parser.parse("2021-09-21T10:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.CONTROLLEDLOAD,
parser.parse("2021-09-21T09:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.CONTROLLEDLOAD,
parser.parse("2021-09-21T09:30:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.CONTROLLEDLOAD,
parser.parse("2021-09-21T10:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.FEEDIN,
parser.parse("2021-09-21T09:00:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.FEEDIN,
parser.parse("2021-09-21T09:30:00+10:00"),
range=True,
advanced_price=True,
),
generate_forecast_interval(
ChannelType.FEEDIN,
parser.parse("2021-09-21T10:00:00+10:00"),
range=True,
advanced_price=True,
),
]

View File

@ -9,7 +9,6 @@ from unittest.mock import Mock, patch
from amberelectric import ApiException
from amberelectric.models.channel import Channel, ChannelType
from amberelectric.models.interval import Interval
from amberelectric.models.price_descriptor import PriceDescriptor
from amberelectric.models.site import Site
from amberelectric.models.site_status import SiteStatus
from amberelectric.models.spike_status import SpikeStatus
@ -17,10 +16,7 @@ from dateutil import parser
import pytest
from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME
from homeassistant.components.amberelectric.coordinator import (
AmberUpdateCoordinator,
normalize_descriptor,
)
from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import UpdateFailed
@ -98,18 +94,6 @@ def mock_api_current_price() -> Generator:
yield instance
def test_normalize_descriptor() -> None:
"""Test normalizing descriptors works correctly."""
assert normalize_descriptor(None) is None
assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative"
assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low"
assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low"
assert normalize_descriptor(PriceDescriptor.LOW) == "low"
assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral"
assert normalize_descriptor(PriceDescriptor.HIGH) == "high"
assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike"
async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None:
"""Test fetching a site with only a general channel."""
@ -120,7 +104,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock)
result = await data_service._async_update_data()
current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
GENERAL_ONLY_SITE_ID, next=288
)
assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
@ -152,7 +136,7 @@ async def test_fetch_no_general_site(
await data_service._async_update_data()
current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
GENERAL_ONLY_SITE_ID, next=288
)
@ -166,7 +150,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) ->
result = await data_service._async_update_data()
current_price_api.get_current_prices.assert_called_with(
GENERAL_ONLY_SITE_ID, next=48
GENERAL_ONLY_SITE_ID, next=288
)
assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
@ -217,7 +201,7 @@ async def test_fetch_general_and_controlled_load_site(
result = await data_service._async_update_data()
current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_CONTROLLED_SITE_ID, next=48
GENERAL_AND_CONTROLLED_SITE_ID, next=288
)
assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance
@ -257,7 +241,7 @@ async def test_fetch_general_and_feed_in_site(
result = await data_service._async_update_data()
current_price_api.get_current_prices.assert_called_with(
GENERAL_AND_FEED_IN_SITE_ID, next=48
GENERAL_AND_FEED_IN_SITE_ID, next=288
)
assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance

View File

@ -0,0 +1,17 @@
"""Test formatters."""
from amberelectric.models.price_descriptor import PriceDescriptor
from homeassistant.components.amberelectric.helpers import normalize_descriptor
def test_normalize_descriptor() -> None:
"""Test normalizing descriptors works correctly."""
assert normalize_descriptor(None) is None
assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative"
assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low"
assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low"
assert normalize_descriptor(PriceDescriptor.LOW) == "low"
assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral"
assert normalize_descriptor(PriceDescriptor.HIGH) == "high"
assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike"

View File

@ -1,119 +1,26 @@
"""Test the Amber Electric Sensors."""
from collections.abc import AsyncGenerator
from unittest.mock import Mock, patch
from amberelectric.models.current_interval import CurrentInterval
from amberelectric.models.interval import Interval
from amberelectric.models.range import Range
import pytest
from homeassistant.components.amberelectric.const import (
CONF_SITE_ID,
CONF_SITE_NAME,
DOMAIN,
)
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .helpers import (
CONTROLLED_LOAD_CHANNEL,
FEED_IN_CHANNEL,
GENERAL_AND_CONTROLLED_SITE_ID,
GENERAL_AND_FEED_IN_SITE_ID,
GENERAL_CHANNEL,
GENERAL_ONLY_SITE_ID,
)
from tests.common import MockConfigEntry
MOCK_API_TOKEN = "psk_0000000000000000"
from . import MockConfigEntry, setup_integration
@pytest.fixture
async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]:
"""Set up general channel."""
MockConfigEntry(
domain="amberelectric",
data={
CONF_SITE_NAME: "mock_title",
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_SITE_ID: GENERAL_ONLY_SITE_ID,
},
).add_to_hass(hass)
instance = Mock()
with patch(
"amberelectric.AmberApi",
return_value=instance,
) as mock_update:
instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@pytest.fixture
async def setup_general_and_controlled_load(
hass: HomeAssistant,
) -> AsyncGenerator[Mock]:
"""Set up general channel and controller load channel."""
MockConfigEntry(
domain="amberelectric",
data={
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID,
},
).add_to_hass(hass)
instance = Mock()
with patch(
"amberelectric.AmberApi",
return_value=instance,
) as mock_update:
instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
@pytest.fixture
async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]:
"""Set up general channel and feed in channel."""
MockConfigEntry(
domain="amberelectric",
data={
CONF_API_TOKEN: MOCK_API_TOKEN,
CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID,
},
).add_to_hass(hass)
instance = Mock()
with patch(
"amberelectric.AmberApi",
return_value=instance,
) as mock_update:
instance.get_current_prices = Mock(
return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL
)
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
yield mock_update.return_value
async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_channel")
async def test_general_price_sensor(
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Test the General Price sensor."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
price = hass.states.get("sensor.mock_title_general_price")
assert price
assert price.state == "0.08"
assert price.state == "0.09"
attributes = price.attributes
assert attributes["duration"] == 30
assert attributes["date"] == "2021-09-21"
assert attributes["per_kwh"] == 0.08
assert attributes["per_kwh"] == 0.09
assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00"
assert attributes["spot_per_kwh"] == 0.01
assert attributes["start_time"] == "2021-09-21T08:00:00+10:00"
@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) ->
assert attributes.get("range_min") is None
assert attributes.get("range_max") is None
with_range: list[CurrentInterval] = GENERAL_CHANNEL
with_range[0].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range")
async def test_general_price_sensor_with_range(
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Test the General Price sensor with a range."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
price = hass.states.get("sensor.mock_title_general_price")
assert price
attributes = price.attributes
assert attributes.get("range_min") == 0.08
assert attributes.get("range_max") == 0.12
assert attributes.get("range_min") == 0.07
assert attributes.get("range_max") == 0.09
@pytest.mark.usefixtures("setup_general_and_controlled_load")
async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load")
async def test_general_and_controlled_load_price_sensor(
hass: HomeAssistant,
general_channel_and_controlled_load_config_entry: MockConfigEntry,
) -> None:
"""Test the Controlled Price sensor."""
await setup_integration(hass, general_channel_and_controlled_load_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_controlled_load_price")
assert price
assert price.state == "0.08"
assert price.state == "0.04"
attributes = price.attributes
assert attributes["duration"] == 30
assert attributes["date"] == "2021-09-21"
assert attributes["per_kwh"] == 0.08
assert attributes["per_kwh"] == 0.04
assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00"
assert attributes["spot_per_kwh"] == 0.01
assert attributes["start_time"] == "2021-09-21T08:00:00+10:00"
@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) ->
assert attributes["attribution"] == "Data provided by Amber Electric"
@pytest.mark.usefixtures("setup_general_and_feed_in")
async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in")
async def test_general_and_feed_in_price_sensor(
hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry
) -> None:
"""Test the Feed In sensor."""
await setup_integration(hass, general_channel_and_feed_in_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_feed_in_price")
assert price
assert price.state == "-0.08"
assert price.state == "-0.01"
attributes = price.attributes
assert attributes["duration"] == 30
assert attributes["date"] == "2021-09-21"
assert attributes["per_kwh"] == -0.08
assert attributes["per_kwh"] == -0.01
assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00"
assert attributes["spot_per_kwh"] == 0.01
assert attributes["start_time"] == "2021-09-21T08:00:00+10:00"
@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None:
assert attributes["attribution"] == "Data provided by Amber Electric"
@pytest.mark.usefixtures("mock_amber_client_general_channel")
async def test_general_forecast_sensor(
hass: HomeAssistant, setup_general: Mock
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Test the General Forecast sensor."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
price = hass.states.get("sensor.mock_title_general_forecast")
assert price
@ -212,29 +128,33 @@ async def test_general_forecast_sensor(
assert first_forecast.get("range_min") is None
assert first_forecast.get("range_max") is None
with_range: list[Interval] = GENERAL_CHANNEL
with_range[1].actual_instance.range = Range(min=7.8, max=12.4)
setup_general.get_current_price.return_value = with_range
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_reload(config_entry.entry_id)
await hass.async_block_till_done()
@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range")
async def test_general_forecast_sensor_with_range(
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Test the General Forecast sensor with a range."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
price = hass.states.get("sensor.mock_title_general_forecast")
assert price
attributes = price.attributes
first_forecast = attributes["forecasts"][0]
assert first_forecast.get("range_min") == 0.08
assert first_forecast.get("range_max") == 0.12
assert first_forecast.get("range_min") == 0.07
assert first_forecast.get("range_max") == 0.09
@pytest.mark.usefixtures("setup_general_and_controlled_load")
async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load")
async def test_controlled_load_forecast_sensor(
hass: HomeAssistant,
general_channel_and_controlled_load_config_entry: MockConfigEntry,
) -> None:
"""Test the Controlled Load Forecast sensor."""
await setup_integration(hass, general_channel_and_controlled_load_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_controlled_load_forecast")
assert price
assert price.state == "0.09"
assert price.state == "0.04"
attributes = price.attributes
assert attributes["channel_type"] == "controlledLoad"
assert attributes["attribution"] == "Data provided by Amber Electric"
@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None:
first_forecast = attributes["forecasts"][0]
assert first_forecast["duration"] == 30
assert first_forecast["date"] == "2021-09-21"
assert first_forecast["per_kwh"] == 0.09
assert first_forecast["per_kwh"] == 0.04
assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00"
assert first_forecast["spot_per_kwh"] == 0.01
assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00"
@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None:
assert first_forecast["descriptor"] == "very_low"
@pytest.mark.usefixtures("setup_general_and_feed_in")
async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in")
async def test_feed_in_forecast_sensor(
hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry
) -> None:
"""Test the Feed In Forecast sensor."""
await setup_integration(hass, general_channel_and_feed_in_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_feed_in_forecast")
assert price
assert price.state == "-0.09"
assert price.state == "-0.01"
attributes = price.attributes
assert attributes["channel_type"] == "feedIn"
assert attributes["attribution"] == "Data provided by Amber Electric"
@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None:
first_forecast = attributes["forecasts"][0]
assert first_forecast["duration"] == 30
assert first_forecast["date"] == "2021-09-21"
assert first_forecast["per_kwh"] == -0.09
assert first_forecast["per_kwh"] == -0.01
assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00"
assert first_forecast["spot_per_kwh"] == 0.01
assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00"
@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None:
assert first_forecast["descriptor"] == "very_low"
@pytest.mark.usefixtures("setup_general")
def test_renewable_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_channel")
async def test_renewable_sensor(
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Testing the creation of the Amber renewables sensor."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
sensor = hass.states.get("sensor.mock_title_renewables")
assert sensor
assert sensor.state == "51"
@pytest.mark.usefixtures("setup_general")
def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_channel")
async def test_general_price_descriptor_descriptor_sensor(
hass: HomeAssistant, general_channel_config_entry: MockConfigEntry
) -> None:
"""Test the General Price Descriptor sensor."""
await setup_integration(hass, general_channel_config_entry)
assert len(hass.states.async_all()) == 6
price = hass.states.get("sensor.mock_title_general_price_descriptor")
assert price
assert price.state == "extremely_low"
@pytest.mark.usefixtures("setup_general_and_controlled_load")
def test_general_and_controlled_load_price_descriptor_sensor(
@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load")
async def test_general_and_controlled_load_price_descriptor_sensor(
hass: HomeAssistant,
general_channel_and_controlled_load_config_entry: MockConfigEntry,
) -> None:
"""Test the Controlled Price Descriptor sensor."""
await setup_integration(hass, general_channel_and_controlled_load_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor")
assert price
assert price.state == "extremely_low"
@pytest.mark.usefixtures("setup_general_and_feed_in")
def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in")
async def test_general_and_feed_in_price_descriptor_sensor(
hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry
) -> None:
"""Test the Feed In Price Descriptor sensor."""
await setup_integration(hass, general_channel_and_feed_in_config_entry)
assert len(hass.states.async_all()) == 9
price = hass.states.get("sensor.mock_title_feed_in_price_descriptor")
assert price

View File

@ -0,0 +1,202 @@
"""Test the Amber Service object."""
import re
import pytest
import voluptuous as vol
from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS
from homeassistant.components.amberelectric.services import (
ATTR_CHANNEL_TYPE,
ATTR_CONFIG_ENTRY_ID,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from . import setup_integration
from .helpers import (
GENERAL_AND_CONTROLLED_SITE_ID,
GENERAL_AND_FEED_IN_SITE_ID,
GENERAL_ONLY_SITE_ID,
)
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_amber_client_forecasts")
async def test_get_general_forecasts(
hass: HomeAssistant,
general_channel_config_entry: MockConfigEntry,
) -> None:
"""Test fetching general forecasts."""
await setup_integration(hass, general_channel_config_entry)
result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"},
blocking=True,
return_response=True,
)
assert len(result["forecasts"]) == 3
first = result["forecasts"][0]
assert first["duration"] == 30
assert first["date"] == "2021-09-21"
assert first["nem_date"] == "2021-09-21T09:00:00+10:00"
assert first["per_kwh"] == 0.09
assert first["spot_per_kwh"] == 0.01
assert first["start_time"] == "2021-09-21T08:30:00+10:00"
assert first["end_time"] == "2021-09-21T09:00:00+10:00"
assert first["renewables"] == 50
assert first["spike_status"] == "none"
assert first["descriptor"] == "very_low"
@pytest.mark.usefixtures("mock_amber_client_forecasts")
async def test_get_controlled_load_forecasts(
hass: HomeAssistant,
general_channel_and_controlled_load_config_entry: MockConfigEntry,
) -> None:
"""Test fetching general forecasts."""
await setup_integration(hass, general_channel_and_controlled_load_config_entry)
result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID,
ATTR_CHANNEL_TYPE: "controlled_load",
},
blocking=True,
return_response=True,
)
assert len(result["forecasts"]) == 3
first = result["forecasts"][0]
assert first["duration"] == 30
assert first["date"] == "2021-09-21"
assert first["nem_date"] == "2021-09-21T09:00:00+10:00"
assert first["per_kwh"] == 0.04
assert first["spot_per_kwh"] == 0.01
assert first["start_time"] == "2021-09-21T08:30:00+10:00"
assert first["end_time"] == "2021-09-21T09:00:00+10:00"
assert first["renewables"] == 50
assert first["spike_status"] == "none"
assert first["descriptor"] == "very_low"
@pytest.mark.usefixtures("mock_amber_client_forecasts")
async def test_get_feed_in_forecasts(
hass: HomeAssistant,
general_channel_and_feed_in_config_entry: MockConfigEntry,
) -> None:
"""Test fetching general forecasts."""
await setup_integration(hass, general_channel_and_feed_in_config_entry)
result = await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID,
ATTR_CHANNEL_TYPE: "feed_in",
},
blocking=True,
return_response=True,
)
assert len(result["forecasts"]) == 3
first = result["forecasts"][0]
assert first["duration"] == 30
assert first["date"] == "2021-09-21"
assert first["nem_date"] == "2021-09-21T09:00:00+10:00"
assert first["per_kwh"] == -0.01
assert first["spot_per_kwh"] == 0.01
assert first["start_time"] == "2021-09-21T08:30:00+10:00"
assert first["end_time"] == "2021-09-21T09:00:00+10:00"
assert first["renewables"] == 50
assert first["spike_status"] == "none"
assert first["descriptor"] == "very_low"
@pytest.mark.usefixtures("mock_amber_client_forecasts")
async def test_incorrect_channel_type(
hass: HomeAssistant,
general_channel_config_entry: MockConfigEntry,
) -> None:
"""Test error when the channel type is incorrect."""
await setup_integration(hass, general_channel_config_entry)
with pytest.raises(
vol.error.MultipleInvalid,
match=re.escape(
"value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']"
),
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID,
ATTR_CHANNEL_TYPE: "incorrect",
},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("mock_amber_client_general_forecasts")
async def test_unavailable_channel_type(
hass: HomeAssistant,
general_channel_config_entry: MockConfigEntry,
) -> None:
"""Test error when the channel type is not found."""
await setup_integration(hass, general_channel_config_entry)
with pytest.raises(
ServiceValidationError, match="There is no controlled_load channel at this site"
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID,
ATTR_CHANNEL_TYPE: "controlled_load",
},
blocking=True,
return_response=True,
)
@pytest.mark.usefixtures("mock_amber_client_forecasts")
async def test_service_entry_availability(
hass: HomeAssistant,
general_channel_config_entry: MockConfigEntry,
) -> None:
"""Test the services without valid entry."""
general_channel_config_entry.add_to_hass(hass)
mock_config_entry2 = MockConfigEntry(domain=DOMAIN)
mock_config_entry2.add_to_hass(hass)
await hass.config_entries.async_setup(general_channel_config_entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{
ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id,
ATTR_CHANNEL_TYPE: "general",
},
blocking=True,
return_response=True,
)
with pytest.raises(
ServiceValidationError,
match='Config entry "bad-config_id" not found in registry',
):
await hass.services.async_call(
DOMAIN,
SERVICE_GET_FORECASTS,
{ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"},
blocking=True,
return_response=True,
)

View File

@ -28,7 +28,8 @@ def mock_setup_entry() -> Generator[AsyncMock]:
def _client_mock():
client_mock = AsyncMock()
client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES)
client_mock.models.get_all.return_value = MOCK_MODELS
client_mock.models.list.return_value = MOCK_MODELS
return client_mock
@ -44,6 +45,10 @@ def mock_async_client() -> Generator[AsyncMock]:
"homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs",
new=mock_async_client,
),
patch(
"homeassistant.components.elevenlabs.tts.AsyncElevenLabs",
new=mock_async_client,
),
):
yield mock_async_client
@ -52,8 +57,12 @@ def mock_async_client() -> Generator[AsyncMock]:
def mock_async_client_api_error() -> Generator[AsyncMock]:
"""Override async ElevenLabs client with ApiError side effect."""
client_mock = _client_mock()
client_mock.models.get_all.side_effect = ApiError
client_mock.voices.get_all.side_effect = ApiError
api_error = ApiError()
api_error.body = {
"detail": {"status": "invalid_api_key", "message": "API key is invalid"}
}
client_mock.models.list.side_effect = api_error
client_mock.voices.get_all.side_effect = api_error
with (
patch(
@ -68,11 +77,51 @@ def mock_async_client_api_error() -> Generator[AsyncMock]:
yield mock_async_client
@pytest.fixture
def mock_async_client_voices_error() -> Generator[AsyncMock]:
"""Override async ElevenLabs client with ApiError side effect."""
client_mock = _client_mock()
api_error = ApiError()
api_error.body = {
"detail": {
"status": "voices_unauthorized",
"message": "API is unauthorized for voices",
}
}
client_mock.voices.get_all.side_effect = api_error
with patch(
"homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs",
return_value=client_mock,
) as mock_async_client:
yield mock_async_client
@pytest.fixture
def mock_async_client_models_error() -> Generator[AsyncMock]:
"""Override async ElevenLabs client with ApiError side effect."""
client_mock = _client_mock()
api_error = ApiError()
api_error.body = {
"detail": {
"status": "models_unauthorized",
"message": "API is unauthorized for models",
}
}
client_mock.models.list.side_effect = api_error
with patch(
"homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs",
return_value=client_mock,
) as mock_async_client:
yield mock_async_client
@pytest.fixture
def mock_async_client_connect_error() -> Generator[AsyncMock]:
"""Override async ElevenLabs client."""
client_mock = _client_mock()
client_mock.models.get_all.side_effect = ConnectError("Unknown")
client_mock.models.list.side_effect = ConnectError("Unknown")
client_mock.voices.get_all.side_effect = ConnectError("Unknown")
with (
patch(

Some files were not shown because too many files have changed in this diff Show More