diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index e6bd59e3d12..de4181b21b6 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.9", "deebot-client==7.2.0"] + "requirements": ["py-sucks==0.9.10", "deebot-client==7.2.0"] } diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index 3b7cf8813a9..0f7417f41b5 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -292,7 +292,8 @@ class TimerManager: timer.cancel() - self.handlers[timer.device_id](TimerEventType.CANCELLED, timer) + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.CANCELLED, timer) _LOGGER.debug( "Timer cancelled: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -320,7 +321,8 @@ class TimerManager: name=f"Timer {timer_id}", ) - self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) if seconds > 0: log_verb = "increased" @@ -357,7 +359,8 @@ class TimerManager: task = self.timer_tasks.pop(timer_id) task.cancel() - self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) _LOGGER.debug( "Timer paused: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -382,7 +385,8 @@ class TimerManager: name=f"Timer {timer.id}", ) - self.handlers[timer.device_id](TimerEventType.UPDATED, timer) + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.UPDATED, timer) _LOGGER.debug( "Timer unpaused: id=%s, name=%s, seconds_left=%s, device_id=%s", timer_id, @@ -397,7 +401,8 @@ class TimerManager: timer.finish() - self.handlers[timer.device_id](TimerEventType.FINISHED, timer) + if timer.device_id in self.handlers: + self.handlers[timer.device_id](TimerEventType.FINISHED, timer) _LOGGER.debug( "Timer finished: id=%s, name=%s, device_id=%s", timer_id, diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index fe6650cbd0f..55d33e2ca41 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -220,6 +220,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -309,13 +310,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): """Publish via mqtt.""" variables = {"action": action, "code": code} payload = self._command_template(None, variables=variables) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) def _validate_code(self, code: str | None, state: str) -> bool: """Validate given code.""" diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 61e5074378d..f1baaf515f1 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -26,7 +26,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt @@ -248,6 +248,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 93fe0c4598e..b5fe2f17f64 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -14,13 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -91,10 +85,4 @@ class MqttButton(MqttEntity, ButtonEntity): This method is a coroutine. """ payload = self._command_template(self._config[CONF_PAYLOAD_PRESS]) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 2c6346f5794..091db98b95a 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -13,7 +13,7 @@ from homeassistant.components import camera from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -124,6 +124,7 @@ class MqttCamera(MqttEntity, Camera): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": None, + "job_type": HassJobType.Callback, } }, ) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 16db9a45b58..50b953c22d8 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -201,6 +201,7 @@ def async_subscribe_internal( msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int = DEFAULT_QOS, encoding: str | None = DEFAULT_ENCODING, + job_type: HassJobType | None = None, ) -> CALLBACK_TYPE: """Subscribe to an MQTT topic. @@ -228,7 +229,7 @@ def async_subscribe_internal( translation_domain=DOMAIN, translation_placeholders={"topic": topic}, ) - return client.async_subscribe(topic, msg_callback, qos, encoding) + return client.async_subscribe(topic, msg_callback, qos, encoding, job_type) @bind_hass @@ -867,12 +868,14 @@ class MQTT: msg_callback: Callable[[ReceiveMessage], Coroutine[Any, Any, None] | None], qos: int, encoding: str | None = None, + job_type: HassJobType | None = None, ) -> Callable[[], None]: """Set up a subscription to a topic with the provided qos.""" if not isinstance(topic, str): raise HomeAssistantError("Topic needs to be a string!") - job_type = get_hassjob_callable_job_type(msg_callback) + if job_type is None: + job_type = get_hassjob_callable_job_type(msg_callback) if job_type is not HassJobType.Callback: # Only wrap the callback with catch_log_exception # if it is not a simple callback since we catch diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 57f71008ecc..d0a9175d9fc 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -43,7 +43,7 @@ from homeassistant.const import ( PRECISION_WHOLE, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -429,6 +429,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): "entity_id": self.entity_id, "qos": qos, "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } def render_template( @@ -515,13 +516,7 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): async def _publish(self, topic: str, payload: PublishPayloadType) -> None: if self._topic[topic] is not None: - await self.async_publish( - self._topic[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._topic[topic], payload) async def _set_climate_attribute( self, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index a4c7c1d8b3b..c0ee5d4254b 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -28,7 +28,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType @@ -478,6 +478,7 @@ class MqttCover(MqttEntity, CoverEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } if self._config.get(CONF_STATE_TOPIC): @@ -491,6 +492,7 @@ class MqttCover(MqttEntity, CoverEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } if self._config.get(CONF_TILT_STATUS_TOPIC) is not None: @@ -504,6 +506,7 @@ class MqttCover(MqttEntity, CoverEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } self._sub_state = subscription.async_prepare_subscribe_topics( @@ -519,12 +522,8 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_OPEN], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OPEN] ) if self._optimistic: # Optimistically assume that cover has changed state. @@ -538,12 +537,8 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_CLOSE], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_CLOSE] ) if self._optimistic: # Optimistically assume that cover has changed state. @@ -557,12 +552,8 @@ class MqttCover(MqttEntity, CoverEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_STOP], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP] ) async def async_open_cover_tilt(self, **kwargs: Any) -> None: @@ -577,12 +568,8 @@ class MqttCover(MqttEntity, CoverEntity): "tilt_max": self._config.get(CONF_TILT_MAX), } tilt_payload = self._set_tilt_template(tilt_open_position, variables=variables) - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload ) if self._tilt_optimistic: self._attr_current_cover_tilt_position = self._tilt_open_percentage @@ -602,12 +589,8 @@ class MqttCover(MqttEntity, CoverEntity): tilt_payload = self._set_tilt_template( tilt_closed_position, variables=variables ) - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_payload ) if self._tilt_optimistic: self._attr_current_cover_tilt_position = self._tilt_closed_percentage @@ -630,13 +613,8 @@ class MqttCover(MqttEntity, CoverEntity): "tilt_max": self._config.get(CONF_TILT_MAX), } tilt_rendered = self._set_tilt_template(tilt_ranged, variables=variables) - - await self.async_publish( - self._config[CONF_TILT_COMMAND_TOPIC], - tilt_rendered, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TILT_COMMAND_TOPIC], tilt_rendered ) if self._tilt_optimistic: _LOGGER.debug("Set tilt value optimistic") @@ -660,13 +638,8 @@ class MqttCover(MqttEntity, CoverEntity): position_rendered = self._set_position_template( position_ranged, variables=variables ) - - await self.async_publish( - self._config[CONF_SET_POSITION_TOPIC], - position_rendered, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_SET_POSITION_TOPIC], position_rendered ) if self._optimistic: self._update_state( diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 72bf1596164..13de33923a1 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -3,10 +3,8 @@ from __future__ import annotations from collections import deque -from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from functools import wraps import time from typing import TYPE_CHECKING, Any @@ -21,34 +19,6 @@ from .models import DATA_MQTT, MessageCallbackType, PublishPayloadType STORED_MESSAGES = 10 -def log_messages( - hass: HomeAssistant, entity_id: str -) -> Callable[[MessageCallbackType], MessageCallbackType]: - """Wrap an MQTT message callback to support message logging.""" - - debug_info_entities = hass.data[DATA_MQTT].debug_info_entities - - def _log_message(msg: Any) -> None: - """Log message.""" - messages = debug_info_entities[entity_id]["subscriptions"][ - msg.subscribed_topic - ]["messages"] - if msg not in messages: - messages.append(msg) - - def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: - @wraps(msg_callback) - def wrapper(msg: Any) -> None: - """Log message.""" - _log_message(msg) - msg_callback(msg) - - setattr(wrapper, "__entity_id", entity_id) - return wrapper - - return _decorator - - @dataclass class TimestampedPublishMessage: """MQTT Message.""" diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 87abba2ac95..2f6f1be9c42 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -25,7 +25,7 @@ from homeassistant.const import ( STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -155,6 +155,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): ), "entity_id": self.entity_id, "qos": self._config[CONF_QOS], + "job_type": HassJobType.Callback, } }, ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 846f7d2decd..4be1a988560 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_PLATFORM -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -476,10 +476,14 @@ async def async_start( # noqa: C901 hass, MQTT_DISCOVERY_DONE.format(*discovery_hash), None ) - # async_subscribe will never suspend so there is no need to create a task - # here and its faster to await them in sequence mqtt_data.discovery_unsubscribe = [ - await mqtt.async_subscribe(hass, topic, async_discovery_message_received, 0) + mqtt.async_subscribe_internal( + hass, + topic, + async_discovery_message_received, + 0, + job_type=HassJobType.Callback, + ) for topic in ( f"{discovery_topic}/+/+/config", f"{discovery_topic}/+/+/+/config", diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index a09579fccef..6377732cd94 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -17,7 +17,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -200,6 +200,7 @@ class MqttEvent(MqttEntity, EventEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } self._sub_state = subscription.async_prepare_subscribe_topics( diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index a418131d5c5..7f5c521e9f3 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -27,7 +27,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, CONF_STATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -45,7 +45,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -447,6 +446,7 @@ class MqttFan(MqttEntity, FanEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } return has_topic @@ -496,12 +496,8 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if percentage: await self.async_set_percentage(percentage) @@ -517,12 +513,8 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = False @@ -537,14 +529,9 @@ class MqttFan(MqttEntity, FanEntity): percentage_to_ranged_value(self._speed_range, percentage) ) mqtt_payload = self._command_templates[ATTR_PERCENTAGE](percentage_payload) - await self.async_publish( - self._topic[CONF_PERCENTAGE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_PERCENTAGE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_percentage: self._attr_percentage = percentage self.async_write_ha_state() @@ -555,15 +542,9 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_PRESET_MODE](preset_mode) - - await self.async_publish( - self._topic[CONF_PRESET_MODE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_PRESET_MODE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_preset_mode: self._attr_preset_mode = preset_mode self.async_write_ha_state() @@ -581,15 +562,9 @@ class MqttFan(MqttEntity, FanEntity): mqtt_payload = self._command_templates[ATTR_OSCILLATING]( self._payload["OSCILLATE_OFF_PAYLOAD"] ) - - await self.async_publish( - self._topic[CONF_OSCILLATION_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_OSCILLATION_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_oscillation: self._attr_oscillating = oscillating self.async_write_ha_state() @@ -600,15 +575,9 @@ class MqttFan(MqttEntity, FanEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_DIRECTION](direction) - - await self.async_publish( - self._topic[CONF_DIRECTION_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_DIRECTION_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_direction: self._attr_current_direction = direction self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 097018f008f..6bb4fdb8561 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, CONF_STATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -47,7 +47,6 @@ from .const import ( CONF_CURRENT_HUMIDITY_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -293,6 +292,7 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): "entity_id": self.entity_id, "qos": qos, "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } @callback @@ -455,12 +455,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_ON"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = True @@ -472,12 +468,8 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[CONF_STATE](self._payload["STATE_OFF"]) - await self.async_publish( - self._topic[CONF_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], mqtt_payload ) if self._optimistic: self._attr_is_on = False @@ -489,14 +481,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): This method is a coroutine. """ mqtt_payload = self._command_templates[ATTR_HUMIDITY](humidity) - await self.async_publish( - self._topic[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_TARGET_HUMIDITY_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_target_humidity: self._attr_target_humidity = humidity self.async_write_ha_state() @@ -511,15 +498,9 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): return mqtt_payload = self._command_templates[ATTR_MODE](mode) - - await self.async_publish( - self._topic[CONF_MODE_COMMAND_TOPIC], - mqtt_payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_MODE_COMMAND_TOPIC], mqtt_payload ) - if self._optimistic_mode: self._attr_mode = mode self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 4fa410c4595..4ae7498a8f1 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -16,7 +16,7 @@ from homeassistant.components import image from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client @@ -202,6 +202,7 @@ class MqttImage(MqttEntity, ImageEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": encoding, + "job_type": HassJobType.Callback, } return has_topic diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 2452b511144..65d1442c8de 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -17,7 +17,7 @@ from homeassistant.components.lawn_mower import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -192,6 +192,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -212,14 +213,7 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): if self._attr_assumed_state: self._attr_activity = activity self.async_write_ha_state() - - await self.async_publish( - self._command_topics[option], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._command_topics[option], payload) async def async_start_mowing(self) -> None: """Start or resume mowing.""" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 583374c8d20..db6d695b4bb 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -37,7 +37,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HassJobType, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -49,7 +49,6 @@ from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -580,6 +579,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } add_topic(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) @@ -664,13 +664,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): async def publish(topic: str, payload: PublishPayloadType) -> None: """Publish an MQTT message.""" - await self.async_publish( - str(self._topic[topic]), - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(str(self._topic[topic]), payload) def scale_rgbx( color: tuple[int, ...], @@ -875,12 +869,8 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - self._payload["off"], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), self._payload["off"] ) if self._optimistic: diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index f6dec17f8f3..3ec88026e9a 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -47,7 +47,7 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import async_get_hass, callback +from homeassistant.core import HassJobType, async_get_hass, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps @@ -522,6 +522,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -737,12 +738,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._attr_brightness = kwargs[ATTR_WHITE] should_update = True - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - json_dumps(message), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), json_dumps(message) ) if self._optimistic: @@ -762,12 +759,8 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): self._set_flash_and_transition(message, **kwargs) - await self.async_publish( - str(self._topic[CONF_COMMAND_TOPIC]), - json_dumps(message), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + str(self._topic[CONF_COMMAND_TOPIC]), json_dumps(message) ) if self._optimistic: diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 193b4d23931..cc734253512 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -29,7 +29,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HassJobType, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -41,7 +41,6 @@ from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_NONE, ) @@ -282,6 +281,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -364,12 +364,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - await self.async_publish( + await self.async_publish_with_config( str(self._topics[CONF_COMMAND_TOPIC]), self._command_templates[CONF_COMMAND_ON_TEMPLATE](None, values), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], ) if self._optimistic: @@ -387,12 +384,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): if ATTR_TRANSITION in kwargs: values["transition"] = kwargs[ATTR_TRANSITION] - await self.async_publish( + await self.async_publish_with_config( str(self._topics[CONF_COMMAND_TOPIC]), self._command_templates[CONF_COMMAND_OFF_TEMPLATE](None, values), - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], ) if self._optimistic: diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 52c2bea2cc3..ce0b97e74bf 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -19,7 +19,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -32,7 +32,6 @@ from .const import ( CONF_ENCODING, CONF_PAYLOAD_RESET, CONF_QOS, - CONF_RETAIN, CONF_STATE_OPEN, CONF_STATE_OPENING, CONF_STATE_TOPIC, @@ -232,6 +231,7 @@ class MqttLock(MqttEntity, LockEntity): "entity_id": self.entity_id, CONF_QOS: qos, CONF_ENCODING: encoding, + "job_type": HassJobType.Callback, } } @@ -254,13 +254,7 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_LOCK], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock has changed state. self._attr_is_locked = True @@ -275,13 +269,7 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_UNLOCK], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock has changed state. self._attr_is_locked = False @@ -296,13 +284,7 @@ class MqttLock(MqttEntity, LockEntity): ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None } payload = self._command_template(self._config[CONF_PAYLOAD_OPEN], tpl_vars) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that the lock unlocks when opened. self._attr_is_open = True diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 0331b49c2a6..a89199ed173 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -5,7 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine import functools -from functools import partial, wraps +from functools import partial import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final @@ -30,7 +30,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HassJobType, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import ( DeviceEntry, @@ -83,6 +83,7 @@ from .const import ( CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, + CONF_RETAIN, CONF_SCHEMA, CONF_SERIAL_NUMBER, CONF_SUGGESTED_AREA, @@ -359,45 +360,6 @@ def init_entity_id_from_config( ) -def write_state_on_attr_change( - entity: Entity, attributes: set[str] -) -> Callable[[MessageCallbackType], MessageCallbackType]: - """Wrap an MQTT message callback to track state attribute changes.""" - - def _attrs_have_changed(tracked_attrs: dict[str, Any]) -> bool: - """Return True if attributes on entity changed or if update is forced.""" - if not (write_state := (getattr(entity, "_attr_force_update", False))): - for attribute, last_value in tracked_attrs.items(): - if getattr(entity, attribute, UNDEFINED) != last_value: - write_state = True - break - - return write_state - - def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: - @wraps(msg_callback) - def wrapper(msg: ReceiveMessage) -> None: - """Track attributes for write state requests.""" - tracked_attrs: dict[str, Any] = { - attribute: getattr(entity, attribute, UNDEFINED) - for attribute in attributes - } - try: - msg_callback(msg) - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if not _attrs_have_changed(tracked_attrs): - return - - mqtt_data = entity.hass.data[DATA_MQTT] - mqtt_data.state_write_requests.write_state_request(entity) - - return wrapper - - return _decorator - - class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -426,9 +388,10 @@ class MqttAttributesMixin(Entity): def _attributes_prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - self._attr_tpl = MqttValueTemplate( - self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE), entity=self - ).async_render_with_possible_json_value + if template := self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE): + self._attr_tpl = MqttValueTemplate( + template, entity=self + ).async_render_with_possible_json_value self._attributes_sub_state = async_prepare_subscribe_topics( self.hass, self._attributes_sub_state, @@ -443,6 +406,7 @@ class MqttAttributesMixin(Entity): "entity_id": self.entity_id, "qos": self._attributes_config.get(CONF_QOS), "encoding": self._attributes_config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -461,9 +425,9 @@ class MqttAttributesMixin(Entity): @callback def _attributes_message_received(self, msg: ReceiveMessage) -> None: """Update extra state attributes.""" - if TYPE_CHECKING: - assert self._attr_tpl is not None - payload = self._attr_tpl(msg.payload) + payload = ( + self._attr_tpl(msg.payload) if self._attr_tpl is not None else msg.payload + ) try: json_dict = json_loads(payload) if isinstance(payload, str) else None except ValueError: @@ -557,6 +521,7 @@ class MqttAvailabilityMixin(Entity): "entity_id": self.entity_id, "qos": self._avail_config[CONF_QOS], "encoding": self._avail_config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } for topic in self._avail_topics } @@ -1192,6 +1157,18 @@ class MqttEntity( encoding, ) + async def async_publish_with_config( + self, topic: str, payload: PublishPayloadType + ) -> None: + """Publish payload to a topic using config.""" + await self.async_publish( + topic, + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + @staticmethod @abstractmethod def config_schema() -> vol.Schema: diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index 57a213491a7..d3e6bdd3fcb 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -14,13 +14,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_RETAIN from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttCommandTemplate from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -83,10 +77,4 @@ class MqttNotify(MqttEntity, NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message.""" payload = self._command_template(message) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 17e7cfe69e0..ededdd14c12 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -39,7 +39,6 @@ from .const import ( CONF_ENCODING, CONF_PAYLOAD_RESET, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, ) from .mixins import MqttEntity, async_setup_entity_entry_helper @@ -214,6 +213,7 @@ class MqttNumber(MqttEntity, RestoreNumber): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -238,11 +238,4 @@ class MqttNumber(MqttEntity, RestoreNumber): if self._attr_assumed_state: self._attr_native_value = current_number self.async_write_ha_state() - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 24b4415a4b2..4381a4ea9a3 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN from .mixins import MqttEntity, async_setup_entity_entry_helper from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic @@ -83,10 +83,6 @@ class MqttScene( This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_ON], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON] ) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index a2814055a7c..6526161d2de 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -12,7 +12,7 @@ from homeassistant.components import select from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -25,7 +25,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, ) from .mixins import MqttEntity, async_setup_entity_entry_helper @@ -154,6 +153,7 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -173,11 +173,4 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index c8fe932ed71..fc6b6dcf273 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -31,7 +31,13 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJobType, + HomeAssistant, + State, + callback, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -297,6 +303,7 @@ class MqttSensor(MqttEntity, RestoreSensor): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } self._sub_state = subscription.async_prepare_subscribe_topics( diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 06cb2677c09..09fd5db2684 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -28,7 +28,7 @@ from homeassistant.const import ( CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps @@ -43,7 +43,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_EMPTY_JSON, @@ -282,6 +281,7 @@ class MqttSiren(MqttEntity, SirenEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -318,13 +318,7 @@ class MqttSiren(MqttEntity, SirenEntity): else: payload = json_dumps(template_variables) if payload and str(payload) != PAYLOAD_NONE: - await self.async_publish( - self._config[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[topic], payload) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the siren on. diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index 9e3ea21222f..40f9f130134 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from . import debug_info from .client import async_subscribe_internal @@ -27,6 +27,7 @@ class EntitySubscription: qos: int = 0 encoding: str = "utf-8" entity_id: str | None = None + job_type: HassJobType | None = None def resubscribe_if_necessary( self, hass: HomeAssistant, other: EntitySubscription | None @@ -62,7 +63,12 @@ class EntitySubscription: if not self.should_subscribe or not self.topic: return self.unsubscribe_callback = async_subscribe_internal( - self.hass, self.topic, self.message_callback, self.qos, self.encoding + self.hass, + self.topic, + self.message_callback, + self.qos, + self.encoding, + self.job_type, ) def _should_resubscribe(self, other: EntitySubscription | None) -> bool: @@ -112,6 +118,7 @@ def async_prepare_subscribe_topics( hass=hass, should_subscribe=None, entity_id=value.get("entity_id", None), + job_type=value.get("job_type", None), ) # Get the current subscription state current = current_subscriptions.pop(key, None) diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 9f266a0e9ab..f66a7a80d3d 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -33,7 +33,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_NONE, ) @@ -145,6 +144,7 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } }, ) @@ -161,12 +161,8 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_ON], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON] ) if self._optimistic: # Optimistically assume that switch has changed state. @@ -178,12 +174,8 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): This method is a coroutine. """ - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - self._config[CONF_PAYLOAD_OFF], - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_OFF] ) if self._optimistic: # Optimistically assume that switch has changed state. diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 55f7e775ae9..59d9c3f87ff 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -142,28 +142,32 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdateMixin): update_device(self.hass, self._config_entry, config) await self.subscribe_topics() + @callback + def _async_tag_scanned(self, msg: ReceiveMessage) -> None: + """Handle new tag scanned.""" + try: + tag_id = str(self._value_template(msg.payload, "")).strip() + except MqttValueTemplateException as exc: + _LOGGER.warning(exc) + return + if not tag_id: # No output from template, ignore + return + + self.hass.async_create_task( + tag.async_scan_tag(self.hass, tag_id, self.device_id) + ) + async def subscribe_topics(self) -> None: """Subscribe to MQTT topics.""" - - async def tag_scanned(msg: ReceiveMessage) -> None: - try: - tag_id = str(self._value_template(msg.payload, "")).strip() - except MqttValueTemplateException as exc: - _LOGGER.warning(exc) - return - if not tag_id: # No output from template, ignore - return - - await tag.async_scan_tag(self.hass, tag_id, self.device_id) - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, { "state_topic": { "topic": self._config[CONF_TOPIC], - "msg_callback": tag_scanned, + "msg_callback": self._async_tag_scanned, "qos": self._config[CONF_QOS], + "job_type": HassJobType.Callback, } }, ) diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index abced8b8744..cc688403a5a 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -20,7 +20,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -32,7 +32,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, ) from .mixins import MqttEntity, async_setup_entity_entry_helper @@ -183,6 +182,7 @@ class MqttTextEntity(MqttEntity, TextEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } add_subscription( @@ -203,14 +203,7 @@ class MqttTextEntity(MqttEntity, TextEntity): async def async_set_value(self, value: str) -> None: """Change the text.""" payload = self._command_template(value) - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 7aa798a7a3c..91ac404a07a 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -10,7 +10,13 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM, CONF_VALUE_TEMPLATE -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HassJobType, + HomeAssistant, + callback, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.template import Template from homeassistant.helpers.trigger import TriggerActionType, TriggerData, TriggerInfo @@ -99,6 +105,11 @@ async def async_attach_trigger( "Attaching MQTT trigger for topic: '%s', payload: '%s'", topic, wanted_payload ) - return await mqtt.async_subscribe( - hass, topic, mqtt_automation_listener, encoding=encoding, qos=qos + return mqtt.async_subscribe_internal( + hass, + topic, + mqtt_automation_listener, + encoding=encoding, + qos=qos, + job_type=HassJobType.Callback, ) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index ee29601e585..d9d8c961ae8 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -16,7 +16,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -229,6 +229,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } add_subscription( @@ -264,14 +265,7 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): ) -> None: """Update the current value.""" payload = self._config[CONF_PAYLOAD_INSTALL] - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) @property def supported_features(self) -> UpdateEntityFeature: diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 5c8c2fd2ba5..b750fdcb49c 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -31,7 +31,7 @@ from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, ) -from homeassistant.core import HomeAssistant, async_get_hass, callback +from homeassistant.core import HassJobType, HomeAssistant, async_get_hass, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -346,6 +346,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics @@ -359,13 +360,8 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Publish a command.""" if self._command_topic is None: return - - await self.async_publish( - self._command_topic, - self._payloads[_FEATURE_PAYLOADS[feature]], - qos=self._config[CONF_QOS], - retain=self._config[CONF_RETAIN], - encoding=self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._command_topic, self._payloads[_FEATURE_PAYLOADS[feature]] ) self.async_write_ha_state() @@ -401,13 +397,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): or (fan_speed not in self.fan_speed_list) ): return - await self.async_publish( - self._set_fan_speed_topic, - fan_speed, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._set_fan_speed_topic, fan_speed) async def async_send_command( self, @@ -427,10 +417,4 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): payload = json_dumps(message) else: payload = command - await self.async_publish( - self._send_command_topic, - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._send_command_topic, payload) diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index ce89c6c2daf..154680cf14a 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -26,7 +26,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HassJobType, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -357,6 +357,7 @@ class MqttValve(MqttEntity, ValveEntity): "entity_id": self.entity_id, "qos": self._config[CONF_QOS], "encoding": self._config[CONF_ENCODING] or None, + "job_type": HassJobType.Callback, } self._sub_state = subscription.async_prepare_subscribe_topics( @@ -375,13 +376,7 @@ class MqttValve(MqttEntity, ValveEntity): payload = self._command_template( self._config.get(CONF_PAYLOAD_OPEN, DEFAULT_PAYLOAD_OPEN) ) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. self._update_state(STATE_OPEN) @@ -395,13 +390,7 @@ class MqttValve(MqttEntity, ValveEntity): payload = self._command_template( self._config.get(CONF_PAYLOAD_CLOSE, DEFAULT_PAYLOAD_CLOSE) ) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) if self._optimistic: # Optimistically assume that valve has changed state. self._update_state(STATE_CLOSED) @@ -413,13 +402,7 @@ class MqttValve(MqttEntity, ValveEntity): This method is a coroutine. """ payload = self._command_template(self._config[CONF_PAYLOAD_STOP]) - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) + await self.async_publish_with_config(self._config[CONF_COMMAND_TOPIC], payload) async def async_set_valve_position(self, position: int) -> None: """Move the valve to a specific position.""" @@ -433,13 +416,8 @@ class MqttValve(MqttEntity, ValveEntity): "position_closed": self._config[CONF_POSITION_CLOSED], } rendered_position = self._command_template(scaled_position, variables=variables) - - await self.async_publish( - self._config[CONF_COMMAND_TOPIC], - rendered_position, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], + await self.async_publish_with_config( + self._config[CONF_COMMAND_TOPIC], rendered_position ) if self._optimistic: self._update_state( diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index f83aed68590..65cea1e2e4c 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -609,6 +609,15 @@ class ScriptEntity(BaseScriptEntity, RestoreEntity): ) coro = self._async_run(variables, context) if wait: + # If we are executing in parallel, we need to copy the script stack so + # that if this script is called in parallel, it will not be seen in the + # stack of the other parallel calls and hit the disallowed recursion + # check as each parallel call would otherwise be appending to the same + # stack. We do not wipe the stack in this case because we still want to + # be able to detect if there is a disallowed recursion. + if script_stack := script_stack_cv.get(): + script_stack_cv.set(script_stack.copy()) + script_result = await coro return script_result.service_response if script_result else None diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 0cffe2576d1..760e4ccd689 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.9"] + "requirements": ["subarulink==0.7.11"] } diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py index 50ed89ca045..ba9b7d46b06 100644 --- a/homeassistant/components/subaru/sensor.py +++ b/homeassistant/components/subaru/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any, cast +from typing import Any import subarulink.const as sc @@ -23,11 +23,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) from homeassistant.util.unit_conversion import DistanceConverter, VolumeConverter -from homeassistant.util.unit_system import ( - LENGTH_UNITS, - PRESSURE_UNITS, - US_CUSTOMARY_SYSTEM, -) +from homeassistant.util.unit_system import METRIC_SYSTEM from . import get_device_info from .const import ( @@ -58,7 +54,7 @@ SAFETY_SENSORS = [ key=sc.ODOMETER, translation_key="odometer", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.TOTAL_INCREASING, ), ] @@ -68,42 +64,42 @@ API_GEN_2_SENSORS = [ SensorEntityDescription( key=sc.AVG_FUEL_CONSUMPTION, translation_key="average_fuel_consumption", - native_unit_of_measurement=FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, + native_unit_of_measurement=FUEL_CONSUMPTION_MILES_PER_GALLON, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.DIST_TO_EMPTY, translation_key="range", device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, + native_unit_of_measurement=UnitOfLength.MILES, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FL, translation_key="tire_pressure_front_left", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_FR, translation_key="tire_pressure_front_right", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RL, translation_key="tire_pressure_rear_left", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=sc.TIRE_PRESSURE_RR, translation_key="tire_pressure_rear_right", device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.HPA, + native_unit_of_measurement=UnitOfPressure.PSI, state_class=SensorStateClass.MEASUREMENT, ), ] @@ -207,30 +203,13 @@ class SubaruSensor( @property def native_value(self) -> int | float | None: """Return the state of the sensor.""" - vehicle_data = self.coordinator.data[self.vin] - current_value = vehicle_data[VEHICLE_STATUS].get(self.entity_description.key) - unit = self.entity_description.native_unit_of_measurement - unit_system = self.hass.config.units - - if current_value is None: - return None - - if unit in LENGTH_UNITS: - return round(unit_system.length(current_value, cast(str, unit)), 1) - - if unit in PRESSURE_UNITS and unit_system == US_CUSTOMARY_SYSTEM: - return round( - unit_system.pressure(current_value, cast(str, unit)), - 1, - ) + current_value = self.coordinator.data[self.vin][VEHICLE_STATUS].get( + self.entity_description.key + ) if ( - unit - in [ - FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, - FUEL_CONSUMPTION_MILES_PER_GALLON, - ] - and unit_system == US_CUSTOMARY_SYSTEM + self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and self.hass.config.units == METRIC_SYSTEM ): return round((100.0 * L_PER_GAL) / (KM_PER_MI * current_value), 1) @@ -239,23 +218,12 @@ class SubaruSensor( @property def native_unit_of_measurement(self) -> str | None: """Return the unit_of_measurement of the device.""" - unit = self.entity_description.native_unit_of_measurement - - if unit in LENGTH_UNITS: - return self.hass.config.units.length_unit - - if unit in PRESSURE_UNITS: - if self.hass.config.units == US_CUSTOMARY_SYSTEM: - return self.hass.config.units.pressure_unit - - if unit in [ - FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS, - FUEL_CONSUMPTION_MILES_PER_GALLON, - ]: - if self.hass.config.units == US_CUSTOMARY_SYSTEM: - return FUEL_CONSUMPTION_MILES_PER_GALLON - - return unit + if ( + self.entity_description.key == sc.AVG_FUEL_CONSUMPTION + and self.hass.config.units == METRIC_SYSTEM + ): + return FUEL_CONSUMPTION_LITERS_PER_HUNDRED_KILOMETERS + return self.entity_description.native_unit_of_measurement @property def available(self) -> bool: diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py index 9454dcabc49..b770c48c11c 100644 --- a/homeassistant/components/switcher_kis/button.py +++ b/homeassistant/components/switcher_kis/button.py @@ -2,11 +2,13 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass +from typing import Any, cast from aioswitcher.api import ( DeviceState, + SwitcherApi, SwitcherBaseResponse, SwitcherType2Api, ThermostatSwing, @@ -34,7 +36,10 @@ from .utils import get_breeze_remote_manager class SwitcherThermostatButtonEntityDescription(ButtonEntityDescription): """Class to describe a Switcher Thermostat Button entity.""" - press_fn: Callable[[SwitcherType2Api, SwitcherBreezeRemote], SwitcherBaseResponse] + press_fn: Callable[ + [SwitcherApi, SwitcherBreezeRemote], + Coroutine[Any, Any, SwitcherBaseResponse], + ] supported: Callable[[SwitcherBreezeRemote], bool] @@ -85,9 +90,10 @@ async def async_setup_entry( async def async_add_buttons(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add button from Switcher device.""" + data = cast(SwitcherBreezeRemote, coordinator.data) if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( - get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id + get_breeze_remote_manager(hass).get_remote, data.remote_id ) async_add_entities( SwitcherThermostatButtonEntity(coordinator, description, remote) @@ -126,7 +132,7 @@ class SwitcherThermostatButtonEntity( async def async_press(self) -> None: """Press the button.""" - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py index 9797873c73b..e6267e15305 100644 --- a/homeassistant/components/switcher_kis/climate.py +++ b/homeassistant/components/switcher_kis/climate.py @@ -9,6 +9,7 @@ from aioswitcher.api.remotes import SwitcherBreezeRemote from aioswitcher.device import ( DeviceCategory, DeviceState, + SwitcherThermostat, ThermostatFanLevel, ThermostatMode, ThermostatSwing, @@ -68,9 +69,10 @@ async def async_setup_entry( async def async_add_climate(coordinator: SwitcherDataUpdateCoordinator) -> None: """Get remote and add climate from Switcher device.""" + data = cast(SwitcherThermostat, coordinator.data) if coordinator.data.device_type.category == DeviceCategory.THERMOSTAT: remote: SwitcherBreezeRemote = await hass.async_add_executor_job( - get_breeze_remote_manager(hass).get_remote, coordinator.data.remote_id + get_breeze_remote_manager(hass).get_remote, data.remote_id ) async_add_entities([SwitcherClimateEntity(coordinator, remote)]) @@ -133,13 +135,13 @@ class SwitcherClimateEntity( def _update_data(self, force_update: bool = False) -> None: """Update data from device.""" - data = self.coordinator.data + data = cast(SwitcherThermostat, self.coordinator.data) features = self._remote.modes_features[data.mode] if data.target_temperature == 0 and not force_update: return - self._attr_current_temperature = cast(float, data.temperature) + self._attr_current_temperature = data.temperature self._attr_target_temperature = float(data.target_temperature) self._attr_hvac_mode = HVACMode.OFF @@ -162,7 +164,7 @@ class SwitcherClimateEntity( async def _async_control_breeze_device(self, **kwargs: Any) -> None: """Call Switcher Control Breeze API.""" - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: @@ -185,9 +187,8 @@ class SwitcherClimateEntity( async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - if not self._remote.modes_features[self.coordinator.data.mode][ - "temperature_control" - ]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["temperature_control"]: raise HomeAssistantError( "Current mode doesn't support setting Target Temperature" ) @@ -199,7 +200,8 @@ class SwitcherClimateEntity( async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - if not self._remote.modes_features[self.coordinator.data.mode]["fan_levels"]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["fan_levels"]: raise HomeAssistantError("Current mode doesn't support setting Fan Mode") await self._async_control_breeze_device(fan_level=HA_TO_DEVICE_FAN[fan_mode]) @@ -215,7 +217,8 @@ class SwitcherClimateEntity( async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" - if not self._remote.modes_features[self.coordinator.data.mode]["swing"]: + data = cast(SwitcherThermostat, self.coordinator.data) + if not self._remote.modes_features[data.mode]["swing"]: raise HomeAssistantError("Current mode doesn't support setting Swing Mode") if swing_mode == SWING_VERTICAL: diff --git a/homeassistant/components/switcher_kis/coordinator.py b/homeassistant/components/switcher_kis/coordinator.py index 08207aa0d79..1fdefda23a2 100644 --- a/homeassistant/components/switcher_kis/coordinator.py +++ b/homeassistant/components/switcher_kis/coordinator.py @@ -45,17 +45,17 @@ class SwitcherDataUpdateCoordinator( @property def model(self) -> str: """Switcher device model.""" - return self.data.device_type.value # type: ignore[no-any-return] + return self.data.device_type.value @property def device_id(self) -> str: """Switcher device id.""" - return self.data.device_id # type: ignore[no-any-return] + return self.data.device_id @property def mac_address(self) -> str: """Switcher device mac address.""" - return self.data.mac_address # type: ignore[no-any-return] + return self.data.mac_address @callback def async_setup(self) -> None: diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py index 8f75ae49905..258af3e1d5e 100644 --- a/homeassistant/components/switcher_kis/cover.py +++ b/homeassistant/components/switcher_kis/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from aioswitcher.api import SwitcherBaseResponse, SwitcherType2Api from aioswitcher.device import DeviceCategory, ShutterDirection, SwitcherShutter @@ -84,7 +84,7 @@ class SwitcherCoverEntity( def _update_data(self) -> None: """Update data from device.""" - data: SwitcherShutter = self.coordinator.data + data = cast(SwitcherShutter, self.coordinator.data) self._attr_current_cover_position = data.position self._attr_is_closed = data.position == 0 self._attr_is_closing = data.direction == ShutterDirection.SHUTTER_DOWN @@ -93,7 +93,7 @@ class SwitcherCoverEntity( async def _async_call_api(self, api: str, *args: Any) -> None: """Call Switcher API.""" _LOGGER.debug("Calling api for %s, api: '%s', args: %s", self.name, api, args) - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index bf236013896..52b218fce9c 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aioswitcher"], "quality_scale": "platinum", - "requirements": ["aioswitcher==3.4.1"], + "requirements": ["aioswitcher==3.4.3"], "single_config_entry": true } diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index 1de4e840d96..2280d6bc845 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -111,7 +111,7 @@ class SwitcherBaseSwitchEntity( _LOGGER.debug( "Calling api for %s, api: '%s', args: %s", self.coordinator.name, api, args ) - response: SwitcherBaseResponse = None + response: SwitcherBaseResponse | None = None error = None try: diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index af2276dbcda..e96cba54bf0 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -28,13 +28,17 @@ from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData PLATFORMS: Final = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.DEVICE_TRACKER, Platform.LOCK, + Platform.MEDIA_PLAYER, + Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py new file mode 100644 index 00000000000..188613d92f7 --- /dev/null +++ b/homeassistant/components/teslemetry/button.py @@ -0,0 +1,85 @@ +"""Button platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from tesla_fleet_api.const import Scope + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryButtonEntityDescription(ButtonEntityDescription): + """Describes a Teslemetry Button entity.""" + + func: Callable[[TeslemetryButtonEntity], Awaitable[Any]] | None = None + + +DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = ( + TeslemetryButtonEntityDescription(key="wake"), # Every button runs wakeup + TeslemetryButtonEntityDescription( + key="flash_lights", func=lambda self: self.api.flash_lights() + ), + TeslemetryButtonEntityDescription( + key="honk", func=lambda self: self.api.honk_horn() + ), + TeslemetryButtonEntityDescription( + key="enable_keyless_driving", func=lambda self: self.api.remote_start_drive() + ), + TeslemetryButtonEntityDescription( + key="boombox", func=lambda self: self.api.remote_boombox(0) + ), + TeslemetryButtonEntityDescription( + key="homelink", + func=lambda self: self.api.trigger_homelink( + lat=self.coordinator.data["drive_state_latitude"], + lon=self.coordinator.data["drive_state_longitude"], + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Button platform from a config entry.""" + + async_add_entities( + TeslemetryButtonEntity(vehicle, description) + for vehicle in entry.runtime_data.vehicles + for description in DESCRIPTIONS + if Scope.VEHICLE_CMDS in entry.runtime_data.scopes + ) + + +class TeslemetryButtonEntity(TeslemetryVehicleEntity, ButtonEntity): + """Base class for Teslemetry buttons.""" + + entity_description: TeslemetryButtonEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryButtonEntityDescription, + ) -> None: + """Initialize the button.""" + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + async def async_press(self) -> None: + """Press the button.""" + await self.wake_up_if_asleep() + if self.entity_description.func: + await self.handle_command(self.entity_description.func(self)) diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py index 84854aaa500..82b06918f7d 100644 --- a/homeassistant/components/teslemetry/entity.py +++ b/homeassistant/components/teslemetry/entity.py @@ -60,6 +60,12 @@ class TeslemetryEntity( """Return a specific value from coordinator data.""" return self.coordinator.data.get(key, default) + def get_number(self, key: str, default: float) -> float: + """Return a specific number from coordinator data.""" + if isinstance(value := self.coordinator.data.get(key), (int, float)): + return value + return default + @property def is_none(self) -> bool: """Return if the value is a literal None.""" diff --git a/homeassistant/components/teslemetry/icons.json b/homeassistant/components/teslemetry/icons.json index 3224fee603b..089a3bea548 100644 --- a/homeassistant/components/teslemetry/icons.json +++ b/homeassistant/components/teslemetry/icons.json @@ -38,6 +38,26 @@ } } }, + "button": { + "boombox": { + "default": "mdi:volume-high" + }, + "enable_keyless_driving": { + "default": "mdi:car-key" + }, + "flash_lights": { + "default": "mdi:flashlight" + }, + "homelink": { + "default": "mdi:garage" + }, + "honk": { + "default": "mdi:bullhorn" + }, + "wake": { + "default": "mdi:sleep-off" + } + }, "climate": { "driver_temp": { "state_attributes": { diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py new file mode 100644 index 00000000000..c7fc1c87438 --- /dev/null +++ b/homeassistant/components/teslemetry/media_player.py @@ -0,0 +1,149 @@ +"""Media player platform for Teslemetry integration.""" + +from __future__ import annotations + +from tesla_fleet_api.const import Scope + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + +STATES = { + "Playing": MediaPlayerState.PLAYING, + "Paused": MediaPlayerState.PAUSED, + "Stopped": MediaPlayerState.IDLE, + "Off": MediaPlayerState.OFF, +} +VOLUME_MAX = 11.0 +VOLUME_STEP = 1.0 / 3 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry Media platform from a config entry.""" + + async_add_entities( + TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity): + """Vehicle media player class.""" + + _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_supported_features = ( + MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.VOLUME_SET + ) + _volume_max: float = VOLUME_MAX + + def __init__( + self, + data: TeslemetryVehicleData, + scoped: bool, + ) -> None: + """Initialize the media player entity.""" + super().__init__(data, "media") + self.scoped = scoped + if not scoped: + self._attr_supported_features = MediaPlayerEntityFeature(0) + + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + self._volume_max = ( + self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX + ) + self._attr_state = STATES.get( + self.get("vehicle_state_media_info_media_playback_status") or "Off", + ) + self._attr_volume_step = ( + 1.0 + / self._volume_max + / ( + self.get("vehicle_state_media_info_audio_volume_increment") + or VOLUME_STEP + ) + ) + + if volume := self.get("vehicle_state_media_info_audio_volume"): + self._attr_volume_level = volume / self._volume_max + else: + self._attr_volume_level = None + + if duration := self.get("vehicle_state_media_info_now_playing_duration"): + self._attr_media_duration = duration / 1000 + else: + self._attr_media_duration = None + + if duration and ( + position := self.get("vehicle_state_media_info_now_playing_elapsed") + ): + self._attr_media_position = position / 1000 + else: + self._attr_media_position = None + + self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title") + self._attr_media_artist = self.get( + "vehicle_state_media_info_now_playing_artist" + ) + self._attr_media_album_name = self.get( + "vehicle_state_media_info_now_playing_album" + ) + self._attr_media_playlist = self.get( + "vehicle_state_media_info_now_playing_station" + ) + self._attr_source = self.get("vehicle_state_media_info_now_playing_source") + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command( + self.api.adjust_volume(int(volume * self._volume_max)) + ) + self._attr_volume_level = volume + self.async_write_ha_state() + + async def async_media_play(self) -> None: + """Send play command.""" + if self.state != MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PLAYING + self.async_write_ha_state() + + async def async_media_pause(self) -> None: + """Send pause command.""" + if self.state == MediaPlayerState.PLAYING: + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_toggle_playback()) + self._attr_state = MediaPlayerState.PAUSED + self.async_write_ha_state() + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_next_track()) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.media_prev_track()) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py new file mode 100644 index 00000000000..baf46487046 --- /dev/null +++ b/homeassistant/components/teslemetry/number.py @@ -0,0 +1,201 @@ +"""Number platform for Teslemetry integration.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from itertools import chain +from typing import Any + +from tesla_fleet_api import EnergySpecific, VehicleSpecific +from tesla_fleet_api.const import Scope + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.icon import icon_for_battery_level + +from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity +from .models import TeslemetryEnergyData, TeslemetryVehicleData + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription): + """Describes Teslemetry Number entity.""" + + func: Callable[[VehicleSpecific, float], Awaitable[Any]] + native_min_value: float + native_max_value: float + min_key: str | None = None + max_key: str + scopes: list[Scope] + + +VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = ( + TeslemetryNumberVehicleEntityDescription( + key="charge_state_charge_current_request", + native_step=PRECISION_WHOLE, + native_min_value=0, + native_max_value=32, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=NumberDeviceClass.CURRENT, + mode=NumberMode.AUTO, + max_key="charge_state_charge_current_request_max", + func=lambda api, value: api.set_charging_amps(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS], + ), + TeslemetryNumberVehicleEntityDescription( + key="charge_state_charge_limit_soc", + native_step=PRECISION_WHOLE, + native_min_value=50, + native_max_value=100, + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + mode=NumberMode.AUTO, + min_key="charge_state_charge_limit_soc_min", + max_key="charge_state_charge_limit_soc_max", + func=lambda api, value: api.set_charge_limit(value), + scopes=[Scope.VEHICLE_CHARGING_CMDS, Scope.VEHICLE_CMDS], + ), +) + + +@dataclass(frozen=True, kw_only=True) +class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription): + """Describes Teslemetry Number entity.""" + + func: Callable[[EnergySpecific, float], Awaitable[Any]] + requires: str | None = None + + +ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] = ( + TeslemetryNumberBatteryEntityDescription( + key="backup_reserve_percent", + func=lambda api, value: api.backup(int(value)), + requires="components_battery", + ), + TeslemetryNumberBatteryEntityDescription( + key="off_grid_vehicle_charging_reserve", + func=lambda api, value: api.off_grid_vehicle_charging_reserve(int(value)), + requires="components_off_grid_vehicle_charging_reserve_supported", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry number platform from a config entry.""" + + async_add_entities( + chain( + ( # Add vehicle entities + TeslemetryVehicleNumberEntity( + vehicle, + description, + entry.runtime_data.scopes, + ) + for vehicle in entry.runtime_data.vehicles + for description in VEHICLE_DESCRIPTIONS + ), + ( # Add energy site entities + TeslemetryEnergyInfoNumberSensorEntity( + energysite, + description, + entry.runtime_data.scopes, + ) + for energysite in entry.runtime_data.energysites + for description in ENERGY_INFO_DESCRIPTIONS + if description.requires is None + or energysite.info_coordinator.data.get(description.requires) + ), + ) + ) + + +class TeslemetryVehicleNumberEntity(TeslemetryVehicleEntity, NumberEntity): + """Vehicle number entity base class.""" + + entity_description: TeslemetryNumberVehicleEntityDescription + + def __init__( + self, + data: TeslemetryVehicleData, + description: TeslemetryNumberVehicleEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = any(scope in scopes for scope in description.scopes) + self.entity_description = description + super().__init__( + data, + description.key, + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + + if (min_key := self.entity_description.min_key) is not None: + self._attr_native_min_value = self.get_number( + min_key, + self.entity_description.native_min_value, + ) + else: + self._attr_native_min_value = self.entity_description.native_min_value + + self._attr_native_max_value = self.get_number( + self.entity_description.max_key, + self.entity_description.native_max_value, + ) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() + + +class TeslemetryEnergyInfoNumberSensorEntity(TeslemetryEnergyInfoEntity, NumberEntity): + """Energy info number entity base class.""" + + entity_description: TeslemetryNumberBatteryEntityDescription + _attr_native_step = PRECISION_WHOLE + _attr_native_min_value = 0 + _attr_native_max_value = 100 + _attr_device_class = NumberDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + + def __init__( + self, + data: TeslemetryEnergyData, + description: TeslemetryNumberBatteryEntityDescription, + scopes: list[Scope], + ) -> None: + """Initialize the number entity.""" + self.scoped = Scope.ENERGY_CMDS in scopes + self.entity_description = description + super().__init__(data, description.key) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + self._attr_native_value = self._value + self._attr_icon = icon_for_battery_level(self.native_value) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + value = int(value) + self.raise_for_scope() + await self.handle_command(self.entity_description.func(self.api, value)) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json index e41fbbd4507..98b1f7f1932 100644 --- a/homeassistant/components/teslemetry/strings.json +++ b/homeassistant/components/teslemetry/strings.json @@ -96,6 +96,26 @@ "name": "Tire pressure warning rear right" } }, + "button": { + "boombox": { + "name": "Play fart" + }, + "enable_keyless_driving": { + "name": "Keyless driving" + }, + "flash_lights": { + "name": "Flash lights" + }, + "homelink": { + "name": "Homelink" + }, + "honk": { + "name": "Honk horn" + }, + "wake": { + "name": "Wake" + } + }, "climate": { "driver_temp": { "name": "[%key:component::climate::title%]", @@ -219,6 +239,25 @@ } } }, + "media_player": { + "media": { + "name": "[%key:component::media_player::title%]" + } + }, + "number": { + "backup_reserve_percent": { + "name": "Backup reserve" + }, + "charge_state_charge_current_request": { + "name": "Charge current" + }, + "charge_state_charge_limit_soc": { + "name": "Charge limit" + }, + "off_grid_vehicle_charging_reserve": { + "name": "Off grid reserve" + } + }, "cover": { "charge_state_charge_port_door_open": { "name": "Charge port door" @@ -415,6 +454,11 @@ "vehicle_state_valet_mode": { "name": "Valet mode" } + }, + "update": { + "vehicle_state_software_update_status": { + "name": "[%key:component::update::title%]" + } } }, "exceptions": { diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py new file mode 100644 index 00000000000..9d5d4aa7453 --- /dev/null +++ b/homeassistant/components/teslemetry/update.py @@ -0,0 +1,105 @@ +"""Update platform for Teslemetry integration.""" + +from __future__ import annotations + +from typing import Any, cast + +from tesla_fleet_api.const import Scope + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import TeslemetryVehicleEntity +from .models import TeslemetryVehicleData + +AVAILABLE = "available" +DOWNLOADING = "downloading" +INSTALLING = "installing" +WIFI_WAIT = "downloading_wifi_wait" +SCHEDULED = "scheduled" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Teslemetry update platform from a config entry.""" + + async_add_entities( + TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes) + for vehicle in entry.runtime_data.vehicles + ) + + +class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity): + """Teslemetry Updates entity.""" + + def __init__( + self, + data: TeslemetryVehicleData, + scopes: list[Scope], + ) -> None: + """Initialize the Update.""" + self.scoped = Scope.VEHICLE_CMDS in scopes + super().__init__( + data, + "vehicle_state_software_update_status", + ) + + def _async_update_attrs(self) -> None: + """Update the attributes of the entity.""" + + # Supported Features + if self.scoped and self._value in ( + AVAILABLE, + SCHEDULED, + ): + # Only allow install when an update has been fully downloaded + self._attr_supported_features = ( + UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL + ) + else: + self._attr_supported_features = UpdateEntityFeature.PROGRESS + + # Installed Version + self._attr_installed_version = self.get("vehicle_state_car_version") + if self._attr_installed_version is not None: + # Remove build from version + self._attr_installed_version = self._attr_installed_version.split(" ")[0] + + # Latest Version + if self._value in ( + AVAILABLE, + SCHEDULED, + INSTALLING, + DOWNLOADING, + WIFI_WAIT, + ): + self._attr_latest_version = self.coordinator.data[ + "vehicle_state_software_update_version" + ] + else: + self._attr_latest_version = self._attr_installed_version + + # In Progress + if self._value in ( + SCHEDULED, + INSTALLING, + ): + self._attr_in_progress = ( + cast(int, self.get("vehicle_state_software_update_install_perc")) + or True + ) + else: + self._attr_in_progress = False + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + self.raise_for_scope() + await self.wake_up_if_asleep() + await self.handle_command(self.api.schedule_software_update(offset_sec=60)) + self._attr_in_progress = True + self.async_write_ha_state() diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 6fb617671b2..4d315f428c3 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -157,7 +157,7 @@ SCRIPT_DEBUG_CONTINUE_STOP: SignalTypeFormat[Literal["continue", "stop"]] = ( ) SCRIPT_DEBUG_CONTINUE_ALL = "script_debug_continue_all" -script_stack_cv: ContextVar[list[int] | None] = ContextVar("script_stack", default=None) +script_stack_cv: ContextVar[list[str] | None] = ContextVar("script_stack", default=None) class ScriptData(TypedDict): @@ -452,7 +452,7 @@ class _ScriptRun: if (script_stack := script_stack_cv.get()) is None: script_stack = [] script_stack_cv.set(script_stack) - script_stack.append(id(self._script)) + script_stack.append(self._script.unique_id) response = None try: @@ -1401,6 +1401,7 @@ class Script: self.sequence = sequence template.attach(hass, self.sequence) self.name = name + self.unique_id = f"{domain}.{name}-{id(self)}" self.domain = domain self.running_description = running_description or f"{domain} script" self._change_listener = change_listener @@ -1723,10 +1724,21 @@ class Script: if ( self.script_mode in (SCRIPT_MODE_RESTART, SCRIPT_MODE_QUEUED) and script_stack is not None - and id(self) in script_stack + and self.unique_id in script_stack ): script_execution_set("disallowed_recursion_detected") - self._log("Disallowed recursion detected", level=logging.WARNING) + formatted_stack = [ + f"- {name_id.partition('-')[0]}" for name_id in script_stack + ] + self._log( + "Disallowed recursion detected, " + f"{script_stack[-1].partition('-')[0]} tried to start " + f"{self.domain}.{self.name} which is already running " + "in the current execution path; " + "Traceback (most recent call last):\n" + f"{"\n".join(formatted_stack)}", + level=logging.WARNING, + ) return None if self.script_mode != SCRIPT_MODE_QUEUED: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 6da13807ad4..314e58290ad 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -327,7 +327,33 @@ def _false(arg: str) -> bool: return False -_cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) +@lru_cache(maxsize=EVAL_CACHE_SIZE) +def _cached_parse_result(render_result: str) -> Any: + """Parse a result and cache the result.""" + result = literal_eval(render_result) + if type(result) in RESULT_WRAPPERS: + result = RESULT_WRAPPERS[type(result)](result, render_result=render_result) + + # If the literal_eval result is a string, use the original + # render, by not returning right here. The evaluation of strings + # resulting in strings impacts quotes, to avoid unexpected + # output; use the original render instead of the evaluated one. + # Complex and scientific values are also unexpected. Filter them out. + if ( + # Filter out string and complex numbers + not isinstance(result, (str, complex)) + and ( + # Pass if not numeric and not a boolean + not isinstance(result, (int, float)) + # Or it's a boolean (inherit from int) + or isinstance(result, bool) + # Or if it's a digit + or _IS_NUMERIC.match(render_result) is not None + ) + ): + return result + + return render_result class RenderInfo: @@ -588,31 +614,7 @@ class Template: def _parse_result(self, render_result: str) -> Any: """Parse the result.""" try: - result = _cached_literal_eval(render_result) - - if type(result) in RESULT_WRAPPERS: - result = RESULT_WRAPPERS[type(result)]( - result, render_result=render_result - ) - - # If the literal_eval result is a string, use the original - # render, by not returning right here. The evaluation of strings - # resulting in strings impacts quotes, to avoid unexpected - # output; use the original render instead of the evaluated one. - # Complex and scientific values are also unexpected. Filter them out. - if ( - # Filter out string and complex numbers - not isinstance(result, (str, complex)) - and ( - # Pass if not numeric and not a boolean - not isinstance(result, (int, float)) - # Or it's a boolean (inherit from int) - or isinstance(result, bool) - # Or if it's a digit - or _IS_NUMERIC.match(render_result) is not None - ) - ): - return result + return _cached_parse_result(render_result) except (ValueError, TypeError, SyntaxError, MemoryError): pass diff --git a/requirements_all.txt b/requirements_all.txt index 6baa552b0f6..c9f6eade715 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -371,7 +371,7 @@ aiosolaredge==0.2.0 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.4.1 +aioswitcher==3.4.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -1646,7 +1646,7 @@ py-nightscout==1.2.2 py-schluter==0.1.7 # homeassistant.components.ecovacs -py-sucks==0.9.9 +py-sucks==0.9.10 # homeassistant.components.synology_dsm py-synologydsm-api==2.4.2 @@ -2650,7 +2650,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.9 +subarulink==0.7.11 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca926fb99ce..b2915017c01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aiosolaredge==0.2.0 aiosteamist==0.3.2 # homeassistant.components.switcher_kis -aioswitcher==3.4.1 +aioswitcher==3.4.3 # homeassistant.components.syncthing aiosyncthing==0.5.1 @@ -1308,7 +1308,7 @@ py-nextbusnext==1.0.2 py-nightscout==1.2.2 # homeassistant.components.ecovacs -py-sucks==0.9.9 +py-sucks==0.9.10 # homeassistant.components.synology_dsm py-synologydsm-api==2.4.2 @@ -2063,7 +2063,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.9 +subarulink==0.7.11 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/tests/components/axis/test_hub.py b/tests/components/axis/test_hub.py index 11ef1ef1cdf..c208f767bfc 100644 --- a/tests/components/axis/test_hub.py +++ b/tests/components/axis/test_hub.py @@ -2,7 +2,7 @@ from ipaddress import ip_address from unittest import mock -from unittest.mock import Mock, call, patch +from unittest.mock import ANY, Mock, call, patch import axis as axislib import pytest @@ -90,7 +90,7 @@ async def test_device_support_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_config_entry ) -> None: """Successful setup.""" - mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8") + mqtt_call = call(f"axis/{MAC}/#", mock.ANY, 0, "utf-8", ANY) assert mqtt_call in mqtt_mock.async_subscribe.call_args_list topic = f"axis/{MAC}/event/tns:onvif/Device/tns:axis/Sensor/PIR/$source/sensor/0" diff --git a/tests/components/fritz/test_button.py b/tests/components/fritz/test_button.py index 94bf752ffe7..ca8b8f9291f 100644 --- a/tests/components/fritz/test_button.py +++ b/tests/components/fritz/test_button.py @@ -71,7 +71,6 @@ async def test_buttons( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - await hass.async_block_till_done() mock_press_action.assert_called_once() button = hass.states.get(entity_id) @@ -105,7 +104,6 @@ async def test_wol_button( {ATTR_ENTITY_ID: "button.printer_wake_on_lan"}, blocking=True, ) - await hass.async_block_till_done() mock_press_action.assert_called_once_with("AA:BB:CC:00:11:22") button = hass.states.get("button.printer_wake_on_lan") diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index fd95c2870f8..f13575cf507 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -145,7 +145,6 @@ async def test_user( == DEFAULT_CONSIDER_HOME.total_seconds() ) assert not result["result"].unique_id - await hass.async_block_till_done() assert mock_setup_entry.called @@ -764,14 +763,12 @@ async def test_options_flow(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) result = await hass.config_entries.options.async_init(mock_config.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ CONF_CONSIDER_HOME: 37, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { diff --git a/tests/components/fritz/test_init.py b/tests/components/fritz/test_init.py index 41638ba4697..de69e0b5914 100644 --- a/tests/components/fritz/test_init.py +++ b/tests/components/fritz/test_init.py @@ -56,7 +56,6 @@ async def test_options_reload( assert entry.state is ConfigEntryState.LOADED result = await hass.config_entries.options.async_init(entry.entry_id) - await hass.async_block_till_done() await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_CONSIDER_HOME: 60}, diff --git a/tests/components/intent/test_timers.py b/tests/components/intent/test_timers.py index d017713bb1d..1c4e38349d0 100644 --- a/tests/components/intent/test_timers.py +++ b/tests/components/intent/test_timers.py @@ -971,6 +971,36 @@ async def test_timers_not_supported(hass: HomeAssistant) -> None: language=hass.config.language, ) + # Start a timer + @callback + def handle_timer(event_type: TimerEventType, timer: TimerInfo) -> None: + pass + + device_id = "test_device" + unregister = timer_manager.register_handler(device_id, handle_timer) + + timer_id = timer_manager.start_timer( + device_id, + hours=None, + minutes=5, + seconds=None, + language=hass.config.language, + ) + + # Unregister handler so device no longer "supports" timers + unregister() + + # All operations on the timer should not crash + timer_manager.add_time(timer_id, 1) + + timer_manager.remove_time(timer_id, 1) + + timer_manager.pause_timer(timer_id) + + timer_manager.unpause_timer(timer_id) + + timer_manager.cancel_timer(timer_id) + async def test_timer_status_with_names(hass: HomeAssistant, init_components) -> None: """Test getting the status of named timers.""" diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index f33eb1c850b..5d451655307 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -27,7 +27,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant from homeassistant.generated.mqtt import MQTT from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -1189,7 +1189,9 @@ async def help_test_entity_id_update_subscriptions( assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) + 2 + DISCOVERY_COUNT for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call( + topic, ANY, ANY, ANY, HassJobType.Callback + ) mqtt_mock.async_subscribe.reset_mock() entity_registry.async_update_entity( @@ -1203,7 +1205,9 @@ async def help_test_entity_id_update_subscriptions( state = hass.states.get(f"{domain}.milk") assert state is not None for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call( + topic, ANY, ANY, ANY, HassJobType.Callback + ) async def help_test_entity_id_update_discovery_update( diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 54acc935f1d..7247458a667 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -154,7 +154,7 @@ async def test_qos_encoding_default( {"test_topic1": {"topic": "test-topic1", "msg_callback": msg_callback}}, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 0, "utf-8", None) async def test_qos_encoding_custom( @@ -183,7 +183,7 @@ async def test_qos_encoding_custom( }, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 1, "utf-16") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 1, "utf-16", None) async def test_no_change( diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index ceb9207e0c2..56fc30f7354 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components import automation from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF -from homeassistant.core import HomeAssistant +from homeassistant.core import HassJobType, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message, async_mock_service, mock_component @@ -239,7 +239,9 @@ async def test_encoding_default(hass: HomeAssistant, calls, setup_comp) -> None: }, ) - setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, "utf-8") + setup_comp.async_subscribe.assert_called_with( + "test-topic", ANY, 0, "utf-8", HassJobType.Callback + ) async def test_encoding_custom(hass: HomeAssistant, calls, setup_comp) -> None: @@ -255,4 +257,6 @@ async def test_encoding_custom(hass: HomeAssistant, calls, setup_comp) -> None: }, ) - setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, None) + setup_comp.async_subscribe.assert_called_with( + "test-topic", ANY, 0, None, HassJobType.Callback + ) diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index 90034382fc8..82def7ef145 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -66,7 +66,7 @@ async def test_subscribe(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> No await hass.async_block_till_done() # Verify that the this entity was subscribed to the topic - mqtt_mock.async_subscribe.assert_called_with(sub_topic, ANY, 0, ANY) + mqtt_mock.async_subscribe.assert_called_with(sub_topic, ANY, 0, ANY, ANY) async def test_state_changed_event_sends_message( diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index 3b56305e0fc..326b01b9a7a 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -128,7 +128,6 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: user_input={CONF_API_KEY: "newkey"}, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data[CONF_API_KEY] == "newkey" diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index b5a24a5972b..72b48e3d572 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -199,8 +199,6 @@ async def test_disable_service_call(hass: HomeAssistant) -> None: blocking=True, ) - await hass.async_block_till_done() - mocked_hole.disable.assert_called_with(1) @@ -219,8 +217,6 @@ async def test_unload(hass: HomeAssistant) -> None: assert isinstance(entry.runtime_data, PiHoleData) assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 790ef7e79bc..ca1d8006637 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -1741,3 +1741,46 @@ async def test_responses_no_response(hass: HomeAssistant) -> None: ) is None ) + + +async def test_script_queued_mode(hass: HomeAssistant) -> None: + """Test calling a queued mode script called in parallel.""" + calls = 0 + + async def async_service_handler(*args, **kwargs) -> None: + """Service that simulates doing background I/O.""" + nonlocal calls + calls += 1 + await asyncio.sleep(0) + + hass.services.async_register("test", "simulated_remote", async_service_handler) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test_main": { + "sequence": [ + { + "parallel": [ + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + {"service": "script.test_sub"}, + ] + } + ] + }, + "test_sub": { + "mode": "queued", + "sequence": [ + {"service": "test.simulated_remote"}, + ], + }, + } + }, + ) + await hass.async_block_till_done() + + await hass.services.async_call("script", "test_main", blocking=True) + assert calls == 4 diff --git a/tests/components/subaru/api_responses.py b/tests/components/subaru/api_responses.py index 52c57e7348a..0e15dead33f 100644 --- a/tests/components/subaru/api_responses.py +++ b/tests/components/subaru/api_responses.py @@ -62,19 +62,13 @@ MOCK_DATETIME = datetime.fromtimestamp(1595560000, UTC) VEHICLE_STATUS_EV = { VEHICLE_STATUS: { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "AVG_FUEL_CONSUMPTION": 51.1, + "DISTANCE_TO_EMPTY_FUEL": 170, "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", @@ -85,37 +79,12 @@ VEHICLE_STATUS_EV = { "EV_STATE_OF_CHARGE_PERCENT": 20, "EV_TIME_TO_FULLY_CHARGED_UTC": MOCK_DATETIME, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 0, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_FRONT_LEFT": 0.0, + "TYRE_PRESSURE_FRONT_RIGHT": 31.9, + "TYRE_PRESSURE_REAR_LEFT": 32.6, "TYRE_PRESSURE_REAR_RIGHT": None, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "WINDOW_BACK_STATUS": "UNKNOWN", "WINDOW_FRONT_LEFT_STATUS": "VENTED", @@ -123,7 +92,6 @@ VEHICLE_STATUS_EV = { "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -132,53 +100,22 @@ VEHICLE_STATUS_EV = { VEHICLE_STATUS_G3 = { VEHICLE_STATUS: { - "AVG_FUEL_CONSUMPTION": 2.3, - "DISTANCE_TO_EMPTY_FUEL": 707, - "DOOR_BOOT_LOCK_STATUS": "UNKNOWN", + "AVG_FUEL_CONSUMPTION": 51.1, + "DISTANCE_TO_EMPTY_FUEL": 170, "DOOR_BOOT_POSITION": "CLOSED", - "DOOR_ENGINE_HOOD_LOCK_STATUS": "UNKNOWN", "DOOR_ENGINE_HOOD_POSITION": "CLOSED", - "DOOR_FRONT_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_LEFT_POSITION": "CLOSED", - "DOOR_FRONT_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_FRONT_RIGHT_POSITION": "CLOSED", - "DOOR_REAR_LEFT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_LEFT_POSITION": "CLOSED", - "DOOR_REAR_RIGHT_LOCK_STATUS": "UNKNOWN", "DOOR_REAR_RIGHT_POSITION": "CLOSED", "REMAINING_FUEL_PERCENT": 77, "ODOMETER": 1234, - "POSITION_HEADING_DEGREE": 150, - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, - "SEAT_BELT_STATUS_FRONT_LEFT": "BELTED", - "SEAT_BELT_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_BELT_STATUS_FRONT_RIGHT": "BELTED", - "SEAT_BELT_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_BELT_STATUS_THIRD_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_FRONT_MIDDLE": "NOT_EQUIPPED", - "SEAT_OCCUPATION_STATUS_FRONT_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_SECOND_RIGHT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_LEFT": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_MIDDLE": "UNKNOWN", - "SEAT_OCCUPATION_STATUS_THIRD_RIGHT": "UNKNOWN", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": 2550, - "TYRE_PRESSURE_FRONT_RIGHT": 2550, - "TYRE_PRESSURE_REAR_LEFT": 2450, + "TYRE_PRESSURE_FRONT_LEFT": 0.0, + "TYRE_PRESSURE_FRONT_RIGHT": 31.9, + "TYRE_PRESSURE_REAR_LEFT": 32.6, "TYRE_PRESSURE_REAR_RIGHT": None, - "TYRE_STATUS_FRONT_LEFT": "UNKNOWN", - "TYRE_STATUS_FRONT_RIGHT": "UNKNOWN", - "TYRE_STATUS_REAR_LEFT": "UNKNOWN", - "TYRE_STATUS_REAR_RIGHT": "UNKNOWN", "VEHICLE_STATE_TYPE": "IGNITION_OFF", "WINDOW_BACK_STATUS": "UNKNOWN", "WINDOW_FRONT_LEFT_STATUS": "VENTED", @@ -186,15 +123,14 @@ VEHICLE_STATUS_G3 = { "WINDOW_REAR_LEFT_STATUS": "UNKNOWN", "WINDOW_REAR_RIGHT_STATUS": "UNKNOWN", "WINDOW_SUNROOF_STATUS": "UNKNOWN", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } } EXPECTED_STATE_EV_IMPERIAL = { - "AVG_FUEL_CONSUMPTION": "102.3", - "DISTANCE_TO_EMPTY_FUEL": "439.3", + "AVG_FUEL_CONSUMPTION": "51.1", + "DISTANCE_TO_EMPTY_FUEL": "170", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", @@ -203,45 +139,37 @@ EXPECTED_STATE_EV_IMPERIAL = { "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "766.8", - "POSITION_HEADING_DEGREE": "150", - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, + "ODOMETER": "1234", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", "TYRE_PRESSURE_FRONT_LEFT": "0.0", - "TYRE_PRESSURE_FRONT_RIGHT": "37.0", - "TYRE_PRESSURE_REAR_LEFT": "35.5", + "TYRE_PRESSURE_FRONT_RIGHT": "31.9", + "TYRE_PRESSURE_REAR_LEFT": "32.6", "TYRE_PRESSURE_REAR_RIGHT": "unknown", "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } EXPECTED_STATE_EV_METRIC = { - "AVG_FUEL_CONSUMPTION": "2.3", - "DISTANCE_TO_EMPTY_FUEL": "707", + "AVG_FUEL_CONSUMPTION": "4.6", + "DISTANCE_TO_EMPTY_FUEL": "274", "EV_CHARGER_STATE_TYPE": "CHARGING", "EV_CHARGE_SETTING_AMPERE_TYPE": "MAXIMUM", "EV_CHARGE_VOLT_TYPE": "CHARGE_LEVEL_1", - "EV_DISTANCE_TO_EMPTY": "1.6", + "EV_DISTANCE_TO_EMPTY": "2", "EV_IS_PLUGGED_IN": "UNLOCKED_CONNECTED", "EV_STATE_OF_CHARGE_MODE": "EV_MODE", "EV_STATE_OF_CHARGE_PERCENT": "20", "EV_TIME_TO_FULLY_CHARGED_UTC": "2020-07-24T03:06:40+00:00", - "ODOMETER": "1234", - "POSITION_HEADING_DEGREE": "150", - "POSITION_SPEED_KMPH": "0", - "POSITION_TIMESTAMP": 1595560000.0, + "ODOMETER": "1986", "TIMESTAMP": 1595560000.0, "TRANSMISSION_MODE": "UNKNOWN", - "TYRE_PRESSURE_FRONT_LEFT": "0", - "TYRE_PRESSURE_FRONT_RIGHT": "2550", - "TYRE_PRESSURE_REAR_LEFT": "2450", + "TYRE_PRESSURE_FRONT_LEFT": "0.0", + "TYRE_PRESSURE_FRONT_RIGHT": "219.9", + "TYRE_PRESSURE_REAR_LEFT": "224.8", "TYRE_PRESSURE_REAR_RIGHT": "unknown", "VEHICLE_STATE_TYPE": "IGNITION_OFF", - "HEADING": 170, "LATITUDE": 40.0, "LONGITUDE": -100.0, } @@ -259,9 +187,6 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "EV_STATE_OF_CHARGE_PERCENT": "unavailable", "EV_TIME_TO_FULLY_CHARGED_UTC": "unavailable", "ODOMETER": "unavailable", - "POSITION_HEADING_DEGREE": "unavailable", - "POSITION_SPEED_KMPH": "unavailable", - "POSITION_TIMESTAMP": "unavailable", "TIMESTAMP": "unavailable", "TRANSMISSION_MODE": "unavailable", "TYRE_PRESSURE_FRONT_LEFT": "unavailable", @@ -269,7 +194,6 @@ EXPECTED_STATE_EV_UNAVAILABLE = { "TYRE_PRESSURE_REAR_LEFT": "unavailable", "TYRE_PRESSURE_REAR_RIGHT": "unavailable", "VEHICLE_STATE_TYPE": "unavailable", - "HEADING": "unavailable", "LATITUDE": "unavailable", "LONGITUDE": "unavailable", } diff --git a/tests/components/subaru/snapshots/test_diagnostics.ambr b/tests/components/subaru/snapshots/test_diagnostics.ambr index 848e48776df..14c19dd78a9 100644 --- a/tests/components/subaru/snapshots/test_diagnostics.ambr +++ b/tests/components/subaru/snapshots/test_diagnostics.ambr @@ -11,19 +11,13 @@ 'data': list([ dict({ 'vehicle_status': dict({ - 'AVG_FUEL_CONSUMPTION': 2.3, - 'DISTANCE_TO_EMPTY_FUEL': 707, - 'DOOR_BOOT_LOCK_STATUS': 'UNKNOWN', + 'AVG_FUEL_CONSUMPTION': 51.1, + 'DISTANCE_TO_EMPTY_FUEL': 170, 'DOOR_BOOT_POSITION': 'CLOSED', - 'DOOR_ENGINE_HOOD_LOCK_STATUS': 'UNKNOWN', 'DOOR_ENGINE_HOOD_POSITION': 'CLOSED', - 'DOOR_FRONT_LEFT_LOCK_STATUS': 'UNKNOWN', 'DOOR_FRONT_LEFT_POSITION': 'CLOSED', - 'DOOR_FRONT_RIGHT_LOCK_STATUS': 'UNKNOWN', 'DOOR_FRONT_RIGHT_POSITION': 'CLOSED', - 'DOOR_REAR_LEFT_LOCK_STATUS': 'UNKNOWN', 'DOOR_REAR_LEFT_POSITION': 'CLOSED', - 'DOOR_REAR_RIGHT_LOCK_STATUS': 'UNKNOWN', 'DOOR_REAR_RIGHT_POSITION': 'CLOSED', 'EV_CHARGER_STATE_TYPE': 'CHARGING', 'EV_CHARGE_SETTING_AMPERE_TYPE': 'MAXIMUM', @@ -33,41 +27,15 @@ 'EV_STATE_OF_CHARGE_MODE': 'EV_MODE', 'EV_STATE_OF_CHARGE_PERCENT': 20, 'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00', - 'HEADING': 170, 'LATITUDE': '**REDACTED**', 'LONGITUDE': '**REDACTED**', 'ODOMETER': '**REDACTED**', - 'POSITION_HEADING_DEGREE': 150, - 'POSITION_SPEED_KMPH': '0', - 'POSITION_TIMESTAMP': 1595560000.0, - 'SEAT_BELT_STATUS_FRONT_LEFT': 'BELTED', - 'SEAT_BELT_STATUS_FRONT_MIDDLE': 'NOT_EQUIPPED', - 'SEAT_BELT_STATUS_FRONT_RIGHT': 'BELTED', - 'SEAT_BELT_STATUS_SECOND_LEFT': 'UNKNOWN', - 'SEAT_BELT_STATUS_SECOND_MIDDLE': 'UNKNOWN', - 'SEAT_BELT_STATUS_SECOND_RIGHT': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_LEFT': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_MIDDLE': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_FRONT_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_FRONT_MIDDLE': 'NOT_EQUIPPED', - 'SEAT_OCCUPATION_STATUS_FRONT_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_MIDDLE': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_MIDDLE': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_RIGHT': 'UNKNOWN', 'TIMESTAMP': 1595560000.0, 'TRANSMISSION_MODE': 'UNKNOWN', - 'TYRE_PRESSURE_FRONT_LEFT': 0, - 'TYRE_PRESSURE_FRONT_RIGHT': 2550, - 'TYRE_PRESSURE_REAR_LEFT': 2450, + 'TYRE_PRESSURE_FRONT_LEFT': 0.0, + 'TYRE_PRESSURE_FRONT_RIGHT': 31.9, + 'TYRE_PRESSURE_REAR_LEFT': 32.6, 'TYRE_PRESSURE_REAR_RIGHT': None, - 'TYRE_STATUS_FRONT_LEFT': 'UNKNOWN', - 'TYRE_STATUS_FRONT_RIGHT': 'UNKNOWN', - 'TYRE_STATUS_REAR_LEFT': 'UNKNOWN', - 'TYRE_STATUS_REAR_RIGHT': 'UNKNOWN', 'VEHICLE_STATE_TYPE': 'IGNITION_OFF', 'WINDOW_BACK_STATUS': 'UNKNOWN', 'WINDOW_FRONT_LEFT_STATUS': 'VENTED', @@ -94,19 +62,13 @@ }), 'data': dict({ 'vehicle_status': dict({ - 'AVG_FUEL_CONSUMPTION': 2.3, - 'DISTANCE_TO_EMPTY_FUEL': 707, - 'DOOR_BOOT_LOCK_STATUS': 'UNKNOWN', + 'AVG_FUEL_CONSUMPTION': 51.1, + 'DISTANCE_TO_EMPTY_FUEL': 170, 'DOOR_BOOT_POSITION': 'CLOSED', - 'DOOR_ENGINE_HOOD_LOCK_STATUS': 'UNKNOWN', 'DOOR_ENGINE_HOOD_POSITION': 'CLOSED', - 'DOOR_FRONT_LEFT_LOCK_STATUS': 'UNKNOWN', 'DOOR_FRONT_LEFT_POSITION': 'CLOSED', - 'DOOR_FRONT_RIGHT_LOCK_STATUS': 'UNKNOWN', 'DOOR_FRONT_RIGHT_POSITION': 'CLOSED', - 'DOOR_REAR_LEFT_LOCK_STATUS': 'UNKNOWN', 'DOOR_REAR_LEFT_POSITION': 'CLOSED', - 'DOOR_REAR_RIGHT_LOCK_STATUS': 'UNKNOWN', 'DOOR_REAR_RIGHT_POSITION': 'CLOSED', 'EV_CHARGER_STATE_TYPE': 'CHARGING', 'EV_CHARGE_SETTING_AMPERE_TYPE': 'MAXIMUM', @@ -116,41 +78,15 @@ 'EV_STATE_OF_CHARGE_MODE': 'EV_MODE', 'EV_STATE_OF_CHARGE_PERCENT': 20, 'EV_TIME_TO_FULLY_CHARGED_UTC': '2020-07-24T03:06:40+00:00', - 'HEADING': 170, 'LATITUDE': '**REDACTED**', 'LONGITUDE': '**REDACTED**', 'ODOMETER': '**REDACTED**', - 'POSITION_HEADING_DEGREE': 150, - 'POSITION_SPEED_KMPH': '0', - 'POSITION_TIMESTAMP': 1595560000.0, - 'SEAT_BELT_STATUS_FRONT_LEFT': 'BELTED', - 'SEAT_BELT_STATUS_FRONT_MIDDLE': 'NOT_EQUIPPED', - 'SEAT_BELT_STATUS_FRONT_RIGHT': 'BELTED', - 'SEAT_BELT_STATUS_SECOND_LEFT': 'UNKNOWN', - 'SEAT_BELT_STATUS_SECOND_MIDDLE': 'UNKNOWN', - 'SEAT_BELT_STATUS_SECOND_RIGHT': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_LEFT': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_MIDDLE': 'UNKNOWN', - 'SEAT_BELT_STATUS_THIRD_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_FRONT_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_FRONT_MIDDLE': 'NOT_EQUIPPED', - 'SEAT_OCCUPATION_STATUS_FRONT_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_MIDDLE': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_SECOND_RIGHT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_LEFT': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_MIDDLE': 'UNKNOWN', - 'SEAT_OCCUPATION_STATUS_THIRD_RIGHT': 'UNKNOWN', 'TIMESTAMP': 1595560000.0, 'TRANSMISSION_MODE': 'UNKNOWN', - 'TYRE_PRESSURE_FRONT_LEFT': 0, - 'TYRE_PRESSURE_FRONT_RIGHT': 2550, - 'TYRE_PRESSURE_REAR_LEFT': 2450, + 'TYRE_PRESSURE_FRONT_LEFT': 0.0, + 'TYRE_PRESSURE_FRONT_RIGHT': 31.9, + 'TYRE_PRESSURE_REAR_LEFT': 32.6, 'TYRE_PRESSURE_REAR_RIGHT': None, - 'TYRE_STATUS_FRONT_LEFT': 'UNKNOWN', - 'TYRE_STATUS_FRONT_RIGHT': 'UNKNOWN', - 'TYRE_STATUS_REAR_LEFT': 'UNKNOWN', - 'TYRE_STATUS_REAR_RIGHT': 'UNKNOWN', 'VEHICLE_STATE_TYPE': 'IGNITION_OFF', 'WINDOW_BACK_STATUS': 'UNKNOWN', 'WINDOW_FRONT_LEFT_STATUS': 'VENTED', diff --git a/tests/components/subaru/test_sensor.py b/tests/components/subaru/test_sensor.py index de1df044d71..418c03dcecd 100644 --- a/tests/components/subaru/test_sensor.py +++ b/tests/components/subaru/test_sensor.py @@ -14,14 +14,11 @@ from homeassistant.components.subaru.sensor import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .api_responses import ( - EXPECTED_STATE_EV_IMPERIAL, EXPECTED_STATE_EV_METRIC, EXPECTED_STATE_EV_UNAVAILABLE, TEST_VIN_2_EV, - VEHICLE_STATUS_EV, ) from .conftest import ( MOCK_API_FETCH, @@ -31,20 +28,6 @@ from .conftest import ( ) -async def test_sensors_ev_imperial(hass: HomeAssistant, ev_entry) -> None: - """Test sensors supporting imperial units.""" - hass.config.units = US_CUSTOMARY_SYSTEM - - with ( - patch(MOCK_API_FETCH), - patch(MOCK_API_GET_DATA, return_value=VEHICLE_STATUS_EV), - ): - advance_time_to_next_fetch(hass) - await hass.async_block_till_done() - - _assert_data(hass, EXPECTED_STATE_EV_IMPERIAL) - - async def test_sensors_ev_metric(hass: HomeAssistant, ev_entry) -> None: """Test sensors supporting metric units.""" _assert_data(hass, EXPECTED_STATE_EV_METRIC) diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 0480520f469..f3d85f019f3 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -693,7 +693,7 @@ async def help_test_entity_id_update_subscriptions( assert state is not None assert mqtt_mock.async_subscribe.call_count == len(topics) for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY, ANY) mqtt_mock.async_subscribe.reset_mock() entity_reg.async_update_entity( @@ -707,7 +707,7 @@ async def help_test_entity_id_update_subscriptions( state = hass.states.get(f"{domain}.milk") assert state is not None for topic in topics: - mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY, ANY) async def help_test_entity_id_update_discovery_update( diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 5a7635c72b2..91832f1f2f0 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -30,7 +30,9 @@ async def test_subscribing_config_topic( discovery_topic = DEFAULT_PREFIX assert mqtt_mock.async_subscribe.called - mqtt_mock.async_subscribe.assert_any_call(discovery_topic + "/#", ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_any_call( + discovery_topic + "/#", ANY, 0, "utf-8", ANY + ) async def test_future_discovery_message( diff --git a/tests/components/teslemetry/fixtures/site_info.json b/tests/components/teslemetry/fixtures/site_info.json index 80a9d25ebce..f581707ff14 100644 --- a/tests/components/teslemetry/fixtures/site_info.json +++ b/tests/components/teslemetry/fixtures/site_info.json @@ -26,7 +26,7 @@ "storm_mode_capable": true, "flex_energy_request_capable": false, "car_charging_data_supported": false, - "off_grid_vehicle_charging_reserve_supported": false, + "off_grid_vehicle_charging_reserve_supported": true, "vehicle_charging_performance_view_enabled": false, "vehicle_charging_solar_offset_view_enabled": false, "battery_solar_offset_view_enabled": true, diff --git a/tests/components/teslemetry/fixtures/vehicle_data.json b/tests/components/teslemetry/fixtures/vehicle_data.json index 25f98406fac..b5b78242496 100644 --- a/tests/components/teslemetry/fixtures/vehicle_data.json +++ b/tests/components/teslemetry/fixtures/vehicle_data.json @@ -204,17 +204,18 @@ "is_user_present": false, "locked": false, "media_info": { - "audio_volume": 2.6667, + "a2dp_source_name": "Pixel 8 Pro", + "audio_volume": 1.6667, "audio_volume_increment": 0.333333, "audio_volume_max": 10.333333, - "media_playback_status": "Stopped", - "now_playing_album": "", - "now_playing_artist": "", - "now_playing_duration": 0, - "now_playing_elapsed": 0, - "now_playing_source": "Spotify", - "now_playing_station": "", - "now_playing_title": "" + "media_playback_status": "Playing", + "now_playing_album": "Elon Musk", + "now_playing_artist": "Walter Isaacson", + "now_playing_duration": 651000, + "now_playing_elapsed": 1000, + "now_playing_source": "Audible", + "now_playing_station": "Elon Musk", + "now_playing_title": "Chapter 51: Cybertruck: Tesla, 2018–2019" }, "media_state": { "remote_control_enabled": true @@ -236,11 +237,11 @@ "service_mode": false, "service_mode_plus": false, "software_update": { - "download_perc": 0, + "download_perc": 100, "expected_duration_sec": 2700, "install_perc": 1, - "status": "", - "version": " " + "status": "available", + "version": "2024.12.0.0" }, "speed_limit_mode": { "active": false, diff --git a/tests/components/teslemetry/snapshots/test_button.ambr b/tests/components/teslemetry/snapshots/test_button.ambr new file mode 100644 index 00000000000..b36a33c282d --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_button.ambr @@ -0,0 +1,323 @@ +# serializer version: 1 +# name: test_button[button.test_flash_lights-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_flash_lights', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Flash lights', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'flash_lights', + 'unique_id': 'VINVINVIN-flash_lights', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_flash_lights-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Flash lights', + }), + 'context': , + 'entity_id': 'button.test_flash_lights', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_force_refresh-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_force_refresh', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Force refresh', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'refresh', + 'unique_id': 'VINVINVIN-refresh', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_force_refresh-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Force refresh', + }), + 'context': , + 'entity_id': 'button.test_force_refresh', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_homelink-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_homelink', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Homelink', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'homelink', + 'unique_id': 'VINVINVIN-homelink', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_homelink-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Homelink', + }), + 'context': , + 'entity_id': 'button.test_homelink', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_honk_horn-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_honk_horn', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Honk horn', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'honk', + 'unique_id': 'VINVINVIN-honk', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_honk_horn-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Honk horn', + }), + 'context': , + 'entity_id': 'button.test_honk_horn', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_keyless_driving-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_keyless_driving', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Keyless driving', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'enable_keyless_driving', + 'unique_id': 'VINVINVIN-enable_keyless_driving', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_keyless_driving-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Keyless driving', + }), + 'context': , + 'entity_id': 'button.test_keyless_driving', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_play_fart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_play_fart', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Play fart', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'boombox', + 'unique_id': 'VINVINVIN-boombox', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_play_fart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Play fart', + }), + 'context': , + 'entity_id': 'button.test_play_fart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button[button.test_wake-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_wake', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wake', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'wake', + 'unique_id': 'VINVINVIN-wake', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[button.test_wake-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Wake', + }), + 'context': , + 'entity_id': 'button.test_wake', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_diagnostics.ambr b/tests/components/teslemetry/snapshots/test_diagnostics.ambr index 41d7ea69f4f..d7348d66d07 100644 --- a/tests/components/teslemetry/snapshots/test_diagnostics.ambr +++ b/tests/components/teslemetry/snapshots/test_diagnostics.ambr @@ -62,7 +62,7 @@ 'components_grid_services_enabled': False, 'components_load_meter': True, 'components_net_meter_mode': 'battery_ok', - 'components_off_grid_vehicle_charging_reserve_supported': False, + 'components_off_grid_vehicle_charging_reserve_supported': True, 'components_set_islanding_mode_enabled': True, 'components_show_grid_import_battery_source_cards': True, 'components_solar': True, @@ -361,17 +361,18 @@ 'vehicle_state_ft': 0, 'vehicle_state_is_user_present': False, 'vehicle_state_locked': False, - 'vehicle_state_media_info_audio_volume': 2.6667, + 'vehicle_state_media_info_a2dp_source_name': 'Pixel 8 Pro', + 'vehicle_state_media_info_audio_volume': 1.6667, 'vehicle_state_media_info_audio_volume_increment': 0.333333, 'vehicle_state_media_info_audio_volume_max': 10.333333, - 'vehicle_state_media_info_media_playback_status': 'Stopped', - 'vehicle_state_media_info_now_playing_album': '', - 'vehicle_state_media_info_now_playing_artist': '', - 'vehicle_state_media_info_now_playing_duration': 0, - 'vehicle_state_media_info_now_playing_elapsed': 0, - 'vehicle_state_media_info_now_playing_source': 'Spotify', - 'vehicle_state_media_info_now_playing_station': '', - 'vehicle_state_media_info_now_playing_title': '', + 'vehicle_state_media_info_media_playback_status': 'Playing', + 'vehicle_state_media_info_now_playing_album': 'Elon Musk', + 'vehicle_state_media_info_now_playing_artist': 'Walter Isaacson', + 'vehicle_state_media_info_now_playing_duration': 651000, + 'vehicle_state_media_info_now_playing_elapsed': 1000, + 'vehicle_state_media_info_now_playing_source': 'Audible', + 'vehicle_state_media_info_now_playing_station': 'Elon Musk', + 'vehicle_state_media_info_now_playing_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', 'vehicle_state_media_state_remote_control_enabled': True, 'vehicle_state_notifications_supported': True, 'vehicle_state_odometer': 6481.019282, @@ -389,11 +390,11 @@ 'vehicle_state_sentry_mode_available': True, 'vehicle_state_service_mode': False, 'vehicle_state_service_mode_plus': False, - 'vehicle_state_software_update_download_perc': 0, + 'vehicle_state_software_update_download_perc': 100, 'vehicle_state_software_update_expected_duration_sec': 2700, 'vehicle_state_software_update_install_perc': 1, - 'vehicle_state_software_update_status': '', - 'vehicle_state_software_update_version': ' ', + 'vehicle_state_software_update_status': 'available', + 'vehicle_state_software_update_version': '2024.12.0.0', 'vehicle_state_speed_limit_mode_active': False, 'vehicle_state_speed_limit_mode_current_limit_mph': 69, 'vehicle_state_speed_limit_mode_max_limit_mph': 120, diff --git a/tests/components/teslemetry/snapshots/test_media_player.ambr b/tests/components/teslemetry/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..f0344ddef4c --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_media_player.ambr @@ -0,0 +1,136 @@ +# serializer version: 1 +# name: test_media_player[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_alt[media_player.test_media_player-statealt] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': '', + 'media_artist': '', + 'media_playlist': '', + 'media_title': '', + 'source': 'Spotify', + 'supported_features': , + 'volume_level': 0.25806775026025003, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_media_player', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Media player', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'media', + 'unique_id': 'VINVINVIN-media', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_noscope[media_player.test_media_player-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'Test Media player', + 'media_album_name': 'Elon Musk', + 'media_artist': 'Walter Isaacson', + 'media_duration': 651.0, + 'media_playlist': 'Elon Musk', + 'media_position': 1.0, + 'media_title': 'Chapter 51: Cybertruck: Tesla, 2018–2019', + 'source': 'Audible', + 'supported_features': , + 'volume_level': 0.16129355359011466, + }), + 'context': , + 'entity_id': 'media_player.test_media_player', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_number.ambr b/tests/components/teslemetry/snapshots/test_number.ambr new file mode 100644 index 00000000000..4cfeaa40696 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_number.ambr @@ -0,0 +1,461 @@ +# serializer version: 1 +# name: test_number[number.energy_site_backup_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_backup_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Backup reserve', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_backup_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Backup reserve', + 'icon': 'mdi:battery-alert', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_backup_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number[number.energy_site_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-alert', + 'original_name': 'Battery', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'backup_reserve_percent', + 'unique_id': '123456-backup_reserve_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Battery', + 'icon': 'mdi:battery-alert', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_number[number.energy_site_battery_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_battery_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Battery', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'off_grid_vehicle_charging_reserve', + 'unique_id': '123456-off_grid_vehicle_charging_reserve', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_battery_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Battery', + 'icon': 'mdi:battery-unknown', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_battery_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.energy_site_off_grid_reserve', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:battery-unknown', + 'original_name': 'Off grid reserve', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'off_grid_vehicle_charging_reserve', + 'unique_id': '123456-off_grid_vehicle_charging_reserve', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.energy_site_off_grid_reserve-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Energy Site Off grid reserve', + 'icon': 'mdi:battery-unknown', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.energy_site_off_grid_reserve', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_number[number.test_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'VINVINVIN-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.test_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_number[number.test_charge_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge current', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'VINVINVIN-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.test_charge_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Charge current', + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_charge_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_number[number.test_charge_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_charge_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charge limit', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_limit_soc', + 'unique_id': 'VINVINVIN-charge_state_charge_limit_soc', + 'unit_of_measurement': '%', + }) +# --- +# name: test_number[number.test_charge_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Charge limit', + 'max': 100, + 'min': 50, + 'mode': , + 'step': 1, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_charge_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_number[number.test_current-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.test_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'charge_state_charge_current_request', + 'unique_id': 'VINVINVIN-charge_state_charge_current_request', + 'unit_of_measurement': , + }) +# --- +# name: test_number[number.test_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test Current', + 'max': 16, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.test_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '16', + }) +# --- diff --git a/tests/components/teslemetry/snapshots/test_update.ambr b/tests/components/teslemetry/snapshots/test_update.ambr new file mode 100644 index 00000000000..ad9c7fea087 --- /dev/null +++ b/tests/components/teslemetry/snapshots/test_update.ambr @@ -0,0 +1,113 @@ +# serializer version: 1 +# name: test_update[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'VINVINVIN-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2024.12.0.0', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_alt[update.test_update-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.test_update', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Update', + 'platform': 'teslemetry', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': 'vehicle_state_software_update_status', + 'unique_id': 'VINVINVIN-vehicle_state_software_update_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_update_alt[update.test_update-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'entity_picture': 'https://brands.home-assistant.io/_/teslemetry/icon.png', + 'friendly_name': 'Test Update', + 'in_progress': False, + 'installed_version': '2023.44.30.8', + 'latest_version': '2023.44.30.8', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.test_update', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/teslemetry/test_button.py b/tests/components/teslemetry/test_button.py new file mode 100644 index 00000000000..a10e3efdff2 --- /dev/null +++ b/tests/components/teslemetry/test_button.py @@ -0,0 +1,53 @@ +"""Test the Teslemetry button platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_button( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the button entities are correct.""" + + entry = await setup_platform(hass, [Platform.BUTTON]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +@pytest.mark.parametrize( + ("name", "func"), + [ + ("flash_lights", "flash_lights"), + ("honk_horn", "honk_horn"), + ("keyless_driving", "remote_start_drive"), + ("play_fart", "remote_boombox"), + ("homelink", "trigger_homelink"), + ], +) +async def test_press(hass: HomeAssistant, name: str, func: str) -> None: + """Test pressing the API buttons.""" + await setup_platform(hass, [Platform.BUTTON]) + + with patch( + f"homeassistant.components.teslemetry.VehicleSpecific.{func}", + return_value=COMMAND_OK, + ) as command: + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: [f"button.test_{name}"]}, + blocking=True, + ) + command.assert_called_once() diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py new file mode 100644 index 00000000000..8544c11a625 --- /dev/null +++ b/tests/components/teslemetry/test_media_player.py @@ -0,0 +1,152 @@ +"""Test the Teslemetry media player platform.""" + +from unittest.mock import patch + +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_LEVEL, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + SERVICE_MEDIA_PAUSE, + SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_VOLUME_SET, + MediaPlayerState, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, assert_entities_alt, setup_platform +from .const import COMMAND_OK, METADATA_NOSCOPE, VEHICLE_DATA_ALT + + +async def test_media_player( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the media player entities are correct.""" + + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities_alt(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the media player entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + state = hass.states.get("media_player.test_media_player") + assert state.state == MediaPlayerState.OFF + + +async def test_media_player_noscope( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_metadata, +) -> None: + """Tests that the media player entities are correct without required scope.""" + + mock_metadata.return_value = METADATA_NOSCOPE + entry = await setup_platform(hass, [Platform.MEDIA_PLAYER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_media_player_services( + hass: HomeAssistant, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the media player services work.""" + + await setup_platform(hass, [Platform.MEDIA_PLAYER]) + + entity_id = "media_player.test_media_player" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.adjust_volume", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.5 + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PAUSE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PAUSED + call.assert_called_once() + + # This test will fail without the previous call to pause playback + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_toggle_playback", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PLAY, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == MediaPlayerState.PLAYING + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_next_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_NEXT_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.media_prev_track", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_MEDIA_PREVIOUS_TRACK, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + state = hass.states.get(entity_id) + call.assert_called_once() diff --git a/tests/components/teslemetry/test_number.py b/tests/components/teslemetry/test_number.py new file mode 100644 index 00000000000..728d37c4d7c --- /dev/null +++ b/tests/components/teslemetry/test_number.py @@ -0,0 +1,113 @@ +"""Test the Teslemetry number platform.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA_ALT + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the number entities are correct.""" + + entry = await setup_platform(hass, [Platform.NUMBER]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_number_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the number entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.NUMBER]) + state = hass.states.get("number.test_charge_current") + assert state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_number_services(hass: HomeAssistant, mock_vehicle_data) -> None: + """Tests that the number services work.""" + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + await setup_platform(hass, [Platform.NUMBER]) + + entity_id = "number.test_charge_current" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.set_charging_amps", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 16}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "16" + call.assert_called_once() + + entity_id = "number.test_charge_limit" + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.set_charge_limit", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 60}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "60" + call.assert_called_once() + + entity_id = "number.energy_site_backup_reserve" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.backup", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 80, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "80" + call.assert_called_once() + + entity_id = "number.energy_site_off_grid_reserve" + with patch( + "homeassistant.components.teslemetry.EnergySpecific.off_grid_vehicle_charging_reserve", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: 88}, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "88" + call.assert_called_once() diff --git a/tests/components/teslemetry/test_update.py b/tests/components/teslemetry/test_update.py new file mode 100644 index 00000000000..447ec524e90 --- /dev/null +++ b/tests/components/teslemetry/test_update.py @@ -0,0 +1,89 @@ +"""Test the Teslemetry update platform.""" + +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion +from tesla_fleet_api.exceptions import VehicleOffline + +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL +from homeassistant.components.teslemetry.update import INSTALLING +from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import assert_entities, setup_platform +from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed + + +async def test_update( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Tests that the update entities are correct.""" + + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_alt( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct.""" + + mock_vehicle_data.return_value = VEHICLE_DATA_ALT + entry = await setup_platform(hass, [Platform.UPDATE]) + assert_entities(hass, entry.entry_id, entity_registry, snapshot) + + +async def test_update_offline( + hass: HomeAssistant, + mock_vehicle_data, +) -> None: + """Tests that the update entities are correct when offline.""" + + mock_vehicle_data.side_effect = VehicleOffline + await setup_platform(hass, [Platform.UPDATE]) + state = hass.states.get("update.test_update") + assert state.state == STATE_UNKNOWN + + +async def test_update_services( + hass: HomeAssistant, + mock_vehicle_data, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, +) -> None: + """Tests that the update services work.""" + + await setup_platform(hass, [Platform.UPDATE]) + + entity_id = "update.test_update" + + with patch( + "homeassistant.components.teslemetry.VehicleSpecific.schedule_software_update", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + call.assert_called_once() + + VEHICLE_DATA["response"]["vehicle_state"]["software_update"]["status"] = INSTALLING + mock_vehicle_data.return_value = VEHICLE_DATA + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes["in_progress"] == 1 diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 948255ccea5..47221a77cee 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -6247,3 +6247,72 @@ async def test_stopping_run_before_starting( # would hang indefinitely. run = script._ScriptRun(hass, script_obj, {}, None, True) await run.async_stop() + + +async def test_disallowed_recursion( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test a queued mode script disallowed recursion.""" + context = Context() + calls = 0 + alias = "event step" + sequence1 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_2"}) + script1_obj = script.Script( + hass, + sequence1, + "Test Name1", + "test_domain1", + script_mode="queued", + running_description="test script1", + ) + + sequence2 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_3"}) + script2_obj = script.Script( + hass, + sequence2, + "Test Name2", + "test_domain2", + script_mode="queued", + running_description="test script2", + ) + + sequence3 = cv.SCRIPT_SCHEMA({"alias": alias, "service": "test.call_script_1"}) + script3_obj = script.Script( + hass, + sequence3, + "Test Name3", + "test_domain3", + script_mode="queued", + running_description="test script3", + ) + + async def _async_service_handler_1(*args, **kwargs) -> None: + await script1_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_1", _async_service_handler_1) + + async def _async_service_handler_2(*args, **kwargs) -> None: + await script2_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_2", _async_service_handler_2) + + async def _async_service_handler_3(*args, **kwargs) -> None: + await script3_obj.async_run(context=context) + + hass.services.async_register("test", "call_script_3", _async_service_handler_3) + + await script1_obj.async_run(context=context) + await hass.async_block_till_done() + + assert calls == 0 + assert ( + "Test Name1: Disallowed recursion detected, " + "test_domain3.Test Name3 tried to start test_domain1.Test Name1" + " which is already running in the current execution path; " + "Traceback (most recent call last):" + ) in caplog.text + assert ( + "- test_domain1.Test Name1\n" + "- test_domain2.Test Name2\n" + "- test_domain3.Test Name3" + ) in caplog.text