Merge branch 'dev' into jbouwh-mqtt-device-discovery

This commit is contained in:
J. Nick Koston
2024-05-26 01:23:15 -10:00
committed by GitHub
86 changed files with 2609 additions and 817 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_flash_lights',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_flash_lights',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_force_refresh-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_force_refresh',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_force_refresh',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_homelink-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_homelink',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_homelink',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_honk_horn-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_honk_horn',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_honk_horn',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_keyless_driving-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_keyless_driving',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_keyless_driving',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_play_fart-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_play_fart',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_play_fart',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_button[button.test_wake-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.test_wake',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'entity_id': 'button.test_wake',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

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

View File

@@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': 'Media player',
'platform': 'teslemetry',
'previous_unique_id': None,
'supported_features': <MediaPlayerEntityFeature: 16437>,
'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, 20182019',
'source': 'Audible',
'supported_features': <MediaPlayerEntityFeature: 16437>,
'volume_level': 0.16129355359011466,
}),
'context': <ANY>,
'entity_id': 'media_player.test_media_player',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <MediaPlayerEntityFeature: 16437>,
'volume_level': 0.25806775026025003,
}),
'context': <ANY>,
'entity_id': 'media_player.test_media_player',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'idle',
})
# ---
# name: test_media_player_noscope[media_player.test_media_player-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'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, 20182019',
'source': 'Audible',
'supported_features': <MediaPlayerEntityFeature: 0>,
'volume_level': 0.16129355359011466,
}),
'context': <ANY>,
'entity_id': 'media_player.test_media_player',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---

View File

@@ -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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.energy_site_backup_reserve',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_number[number.energy_site_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.energy_site_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.energy_site_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_number[number.energy_site_battery_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.energy_site_battery_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.energy_site_off_grid_reserve',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_number[number.test_battery-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 50,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.test_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '80',
})
# ---
# name: test_number[number.test_charge_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 16,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_charge_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_number[number.test_charge_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Test Charge current',
'max': 16,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'number.test_charge_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16',
})
# ---
# name: test_number[number.test_charge_limit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 100,
'min': 50,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_charge_limit',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.BATTERY: 'battery'>,
'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': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'number.test_charge_limit',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '80',
})
# ---
# name: test_number[number.test_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 16,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': None,
'entity_id': 'number.test_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.CURRENT: 'current'>,
'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': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_number[number.test_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Test Current',
'max': 16,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 1,
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'number.test_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '16',
})
# ---

View File

@@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'update.test_update',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Update',
'platform': 'teslemetry',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 5>,
'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': <UpdateEntityFeature: 5>,
'title': None,
}),
'context': <ANY>,
'entity_id': 'update.test_update',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_update_alt[update.test_update-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'update',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'update.test_update',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Update',
'platform': 'teslemetry',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 4>,
'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': <UpdateEntityFeature: 4>,
'title': None,
}),
'context': <ANY>,
'entity_id': 'update.test_update',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

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

View File

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

View File

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

View File

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

View File

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