Merge branch 'dev' into esphome_bronze

This commit is contained in:
J. Nick Koston 2025-04-19 06:58:11 -10:00 committed by GitHub
commit 96a29b9cc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
112 changed files with 5868 additions and 1300 deletions

View File

@ -52,7 +52,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
@ -134,9 +134,8 @@ class AnthropicOptionsFlow(OptionsFlow):
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
if user_input.get(
CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
@ -151,12 +150,16 @@ class AnthropicOptionsFlow(OptionsFlow):
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
}
suggested_values = options.copy()
if not suggested_values.get(CONF_PROMPT):
suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT
if (
suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API)
) and isinstance(suggested_llm_apis, str):
suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
schema = self.add_suggested_values_to_schema(
vol.Schema(anthropic_config_option_schema(self.hass, options)),
@ -176,24 +179,18 @@ def anthropic_config_option_schema(
) -> dict:
"""Return a schema for Anthropic completion options."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
)
]
schema = {
vol.Optional(CONF_PROMPT): TemplateSelector(),
vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector(
SelectSelectorConfig(options=hass_apis)
),
vol.Optional(
CONF_LLM_HASS_API,
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,

View File

@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
"requirements": ["pyblu==2.0.0"],
"requirements": ["pyblu==2.0.1"],
"zeroconf": [
{
"type": "_musc._tcp.local."

View File

@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self._status.input_id is not None:
for input_ in self._inputs:
if input_.id == self._status.input_id:
# the input might not have an id => also try to match on the stream_url/url
# we have to use both because neither matches all the time
if (
input_.id == self._status.input_id
or input_.url == self._status.stream_url
):
return input_.text
for preset in self._presets:

View File

@ -197,6 +197,7 @@ class ChatLog:
(
"?",
";", # Greek question mark
"", # Chinese question mark
)
)
)

View File

@ -73,14 +73,14 @@ async def _async_set_position(
Returns True if the position was set, False if there is no
supported method for setting the position.
"""
if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features:
await service_call(SERVICE_CLOSE_COVER, service_data)
elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features:
await service_call(SERVICE_OPEN_COVER, service_data)
elif CoverEntityFeature.SET_POSITION in features:
if CoverEntityFeature.SET_POSITION in features:
await service_call(
SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position}
)
elif target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features:
await service_call(SERVICE_CLOSE_COVER, service_data)
elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features:
await service_call(SERVICE_OPEN_COVER, service_data)
else:
# Requested a position but the cover doesn't support it
return False
@ -98,15 +98,17 @@ async def _async_set_tilt_position(
Returns True if the tilt position was set, False if there is no
supported method for setting the tilt position.
"""
if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features:
await service_call(SERVICE_CLOSE_COVER_TILT, service_data)
elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features:
await service_call(SERVICE_OPEN_COVER_TILT, service_data)
elif CoverEntityFeature.SET_TILT_POSITION in features:
if CoverEntityFeature.SET_TILT_POSITION in features:
await service_call(
SERVICE_SET_COVER_TILT_POSITION,
service_data | {ATTR_TILT_POSITION: target_tilt_position},
)
elif (
target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features
):
await service_call(SERVICE_CLOSE_COVER_TILT, service_data)
elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features:
await service_call(SERVICE_OPEN_COVER_TILT, service_data)
else:
# Requested a tilt position but the cover doesn't support it
return False
@ -183,12 +185,12 @@ async def _async_reproduce_state(
current_attrs = cur_state.attributes
target_attrs = state.attributes
current_position = current_attrs.get(ATTR_CURRENT_POSITION)
target_position = target_attrs.get(ATTR_CURRENT_POSITION)
current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION)
target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION)
position_matches = current_position == target_position
current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION)
target_tilt_position = target_attrs.get(ATTR_CURRENT_TILT_POSITION)
current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION)
target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION)
tilt_position_matches = current_tilt_position == target_tilt_position
state_matches = cur_state.state == target_state
@ -214,20 +216,12 @@ async def _async_reproduce_state(
)
service_data = {ATTR_ENTITY_ID: entity_id}
set_position = (
not position_matches
and target_position is not None
and await _async_set_position(
set_position = target_position is not None and await _async_set_position(
service_call, service_data, features, target_position
)
)
set_tilt = (
not tilt_position_matches
and target_tilt_position is not None
and await _async_set_tilt_position(
set_tilt = target_tilt_position is not None and await _async_set_tilt_position(
service_call, service_data, features, target_tilt_position
)
)
if target_state in CLOSING_STATES:
await _async_close_cover(

View File

@ -23,6 +23,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
@ -44,6 +45,7 @@ from .const import (
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DEFAULT_PORT,
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
@ -63,6 +65,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_reauth_entry: ConfigEntry
_reconfig_entry: ConfigEntry
def __init__(self) -> None:
"""Initialize flow."""
@ -88,7 +91,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
fields: dict[Any, type] = OrderedDict()
fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str
fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int
fields[vol.Optional(CONF_PORT, default=self._port or DEFAULT_PORT)] = int
errors = {}
if error is not None:
@ -140,7 +143,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow when encryption was removed."""
if user_input is not None:
self._noise_psk = None
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
return self.async_show_form(
step_id="reauth_encryption_removed_confirm",
@ -172,6 +175,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders={"name": self._name},
)
async def async_step_reconfigure(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a flow initialized by a reconfig request."""
self._reconfig_entry = self._get_reconfigure_entry()
data = self._reconfig_entry.data
self._host = data[CONF_HOST]
self._port = data.get(CONF_PORT, DEFAULT_PORT)
self._noise_psk = data.get(CONF_NOISE_PSK)
self._device_name = data.get(CONF_DEVICE_NAME)
return await self._async_step_user_base()
@property
def _name(self) -> str:
return self.__name or "ESPHome"
@ -230,7 +245,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_authenticate()
self._password = ""
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
@ -270,13 +285,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_validate_mac_abort_configured(
mac_address, self._host, self._port
)
return await self.async_step_discovery_confirm()
async def _async_validate_mac_abort_configured(
self, formatted_mac: str, host: str, port: int | None
) -> None:
"""Validate if the MAC address is already configured."""
assert self.unique_id is not None
if not (
entry := self.hass.config_entries.async_entry_for_domain_unique_id(
self.handler, formatted_mac
@ -393,7 +408,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
data={
**self._entry_with_name_conflict.data,
CONF_HOST: self._host,
CONF_PORT: self._port or 6053,
CONF_PORT: self._port or DEFAULT_PORT,
CONF_PASSWORD: self._password or "",
CONF_NOISE_PSK: self._noise_psk or "",
},
@ -417,20 +432,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id
)
return self._async_get_entry()
async def _async_get_entry_or_resolve_conflict(self) -> ConfigFlowResult:
"""Return the entry or resolve a conflict."""
if self.source != SOURCE_REAUTH:
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
return self._async_get_entry()
return self._async_create_entry()
@callback
def _async_get_entry(self) -> ConfigFlowResult:
config_data = {
def _async_create_entry(self) -> ConfigFlowResult:
"""Create the config entry."""
assert self._name is not None
return self.async_create_entry(
title=self._name,
data=self._async_make_config_data(),
options={
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
},
)
@callback
def _async_make_config_data(self) -> dict[str, Any]:
"""Return config data for the entry."""
return {
CONF_HOST: self._host,
CONF_PORT: self._port,
# The API uses protobuf, so empty string denotes absence
@ -438,19 +457,99 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_NOISE_PSK: self._noise_psk or "",
CONF_DEVICE_NAME: self._device_name,
}
config_options = {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
}
async def _async_validated_connection(self) -> ConfigFlowResult:
"""Handle validated connection."""
if self.source == SOURCE_RECONFIGURE:
return await self._async_reconfig_validated_connection()
if self.source == SOURCE_REAUTH:
return await self._async_reauth_validated_connection()
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = entry
return await self.async_step_name_conflict()
return self._async_create_entry()
async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
"""Handle reauth validated connection."""
assert self._reauth_entry.unique_id is not None
if self.unique_id == self._reauth_entry.unique_id:
return self.async_update_reload_and_abort(
self._reauth_entry, data=self._reauth_entry.data | config_data
self._reauth_entry,
data=self._reauth_entry.data | self._async_make_config_data(),
)
assert self._host is not None
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
# Reauth was triggered a while ago, and since than
# a new device resides at the same IP address.
assert self._device_name is not None
return self.async_abort(
reason="reauth_unique_id_changed",
description_placeholders={
"name": self._reauth_entry.data.get(
CONF_DEVICE_NAME, self._reauth_entry.title
),
"host": self._host,
"expected_mac": format_mac(self._reauth_entry.unique_id),
"unexpected_mac": format_mac(self.unique_id),
"unexpected_device_name": self._device_name,
},
)
assert self._name is not None
return self.async_create_entry(
title=self._name,
data=config_data,
options=config_options,
async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
"""Handle reconfigure validated connection."""
assert self._reconfig_entry.unique_id is not None
assert self._host is not None
assert self._device_name is not None
if not (
unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id)
):
self._abort_if_unique_id_configured(
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
for entry in self._async_current_entries(include_ignore=False):
if (
entry.entry_id != self._reconfig_entry.entry_id
and entry.data.get(CONF_DEVICE_NAME) == self._device_name
):
return self.async_abort(
reason="reconfigure_name_conflict",
description_placeholders={
"name": self._reconfig_entry.data[CONF_DEVICE_NAME],
"host": self._host,
"expected_mac": format_mac(self._reconfig_entry.unique_id),
"existing_title": entry.title,
},
)
if unique_id_matches:
return self.async_update_reload_and_abort(
self._reconfig_entry,
data=self._reconfig_entry.data | self._async_make_config_data(),
)
if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
self._entry_with_name_conflict = self._reconfig_entry
return await self.async_step_name_conflict()
return self.async_abort(
reason="reconfigure_unique_id_changed",
description_placeholders={
"name": self._reconfig_entry.data.get(
CONF_DEVICE_NAME, self._reconfig_entry.title
),
"host": self._host,
"expected_mac": format_mac(self._reconfig_entry.unique_id),
"unexpected_mac": format_mac(self.unique_id),
"unexpected_device_name": self._device_name,
},
)
async def async_step_encryption_key(
@ -481,7 +580,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
error = await self.try_login()
if error:
return await self.async_step_authenticate(error=error)
return await self._async_get_entry_or_resolve_conflict()
return await self._async_validated_connection()
errors = {}
if error is not None:
@ -501,12 +600,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
cli = APIClient(
host,
port or 6053,
port or DEFAULT_PORT,
"",
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
)
try:
await cli.connect()
self._device_info = await cli.device_info()
@ -541,9 +639,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
assert self._device_info is not None
mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source != SOURCE_REAUTH:
if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
self._abort_if_unique_id_configured(
updates={CONF_HOST: self._host, CONF_PORT: self._port}
updates={
CONF_HOST: self._host,
CONF_PORT: self._port,
CONF_NOISE_PSK: self._noise_psk,
}
)
return None

View File

@ -1,5 +1,7 @@
"""ESPHome constants."""
from typing import Final
from awesomeversion import AwesomeVersion
DOMAIN = "esphome"
@ -13,6 +15,7 @@ CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2025.2.2"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)

View File

@ -198,6 +198,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
"""Define a base esphome entity."""
_attr_should_poll = False
_attr_has_entity_name = True
_static_info: _InfoT
_state: _StateT
_has_state: bool
@ -223,24 +224,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
#
# If `friendly_name` is set, we use the Friendly naming rules, if
# `friendly_name` is not set we make an exception to the naming rules for
# backwards compatibility and use the Legacy naming rules.
#
# Friendly naming
# - Friendly name is prepended to entity names
# - Device Name is prepended to entity ids
# - Entity id is constructed from device name and object id
#
# Legacy naming
# - Device name is not prepended to entity names
# - Device name is not prepended to entity ids
# - Entity id is constructed from entity name
#
if not device_info.friendly_name:
return
self._attr_has_entity_name = True
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
async def async_added_to_hass(self) -> None:

View File

@ -520,6 +520,15 @@ class ESPHomeManager:
if device_info.name:
reconnect_logic.name = device_info.name
if not device_info.friendly_name:
_LOGGER.info(
"No `friendly_name` set in the `esphome:` section of the "
"YAML config for device '%s' (MAC: %s); It's recommended "
"to add one for easier identification and better alignment "
"with Home Assistant naming conventions",
device_info.name,
device_mac,
)
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
entry_data.async_update_device_state()
@ -756,7 +765,7 @@ def _async_setup_device_registry(
config_entry_id=entry.entry_id,
configuration_url=configuration_url,
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
name=entry_data.friendly_name,
name=entry_data.friendly_name or entry_data.name,
manufacturer=manufacturer,
model=model,
sw_version=sw_version,

View File

@ -10,7 +10,11 @@
"mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties.",
"mqtt_missing_payload": "Missing MQTT Payload.",
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`."
"name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).",
"reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.",
"reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)."
},
"error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",

View File

@ -183,10 +183,10 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
if not (
user_input.get(CONF_LLM_HASS_API, "none") != "none"
user_input.get(CONF_LLM_HASS_API)
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
):
# Don't allow to save options that enable the Google Seearch tool with an Assist API
@ -213,18 +213,16 @@ async def google_generative_ai_config_option_schema(
) -> dict:
"""Return a schema for Google Generative AI completion options."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
)
]
if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance(
suggested_llm_apis, str
):
suggested_llm_apis = [suggested_llm_apis]
schema = {
vol.Optional(
@ -237,9 +235,8 @@ async def google_generative_ai_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default="none",
): SelectSelector(SelectSelectorConfig(options=hass_apis)),
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,

View File

@ -46,6 +46,8 @@ RESPONSE_HEADERS_FILTER = {
MIN_COMPRESSED_SIZE = 128
MAX_SIMPLE_RESPONSE_SIZE = 4194000
DISABLED_TIMEOUT = ClientTimeout(total=None)
@callback
def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None:
@ -167,7 +169,7 @@ class HassIOIngress(HomeAssistantView):
params=request.query,
allow_redirects=False,
data=request.content if request.method != "GET" else None,
timeout=ClientTimeout(total=None),
timeout=DISABLED_TIMEOUT,
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)

View File

@ -52,7 +52,7 @@ class HistoryLiveStream:
subscriptions: list[CALLBACK_TYPE]
end_time_unsub: CALLBACK_TYPE | None = None
task: asyncio.Task | None = None
wait_sync_task: asyncio.Task | None = None
wait_sync_future: asyncio.Future[None] | None = None
@callback
@ -491,8 +491,8 @@ async def ws_stream(
subscriptions.clear()
if live_stream.task:
live_stream.task.cancel()
if live_stream.wait_sync_task:
live_stream.wait_sync_task.cancel()
if live_stream.wait_sync_future:
live_stream.wait_sync_future.cancel()
if live_stream.end_time_unsub:
live_stream.end_time_unsub()
live_stream.end_time_unsub = None
@ -554,10 +554,12 @@ async def ws_stream(
)
)
live_stream.wait_sync_task = create_eager_task(
get_instance(hass).async_block_till_done()
)
await live_stream.wait_sync_task
if sync_future := get_instance(hass).async_get_commit_future():
# Set the future so we can cancel it if the client
# unsubscribes before the commit is done so we don't
# query the database needlessly
live_stream.wait_sync_future = sync_future
await live_stream.wait_sync_future
#
# Fetch any states from the database that have

View File

@ -12,7 +12,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -43,7 +43,6 @@ async def async_setup_entry(
class InComfortClimate(IncomfortEntity, ClimateEntity):
"""Representation of an InComfort/InTouch climate device."""
_attr_entity_category = EntityCategory.CONFIG
_attr_min_temp = 5.0
_attr_max_temp = 30.0
_attr_name = None

View File

@ -197,6 +197,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
super().__init__(config_entry, description)
self._attrs: dict[str, str] = {}
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
await self.async_update()
async def async_update(self) -> None:
"""Update the state of the sensor."""
now = dt_util.now()

View File

@ -40,6 +40,7 @@ PLATFORMS = [
Platform.CALENDAR,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]

View File

@ -4,8 +4,9 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType
from pylamarzocco.models import BackFlush, BaseWidgetOutput, MachineStatus
from pylamarzocco.models import BackFlush, MachineStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@ -30,7 +31,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
is_on_fn: Callable[[dict[WidgetType, BaseWidgetOutput]], bool | None]
is_on_fn: Callable[[LaMarzoccoMachine], bool | None]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@ -38,7 +39,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
key="water_tank",
translation_key="water_tank",
device_class=BinarySensorDeviceClass.PROBLEM,
is_on_fn=lambda config: WidgetType.CM_NO_WATER in config,
is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config,
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoBinarySensorEntityDescription(
@ -46,8 +47,8 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
translation_key="brew_active",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=(
lambda config: cast(
MachineStatus, config[WidgetType.CM_MACHINE_STATUS]
lambda machine: cast(
MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
).status
is MachineState.BREWING
),
@ -59,11 +60,21 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
translation_key="backflush_enabled",
device_class=BinarySensorDeviceClass.RUNNING,
is_on_fn=(
lambda config: cast(BackFlush, config[WidgetType.CM_BACK_FLUSH]).status
lambda machine: cast(
BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH]
).status
is BackFlushStatus.REQUESTED
),
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoBinarySensorEntityDescription(
key="websocket_connected",
translation_key="websocket_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
is_on_fn=(lambda machine: machine.websocket.connected),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
@ -90,6 +101,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.is_on_fn(
self.coordinator.device.dashboard.config
)
return self.entity_description.is_on_fn(self.coordinator.device)

View File

@ -49,6 +49,7 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3")
_LOGGER = logging.getLogger(__name__)
@ -105,7 +106,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
self._config = data
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
self._get_reauth_entry(), data_updates=data
)
if self._discovered:
if self._discovered[CONF_MACHINE] not in self._things:
@ -169,10 +170,15 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
if not errors:
if self.source == SOURCE_RECONFIGURE:
for service_info in async_discovered_service_info(self.hass):
if service_info.name.startswith(BT_MODEL_PREFIXES):
self._discovered[service_info.name] = service_info.address
if self._discovered:
return await self.async_step_bluetooth_selection()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data_updates=self._config,
)
return self.async_create_entry(
title=selected_device.name,
@ -217,8 +223,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
**self._config,
data_updates={
CONF_MAC: user_input[CONF_MAC],
},
)

View File

@ -36,6 +36,15 @@
},
"smart_standby_time": {
"default": "mdi:timer"
},
"preinfusion_time": {
"default": "mdi:water"
},
"prebrew_time_on": {
"default": "mdi:water"
},
"prebrew_time_off": {
"default": "mdi:water-off"
}
},
"select": {
@ -63,6 +72,14 @@
}
}
},
"sensor": {
"coffee_boiler_ready_time": {
"default": "mdi:av-timer"
},
"steam_boiler_ready_time": {
"default": "mdi:av-timer"
}
},
"switch": {
"main": {
"default": "mdi:power",

View File

@ -5,9 +5,9 @@ from dataclasses import dataclass
from typing import Any, cast
from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import WidgetType
from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import CoffeeBoiler
from pylamarzocco.models import CoffeeBoiler, PreBrewing
from homeassistant.components.number import (
NumberDeviceClass,
@ -77,6 +77,123 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
),
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
LaMarzoccoNumberEntityDescription(
key="preinfusion_off",
translation_key="preinfusion_time",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
seconds_on=0,
seconds_off=float(value),
)
),
native_value_fn=(
lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
)
.times.pre_infusion[0]
.seconds.seconds_out
),
available_fn=(
lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
).mode
is PreExtractionMode.PREINFUSION
),
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (
ModelName.LINEA_MICRA,
ModelName.LINEA_MINI,
ModelName.LINEA_MINI_R,
)
),
),
LaMarzoccoNumberEntityDescription(
key="prebrew_on",
translation_key="prebrew_time_on",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
seconds_on=float(value),
seconds_off=cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
)
.times.pre_brewing[0]
.seconds.seconds_out,
)
),
native_value_fn=(
lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
)
.times.pre_brewing[0]
.seconds.seconds_in
),
available_fn=lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
).mode
is PreExtractionMode.PREBREWING,
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (
ModelName.LINEA_MICRA,
ModelName.LINEA_MINI,
ModelName.LINEA_MINI_R,
)
),
),
LaMarzoccoNumberEntityDescription(
key="prebrew_off",
translation_key="prebrew_time_off",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
native_step=PRECISION_TENTHS,
native_min_value=0,
native_max_value=10,
entity_category=EntityCategory.CONFIG,
set_value_fn=(
lambda machine, value: machine.set_pre_extraction_times(
seconds_on=cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
)
.times.pre_brewing[0]
.seconds.seconds_in,
seconds_off=float(value),
)
),
native_value_fn=(
lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
)
.times.pre_brewing[0]
.seconds.seconds_out
),
available_fn=(
lambda machine: cast(
PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
).mode
is PreExtractionMode.PREBREWING
),
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (
ModelName.LINEA_MICRA,
ModelName.LINEA_MINI,
ModelName.LINEA_MINI_R,
)
),
),
)

View File

@ -0,0 +1,115 @@
"""Sensor platform for La Marzocco espresso machines."""
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import cast
from pylamarzocco.const import ModelName, WidgetType
from pylamarzocco.models import (
BaseWidgetOutput,
CoffeeBoiler,
SteamBoilerLevel,
SteamBoilerTemperature,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSensorEntityDescription(
LaMarzoccoEntityDescription,
SensorEntityDescription,
):
"""Description of a La Marzocco sensor."""
value_fn: Callable[
[dict[WidgetType, BaseWidgetOutput]], StateType | datetime | None
]
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="coffee_boiler_ready_time",
translation_key="coffee_boiler_ready_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=(
lambda config: cast(
CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER]
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
translation_key="steam_boiler_ready_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=(
lambda config: cast(
SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL]
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R)
),
),
LaMarzoccoSensorEntityDescription(
key="steam_boiler_ready_time",
translation_key="steam_boiler_ready_time",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=(
lambda config: cast(
SteamBoilerTemperature, config[WidgetType.CM_STEAM_BOILER_TEMPERATURE]
).ready_start_time
),
entity_category=EntityCategory.DIAGNOSTIC,
supported_fn=(
lambda coordinator: coordinator.device.dashboard.model_name
in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
coordinator = entry.runtime_data.config_coordinator
async_add_entities(
LaMarzoccoSensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
)
class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
"""Sensor representing espresso machine water reservoir status."""
entity_description: LaMarzoccoSensorEntityDescription
@property
def native_value(self) -> StateType | datetime | None:
"""Return value of the sensor."""
return self.entity_description.value_fn(
self.coordinator.device.dashboard.config
)

View File

@ -83,6 +83,9 @@
},
"water_tank": {
"name": "Water tank empty"
},
"websocket_connected": {
"name": "WebSocket connected"
}
},
"button": {
@ -101,6 +104,15 @@
},
"smart_standby_time": {
"name": "Smart standby time"
},
"preinfusion_time": {
"name": "Preinfusion time"
},
"prebrew_time_on": {
"name": "Prebrew on time"
},
"prebrew_time_off": {
"name": "Prebrew off time"
}
},
"select": {
@ -128,6 +140,14 @@
}
}
},
"sensor": {
"coffee_boiler_ready_time": {
"name": "Coffee boiler ready time"
},
"steam_boiler_ready_time": {
"name": "Steam boiler ready time"
}
},
"switch": {
"auto_on_off": {
"name": "Auto on/off ({id})"

View File

@ -1,9 +1,10 @@
"""Support for La Marzocco update entities."""
import asyncio
from dataclasses import dataclass
from typing import Any
from pylamarzocco.const import FirmwareType
from pylamarzocco.const import FirmwareType, UpdateCommandStatus
from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.update import (
@ -22,6 +23,7 @@ from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
MAX_UPDATE_WAIT = 150
@dataclass(frozen=True, kw_only=True)
@ -71,7 +73,11 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"""Entity representing the update state."""
entity_description: LaMarzoccoUpdateEntityDescription
_attr_supported_features = UpdateEntityFeature.INSTALL
_attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.PROGRESS
| UpdateEntityFeature.RELEASE_NOTES
)
@property
def installed_version(self) -> str:
@ -94,15 +100,40 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"""Return the release notes URL."""
return "https://support-iot.lamarzocco.com/firmware-updates/"
def release_notes(self) -> str | None:
"""Return the release notes for the latest firmware version."""
if available_update := self.coordinator.device.settings.firmwares[
self.entity_description.component
].available_update:
return available_update.change_log
return None
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
self._attr_in_progress = True
self.async_write_ha_state()
counter = 0
def _raise_timeout_error() -> None: # to avoid TRY301
raise TimeoutError("Update timed out")
try:
await self.coordinator.device.update_firmware()
except RequestNotSuccessful as exc:
while (
update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateCommandStatus.IN_PROGRESS:
if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage
self.async_write_ha_state()
await asyncio.sleep(3)
counter += 1
except (TimeoutError, RequestNotSuccessful) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
@ -110,5 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"key": self.entity_description.key,
},
) from exc
finally:
self._attr_in_progress = False
await self.coordinator.async_request_refresh()

View File

@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_CONNECT_CLIENT_ID, MQTT_SUBSCRIPTION_INTERVAL
from .const import CONF_CONNECT_CLIENT_ID, DOMAIN, MQTT_SUBSCRIPTION_INTERVAL
from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator
from .mqtt import ThinQMQTT
@ -137,7 +137,15 @@ async def async_setup_mqtt(
entry.runtime_data.mqtt_client = mqtt_client
# Try to connect.
try:
result = await mqtt_client.async_connect()
except (AttributeError, ThinQAPIException, TypeError, ValueError) as exc:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_connect_mqtt",
translation_placeholders={"error": str(exc)},
) from exc
if not result:
_LOGGER.error("Failed to set up mqtt connection")
return

View File

@ -43,7 +43,7 @@ class ThinQMQTT:
async def async_connect(self) -> bool:
"""Create a mqtt client and then try to connect."""
try:
self.client = await ThinQMQTTClient(
self.thinq_api, self.client_id, self.on_message_received
)
@ -52,9 +52,6 @@ class ThinQMQTT:
# Connect to server and create certificate.
return await self.client.async_prepare_mqtt()
except (ThinQAPIException, TypeError, ValueError):
_LOGGER.exception("Failed to connect")
return False
async def async_disconnect(self, event: Event | None = None) -> None:
"""Unregister client and disconnects handlers."""

View File

@ -1034,5 +1034,10 @@
}
}
}
},
"exceptions": {
"failed_to_connect_mqtt": {
"message": "Failed to connect MQTT: {error}"
}
}
}

View File

@ -47,7 +47,7 @@ class LogbookLiveStream:
subscriptions: list[CALLBACK_TYPE]
end_time_unsub: CALLBACK_TYPE | None = None
task: asyncio.Task | None = None
wait_sync_task: asyncio.Task | None = None
wait_sync_future: asyncio.Future[None] | None = None
@callback
@ -329,8 +329,8 @@ async def ws_event_stream(
subscriptions.clear()
if live_stream.task:
live_stream.task.cancel()
if live_stream.wait_sync_task:
live_stream.wait_sync_task.cancel()
if live_stream.wait_sync_future:
live_stream.wait_sync_future.cancel()
if live_stream.end_time_unsub:
live_stream.end_time_unsub()
live_stream.end_time_unsub = None
@ -399,10 +399,12 @@ async def ws_event_stream(
)
)
live_stream.wait_sync_task = create_eager_task(
get_instance(hass).async_block_till_done()
)
await live_stream.wait_sync_task
if sync_future := get_instance(hass).async_get_commit_future():
# Set the future so we can cancel it if the client
# unsubscribes before the commit is done so we don't
# query the database needlessly
live_stream.wait_sync_future = sync_future
await live_stream.wait_sync_future
#
# Fetch any events from the database that have

View File

@ -169,6 +169,8 @@ browse_media:
target:
entity:
domain: media_player
supported_features:
- media_player.MediaPlayerEntityFeature.BROWSE_MEDIA
fields:
media_content_type:
required: false

View File

@ -154,9 +154,8 @@ class OpenAIOptionsFlow(OptionsFlow):
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none":
user_input.pop(CONF_LLM_HASS_API)
if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API, None)
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
errors[CONF_CHAT_MODEL] = "model_not_supported"
@ -178,7 +177,7 @@ class OpenAIOptionsFlow(OptionsFlow):
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
}
schema = openai_config_option_schema(self.hass, options)
@ -248,19 +247,16 @@ def openai_config_option_schema(
) -> VolDictType:
"""Return a schema for OpenAI completion options."""
hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
)
]
if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance(
suggested_llm_apis, str
):
suggested_llm_apis = [suggested_llm_apis]
schema: VolDictType = {
vol.Optional(
CONF_PROMPT,
@ -272,9 +268,8 @@ def openai_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
default="none",
): SelectSelector(SelectSelectorConfig(options=hass_apis)),
description={"suggested_value": suggested_llm_apis},
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,

View File

@ -49,6 +49,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
key=OverkizState.CORE_WATER_DETECTION,
name="Water",
icon="mdi:water",
device_class=BinarySensorDeviceClass.MOISTURE,
value_fn=lambda state: state == OverkizCommandParam.DETECTED,
),
# AirSensor/AirFlowSensor

View File

@ -14,7 +14,7 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -172,6 +172,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
native_max_value=7,
set_native_value=_async_set_native_value_boost_mode_duration,
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
),
# DomesticHotWaterProduction - away mode in days (0 - 6)
OverkizNumberDescription(
@ -182,6 +184,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
native_min_value=0,
native_max_value=6,
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.DAYS,
),
]

View File

@ -23,6 +23,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfEnergy,
UnitOfPower,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
@ -126,6 +127,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Outlet engine",
icon="mdi:fan-chevron-down",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
state_class=SensorStateClass.MEASUREMENT,
),
OverkizSensorDescription(
@ -152,14 +154,23 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
OverkizSensorDescription(
key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION,
name="Fossil energy consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_GAS_CONSUMPTION,
name="Gas consumption",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION,
name="Thermal energy consumption",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
# LightSensor/LuminanceSensor
OverkizSensorDescription(
@ -204,7 +215,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF2,
@ -213,7 +224,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF3,
@ -222,7 +233,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF4,
@ -231,7 +242,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF5,
@ -240,7 +251,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF6,
@ -249,7 +260,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF7,
@ -258,7 +269,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF8,
@ -267,7 +278,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF9,
@ -276,7 +287,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
state_class=SensorStateClass.TOTAL_INCREASING,
),
# HumiditySensor/RelativeHumiditySensor
OverkizSensorDescription(
@ -342,6 +353,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Sun energy",
native_value=lambda value: round(cast(float, value), 2),
icon="mdi:solar-power",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
# WindSensor/WindSpeedSensor
@ -350,6 +363,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Wind speed",
native_value=lambda value: round(cast(float, value), 2),
icon="mdi:weather-windy",
device_class=SensorDeviceClass.WIND_SPEED,
native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
# SmokeSensor/SmokeSensor
@ -398,6 +413,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
native_value=lambda value: OVERKIZ_STATE_TO_TRANSLATION.get(
cast(str, value), cast(str, value)
),
device_class=SensorDeviceClass.ENUM,
options=["dead", "low_battery", "maintenance_required", "no_defect"],
),
# DomesticHotWaterProduction/WaterHeatingSystem
OverkizSensorDescription(

View File

@ -1307,11 +1307,17 @@ class Recorder(threading.Thread):
async def async_block_till_done(self) -> None:
"""Async version of block_till_done."""
if future := self.async_get_commit_future():
await future
@callback
def async_get_commit_future(self) -> asyncio.Future[None] | None:
"""Return a future that will wait for the next commit or None if nothing pending."""
if self._queue.empty() and not self._event_session_has_pending_writes:
return
event = asyncio.Event()
self.queue_task(SynchronizeTask(event))
await event.wait()
return None
future: asyncio.Future[None] = self.hass.loop.create_future()
self.queue_task(SynchronizeTask(future))
return future
def block_till_done(self) -> None:
"""Block till all events processed.

View File

@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask):
"""Ensure all pending data has been committed."""
# commit_before is the default
event: asyncio.Event
future: asyncio.Future
def run(self, instance: Recorder) -> None:
"""Handle the task."""
# Does not use a tracked task to avoid
# blocking shutdown if the recorder is broken
instance.hass.loop.call_soon_threadsafe(self.event.set)
instance.hass.loop.call_soon_threadsafe(self._set_result_if_not_done)
def _set_result_if_not_done(self) -> None:
"""Set the result if not done."""
if not self.future.done():
self.future.set_result(None)
@dataclass(slots=True)

View File

@ -7,7 +7,9 @@ DOMAIN = "renault"
CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
DEFAULT_SCAN_INTERVAL = 420 # 7 minutes
# normal number of allowed calls per hour to the API
# for a single car and the 7 coordinator, it is a scan every 7mn
MAX_CALLS_PER_HOURS = 60
# If throttled time to pause the updates, in seconds
COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes

View File

@ -32,9 +32,9 @@ from time import time
from .const import (
CONF_KAMEREON_ACCOUNT_ID,
COOLING_UPDATES_SECONDS,
DEFAULT_SCAN_INTERVAL,
MAX_CALLS_PER_HOURS,
)
from .renault_vehicle import RenaultVehicleProxy
from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
LOGGER = logging.getLogger(__name__)
@ -82,7 +82,6 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy."""
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
self._account = await self._client.get_api_account(account_id)
vehicles = await self._account.get_vehicles()
@ -94,6 +93,12 @@ class RenaultHub:
raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers"
)
num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks)
scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
device_registry = dr.async_get(self._hass)
await asyncio.gather(
*(
@ -108,6 +113,21 @@ class RenaultHub:
)
)
# all vehicles have been initiated with the right number of active coordinators
num_call_per_scan = 0
for vehicle_link in vehicles.vehicleLinks:
vehicle = self._vehicles[str(vehicle_link.vin)]
num_call_per_scan += len(vehicle.coordinators)
new_scan_interval = timedelta(
seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
)
if new_scan_interval != scan_interval:
# we need to change the vehicles with the right scan interval
for vehicle_link in vehicles.vehicleLinks:
vehicle = self._vehicles[str(vehicle_link.vin)]
vehicle.update_scan_interval(new_scan_interval)
async def async_initialise_vehicle(
self,
vehicle_link: KamereonVehiclesLink,

View File

@ -91,6 +91,13 @@ class RenaultVehicleProxy:
self._scan_interval = scan_interval
self._hub = hub
def update_scan_interval(self, scan_interval: timedelta) -> None:
"""Set the scan interval for the vehicle."""
if scan_interval != self._scan_interval:
self._scan_interval = scan_interval
for coordinator in self.coordinators.values():
coordinator.update_interval = scan_interval
@property
def details(self) -> models.KamereonVehicleDetails:
"""Return the specs of the vehicle."""

View File

@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
import logging
from typing import TYPE_CHECKING, Any
@ -105,31 +104,30 @@ SERVICES = [
]
def setup_services(hass: HomeAssistant) -> None:
"""Register the Renault services."""
async def ac_cancel(service_call: ServiceCall) -> None:
"""Cancel A/C."""
proxy = get_vehicle_proxy(service_call.data)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("A/C cancel attempt")
result = await proxy.set_ac_stop()
LOGGER.debug("A/C cancel result: %s", result)
async def ac_start(service_call: ServiceCall) -> None:
"""Start A/C."""
temperature: float = service_call.data[ATTR_TEMPERATURE]
when: datetime | None = service_call.data.get(ATTR_WHEN)
proxy = get_vehicle_proxy(service_call.data)
proxy = get_vehicle_proxy(service_call)
LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
result = await proxy.set_ac_start(temperature, when)
LOGGER.debug("A/C start result: %s", result.raw_data)
async def charge_set_schedules(service_call: ServiceCall) -> None:
"""Set charge schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
proxy = get_vehicle_proxy(service_call.data)
proxy = get_vehicle_proxy(service_call)
charge_schedules = await proxy.get_charging_settings()
for schedule in schedules:
charge_schedules.update(schedule)
@ -144,10 +142,11 @@ def setup_services(hass: HomeAssistant) -> None:
"It may take some time before these changes are reflected in your vehicle"
)
async def ac_set_schedules(service_call: ServiceCall) -> None:
"""Set A/C schedules."""
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
proxy = get_vehicle_proxy(service_call.data)
proxy = get_vehicle_proxy(service_call)
hvac_schedules = await proxy.get_hvac_settings()
for schedule in schedules:
@ -163,10 +162,11 @@ def setup_services(hass: HomeAssistant) -> None:
"It may take some time before these changes are reflected in your vehicle"
)
def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy:
def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
"""Get vehicle from service_call data."""
device_registry = dr.async_get(hass)
device_id = service_call_data[ATTR_VEHICLE]
device_registry = dr.async_get(service_call.hass)
device_id = service_call.data[ATTR_VEHICLE]
device_entry = device_registry.async_get(device_id)
if device_entry is None:
raise ServiceValidationError(
@ -177,7 +177,7 @@ def setup_services(hass: HomeAssistant) -> None:
loaded_entries: list[RenaultConfigEntry] = [
entry
for entry in hass.config_entries.async_loaded_entries(DOMAIN)
for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN)
if entry.entry_id in device_entry.config_entries
]
for entry in loaded_entries:
@ -190,6 +190,10 @@ def setup_services(hass: HomeAssistant) -> None:
translation_placeholders={"device_id": device_entry.name or device_id},
)
def setup_services(hass: HomeAssistant) -> None:
"""Register the Renault services."""
hass.services.async_register(
DOMAIN,
SERVICE_AC_CANCEL,

View File

@ -59,10 +59,11 @@ CAPABILITY_TO_SENSORS: dict[
Category.DOOR: BinarySensorDeviceClass.DOOR,
Category.WINDOW: BinarySensorDeviceClass.WINDOW,
},
exists_fn=lambda key: key in {"freezer", "cooler"},
exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"},
component_translation_key={
"freezer": "freezer_door",
"cooler": "cooler_door",
"cvroom": "cool_select_plus_door",
},
deprecated_fn=(
lambda status: "fridge_door"

View File

@ -23,7 +23,6 @@ from .entity import SmartThingsEntity
MEDIA_PLAYER_CAPABILITIES = (
Capability.AUDIO_MUTE,
Capability.AUDIO_VOLUME,
Capability.MEDIA_PLAYBACK,
)
CONTROLLABLE_SOURCES = ["bluetooth", "wifi"]
@ -100,7 +99,12 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
)
def _determine_features(self) -> MediaPlayerEntityFeature:
flags = MediaPlayerEntityFeature(0)
flags = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE
)
if self.supports_capability(Capability.MEDIA_PLAYBACK):
playback_commands = self.get_attribute_value(
Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS
)
@ -114,13 +118,6 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK
if "fastForward" in playback_commands:
flags |= MediaPlayerEntityFeature.NEXT_TRACK
if self.supports_capability(Capability.AUDIO_VOLUME):
flags |= (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_STEP
)
if self.supports_capability(Capability.AUDIO_MUTE):
flags |= MediaPlayerEntityFeature.VOLUME_MUTE
if self.supports_capability(Capability.SWITCH):
flags |= (
MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
@ -270,6 +267,13 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
def state(self) -> MediaPlayerState | None:
"""State of the media player."""
if self.supports_capability(Capability.SWITCH):
if not self.supports_capability(Capability.MEDIA_PLAYBACK):
if (
self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
== "on"
):
return MediaPlayerState.ON
return MediaPlayerState.OFF
if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on":
if (
self.source is not None

View File

@ -194,13 +194,7 @@ CAPABILITY_TO_SENSORS: dict[
native_unit_of_measurement=PERCENTAGE,
deprecated=(
lambda status: "media_player"
if all(
capability in status
for capability in (
Capability.AUDIO_MUTE,
Capability.MEDIA_PLAYBACK,
)
)
if Capability.AUDIO_MUTE in status
else None
),
)

View File

@ -48,6 +48,9 @@
"cooler_door": {
"name": "Cooler door"
},
"cool_select_plus_door": {
"name": "CoolSelect+ door"
},
"remote_control": {
"name": "Remote control"
},

View File

@ -38,7 +38,6 @@ AC_CAPABILITIES = (
MEDIA_PLAYER_CAPABILITIES = (
Capability.AUDIO_MUTE,
Capability.AUDIO_VOLUME,
Capability.MEDIA_PLAYBACK,
)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smhi",
"iot_class": "cloud_polling",
"loggers": ["pysmhi"],
"requirements": ["pysmhi==1.0.1"]
"requirements": ["pysmhi==1.0.2"]
}

View File

@ -6,8 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
from teslemetry_stream import Signal
from teslemetry_stream.const import WindowState
from teslemetry_stream.vehicle import TeslemetryStreamVehicle
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@ -32,6 +31,12 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
PARALLEL_UPDATES = 0
WINDOW_STATES = {
"Opened": True,
"PartiallyOpen": True,
"Closed": False,
}
@dataclass(frozen=True, kw_only=True)
class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription):
@ -39,11 +44,14 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription):
polling_value_fn: Callable[[StateType], bool | None] = bool
polling: bool = False
streaming_key: Signal | None = None
streaming_listener: (
Callable[
[TeslemetryStreamVehicle, Callable[[bool | None], None]],
Callable[[], None],
]
| None
) = None
streaming_firmware: str = "2024.26"
streaming_value_fn: Callable[[StateType], bool | None] = (
lambda x: x is True or x == "true"
)
VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
@ -56,7 +64,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_battery_heater_on",
polling=True,
streaming_key=Signal.BATTERY_HEATER_ON,
streaming_listener=lambda x, y: x.listen_BatteryHeaterOn(y),
device_class=BinarySensorDeviceClass.HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@ -64,15 +72,16 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_charger_phases",
polling=True,
streaming_key=Signal.CHARGER_PHASES,
streaming_listener=lambda x, y: x.listen_ChargerPhases(
lambda z: y(None if z is None else z > 1)
),
polling_value_fn=lambda x: cast(int, x) > 1,
streaming_value_fn=lambda x: cast(int, x) > 1,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_preconditioning_enabled",
polling=True,
streaming_key=Signal.PRECONDITIONING_ENABLED,
streaming_listener=lambda x, y: x.listen_PreconditioningEnabled(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@ -85,7 +94,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_scheduled_charging_pending",
polling=True,
streaming_key=Signal.SCHEDULED_CHARGING_PENDING,
streaming_listener=lambda x, y: x.listen_ScheduledChargingPending(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@ -153,32 +162,36 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fd_window",
polling=True,
streaming_key=Signal.FD_WINDOW,
streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
streaming_listener=lambda x, y: x.listen_FrontDriverWindow(
lambda z: y(WINDOW_STATES.get(z))
),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fp_window",
polling=True,
streaming_key=Signal.FP_WINDOW,
streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
streaming_listener=lambda x, y: x.listen_FrontPassengerWindow(
lambda z: y(WINDOW_STATES.get(z))
),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rd_window",
polling=True,
streaming_key=Signal.RD_WINDOW,
streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
streaming_listener=lambda x, y: x.listen_RearDriverWindow(
lambda z: y(WINDOW_STATES.get(z))
),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rp_window",
polling=True,
streaming_key=Signal.RP_WINDOW,
streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
streaming_listener=lambda x, y: x.listen_RearPassengerWindow(
lambda z: y(WINDOW_STATES.get(z))
),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
@ -186,180 +199,177 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
key="vehicle_state_df",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
streaming_key=Signal.DOOR_STATE,
streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"),
streaming_listener=lambda x, y: x.listen_FrontDriverDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_dr",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
streaming_key=Signal.DOOR_STATE,
streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"),
streaming_listener=lambda x, y: x.listen_RearDriverDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pf",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
streaming_key=Signal.DOOR_STATE,
streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"),
streaming_listener=lambda x, y: x.listen_FrontPassengerDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pr",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
streaming_key=Signal.DOOR_STATE,
streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"),
streaming_listener=lambda x, y: x.listen_RearPassengerDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="automatic_blind_spot_camera",
streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA,
streaming_listener=lambda x, y: x.listen_AutomaticBlindSpotCamera(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="automatic_emergency_braking_off",
streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF,
streaming_listener=lambda x, y: x.listen_AutomaticEmergencyBrakingOff(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="blind_spot_collision_warning_chime",
streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME,
streaming_listener=lambda x, y: x.listen_BlindSpotCollisionWarningChime(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="bms_full_charge_complete",
streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE,
streaming_listener=lambda x, y: x.listen_BmsFullchargecomplete(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="brake_pedal",
streaming_key=Signal.BRAKE_PEDAL,
streaming_listener=lambda x, y: x.listen_BrakePedal(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_port_cold_weather_mode",
streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE,
streaming_listener=lambda x, y: x.listen_ChargePortColdWeatherMode(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="service_mode",
streaming_key=Signal.SERVICE_MODE,
streaming_listener=lambda x, y: x.listen_ServiceMode(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="pin_to_drive_enabled",
streaming_key=Signal.PIN_TO_DRIVE_ENABLED,
streaming_listener=lambda x, y: x.listen_PinToDriveEnabled(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="drive_rail",
streaming_key=Signal.DRIVE_RAIL,
streaming_listener=lambda x, y: x.listen_DriveRail(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="driver_seat_belt",
streaming_key=Signal.DRIVER_SEAT_BELT,
streaming_listener=lambda x, y: x.listen_DriverSeatBelt(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="driver_seat_occupied",
streaming_key=Signal.DRIVER_SEAT_OCCUPIED,
streaming_listener=lambda x, y: x.listen_DriverSeatOccupied(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="passenger_seat_belt",
streaming_key=Signal.PASSENGER_SEAT_BELT,
streaming_listener=lambda x, y: x.listen_PassengerSeatBelt(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="fast_charger_present",
streaming_key=Signal.FAST_CHARGER_PRESENT,
streaming_listener=lambda x, y: x.listen_FastChargerPresent(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="gps_state",
streaming_key=Signal.GPS_STATE,
streaming_listener=lambda x, y: x.listen_GpsState(y),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
TeslemetryBinarySensorEntityDescription(
key="guest_mode_enabled",
streaming_key=Signal.GUEST_MODE_ENABLED,
streaming_listener=lambda x, y: x.listen_GuestModeEnabled(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="dc_dc_enable",
streaming_key=Signal.DCDC_ENABLE,
streaming_listener=lambda x, y: x.listen_DCDCEnable(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="emergency_lane_departure_avoidance",
streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE,
streaming_listener=lambda x, y: x.listen_EmergencyLaneDepartureAvoidance(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="supercharger_session_trip_planner",
streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER,
streaming_listener=lambda x, y: x.listen_SuperchargerSessionTripPlanner(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="wiper_heat_enabled",
streaming_key=Signal.WIPER_HEAT_ENABLED,
streaming_listener=lambda x, y: x.listen_WiperHeatEnabled(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="rear_display_hvac_enabled",
streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED,
streaming_listener=lambda x, y: x.listen_RearDisplayHvacEnabled(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="offroad_lightbar_present",
streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT,
streaming_listener=lambda x, y: x.listen_OffroadLightbarPresent(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="homelink_nearby",
streaming_key=Signal.HOMELINK_NEARBY,
streaming_listener=lambda x, y: x.listen_HomelinkNearby(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="europe_vehicle",
streaming_key=Signal.EUROPE_VEHICLE,
streaming_listener=lambda x, y: x.listen_EuropeVehicle(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="right_hand_drive",
streaming_key=Signal.RIGHT_HAND_DRIVE,
streaming_listener=lambda x, y: x.listen_RightHandDrive(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="located_at_home",
streaming_key=Signal.LOCATED_AT_HOME,
streaming_listener=lambda x, y: x.listen_LocatedAtHome(y),
streaming_firmware="2024.44.32",
),
TeslemetryBinarySensorEntityDescription(
key="located_at_work",
streaming_key=Signal.LOCATED_AT_WORK,
streaming_listener=lambda x, y: x.listen_LocatedAtWork(y),
streaming_firmware="2024.44.32",
),
TeslemetryBinarySensorEntityDescription(
key="located_at_favorite",
streaming_key=Signal.LOCATED_AT_FAVORITE,
streaming_listener=lambda x, y: x.listen_LocatedAtFavorite(y),
streaming_firmware="2024.44.32",
entity_registry_enabled_default=False,
),
)
ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(key="backup_capable"),
BinarySensorEntityDescription(key="grid_services_active"),
@ -386,7 +396,7 @@ async def async_setup_entry(
for description in VEHICLE_DESCRIPTIONS:
if (
not vehicle.api.pre2021
and description.streaming_key
and description.streaming_listener
and vehicle.firmware >= description.streaming_firmware
):
entities.append(
@ -453,8 +463,7 @@ class TeslemetryVehicleStreamingBinarySensorEntity(
) -> None:
"""Initialize the sensor."""
self.entity_description = description
assert description.streaming_key
super().__init__(data, description.key, description.streaming_key)
super().__init__(data, description.key)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@ -462,11 +471,18 @@ class TeslemetryVehicleStreamingBinarySensorEntity(
if (state := await self.async_get_last_state()) is not None:
self._attr_is_on = state.state == STATE_ON
def _async_value_from_stream(self, value) -> None:
assert self.entity_description.streaming_listener
self.async_on_remove(
self.entity_description.streaming_listener(
self.vehicle.stream_vehicle, self._async_value_from_stream
)
)
def _async_value_from_stream(self, value: bool | None) -> None:
"""Update the value of the entity."""
self._attr_available = value is not None
if self._attr_available:
self._attr_is_on = self.entity_description.streaming_value_fn(value)
self._attr_is_on = value
self.async_write_ha_state()
class TeslemetryEnergyLiveBinarySensorEntity(

View File

@ -7,8 +7,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from propcache.api import cached_property
from teslemetry_stream import Signal, TeslemetryStreamVehicle
from teslemetry_stream.const import ShiftState
from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.sensor import (
RestoreSensor,
@ -70,8 +69,13 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
polling: bool = False
polling_value_fn: Callable[[StateType], StateType] = lambda x: x
nullable: bool = False
streaming_key: Signal | None = None
streaming_value_fn: Callable[[str | int | float], StateType] = lambda x: x
streaming_listener: (
Callable[
[TeslemetryStreamVehicle, Callable[[StateType], None]],
Callable[[], None],
]
| None
) = None
streaming_firmware: str = "2024.26"
@ -79,18 +83,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charging_state",
polling=True,
streaming_key=Signal.DETAILED_CHARGE_STATE,
polling_value_fn=lambda value: CHARGE_STATES.get(str(value)),
streaming_value_fn=lambda value: CHARGE_STATES.get(
str(value).replace("DetailedChargeState", "")
streaming_listener=lambda x, y: x.listen_DetailedChargeState(
lambda z: None if z is None else y(z.lower())
),
polling_value_fn=lambda value: CHARGE_STATES.get(str(value)),
options=list(CHARGE_STATES.values()),
device_class=SensorDeviceClass.ENUM,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_battery_level",
polling=True,
streaming_key=Signal.BATTERY_LEVEL,
streaming_listener=lambda x, y: x.listen_BatteryLevel(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@ -99,15 +102,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_usable_battery_level",
polling=True,
streaming_listener=lambda x, y: x.listen_Soc(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
suggested_display_precision=1,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charge_energy_added",
polling=True,
streaming_key=Signal.AC_CHARGING_ENERGY_IN,
streaming_listener=lambda x, y: x.listen_ACChargingEnergyIn(y),
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
@ -116,7 +121,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_power",
polling=True,
streaming_key=Signal.AC_CHARGING_POWER,
streaming_listener=lambda x, y: x.listen_ACChargingPower(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
@ -124,7 +129,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_voltage",
polling=True,
streaming_key=Signal.CHARGER_VOLTAGE,
streaming_listener=lambda x, y: x.listen_ChargerVoltage(y),
streaming_firmware="2024.44.32",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
@ -134,7 +139,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_actual_current",
polling=True,
streaming_key=Signal.CHARGE_AMPS,
streaming_listener=lambda x, y: x.listen_ChargeAmps(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
@ -151,14 +156,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_conn_charge_cable",
polling=True,
streaming_key=Signal.CHARGING_CABLE_TYPE,
streaming_listener=lambda x, y: x.listen_ChargingCableType(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_fast_charger_type",
polling=True,
streaming_key=Signal.FAST_CHARGER_TYPE,
streaming_listener=lambda x, y: x.listen_FastChargerType(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@ -173,7 +178,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_est_battery_range",
polling=True,
streaming_key=Signal.EST_BATTERY_RANGE,
streaming_listener=lambda x, y: x.listen_EstBatteryRange(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@ -183,7 +188,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_ideal_battery_range",
polling=True,
streaming_key=Signal.IDEAL_BATTERY_RANGE,
streaming_listener=lambda x, y: x.listen_IdealBatteryRange(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@ -194,7 +199,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
key="drive_state_speed",
polling=True,
polling_value_fn=lambda value: value or 0,
streaming_key=Signal.VEHICLE_SPEED,
streaming_listener=lambda x, y: x.listen_VehicleSpeed(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
@ -213,10 +218,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_shift_state",
polling=True,
nullable=True,
polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
streaming_key=Signal.GEAR,
streaming_value_fn=lambda x: str(ShiftState.get(x, "P")).lower(),
nullable=True,
streaming_listener=lambda x, y: x.listen_Gear(
lambda z: y("p" if z is None else z.lower())
),
options=list(SHIFT_STATES.values()),
device_class=SensorDeviceClass.ENUM,
entity_registry_enabled_default=False,
@ -224,7 +230,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_odometer",
polling=True,
streaming_key=Signal.ODOMETER,
streaming_listener=lambda x, y: x.listen_Odometer(y),
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@ -235,7 +241,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fl",
polling=True,
streaming_key=Signal.TPMS_PRESSURE_FL,
streaming_listener=lambda x, y: x.listen_TpmsPressureFl(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@ -247,7 +253,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fr",
polling=True,
streaming_key=Signal.TPMS_PRESSURE_FR,
streaming_listener=lambda x, y: x.listen_TpmsPressureFr(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@ -259,7 +265,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rl",
polling=True,
streaming_key=Signal.TPMS_PRESSURE_RL,
streaming_listener=lambda x, y: x.listen_TpmsPressureRl(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@ -271,7 +277,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rr",
polling=True,
streaming_key=Signal.TPMS_PRESSURE_RR,
streaming_listener=lambda x, y: x.listen_TpmsPressureRr(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@ -283,7 +289,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="climate_state_inside_temp",
polling=True,
streaming_key=Signal.INSIDE_TEMP,
streaming_listener=lambda x, y: x.listen_InsideTemp(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@ -292,7 +298,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="climate_state_outside_temp",
polling=True,
streaming_key=Signal.OUTSIDE_TEMP,
streaming_listener=lambda x, y: x.listen_OutsideTemp(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@ -321,7 +327,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_traffic_minutes_delay",
polling=True,
streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY,
streaming_listener=lambda x, y: x.listen_RouteTrafficMinutesDelay(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
@ -330,7 +336,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_energy_at_arrival",
polling=True,
streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL,
streaming_listener=lambda x, y: x.listen_ExpectedEnergyPercentAtTripArrival(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@ -340,7 +346,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_miles_to_arrival",
polling=True,
streaming_key=Signal.MILES_TO_ARRIVAL,
streaming_listener=lambda x, y: x.listen_MilesToArrival(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@ -358,14 +364,14 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription):
Callable[[], None],
]
streaming_firmware: str = "2024.26"
streaming_value_fn: Callable[[float], float] = lambda x: x
streaming_unit: str
VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = (
TeslemetryTimeEntityDescription(
key="charge_state_minutes_to_full_charge",
streaming_value_fn=lambda x: x * 60,
streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y),
streaming_unit="hours",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
variance=4,
@ -373,6 +379,7 @@ VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = (
TeslemetryTimeEntityDescription(
key="drive_state_active_route_minutes_to_arrival",
streaming_listener=lambda x, y: x.listen_MinutesToArrival(y),
streaming_unit="minutes",
device_class=SensorDeviceClass.TIMESTAMP,
variance=1,
),
@ -547,7 +554,7 @@ async def async_setup_entry(
for description in VEHICLE_DESCRIPTIONS:
if (
not vehicle.api.pre2021
and description.streaming_key
and description.streaming_listener
and vehicle.firmware >= description.streaming_firmware
):
entities.append(TeslemetryStreamSensorEntity(vehicle, description))
@ -613,8 +620,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor)
) -> None:
"""Initialize the sensor."""
self.entity_description = description
assert description.streaming_key
super().__init__(data, description.key, description.streaming_key)
super().__init__(data, description.key)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@ -623,17 +629,22 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor)
if (sensor_data := await self.async_get_last_sensor_data()) is not None:
self._attr_native_value = sensor_data.native_value
if self.entity_description.streaming_listener is not None:
self.async_on_remove(
self.entity_description.streaming_listener(
self.vehicle.stream_vehicle, self._async_value_from_stream
)
)
@cached_property
def available(self) -> bool:
"""Return True if entity is available."""
return self.stream.connected
def _async_value_from_stream(self, value) -> None:
def _async_value_from_stream(self, value: StateType) -> None:
"""Update the value of the entity."""
if self.entity_description.nullable or value is not None:
self._attr_native_value = self.entity_description.streaming_value_fn(value)
else:
self._attr_native_value = None
self._attr_native_value = value
self.async_write_ha_state()
class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
@ -676,7 +687,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti
self.entity_description = description
self._get_timestamp = ignore_variance(
func=lambda value: dt_util.now()
+ timedelta(minutes=description.streaming_value_fn(value)),
+ timedelta(**{self.entity_description.streaming_unit: value}),
ignored_variance=timedelta(minutes=description.variance),
)
super().__init__(data, description.key)
@ -696,6 +707,7 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti
self._attr_native_value = None
else:
self._attr_native_value = self._get_timestamp(value)
self.async_write_ha_state()
class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):

View File

@ -72,7 +72,7 @@ class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of a Time entity."""
entity_description: TimeEntityDescription
_attr_native_value: time | None
_attr_native_value: time | None = None
_attr_device_class: None = None
_attr_state: None = None

View File

@ -31,6 +31,7 @@ from .const import (
ATTR_MINUTES_DAY_SLEEP,
ATTR_MINUTES_NIGHT_SLEEP,
ATTR_MINUTES_REST,
ATTR_POWER_SAVING,
ATTR_SLEEP_LABEL,
ATTR_TRACKER_STATE,
CLIENT_ID,
@ -277,6 +278,7 @@ class TractiveClient:
payload = {
ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"],
ATTR_TRACKER_STATE: event["tracker_state"].lower(),
ATTR_POWER_SAVING: event.get("tracker_state_reason") == "POWER_SAVING",
ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING",
}
self._dispatch_tracker_event(

View File

@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.binary_sensor import (
@ -14,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Trackables, TractiveClient, TractiveConfigEntry
from .const import TRACKER_HARDWARE_STATUS_UPDATED
from .const import ATTR_POWER_SAVING, TRACKER_HARDWARE_STATUS_UPDATED
from .entity import TractiveEntity
@ -25,7 +27,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity):
self,
client: TractiveClient,
item: Trackables,
description: BinarySensorEntityDescription,
description: TractiveBinarySensorEntityDescription,
) -> None:
"""Initialize sensor entity."""
super().__init__(
@ -47,12 +49,27 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity):
super().handle_status_update(event)
SENSOR_TYPE = BinarySensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class describing Tractive binary sensor entities."""
supported: Callable[[dict], bool] = lambda _: True
SENSOR_TYPES = [
TractiveBinarySensorEntityDescription(
key=ATTR_BATTERY_CHARGING,
translation_key="tracker_battery_charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
)
supported=lambda details: details.get("charging_state") is not None,
),
TractiveBinarySensorEntityDescription(
key=ATTR_POWER_SAVING,
translation_key="tracker_power_saving",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
async def async_setup_entry(
@ -65,9 +82,10 @@ async def async_setup_entry(
trackables = entry.runtime_data.trackables
entities = [
TractiveBinarySensor(client, item, SENSOR_TYPE)
TractiveBinarySensor(client, item, description)
for description in SENSOR_TYPES
for item in trackables
if item.tracker_details.get("charging_state") is not None
if description.supported(item.tracker_details)
]
async_add_entities(entities)

View File

@ -16,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active"
ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep"
ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep"
ATTR_MINUTES_REST = "minutes_rest"
ATTR_POWER_SAVING = "power_saving"
ATTR_SLEEP_LABEL = "sleep_label"
ATTR_TRACKER_STATE = "tracker_state"

View File

@ -22,6 +22,9 @@
"binary_sensor": {
"tracker_battery_charging": {
"name": "Tracker battery charging"
},
"tracker_power_saving": {
"name": "Tracker power saving"
}
},
"device_tracker": {

View File

@ -14,8 +14,6 @@ import mimetypes
import os
import re
import secrets
import subprocess
import tempfile
from time import monotonic
from typing import Any, Final
@ -309,81 +307,74 @@ async def _async_convert_audio(
) -> AsyncGenerator[bytes]:
"""Convert audio to a preferred format using ffmpeg."""
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
audio_bytes = b"".join([chunk async for chunk in audio_bytes_gen])
data = await hass.async_add_executor_job(
lambda: _convert_audio(
ffmpeg_manager.binary,
from_extension,
audio_bytes,
to_extension,
to_sample_rate=to_sample_rate,
to_sample_channels=to_sample_channels,
to_sample_bytes=to_sample_bytes,
)
)
yield data
def _convert_audio(
ffmpeg_binary: str,
from_extension: str,
audio_bytes: bytes,
to_extension: str,
to_sample_rate: int | None = None,
to_sample_channels: int | None = None,
to_sample_bytes: int | None = None,
) -> bytes:
"""Convert audio to a preferred format using ffmpeg."""
# We have to use a temporary file here because some formats like WAV store
# the length of the file in the header, and therefore cannot be written in a
# streaming fashion.
with tempfile.NamedTemporaryFile(
mode="wb+", suffix=f".{to_extension}"
) as output_file:
# input
command = [
ffmpeg_binary,
"-y", # overwrite temp file
ffmpeg_manager.binary,
"-hide_banner",
"-loglevel",
"error",
"-f",
from_extension,
"-i",
"pipe:", # input from stdin
"pipe:",
"-f",
to_extension,
]
# output
command.extend(["-f", to_extension])
if to_sample_rate is not None:
command.extend(["-ar", str(to_sample_rate)])
if to_sample_channels is not None:
command.extend(["-ac", str(to_sample_channels)])
if to_extension == "mp3":
# Max quality for MP3
# Max quality for MP3.
command.extend(["-q:a", "0"])
if to_sample_bytes == 2:
# 16-bit samples
# 16-bit samples.
command.extend(["-sample_fmt", "s16"])
command.append("pipe:1") # Send output to stdout.
command.append(output_file.name)
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
with subprocess.Popen(
command, stdin=subprocess.PIPE, stderr=subprocess.PIPE
) as proc:
_stdout, stderr = proc.communicate(input=audio_bytes)
if proc.returncode != 0:
_LOGGER.error(stderr.decode())
async def write_input() -> None:
assert process.stdin
try:
async for chunk in audio_bytes_gen:
process.stdin.write(chunk)
await process.stdin.drain()
finally:
if process.stdin:
process.stdin.close()
writer_task = hass.async_create_background_task(
write_input(), "tts_ffmpeg_conversion"
)
assert process.stdout
chunk_size = 4096
try:
while True:
chunk = await process.stdout.read(chunk_size)
if not chunk:
break
yield chunk
finally:
# Ensure we wait for the input writer to complete.
await writer_task
# Wait for process termination and check for errors.
retcode = await process.wait()
if retcode != 0:
assert process.stderr
stderr_data = await process.stderr.read()
_LOGGER.error(stderr_data.decode())
raise RuntimeError(
f"Unexpected error while running ffmpeg with arguments: {command}. "
"See log for details."
)
output_file.seek(0)
return output_file.read()
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up TTS."""

View File

@ -178,7 +178,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
self._pipeline_ended_event.set()
self.device.set_is_active(False)
elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START:
self.hass.add_job(self._client.write_event(Detect().event()))
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(Detect().event()),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END:
# Wake word detection
# Inform client of wake word detection
@ -187,46 +191,59 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
name=wake_word_output["wake_word_id"],
timestamp=wake_word_output.get("timestamp"),
)
self.hass.add_job(self._client.write_event(detection.event()))
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(detection.event()),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_START:
# Speech-to-text
self.device.set_is_active(True)
if event.data:
self.hass.add_job(
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(
Transcribe(language=event.data["metadata"]["language"]).event()
)
),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START:
# User started speaking
if event.data:
self.hass.add_job(
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(
VoiceStarted(timestamp=event.data["timestamp"]).event()
)
),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END:
# User stopped speaking
if event.data:
self.hass.add_job(
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(
VoiceStopped(timestamp=event.data["timestamp"]).event()
)
),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_END:
# Speech-to-text transcript
if event.data:
# Inform client of transript
stt_text = event.data["stt_output"]["text"]
self.hass.add_job(
self._client.write_event(Transcript(text=stt_text).event())
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(Transcript(text=stt_text).event()),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.TTS_START:
# Text-to-speech text
if event.data:
# Inform client of text
self.hass.add_job(
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(
Synthesize(
text=event.data["tts_input"],
@ -235,22 +252,32 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
language=event.data.get("language"),
),
).event()
)
),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.TTS_END:
# TTS stream
if event.data and (tts_output := event.data["tts_output"]):
media_id = tts_output["media_id"]
self.hass.add_job(self._stream_tts(media_id))
if (
event.data
and (tts_output := event.data["tts_output"])
and (stream := tts.async_get_stream(self.hass, tts_output["token"]))
):
self.config_entry.async_create_background_task(
self.hass,
self._stream_tts(stream),
f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.ERROR:
# Pipeline error
if event.data:
self.hass.add_job(
self.config_entry.async_create_background_task(
self.hass,
self._client.write_event(
Error(
text=event.data["message"], code=event.data["code"]
).event()
)
),
f"{self.entity_id} {event.type}",
)
async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None:
@ -662,13 +689,16 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
await self._client.disconnect()
self._client = None
async def _stream_tts(self, media_id: str) -> None:
async def _stream_tts(self, tts_result: tts.ResultStream) -> None:
"""Stream TTS WAV audio to satellite in chunks."""
assert self._client is not None
extension, data = await tts.async_get_media_source_audio(self.hass, media_id)
if extension != "wav":
raise ValueError(f"Cannot stream audio format to satellite: {extension}")
if tts_result.extension != "wav":
raise ValueError(
f"Cannot stream audio format to satellite: {tts_result.extension}"
)
data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
sample_rate = wav_file.getframerate()

View File

@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
"requirements": ["zha==0.0.55"],
"requirements": ["zha==0.0.56"],
"usb": [
{
"vid": "10C4",

View File

@ -629,6 +629,10 @@ class _ScriptRun:
self, script: Script, *, parallel: bool = False
) -> None:
"""Execute a script."""
if not script.enabled:
self._log("Skipping disabled script: %s", script.name)
trace_set_result(enabled=False)
return
result = await self._async_run_long_action(
self._hass.async_create_task_internal(
script.async_run(
@ -1442,8 +1446,12 @@ class Script:
script_mode: str = DEFAULT_SCRIPT_MODE,
top_level: bool = True,
variables: ScriptVariables | None = None,
enabled: bool = True,
) -> None:
"""Initialize the script."""
"""Initialize the script.
enabled attribute is only used for non-top-level scripts.
"""
if not (all_scripts := hass.data.get(DATA_SCRIPTS)):
all_scripts = hass.data[DATA_SCRIPTS] = []
hass.bus.async_listen_once(
@ -1462,6 +1470,7 @@ class Script:
self.name = name
self.unique_id = f"{domain}.{name}-{id(self)}"
self.domain = domain
self.enabled = enabled
self.running_description = running_description or f"{domain} script"
self._change_listener = change_listener
self._change_listener_job = (
@ -2002,6 +2011,7 @@ class Script:
max_runs=self.max_runs,
logger=self._logger,
top_level=False,
enabled=parallel_script.get(CONF_ENABLED, True),
)
parallel_script.change_listener = partial(
self._chain_change_listener, parallel_script

View File

@ -127,6 +127,7 @@
"discharging": "Discharging",
"disconnected": "Disconnected",
"enabled": "Enabled",
"error": "Error",
"high": "High",
"home": "Home",
"idle": "Idle",

6
requirements_all.txt generated
View File

@ -1852,7 +1852,7 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==2.0.0
pyblu==2.0.1
# homeassistant.components.neato
pybotvac==0.0.26
@ -2331,7 +2331,7 @@ pysmartthings==3.0.4
pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.1
pysmhi==1.0.2
# homeassistant.components.edl21
pysml==0.0.12
@ -3158,7 +3158,7 @@ zeroconf==0.146.5
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.55
zha==0.0.56
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13

View File

@ -35,19 +35,19 @@ requests-mock==1.12.1
respx==0.22.0
syrupy==4.8.1
tqdm==4.67.1
types-aiofiles==24.1.0.20241221
types-aiofiles==24.1.0.20250326
types-atomicwrites==1.4.5.1
types-croniter==5.0.1.20241205
types-croniter==6.0.0.20250411
types-caldav==1.3.0.20241107
types-chardet==0.1.5
types-decorator==5.1.8.20250121
types-decorator==5.2.0.20250324
types-pexpect==4.9.0.20241208
types-protobuf==5.29.1.20241207
types-psutil==6.1.0.20241221
types-pyserial==3.5.0.20250130
types-protobuf==5.29.1.20250403
types-psutil==7.0.0.20250401
types-pyserial==3.5.0.20250326
types-python-dateutil==2.9.0.20241206
types-python-slugify==8.0.2.20240310
types-pytz==2025.1.0.20250204
types-PyYAML==6.0.12.20241230
types-pytz==2025.2.0.20250326
types-PyYAML==6.0.12.20250402
types-requests==2.31.0.3
types-xmltodict==0.13.0.3

View File

@ -1530,7 +1530,7 @@ pybalboa==1.1.3
pyblackbird==0.6
# homeassistant.components.bluesound
pyblu==2.0.0
pyblu==2.0.1
# homeassistant.components.neato
pybotvac==0.0.26
@ -1904,7 +1904,7 @@ pysmartthings==3.0.4
pysmarty2==0.10.2
# homeassistant.components.smhi
pysmhi==1.0.1
pysmhi==1.0.2
# homeassistant.components.edl21
pysml==0.0.12
@ -2554,7 +2554,7 @@ zeroconf==0.146.5
zeversolar==0.3.2
# homeassistant.components.zha
zha==0.0.55
zha==0.0.56
# homeassistant.components.zwave_js
zwave-js-server-python==0.62.0

View File

@ -196,13 +196,13 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
(
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla",
},
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3,
CONF_LLM_HASS_API: [],
},
{
CONF_RECOMMENDED: False,
@ -224,15 +224,32 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
),
(
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: "assist",
},
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: ["assist"],
},
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: ["assist"],
},
),
],
)
async def test_options_switching(

View File

@ -23,8 +23,7 @@ from . import (
@pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package")
def disable_bluez_manager_socket():
"""Mock the bluez manager socket."""
with patch.object(bleak_manager, "get_global_bluez_manager_with_timeout"):
yield
bleak_manager.get_global_bluez_manager_with_timeout._has_dbus_socket = False
@pytest.fixture(name="disable_dbus_socket", autouse=True, scope="package")

View File

@ -178,6 +178,22 @@ async def test_reproducing_states(
| CoverEntityFeature.OPEN,
},
)
hass.states.async_set(
"cover.closed_supports_all_features",
CoverState.CLOSED,
{
ATTR_CURRENT_POSITION: 0,
ATTR_CURRENT_TILT_POSITION: 0,
ATTR_SUPPORTED_FEATURES: CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
| CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.STOP_TILT
| CoverEntityFeature.SET_TILT_POSITION,
},
)
hass.states.async_set(
"cover.tilt_only_open",
CoverState.OPEN,
@ -249,6 +265,14 @@ async def test_reproducing_states(
await async_reproduce_state(
hass,
[
State(
"cover.closed_supports_all_features",
CoverState.CLOSED,
{
ATTR_CURRENT_POSITION: 0,
ATTR_CURRENT_TILT_POSITION: 0,
},
),
State("cover.entity_close", CoverState.CLOSED),
State("cover.closed_only_supports_close_open", CoverState.CLOSED),
State("cover.closed_only_supports_tilt_close_open", CoverState.CLOSED),
@ -364,6 +388,11 @@ async def test_reproducing_states(
await async_reproduce_state(
hass,
[
State(
"cover.closed_supports_all_features",
CoverState.CLOSED,
{ATTR_CURRENT_POSITION: 0, ATTR_CURRENT_TILT_POSITION: 50},
),
State("cover.entity_close", CoverState.OPEN),
State(
"cover.closed_only_supports_close_open",
@ -458,7 +487,6 @@ async def test_reproducing_states(
valid_close_calls = [
{"entity_id": "cover.entity_open"},
{"entity_id": "cover.entity_open_attr"},
{"entity_id": "cover.entity_entirely_open"},
{"entity_id": "cover.open_only_supports_close_open"},
{"entity_id": "cover.open_missing_all_features"},
]
@ -481,11 +509,8 @@ async def test_reproducing_states(
valid_open_calls.remove(call.data)
valid_close_tilt_calls = [
{"entity_id": "cover.entity_open_tilt"},
{"entity_id": "cover.entity_entirely_open"},
{"entity_id": "cover.tilt_only_open"},
{"entity_id": "cover.entity_open_attr"},
{"entity_id": "cover.tilt_only_tilt_position_100"},
{"entity_id": "cover.open_only_supports_tilt_close_open"},
]
assert len(close_tilt_calls) == len(valid_close_tilt_calls)
@ -495,9 +520,7 @@ async def test_reproducing_states(
valid_close_tilt_calls.remove(call.data)
valid_open_tilt_calls = [
{"entity_id": "cover.entity_close_tilt"},
{"entity_id": "cover.tilt_only_closed"},
{"entity_id": "cover.tilt_only_tilt_position_0"},
{"entity_id": "cover.closed_only_supports_tilt_close_open"},
]
assert len(open_tilt_calls) == len(valid_open_tilt_calls)
@ -523,6 +546,14 @@ async def test_reproducing_states(
"entity_id": "cover.open_only_supports_position",
ATTR_POSITION: 0,
},
{
"entity_id": "cover.closed_supports_all_features",
ATTR_POSITION: 0,
},
{
"entity_id": "cover.entity_entirely_open",
ATTR_POSITION: 0,
},
]
assert len(position_calls) == len(valid_position_calls)
for call in position_calls:
@ -551,7 +582,34 @@ async def test_reproducing_states(
"entity_id": "cover.tilt_partial_open_only_supports_tilt_position",
ATTR_TILT_POSITION: 70,
},
{
"entity_id": "cover.closed_supports_all_features",
ATTR_TILT_POSITION: 50,
},
{
"entity_id": "cover.entity_close_tilt",
ATTR_TILT_POSITION: 100,
},
{
"entity_id": "cover.entity_open_tilt",
ATTR_TILT_POSITION: 0,
},
{
"entity_id": "cover.entity_entirely_open",
ATTR_TILT_POSITION: 0,
},
{
"entity_id": "cover.tilt_only_tilt_position_100",
ATTR_TILT_POSITION: 0,
},
{
"entity_id": "cover.tilt_only_tilt_position_0",
ATTR_TILT_POSITION: 100,
},
]
for call in position_tilt_calls:
if ATTR_TILT_POSITION not in call.data:
continue
assert len(position_tilt_calls) == len(valid_position_tilt_calls)
for call in position_tilt_calls:
assert call.domain == "cover"

View File

@ -813,12 +813,15 @@ async def test_reauth_confirm_valid(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
@ -828,6 +831,48 @@ async def test_reauth_confirm_valid(
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reauth_attempt_to_change_mac_aborts(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reauth initiation with valid PSK attempting to change mac.
This can happen if reauth starts, but they don't finish it before
a new device takes the place of the old one at the same IP.
"""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reauth_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_unique_id_changed"
assert CONF_NOISE_PSK not in entry.data
assert result["description_placeholders"] == {
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.1",
"name": "test",
"unexpected_device_name": "test",
"unexpected_mac": "11:22:33:44:55:bb",
}
@pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_fixed_via_dashboard(
hass: HomeAssistant,
@ -845,10 +890,13 @@ async def test_reauth_fixed_via_dashboard(
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
mock_dashboard["configured"].append(
{
@ -883,7 +931,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
"""Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
)
mock_dashboard["configured"].append(
@ -917,7 +965,9 @@ async def test_reauth_fixed_via_remove_password(
mock_setup_entry: None,
) -> None:
"""Test reauth fixed automatically by seeing password removed."""
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await mock_config_entry.start_reauth_flow(hass)
@ -943,10 +993,13 @@ async def test_reauth_fixed_via_dashboard_at_confirm(
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
mock_client.device_info.return_value = DeviceInfo(uses_password=False, name="test")
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await entry.start_reauth_flow(hass)
@ -984,6 +1037,7 @@ async def test_reauth_confirm_invalid(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1000,7 +1054,9 @@ async def test_reauth_confirm_invalid(
assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1019,7 +1075,7 @@ async def test_reauth_confirm_invalid_with_unique_id(
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="test",
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1036,7 +1092,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
@ -1049,7 +1107,7 @@ async def test_reauth_confirm_invalid_with_unique_id(
@pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_encryption_key_removed(
hass: HomeAssistant, mock_client, mock_setup_entry: None
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None:
"""Test reauth when the encryption key was removed."""
entry = MockConfigEntry(
@ -1060,7 +1118,7 @@ async def test_reauth_encryption_key_removed(
CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK,
},
unique_id="test",
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
@ -1660,7 +1718,11 @@ async def test_user_flow_name_conflict_migrate(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated"
assert result["description_placeholders"] == {
"existing_mac": "11:22:33:44:55:cc",
"mac": "11:22:33:44:55:aa",
"name": "test",
}
assert existing_entry.data == {
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
@ -1715,3 +1777,321 @@ async def test_user_flow_name_conflict_overwrite(
CONF_DEVICE_NAME: "test",
}
assert result["context"]["unique_id"] == "11:22:33:44:55:aa"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_same_ip_new_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with same ip and new name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "other"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_new_ip_new_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and new name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.2"
assert entry.data[CONF_DEVICE_NAME] == "other"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_success_with_new_ip_same_name(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with new ip and same name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
CONF_NOISE_PSK: VALID_NOISE_PSK,
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.1", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert entry.data[CONF_HOST] == "127.0.0.1"
assert entry.data[CONF_DEVICE_NAME] == "test"
assert entry.data[CONF_NOISE_PSK] == VALID_NOISE_PSK
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_with_existing_entry(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig with a name conflict with an existing entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
entry2 = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "other",
},
unique_id="11:22:33:44:55:bb",
)
entry2.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.3", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_name_conflict"
assert result["description_placeholders"] == {
"existing_title": "Mock Title",
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.3",
"name": "test",
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_attempt_to_change_mac_aborts(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation with valid PSK attempting to change mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="other", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_unique_id_changed"
assert CONF_NOISE_PSK not in entry.data
assert result["description_placeholders"] == {
"expected_mac": "11:22:33:44:55:aa",
"host": "127.0.0.2",
"name": "test",
"unexpected_device_name": "other",
"unexpected_mac": "11:22:33:44:55:bb",
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_mac_used_by_other_entry(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig when there is another entry for the mac."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
entry2 = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test4",
},
unique_id="11:22:33:44:55:bb",
)
entry2.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_migrate(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation when device has been replaced."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_migrate"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated"
assert entry.data == {
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert entry.unique_id == "11:22:33:44:55:bb"
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reconfig_name_conflict_overwrite(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reconfig initiation when device has been replaced."""
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "127.0.0.1",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test",
},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
result = await entry.start_reconfigure_flow(hass)
mock_client.device_info.return_value = DeviceInfo(
uses_password=False, name="test", mac_address="11:22:33:44:55:bb"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_HOST: "127.0.0.2", CONF_PORT: 6053}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "name_conflict"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": "name_conflict_overwrite"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "127.0.0.2",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_NOISE_PSK: "",
CONF_DEVICE_NAME: "test",
}
assert result["context"]["unique_id"] == "11:22:33:44:55:bb"
assert (
hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, "11:22:33:44:55:aa"
)
is None
)

View File

@ -193,7 +193,7 @@ async def test_new_dashboard_fix_reauth(
"""Test config entries waiting for reauth are triggered."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"),
)
with patch(

View File

@ -500,6 +500,6 @@ async def test_esphome_device_without_friendly_name(
states=states,
device_info={"friendly_name": None},
)
state = hass.states.get("binary_sensor.my_binary_sensor")
state = hass.states.get("binary_sensor.test_mybinary_sensor")
assert state is not None
assert state.state == STATE_ON

View File

@ -1577,3 +1577,51 @@ async def test_entry_missing_bluetooth_mac_address(
)
await hass.async_block_till_done()
assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC"
async def test_device_adds_friendly_name(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: Callable[
[APIClient, list[EntityInfo], list[UserService], list[EntityState]],
Awaitable[MockESPHomeDevice],
],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a device with user services that change arguments."""
entity_info = []
states = []
device: MockESPHomeDevice = await mock_esphome_device(
mock_client=mock_client,
entity_info=entity_info,
user_service=[],
device_info={"name": "nofriendlyname", "friendly_name": ""},
states=states,
)
await hass.async_block_till_done()
dev_reg = dr.async_get(hass)
dev = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)}
)
assert dev.name == "Nofriendlyname"
assert (
"No `friendly_name` set in the `esphome:` section of "
"the YAML config for device 'nofriendlyname'"
) in caplog.text
caplog.clear()
await device.mock_disconnect(True)
await hass.async_block_till_done()
device.device_info = DeviceInfo(
**{**device.device_info.to_dict(), "friendly_name": "I have a friendly name"}
)
mock_client.device_info = AsyncMock(return_value=device.device_info)
await device.mock_connect()
await hass.async_block_till_done()
dev = dev_reg.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, device.entry.unique_id)}
)
assert dev.name == "I have a friendly name"
assert (
"No `friendly_name` set in the `esphome:` section of the YAML config for device"
) not in caplog.text

View File

@ -125,7 +125,6 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
(
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla",
},
{
@ -162,12 +161,12 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
None,
@ -235,7 +234,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
{
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P,
@ -263,6 +262,24 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
},
{CONF_USE_GOOGLE_SEARCH_TOOL: "invalid_google_search_option"},
),
(
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: "assist",
},
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: ["assist"],
},
{
CONF_RECOMMENDED: True,
CONF_PROMPT: "",
CONF_LLM_HASS_API: ["assist"],
},
None,
),
],
)
@pytest.mark.usefixtures("mock_init_component")

View File

@ -17,7 +17,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
@ -84,7 +84,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
@ -151,7 +151,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,
@ -218,7 +218,7 @@
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_category': None,
'entity_id': 'climate.thermostat_1',
'has_entity_name': True,
'hidden_by': None,

View File

@ -1,57 +1 @@
"""Tests for the jewish_calendar component."""
from collections import namedtuple
from datetime import datetime
from homeassistant.components import jewish_calendar
from homeassistant.util import dt as dt_util
_LatLng = namedtuple("_LatLng", ["lat", "lng"]) # noqa: PYI024
HDATE_DEFAULT_ALTITUDE = 754
NYC_LATLNG = _LatLng(40.7128, -74.0060)
JERUSALEM_LATLNG = _LatLng(31.778, 35.235)
def make_nyc_test_params(dtime, results, havdalah_offset=0):
"""Make test params for NYC."""
if isinstance(results, dict):
time_zone = dt_util.get_time_zone("America/New_York")
results = {
key: value.replace(tzinfo=time_zone)
if isinstance(value, datetime)
else value
for key, value in results.items()
}
return (
dtime,
jewish_calendar.DEFAULT_CANDLE_LIGHT,
havdalah_offset,
True,
"America/New_York",
NYC_LATLNG.lat,
NYC_LATLNG.lng,
results,
)
def make_jerusalem_test_params(dtime, results, havdalah_offset=0):
"""Make test params for Jerusalem."""
if isinstance(results, dict):
time_zone = dt_util.get_time_zone("Asia/Jerusalem")
results = {
key: value.replace(tzinfo=time_zone)
if isinstance(value, datetime)
else value
for key, value in results.items()
}
return (
dtime,
40,
havdalah_offset,
False,
"Asia/Jerusalem",
JERUSALEM_LATLNG.lat,
JERUSALEM_LATLNG.lng,
results,
)

View File

@ -1,22 +1,39 @@
"""Common fixtures for the jewish_calendar tests."""
from collections.abc import Generator
from collections.abc import AsyncGenerator, Generator, Iterable
import datetime as dt
from typing import NamedTuple
from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import pytest
from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN
from homeassistant.components.jewish_calendar.const import (
CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA,
CONF_HAVDALAH_OFFSET_MINUTES,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from tests.common import MockConfigEntry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
)
class _LocationData(NamedTuple):
timezone: str
diaspora: bool
lat: float
lng: float
candle_lighting: int
LOCATIONS = {
"Jerusalem": _LocationData("Asia/Jerusalem", False, 31.7683, 35.2137, 40),
"New York": _LocationData("America/New_York", True, 40.7128, -74.006, 18),
}
@pytest.fixture
@ -26,3 +43,109 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.jewish_calendar.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def location_data(request: pytest.FixtureRequest) -> _LocationData | None:
"""Return data based on location name."""
if not hasattr(request, "param"):
return None
return LOCATIONS[request.param]
@pytest.fixture
def tz_info(hass: HomeAssistant, location_data: _LocationData | None) -> dt.tzinfo:
"""Return time zone info."""
if location_data is None:
return dt_util.get_time_zone(hass.config.time_zone)
return dt_util.get_time_zone(location_data.timezone)
@pytest.fixture(name="test_time")
def _test_time(
request: pytest.FixtureRequest, tz_info: dt.tzinfo
) -> dt.datetime | None:
"""Return localized test time based."""
if not hasattr(request, "param"):
return None
return request.param.replace(tzinfo=tz_info)
@pytest.fixture
def results(request: pytest.FixtureRequest, tz_info: dt.tzinfo) -> Iterable:
"""Return localized results."""
if not hasattr(request, "param"):
return None
if isinstance(request.param, dict):
return {
key: value.replace(tzinfo=tz_info)
if isinstance(value, dt.datetime)
else value
for key, value in request.param.items()
}
return request.param
@pytest.fixture
def havdalah_offset() -> int | None:
"""Return None if default havdalah offset is not specified."""
return None
@pytest.fixture
def language() -> str:
"""Return default language value, unless language is parametrized."""
return "english"
@pytest.fixture(autouse=True)
async def setup_hass(hass: HomeAssistant, location_data: _LocationData | None) -> None:
"""Set up Home Assistant for testing the jewish_calendar integration."""
if location_data:
await hass.config.async_set_time_zone(location_data.timezone)
hass.config.latitude = location_data.lat
hass.config.longitude = location_data.lng
@pytest.fixture
def config_entry(
location_data: _LocationData | None,
language: str,
havdalah_offset: int | None,
) -> MockConfigEntry:
"""Set up the jewish_calendar integration for testing."""
param_data = {}
param_options = {}
if location_data:
param_data = {
CONF_DIASPORA: location_data.diaspora,
CONF_TIME_ZONE: location_data.timezone,
}
param_options[CONF_CANDLE_LIGHT_MINUTES] = location_data.candle_lighting
if havdalah_offset:
param_options[CONF_HAVDALAH_OFFSET_MINUTES] = havdalah_offset
return MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
data={CONF_LANGUAGE: language, **param_data},
options=param_options,
)
@pytest.fixture
async def setup_at_time(
test_time: dt.datetime, hass: HomeAssistant, config_entry: MockConfigEntry
) -> AsyncGenerator[None]:
"""Set up the jewish_calendar integration at a specific time."""
with freeze_time(test_time):
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
yield

View File

@ -1,301 +1,145 @@
"""The tests for the Jewish calendar binary sensors."""
from datetime import datetime as dt, timedelta
import logging
from typing import Any
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.jewish_calendar.const import (
CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA,
CONF_HAVDALAH_OFFSET_MINUTES,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.components.jewish_calendar.const import DOMAIN
from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import make_jerusalem_test_params, make_nyc_test_params
from tests.common import MockConfigEntry, async_fire_time_changed
_LOGGER = logging.getLogger(__name__)
from tests.common import async_fire_time_changed
MELACHA_PARAMS = [
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 16, 0),
{
"state": STATE_ON,
"update": dt(2018, 9, 1, 20, 14),
"new_state": STATE_OFF,
},
{"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF},
id="currently_first_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 20, 21),
{
"state": STATE_OFF,
"update": dt(2018, 9, 2, 6, 21),
"new_state": STATE_OFF,
},
{"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF},
id="after_first_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 7, 13, 1),
{
"state": STATE_OFF,
"update": dt(2018, 9, 7, 19, 4),
"new_state": STATE_ON,
},
{"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON},
id="friday_upcoming_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 8, 21, 25),
{
"state": STATE_OFF,
"update": dt(2018, 9, 9, 6, 27),
"new_state": STATE_OFF,
},
{"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF},
id="upcoming_rosh_hashana",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 9, 21, 25),
{
"state": STATE_ON,
"update": dt(2018, 9, 10, 6, 28),
"new_state": STATE_ON,
},
{"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON},
id="currently_rosh_hashana",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 10, 21, 25),
{
"state": STATE_ON,
"update": dt(2018, 9, 11, 6, 29),
"new_state": STATE_ON,
},
{"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON},
id="second_day_rosh_hashana_night",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 11, 11, 25),
{
"state": STATE_ON,
"update": dt(2018, 9, 11, 19, 57),
"new_state": STATE_OFF,
},
{"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF},
id="second_day_rosh_hashana_day",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 29, 16, 25),
{
"state": STATE_ON,
"update": dt(2018, 9, 29, 19, 25),
"new_state": STATE_OFF,
},
{"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF},
id="currently_shabbat_chol_hamoed",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 29, 21, 25),
{
"state": STATE_OFF,
"update": dt(2018, 9, 30, 6, 48),
"new_state": STATE_OFF,
},
{"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF},
id="upcoming_two_day_yomtov_in_diaspora",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 30, 21, 25),
{
"state": STATE_ON,
"update": dt(2018, 10, 1, 6, 49),
"new_state": STATE_ON,
},
{"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON},
id="currently_first_day_of_two_day_yomtov_in_diaspora",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 10, 1, 21, 25),
{
"state": STATE_ON,
"update": dt(2018, 10, 2, 6, 50),
"new_state": STATE_ON,
},
{"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON},
id="currently_second_day_of_two_day_yomtov_in_diaspora",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 9, 29, 21, 25),
{
"state": STATE_OFF,
"update": dt(2018, 9, 30, 6, 29),
"new_state": STATE_OFF,
},
{"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF},
id="upcoming_one_day_yom_tov_in_israel",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 10, 1, 11, 25),
{
"state": STATE_ON,
"update": dt(2018, 10, 1, 19, 2),
"new_state": STATE_OFF,
},
{"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF},
id="currently_one_day_yom_tov_in_israel",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 10, 1, 21, 25),
{
"state": STATE_OFF,
"update": dt(2018, 10, 2, 6, 31),
"new_state": STATE_OFF,
},
{"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF},
id="after_one_day_yom_tov_in_israel",
),
]
MELACHA_TEST_IDS = [
"currently_first_shabbat",
"after_first_shabbat",
"friday_upcoming_shabbat",
"upcoming_rosh_hashana",
"currently_rosh_hashana",
"second_day_rosh_hashana_night",
"second_day_rosh_hashana_day",
"currently_shabbat_chol_hamoed",
"upcoming_two_day_yomtov_in_diaspora",
"currently_first_day_of_two_day_yomtov_in_diaspora",
"currently_second_day_of_two_day_yomtov_in_diaspora",
"upcoming_one_day_yom_tov_in_israel",
"currently_one_day_yom_tov_in_israel",
"after_one_day_yom_tov_in_israel",
]
@pytest.mark.parametrize(
(
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
MELACHA_PARAMS,
ids=MELACHA_TEST_IDS,
("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True
)
@pytest.mark.usefixtures("setup_at_time")
async def test_issur_melacha_sensor(
hass: HomeAssistant,
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any]
) -> None:
"""Test Issur Melacha sensor output."""
time_zone = dt_util.get_time_zone(tzname)
test_time = now.replace(tzinfo=time_zone)
sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
assert hass.states.get(sensor_id).state == results["state"]
await hass.config.async_set_time_zone(tzname)
hass.config.latitude = latitude
hass.config.longitude = longitude
with freeze_time(test_time):
entry = MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
data={
CONF_LANGUAGE: "english",
CONF_DIASPORA: diaspora,
CONF_CANDLE_LIGHT_MINUTES: candle_lighting,
CONF_HAVDALAH_OFFSET_MINUTES: havdalah,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
freezer.move_to(results["update"])
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get(
"binary_sensor.jewish_calendar_issur_melacha_in_effect"
).state
== result["state"]
)
with freeze_time(result["update"]):
async_fire_time_changed(hass, result["update"])
await hass.async_block_till_done()
assert (
hass.states.get(
"binary_sensor.jewish_calendar_issur_melacha_in_effect"
).state
== result["new_state"]
)
assert hass.states.get(sensor_id).state == results["new_state"]
@pytest.mark.parametrize(
(
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
("location_data", "test_time", "results"),
[
make_nyc_test_params(
dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]
),
make_nyc_test_params(
dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]
),
("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]),
("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]),
],
ids=["before_candle_lighting", "before_havdalah"],
indirect=True,
)
@pytest.mark.usefixtures("setup_at_time")
async def test_issur_melacha_sensor_update(
hass: HomeAssistant,
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str]
) -> None:
"""Test Issur Melacha sensor output."""
time_zone = dt_util.get_time_zone(tzname)
test_time = now.replace(tzinfo=time_zone)
sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
assert hass.states.get(sensor_id).state == results[0]
await hass.config.async_set_time_zone(tzname)
hass.config.latitude = latitude
hass.config.longitude = longitude
with freeze_time(test_time):
entry = MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
data={
CONF_LANGUAGE: "english",
CONF_DIASPORA: diaspora,
CONF_CANDLE_LIGHT_MINUTES: candle_lighting,
CONF_HAVDALAH_OFFSET_MINUTES: havdalah,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
freezer.tick(timedelta(microseconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
hass.states.get(
"binary_sensor.jewish_calendar_issur_melacha_in_effect"
).state
== result[0]
)
test_time += timedelta(microseconds=1)
with freeze_time(test_time):
async_fire_time_changed(hass, test_time)
await hass.async_block_till_done()
assert (
hass.states.get(
"binary_sensor.jewish_calendar_issur_melacha_in_effect"
).state
== result[1]
)
assert hass.states.get(sensor_id).state == results[1]
async def test_no_discovery_info(

View File

@ -57,10 +57,10 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
async def test_single_instance_allowed(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
config_entry: MockConfigEntry,
) -> None:
"""Test we abort if already setup."""
mock_config_entry.add_to_hass(hass)
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@ -70,11 +70,11 @@ async def test_single_instance_allowed(
assert result.get("reason") == "single_instance_allowed"
async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
async def test_options(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Test updating options."""
mock_config_entry.add_to_hass(hass)
config_entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
@ -95,16 +95,16 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry)
async def test_options_reconfigure(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
hass: HomeAssistant, config_entry: MockConfigEntry
) -> None:
"""Test that updating the options of the Jewish Calendar integration triggers a value update."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert CONF_CANDLE_LIGHT_MINUTES not in mock_config_entry.options
assert CONF_CANDLE_LIGHT_MINUTES not in config_entry.options
# Update the CONF_CANDLE_LIGHT_MINUTES option to a new value
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
@ -114,21 +114,17 @@ async def test_options_reconfigure(
assert result["result"]
# The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value
assert (
mock_config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1
)
assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1
async def test_reconfigure(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
async def test_reconfigure(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Test starting a reconfigure flow."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
# init user flow
result = await mock_config_entry.start_reconfigure_flow(hass)
result = await config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
@ -141,4 +137,4 @@ async def test_reconfigure(
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA
assert config_entry.data[CONF_DIASPORA] is not DEFAULT_DIASPORA

View File

@ -21,24 +21,24 @@ from tests.common import MockConfigEntry
async def test_migrate_unique_id(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
old_key: str,
new_key: str,
) -> None:
"""Test unique id migration."""
entry = MockConfigEntry(domain=DOMAIN, data={})
entry.add_to_hass(hass)
config_entry.add_to_hass(hass)
entity: er.RegistryEntry = entity_registry.async_get_or_create(
domain=SENSOR_DOMAIN,
platform=DOMAIN,
unique_id=f"{entry.entry_id}-{old_key}",
config_entry=entry,
unique_id=f"{config_entry.entry_id}-{old_key}",
config_entry=config_entry,
)
assert entity.unique_id.endswith(f"-{old_key}")
await hass.config_entries.async_setup(entry.entry_id)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
entity_migrated = entity_registry.async_get(entity.entity_id)
assert entity_migrated
assert entity_migrated.unique_id == f"{entry.entry_id}-{new_key}"
assert entity_migrated.unique_id == f"{config_entry.entry_id}-{new_key}"

View File

@ -1,94 +1,62 @@
"""The tests for the Jewish calendar sensors."""
from datetime import datetime as dt, timedelta
from datetime import datetime as dt
from typing import Any
from freezegun import freeze_time
from hdate.holidays import HolidayDatabase
from hdate.parasha import Parasha
import pytest
from homeassistant.components.jewish_calendar.const import (
CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA,
CONF_HAVDALAH_OFFSET_MINUTES,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.components.jewish_calendar.const import DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM
from homeassistant.const import CONF_PLATFORM
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from . import make_jerusalem_test_params, make_nyc_test_params
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.common import MockConfigEntry
async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("language", ["english", "hebrew"])
async def test_min_config(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Test minimum jewish calendar configuration."""
entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN, data={})
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("sensor.jewish_calendar_date") is not None
async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None:
"""Test jewish calendar sensor with language set to hebrew."""
entry = MockConfigEntry(
title=DEFAULT_NAME, domain=DOMAIN, data={"language": "hebrew"}
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("sensor.jewish_calendar_date") is not None
TEST_PARAMS = [
(
pytest.param(
"Jerusalem",
dt(2018, 9, 3),
"UTC",
31.778,
35.235,
{"state": "23 Elul 5778"},
"english",
"date",
False,
"23 Elul 5778",
None,
id="date_output",
),
(
pytest.param(
"Jerusalem",
dt(2018, 9, 3),
"UTC",
31.778,
35.235,
{"state": 'כ"ג אלול ה\' תשע"ח'},
"hebrew",
"date",
False,
'כ"ג אלול ה\' תשע"ח',
None,
id="date_output_hebrew",
),
(
pytest.param(
"Jerusalem",
dt(2018, 9, 10),
"UTC",
31.778,
35.235,
{"state": "א' ראש השנה"},
"hebrew",
"holiday",
False,
"א' ראש השנה",
None,
id="holiday",
),
(
pytest.param(
"Jerusalem",
dt(2018, 9, 10),
"UTC",
31.778,
35.235,
"english",
"holiday",
False,
"Rosh Hashana I",
{
"state": "Rosh Hashana I",
"attr": {
"device_class": "enum",
"friendly_name": "Jewish Calendar Holiday",
"icon": "mdi:calendar-star",
@ -96,17 +64,17 @@ TEST_PARAMS = [
"type": "YOM_TOV",
"options": HolidayDatabase(False).get_all_names("english"),
},
),
(
dt(2024, 12, 31),
"UTC",
31.778,
35.235,
},
"english",
"holiday",
False,
"Chanukah, Rosh Chodesh",
id="holiday_english",
),
pytest.param(
"Jerusalem",
dt(2024, 12, 31),
{
"state": "Chanukah, Rosh Chodesh",
"attr": {
"device_class": "enum",
"friendly_name": "Jewish Calendar Holiday",
"icon": "mdi:calendar-star",
@ -114,169 +82,103 @@ TEST_PARAMS = [
"type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH",
"options": HolidayDatabase(False).get_all_names("english"),
},
},
"english",
"holiday",
id="holiday_multiple",
),
(
pytest.param(
"Jerusalem",
dt(2018, 9, 8),
"UTC",
31.778,
35.235,
"hebrew",
"parshat_hashavua",
False,
"נצבים",
{
"state": "נצבים",
"attr": {
"device_class": "enum",
"friendly_name": "Jewish Calendar Parshat Hashavua",
"icon": "mdi:book-open-variant",
"options": list(Parasha),
},
),
(
dt(2018, 9, 8),
"America/New_York",
40.7128,
-74.0060,
"hebrew",
"t_set_hakochavim",
True,
dt(2018, 9, 8, 19, 47),
None,
),
(
dt(2018, 9, 8),
"Asia/Jerusalem",
31.778,
35.235,
"hebrew",
"t_set_hakochavim",
False,
dt(2018, 9, 8, 19, 21),
None,
),
(
dt(2018, 10, 14),
"Asia/Jerusalem",
31.778,
35.235,
},
"hebrew",
"parshat_hashavua",
False,
"לך לך",
None,
id="torah_reading",
),
(
pytest.param(
"New York",
dt(2018, 9, 8),
{"state": dt(2018, 9, 8, 19, 47)},
"hebrew",
"t_set_hakochavim",
id="first_stars_ny",
),
pytest.param(
"Jerusalem",
dt(2018, 9, 8),
{"state": dt(2018, 9, 8, 19, 21)},
"hebrew",
"t_set_hakochavim",
id="first_stars_jerusalem",
),
pytest.param(
"Jerusalem",
dt(2018, 10, 14),
{"state": "לך לך"},
"hebrew",
"parshat_hashavua",
id="torah_reading_weekday",
),
pytest.param(
"Jerusalem",
dt(2018, 10, 14, 17, 0, 0),
"Asia/Jerusalem",
31.778,
35.235,
{"state": "ה' מרחשוון ה' תשע\"ט"},
"hebrew",
"date",
False,
"ה' מרחשוון ה' תשע\"ט",
None,
id="date_before_sunset",
),
(
pytest.param(
"Jerusalem",
dt(2018, 10, 14, 19, 0, 0),
"Asia/Jerusalem",
31.778,
35.235,
"hebrew",
"date",
False,
"ו' מרחשוון ה' תשע\"ט",
{
"state": "ו' מרחשוון ה' תשע\"ט",
"attr": {
"hebrew_year": "5779",
"hebrew_month_name": "מרחשוון",
"hebrew_day": "6",
"icon": "mdi:star-david",
"friendly_name": "Jewish Calendar Date",
},
},
"hebrew",
"date",
id="date_after_sunset",
),
]
TEST_IDS = [
"date_output",
"date_output_hebrew",
"holiday",
"holiday_english",
"holiday_multiple",
"torah_reading",
"first_stars_ny",
"first_stars_jerusalem",
"torah_reading_weekday",
"date_before_sunset",
"date_after_sunset",
]
@pytest.mark.parametrize(
(
"now",
"tzname",
"latitude",
"longitude",
"language",
"sensor",
"diaspora",
"result",
"attrs",
),
("location_data", "test_time", "results", "language", "sensor"),
TEST_PARAMS,
ids=TEST_IDS,
indirect=["location_data", "test_time", "results"],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
async def test_jewish_calendar_sensor(
hass: HomeAssistant,
now,
tzname,
latitude,
longitude,
language,
sensor,
diaspora,
result,
attrs,
hass: HomeAssistant, results: dict[str, Any], sensor: str
) -> None:
"""Test Jewish calendar sensor output."""
time_zone = dt_util.get_time_zone(tzname)
test_time = now.replace(tzinfo=time_zone)
await hass.config.async_set_time_zone(tzname)
hass.config.latitude = latitude
hass.config.longitude = longitude
with freeze_time(test_time):
entry = MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
data={
CONF_LANGUAGE: language,
CONF_DIASPORA: diaspora,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
future = test_time + timedelta(seconds=30)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
result = (
dt_util.as_utc(result.replace(tzinfo=time_zone)).isoformat()
if isinstance(result, dt)
else result
)
result = results["state"]
if isinstance(result, dt):
result = dt_util.as_utc(result).isoformat()
sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}")
assert sensor_object.state == result
if attrs:
if attrs := results.get("attr"):
assert sensor_object.attributes == attrs
SHABBAT_PARAMS = [
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 16, 0),
{
"english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -286,8 +188,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא",
},
None,
id="currently_first_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 16, 0),
{
"english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -297,9 +202,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא",
},
havdalah_offset=50,
50, # Havdalah offset
id="currently_first_shabbat_with_havdalah_offset",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 20, 0),
{
"english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -309,8 +216,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא",
},
None,
id="currently_first_shabbat_bein_hashmashot_lagging_date",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 1, 20, 21),
{
"english_upcoming_candle_lighting": dt(2018, 9, 7, 19),
@ -320,8 +230,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Nitzavim",
"hebrew_parshat_hashavua": "נצבים",
},
None,
id="after_first_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 7, 13, 1),
{
"english_upcoming_candle_lighting": dt(2018, 9, 7, 19),
@ -331,8 +244,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Nitzavim",
"hebrew_parshat_hashavua": "נצבים",
},
None,
id="friday_upcoming_shabbat",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 8, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -344,8 +260,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Erev Rosh Hashana",
"hebrew_holiday": "ערב ראש השנה",
},
None,
id="upcoming_rosh_hashana",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 9, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -357,8 +276,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana I",
"hebrew_holiday": "א' ראש השנה",
},
None,
id="currently_rosh_hashana",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 10, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -370,8 +292,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana II",
"hebrew_holiday": "ב' ראש השנה",
},
None,
id="second_day_rosh_hashana",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 28, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25),
@ -381,8 +306,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "none",
"hebrew_parshat_hashavua": "none",
},
None,
id="currently_shabbat_chol_hamoed",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 29, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -394,8 +322,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Hoshana Raba",
"hebrew_holiday": "הושענא רבה",
},
None,
id="upcoming_two_day_yomtov_in_diaspora",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 9, 30, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -407,8 +338,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shmini Atzeret",
"hebrew_holiday": "שמיני עצרת",
},
None,
id="currently_first_day_of_two_day_yomtov_in_diaspora",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2018, 10, 1, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -420,11 +354,14 @@ SHABBAT_PARAMS = [
"english_holiday": "Simchat Torah",
"hebrew_holiday": "שמחת תורה",
},
None,
id="currently_second_day_of_two_day_yomtov_in_diaspora",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 9, 29, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45),
"english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46),
"english_upcoming_havdalah": dt(2018, 10, 1, 19, 1),
"english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39),
"english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54),
@ -433,11 +370,14 @@ SHABBAT_PARAMS = [
"english_holiday": "Hoshana Raba",
"hebrew_holiday": "הושענא רבה",
},
None,
id="upcoming_one_day_yom_tov_in_israel",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 9, 30, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 45),
"english_upcoming_candle_lighting": dt(2018, 9, 30, 17, 46),
"english_upcoming_havdalah": dt(2018, 10, 1, 19, 1),
"english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39),
"english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54),
@ -446,8 +386,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shmini Atzeret, Simchat Torah",
"hebrew_holiday": "שמיני עצרת, שמחת תורה",
},
None,
id="currently_one_day_yom_tov_in_israel",
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2018, 10, 1, 21, 25),
{
"english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39),
@ -457,8 +400,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Bereshit",
"hebrew_parshat_hashavua": "בראשית",
},
None,
id="after_one_day_yom_tov_in_israel",
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2016, 6, 11, 8, 25),
{
"english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9),
@ -470,8 +416,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Erev Shavuot",
"hebrew_holiday": "ערב שבועות",
},
None,
id="currently_first_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon
),
make_nyc_test_params(
pytest.param(
"New York",
dt(2016, 6, 12, 8, 25),
{
"english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9),
@ -483,8 +432,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shavuot",
"hebrew_holiday": "שבועות",
},
None,
id="currently_second_day_of_three_day_type1_yomtov_in_diaspora", # Type 1 = Sat/Sun/Mon
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2017, 9, 21, 8, 25),
{
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -496,8 +448,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana I",
"hebrew_holiday": "א' ראש השנה",
},
None,
id="currently_first_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2017, 9, 22, 8, 25),
{
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -509,8 +464,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana II",
"hebrew_holiday": "ב' ראש השנה",
},
None,
id="currently_second_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat
),
make_jerusalem_test_params(
pytest.param(
"Jerusalem",
dt(2017, 9, 23, 8, 25),
{
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -522,179 +480,70 @@ SHABBAT_PARAMS = [
"english_holiday": "",
"hebrew_holiday": "",
},
None,
id="currently_third_day_of_three_day_type2_yomtov_in_israel", # Type 2 = Thurs/Fri/Sat
),
]
SHABBAT_TEST_IDS = [
"currently_first_shabbat",
"currently_first_shabbat_with_havdalah_offset",
"currently_first_shabbat_bein_hashmashot_lagging_date",
"after_first_shabbat",
"friday_upcoming_shabbat",
"upcoming_rosh_hashana",
"currently_rosh_hashana",
"second_day_rosh_hashana",
"currently_shabbat_chol_hamoed",
"upcoming_two_day_yomtov_in_diaspora",
"currently_first_day_of_two_day_yomtov_in_diaspora",
"currently_second_day_of_two_day_yomtov_in_diaspora",
"upcoming_one_day_yom_tov_in_israel",
"currently_one_day_yom_tov_in_israel",
"after_one_day_yom_tov_in_israel",
# Type 1 = Sat/Sun/Mon
"currently_first_day_of_three_day_type1_yomtov_in_diaspora",
"currently_second_day_of_three_day_type1_yomtov_in_diaspora",
# Type 2 = Thurs/Fri/Sat
"currently_first_day_of_three_day_type2_yomtov_in_israel",
"currently_second_day_of_three_day_type2_yomtov_in_israel",
"currently_third_day_of_three_day_type2_yomtov_in_israel",
]
@pytest.mark.parametrize("language", ["english", "hebrew"])
@pytest.mark.parametrize(
(
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
("location_data", "test_time", "results", "havdalah_offset"),
SHABBAT_PARAMS,
ids=SHABBAT_TEST_IDS,
indirect=("location_data", "test_time", "results"),
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
async def test_shabbat_times_sensor(
hass: HomeAssistant,
language,
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
hass: HomeAssistant, results: dict[str, Any], language: str
) -> None:
"""Test sensor output for upcoming shabbat/yomtov times."""
time_zone = dt_util.get_time_zone(tzname)
test_time = now.replace(tzinfo=time_zone)
await hass.config.async_set_time_zone(tzname)
hass.config.latitude = latitude
hass.config.longitude = longitude
with freeze_time(test_time):
entry = MockConfigEntry(
title=DEFAULT_NAME,
domain=DOMAIN,
data={
CONF_LANGUAGE: language,
CONF_DIASPORA: diaspora,
},
options={
CONF_CANDLE_LIGHT_MINUTES: candle_lighting,
CONF_HAVDALAH_OFFSET_MINUTES: havdalah,
},
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
future = test_time + timedelta(seconds=30)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
for sensor_type, result_value in result.items():
for sensor_type, result_value in results.items():
if not sensor_type.startswith(language):
continue
sensor_type = sensor_type.replace(f"{language}_", "")
result_value = (
dt_util.as_utc(result_value).isoformat()
if isinstance(result_value, dt)
else result_value
)
if isinstance(result_value, dt):
result_value = dt_util.as_utc(result_value).isoformat()
assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str(
result_value
), f"Value for {sensor_type}"
OMER_PARAMS = [
(dt(2019, 4, 21, 0), "1"),
(dt(2019, 4, 21, 23), "2"),
(dt(2019, 5, 23, 0), "33"),
(dt(2019, 6, 8, 0), "49"),
(dt(2019, 6, 9, 0), "0"),
(dt(2019, 1, 1, 0), "0"),
]
OMER_TEST_IDS = [
"first_day_of_omer",
"first_day_of_omer_after_tzeit",
"lag_baomer",
"last_day_of_omer",
"shavuot_no_omer",
"jan_1st_no_omer",
]
@pytest.mark.parametrize(("test_time", "result"), OMER_PARAMS, ids=OMER_TEST_IDS)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None:
@pytest.mark.parametrize(
("test_time", "results"),
[
pytest.param(dt(2019, 4, 21, 0), "1", id="first_day_of_omer"),
pytest.param(dt(2019, 4, 21, 23), "2", id="first_day_of_omer_after_tzeit"),
pytest.param(dt(2019, 5, 23, 0), "33", id="lag_baomer"),
pytest.param(dt(2019, 6, 8, 0), "49", id="last_day_of_omer"),
pytest.param(dt(2019, 6, 9, 0), "0", id="shavuot_no_omer"),
pytest.param(dt(2019, 1, 1, 0), "0", id="jan_1st_no_omer"),
],
indirect=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
async def test_omer_sensor(hass: HomeAssistant, results: str) -> None:
"""Test Omer Count sensor output."""
test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone))
with freeze_time(test_time):
entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
future = test_time + timedelta(seconds=30)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == result
assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == results
DAFYOMI_PARAMS = [
(dt(2014, 4, 28, 0), "Beitzah 29"),
(dt(2020, 1, 4, 0), "Niddah 73"),
(dt(2020, 1, 5, 0), "Berachos 2"),
(dt(2020, 3, 7, 0), "Berachos 64"),
(dt(2020, 3, 8, 0), "Shabbos 2"),
]
DAFYOMI_TEST_IDS = [
"randomly_picked_date",
"end_of_cycle13",
"start_of_cycle14",
"cycle14_end_of_berachos",
"cycle14_start_of_shabbos",
]
@pytest.mark.parametrize(("test_time", "result"), DAFYOMI_PARAMS, ids=DAFYOMI_TEST_IDS)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None:
@pytest.mark.parametrize(
("test_time", "results"),
[
pytest.param(dt(2014, 4, 28, 0), "Beitzah 29", id="randomly_picked_date"),
pytest.param(dt(2020, 1, 4, 0), "Niddah 73", id="end_of_cycle13"),
pytest.param(dt(2020, 1, 5, 0), "Berachos 2", id="start_of_cycle14"),
pytest.param(dt(2020, 3, 7, 0), "Berachos 64", id="cycle14_end_of_berachos"),
pytest.param(dt(2020, 3, 8, 0), "Shabbos 2", id="cycle14_start_of_shabbos"),
],
indirect=True,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None:
"""Test Daf Yomi sensor output."""
test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone))
with freeze_time(test_time):
entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
future = test_time + timedelta(seconds=30)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == result
assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results
async def test_no_discovery_info(

View File

@ -33,15 +33,15 @@ from tests.common import MockConfigEntry
)
async def test_get_omer_blessing(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
config_entry: MockConfigEntry,
test_date: dt.date,
nusach: str,
language: Language,
expected: str,
) -> None:
"""Test get omer blessing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.services.async_call(

View File

@ -143,3 +143,51 @@
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.gs012345_websocket_connected',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'WebSocket connected',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'websocket_connected',
'unique_id': 'GS012345_websocket_connected',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.gs012345_websocket_connected-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'GS012345 WebSocket connected',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.gs012345_websocket_connected',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@ -115,3 +115,177 @@
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_prebrew_off[Linea Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Prebrew off time',
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'number.mr012345_prebrew_off_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_prebrew_off[Linea Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mr012345_prebrew_off_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Prebrew off time',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'prebrew_time_off',
'unique_id': 'MR012345_prebrew_off',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_prebrew_on[Linea Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Prebrew on time',
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
}),
'context': <ANY>,
'entity_id': 'number.mr012345_prebrew_on_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_prebrew_on[Linea Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mr012345_prebrew_on_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Prebrew on time',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'prebrew_time_on',
'unique_id': 'MR012345_prebrew_on',
'unit_of_measurement': <UnitOfTime.MINUTES: 'min'>,
})
# ---
# name: test_preinfusion[Linea Micra]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'MR012345 Preinfusion time',
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
}),
'context': <ANY>,
'entity_id': 'number.mr012345_preinfusion_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4.0',
})
# ---
# name: test_preinfusion[Linea Micra].1
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 10,
'min': 0,
'mode': <NumberMode.AUTO: 'auto'>,
'step': 0.1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.mr012345_preinfusion_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Preinfusion time',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'preinfusion_time',
'unique_id': 'MR012345_preinfusion_off',
'unit_of_measurement': <UnitOfTime.SECONDS: 's'>,
})
# ---

View File

@ -0,0 +1,97 @@
# serializer version: 1
# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_coffee_boiler_ready_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Coffee boiler ready time',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'coffee_boiler_ready_time',
'unique_id': 'GS012345_coffee_boiler_ready_time',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.gs012345_coffee_boiler_ready_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'GS012345 Coffee boiler ready time',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_coffee_boiler_ready_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.gs012345_steam_boiler_ready_time',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Steam boiler ready time',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'steam_boiler_ready_time',
'unique_id': 'GS012345_steam_boiler_ready_time',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.gs012345_steam_boiler_ready_time-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'timestamp',
'friendly_name': 'GS012345 Steam boiler ready time',
}),
'context': <ANY>,
'entity_id': 'sensor.gs012345_steam_boiler_ready_time',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -27,7 +27,7 @@
'original_name': 'Gateway firmware',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 1>,
'supported_features': <UpdateEntityFeature: 21>,
'translation_key': 'gateway_firmware',
'unique_id': 'GS012345_gateway_firmware',
'unit_of_measurement': None,
@ -47,7 +47,7 @@
'release_summary': None,
'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/',
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 1>,
'supported_features': <UpdateEntityFeature: 21>,
'title': None,
'update_percentage': None,
}),
@ -87,7 +87,7 @@
'original_name': 'Machine firmware',
'platform': 'lamarzocco',
'previous_unique_id': None,
'supported_features': <UpdateEntityFeature: 1>,
'supported_features': <UpdateEntityFeature: 21>,
'translation_key': 'machine_firmware',
'unique_id': 'GS012345_machine_firmware',
'unit_of_measurement': None,
@ -107,7 +107,7 @@
'release_summary': None,
'release_url': 'https://support-iot.lamarzocco.com/firmware-updates/',
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 1>,
'supported_features': <UpdateEntityFeature: 21>,
'title': None,
'update_percentage': None,
}),

View File

@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
@ -16,6 +17,7 @@ from . import async_init_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@ -20,6 +20,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import USER_INPUT, async_init_integration, get_bluetooth_service_info
@ -259,6 +260,61 @@ async def test_reconfigure_flow(
}
@pytest.mark.parametrize(
"discovered",
[
[],
[
BluetoothServiceInfo(
name="SomeDevice",
address="aa:bb:cc:dd:ee:ff",
rssi=-63,
manufacturer_data={},
service_data={},
service_uuids=[],
source="local",
)
],
],
)
async def test_reconfigure_flow_no_machines(
hass: HomeAssistant,
mock_cloud_client: MagicMock,
mock_config_entry: MockConfigEntry,
discovered: list[BluetoothServiceInfo],
) -> None:
"""Testing reconfgure flow."""
mock_config_entry.add_to_hass(hass)
data = deepcopy(dict(mock_config_entry.data))
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await __do_successful_user_step(hass, result, mock_cloud_client)
with (
patch(
"homeassistant.components.lamarzocco.config_flow.async_discovered_service_info",
return_value=discovered,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_MACHINE: "GS012345",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.title == "My LaMarzocco"
assert CONF_MAC not in mock_config_entry.data
assert dict(mock_config_entry.data) == data
async def test_bluetooth_discovery(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,

View File

@ -3,7 +3,12 @@
from typing import Any
from unittest.mock import MagicMock
from pylamarzocco.const import SmartStandByType
from pylamarzocco.const import (
ModelName,
PreExtractionMode,
SmartStandByType,
WidgetType,
)
from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion
@ -85,6 +90,140 @@ async def test_general_numbers(
mock_func.assert_called_once_with(**kwargs)
@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA])
async def test_preinfusion(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test preinfusion number."""
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
entity_id = f"number.{serial_number}_preinfusion_time"
state = hass.states.get(entity_id)
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: 5.3,
},
blocking=True,
)
mock_lamarzocco.set_pre_extraction_times.assert_called_once_with(
seconds_off=5.3,
seconds_on=0,
)
@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA])
async def test_prebrew_on(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test prebrew on number."""
mock_lamarzocco.dashboard.config[
WidgetType.CM_PRE_BREWING
].mode = PreExtractionMode.PREBREWING
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
entity_id = f"number.{serial_number}_prebrew_on_time"
state = hass.states.get(entity_id)
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: 5.3,
},
blocking=True,
)
mock_lamarzocco.set_pre_extraction_times.assert_called_once_with(
seconds_on=5.3,
seconds_off=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING]
.times.pre_brewing[0]
.seconds.seconds_out,
)
@pytest.mark.parametrize("device_fixture", [ModelName.LINEA_MICRA])
async def test_prebrew_off(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test prebrew off number."""
mock_lamarzocco.dashboard.config[
WidgetType.CM_PRE_BREWING
].mode = PreExtractionMode.PREBREWING
await async_init_integration(hass, mock_config_entry)
serial_number = mock_lamarzocco.serial_number
entity_id = f"number.{serial_number}_prebrew_off_time"
state = hass.states.get(entity_id)
assert state
assert state == snapshot
entry = entity_registry.async_get(state.entity_id)
assert entry
assert entry.device_id
assert entry == snapshot
# service call
await hass.services.async_call(
NUMBER_DOMAIN,
SERVICE_SET_VALUE,
{
ATTR_ENTITY_ID: entity_id,
ATTR_VALUE: 7,
},
blocking=True,
)
mock_lamarzocco.set_pre_extraction_times.assert_called_once_with(
seconds_off=7,
seconds_on=mock_lamarzocco.dashboard.config[WidgetType.CM_PRE_BREWING]
.times.pre_brewing[0]
.seconds.seconds_in,
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_number_error(
hass: HomeAssistant,

View File

@ -0,0 +1,52 @@
"""Tests for La Marzocco sensors."""
from unittest.mock import MagicMock, patch
from pylamarzocco.const import ModelName
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_sensors(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test the La Marzocco sensors."""
with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]):
await async_init_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
"device_fixture",
[ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI, ModelName.LINEA_MICRA],
)
async def test_steam_ready_entity_for_all_machines(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test the La Marzocco steam ready sensor for all machines."""
serial_number = mock_lamarzocco.serial_number
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(f"sensor.{serial_number}_steam_boiler_ready_time")
assert state
entry = entity_registry.async_get(state.entity_id)
assert entry

View File

@ -1,8 +1,16 @@
"""Tests for the La Marzocco Update Entities."""
from unittest.mock import MagicMock, patch
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from pylamarzocco.const import (
FirmwareType,
UpdateCommandStatus,
UpdateProgressInfo,
UpdateStatus,
)
from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import UpdateDetails
import pytest
from syrupy import SnapshotAssertion
@ -15,6 +23,17 @@ from homeassistant.helpers import entity_registry as er
from . import async_init_integration
from tests.common import MockConfigEntry, snapshot_platform
from tests.typing import WebSocketGenerator
@pytest.fixture(autouse=True)
def mock_sleep() -> Generator[AsyncMock]:
"""Mock asyncio.sleep."""
with patch(
"homeassistant.components.lamarzocco.update.asyncio.sleep",
return_value=AsyncMock(),
) as mock_sleep:
yield mock_sleep
async def test_update(
@ -29,17 +48,51 @@ async def test_update(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_update_entites(
async def test_update_process(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the La Marzocco update entities."""
serial_number = mock_lamarzocco.serial_number
mock_lamarzocco.get_firmware.side_effect = [
UpdateDetails(
status=UpdateStatus.TO_UPDATE,
command_status=UpdateCommandStatus.IN_PROGRESS,
progress_info=UpdateProgressInfo.STARTING_PROCESS,
progress_percentage=0,
),
UpdateDetails(
status=UpdateStatus.UPDATED,
command_status=None,
progress_info=None,
progress_percentage=None,
),
]
await async_init_integration(hass, mock_config_entry)
client = await hass_ws_client(hass)
await hass.async_block_till_done()
await client.send_json(
{
"id": 1,
"type": "update/release_notes",
"entity_id": f"update.{serial_number}_gateway_firmware",
}
)
result = await client.receive_json()
assert (
mock_lamarzocco.settings.firmwares[
FirmwareType.GATEWAY
].available_update.change_log
in result["result"]
)
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
@ -76,3 +129,35 @@ async def test_update_error(
blocking=True,
)
assert exc_info.value.translation_key == "update_failed"
async def test_update_times_out(
hass: HomeAssistant,
mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test error during update."""
mock_lamarzocco.get_firmware.return_value = UpdateDetails(
status=UpdateStatus.TO_UPDATE,
command_status=UpdateCommandStatus.IN_PROGRESS,
progress_info=UpdateProgressInfo.STARTING_PROCESS,
progress_percentage=0,
)
await async_init_integration(hass, mock_config_entry)
state = hass.states.get(f"update.{mock_lamarzocco.serial_number}_gateway_firmware")
assert state
with (
patch("homeassistant.components.lamarzocco.update.MAX_UPDATE_WAIT", 0),
pytest.raises(HomeAssistantError) as exc_info,
):
await hass.services.async_call(
UPDATE_DOMAIN,
SERVICE_INSTALL,
{
ATTR_ENTITY_ID: f"update.{mock_lamarzocco.serial_number}_gateway_firmware",
},
blocking=True,
)
assert exc_info.value.translation_key == "update_failed"

View File

@ -68,7 +68,7 @@ def mock_uuid() -> Generator[AsyncMock]:
@pytest.fixture
def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]:
def mock_config_thinq_api() -> Generator[AsyncMock]:
"""Mock a thinq api."""
with (
patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api,
@ -77,6 +77,26 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]:
new=mock_api,
),
):
thinq_api = mock_api.return_value
thinq_api.async_get_device_list.return_value = ["air_conditioner"]
yield thinq_api
@pytest.fixture
def mock_invalid_thinq_api(mock_config_thinq_api: AsyncMock) -> AsyncMock:
"""Mock an invalid thinq api."""
mock_config_thinq_api.async_get_device_list = AsyncMock(
side_effect=ThinQAPIException(
code="1309", message="Not allowed api call", headers=None
)
)
return mock_config_thinq_api
@pytest.fixture
def mock_thinq_api() -> Generator[AsyncMock]:
"""Mock a thinq api."""
with patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api:
thinq_api = mock_api.return_value
thinq_api.async_get_device_list.return_value = [
load_json_object_fixture("air_conditioner/device.json", DOMAIN)
@ -92,19 +112,10 @@ def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]:
@pytest.fixture
def mock_thinq_mqtt_client() -> Generator[AsyncMock]:
"""Mock a thinq api."""
"""Mock a thinq mqtt client."""
with patch(
"homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True
) as mock_api:
yield mock_api
@pytest.fixture
def mock_invalid_thinq_api(mock_thinq_api: AsyncMock) -> AsyncMock:
"""Mock an invalid thinq api."""
mock_thinq_api.async_get_device_list = AsyncMock(
side_effect=ThinQAPIException(
code="1309", message="Not allowed api call", headers=None
)
)
return mock_thinq_api
"homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient",
autospec=True,
return_value=True,
):
yield

View File

@ -15,7 +15,7 @@ from tests.common import MockConfigEntry
async def test_config_flow(
hass: HomeAssistant,
mock_thinq_api: AsyncMock,
mock_config_thinq_api: AsyncMock,
mock_uuid: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
@ -37,11 +37,12 @@ async def test_config_flow(
CONF_CONNECT_CLIENT_ID: MOCK_CONNECT_CLIENT_ID,
}
mock_thinq_api.async_get_device_list.assert_called_once()
mock_config_thinq_api.async_get_device_list.assert_called_once()
async def test_config_flow_invalid_pat(
hass: HomeAssistant, mock_invalid_thinq_api: AsyncMock
hass: HomeAssistant,
mock_invalid_thinq_api: AsyncMock,
) -> None:
"""Test that an thinq flow should be aborted with an invalid PAT."""
result = await hass.config_entries.flow.async_init(
@ -55,7 +56,9 @@ async def test_config_flow_invalid_pat(
async def test_config_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_thinq_api: AsyncMock
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_config_thinq_api: AsyncMock,
) -> None:
"""Test that thinq flow should be aborted when already configured."""
mock_config_entry.add_to_hass(hass)

View File

@ -1,22 +1,29 @@
"""Tests for the LG ThinQ integration."""
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
async def test_load_unload_entry(
hass: HomeAssistant,
mock_thinq_api: AsyncMock,
mock_thinq_mqtt_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
with patch(
"homeassistant.components.lg_thinq.ThinQMQTT.async_connect",
return_value=True,
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
@ -24,3 +31,21 @@ async def test_load_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize("exception", [AttributeError(), TypeError(), ValueError()])
async def test_config_not_ready(
hass: HomeAssistant,
mock_thinq_api: AsyncMock,
mock_thinq_mqtt_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
) -> None:
"""Test for setup failure exception occurred."""
with patch(
"homeassistant.components.lg_thinq.ThinQMQTT.async_connect",
side_effect=exception,
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -111,7 +111,7 @@ async def test_options_unsupported_model(
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_CHAT_MODEL: "o1-mini",
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
},
)
await hass.async_block_till_done()
@ -168,7 +168,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
(
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla",
},
{
@ -202,6 +201,18 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
CONF_WEB_SEARCH_CONTEXT_SIZE: "medium",
CONF_WEB_SEARCH_USER_LOCATION: False,
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
),
(
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
@ -209,7 +220,12 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
),
@ -338,7 +354,7 @@ async def test_options_web_search_unsupported_model(
CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate",
CONF_CHAT_MODEL: "o1-pro",
CONF_LLM_HASS_API: "assist",
CONF_LLM_HASS_API: ["assist"],
CONF_WEB_SEARCH: True,
},
)

View File

@ -0,0 +1,291 @@
{
"accountId": "account-id-2",
"country": "IT",
"vehicleLinks": [
{
"brand": "RENAULT",
"vin": "VF1AAAAA555777999",
"status": "ACTIVE",
"linkType": "OWNER",
"garageBrand": "RENAULT",
"annualMileage": 16000,
"mileage": 26464,
"startDate": "2017-08-07",
"createdDate": "2019-05-23T21:38:16.409008Z",
"lastModifiedDate": "2020-11-17T08:41:40.497400Z",
"ownershipStartDate": "2017-08-01",
"cancellationReason": {},
"connectedDriver": {
"role": "MAIN_DRIVER",
"createdDate": "2019-06-17T09:49:06.880627Z",
"lastModifiedDate": "2019-06-17T09:49:06.880627Z"
},
"vehicleDetails": {
"vin": "VF1AAAAA555777999",
"registrationDate": "2017-08-01",
"firstRegistrationDate": "2017-08-01",
"engineType": "5AQ",
"engineRatio": "601",
"modelSCR": "ZOE",
"deliveryCountry": {
"code": "FR",
"label": "FRANCE"
},
"family": {
"code": "X10",
"label": "FAMILLE X10",
"group": "007"
},
"tcu": {
"code": "TCU0G2",
"label": "TCU VER 0 GEN 2",
"group": "E70"
},
"navigationAssistanceLevel": {
"code": "NAV3G5",
"label": "LEVEL 3 TYPE 5 NAVIGATION",
"group": "408"
},
"battery": {
"code": "BT4AR1",
"label": "BATTERIE BT4AR1",
"group": "968"
},
"radioType": {
"code": "RAD37A",
"label": "RADIO 37A",
"group": "425"
},
"registrationCountry": {
"code": "FR"
},
"brand": {
"label": "RENAULT"
},
"model": {
"code": "X101VE",
"label": "ZOE",
"group": "971"
},
"gearbox": {
"code": "BVEL",
"label": "BOITE A VARIATEUR ELECTRIQUE",
"group": "427"
},
"version": {
"code": "INT MB 10R"
},
"energy": {
"code": "ELEC",
"label": "ELECTRIQUE",
"group": "019"
},
"registrationNumber": "REG-NUMBER",
"vcd": "SYTINC/SKTPOU/SAND41/FDIU1/SSESM/MAPSUP/SSCALL/SAND88/SAND90/SQKDRO/SDIFPA/FACBA2/PRLEX1/SSRCAR/CABDO2/TCU0G2/SWALBO/EVTEC1/STANDA/X10/B10/EA2/MB/ELEC/DG/TEMP/TR4X2/RV/ABS/CAREG/LAC/VT003/CPE/RET03/SPROJA/RALU16/CEAVRH/AIRBA1/SERIE/DRA/DRAP08/HARM02/ATAR/TERQG/SFBANA/KM/DPRPN/AVREPL/SSDECA/ASRESP/RDAR02/ALEVA/CACBL2/SOP02C/CTHAB2/TRNOR/LVAVIP/LVAREL/SASURV/KTGREP/SGSCHA/APL03/ALOUCC/CMAR3P/NAV3G5/RAD37A/BVEL/AUTAUG/RNORM/ISOFIX/EQPEUR/HRGM01/SDPCLV/TLFRAN/SPRODI/SAN613/SSAPEX/GENEV1/ELC1/SANCML/PE2012/PHAS1/SAN913/045KWH/BT4AR1/VEC153/X101VE/NBT017/5AQ",
"assets": [
{
"assetType": "PICTURE",
"renditions": [
{
"resolutionType": "ONE_MYRENAULT_LARGE",
"url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE"
},
{
"resolutionType": "ONE_MYRENAULT_SMALL",
"url": "https://3dv2.renault.com/ImageFromBookmark?configuration=SKTPOU%2FPRLEX1%2FSTANDA%2FB10%2FEA2%2FDG%2FVT003%2FRET03%2FRALU16%2FDRAP08%2FHARM02%2FTERQG%2FRDAR02%2FALEVA%2FSOP02C%2FTRNOR%2FLVAVIP%2FLVAREL%2FNAV3G5%2FRAD37A%2FSDPCLV%2FTLFRAN%2FGENEV1%2FSAN913%2FBT4AR1%2FNBT017&databaseId=1d514feb-93a6-4b45-8785-e11d2a6f1864&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2"
}
]
},
{
"assetType": "PDF",
"assetRole": "GUIDE",
"title": "PDF Guide",
"description": "",
"renditions": [
{
"url": "https://cdn.group.renault.com/ren/gb/myr/assets/x101ve/manual.pdf.asset.pdf/1558704861676.pdf"
}
]
},
{
"assetType": "URL",
"assetRole": "GUIDE",
"title": "e-guide",
"description": "",
"renditions": [
{
"url": "http://gb.e-guide.renault.com/eng/Zoe"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "10 Fundamentals about getting the best out of your electric vehicle",
"description": "",
"renditions": [
{
"url": "39r6QEKcOM4"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Automatic Climate Control",
"description": "",
"renditions": [
{
"url": "Va2FnZFo_GE"
}
]
},
{
"assetType": "URL",
"assetRole": "CAR",
"title": "More videos",
"description": "",
"renditions": [
{
"url": "https://www.youtube.com/watch?v=wfpCMkK1rKI"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Charging the battery",
"description": "",
"renditions": [
{
"url": "RaEad8DjUJs"
}
]
},
{
"assetType": "VIDEO",
"assetRole": "CAR",
"title": "Charging the battery at a station with a flap",
"description": "",
"renditions": [
{
"url": "zJfd7fJWtr0"
}
]
}
],
"yearsOfMaintenance": 12,
"connectivityTechnology": "RLINK1",
"easyConnectStore": false,
"electrical": true,
"rlinkStore": false,
"deliveryDate": "2017-08-11",
"retrievedFromDhs": false,
"engineEnergyType": "ELEC",
"radioCode": "1234"
}
},
{
"brand": "RENAULT",
"vin": "VF1AAAAA555777123",
"status": "ACTIVE",
"linkType": "USER",
"garageBrand": "RENAULT",
"mileage": 346,
"startDate": "2020-06-12",
"createdDate": "2020-06-12T15:02:00.555432Z",
"lastModifiedDate": "2020-06-15T06:21:43.762467Z",
"cancellationReason": {},
"connectedDriver": {
"role": "MAIN_DRIVER",
"createdDate": "2020-06-15T06:20:39.107794Z",
"lastModifiedDate": "2020-06-15T06:20:39.107794Z"
},
"vehicleDetails": {
"vin": "VF1AAAAA555777123",
"engineType": "H5H",
"engineRatio": "470",
"modelSCR": "CP1",
"deliveryCountry": {
"code": "BE",
"label": "BELGIQUE"
},
"family": {
"code": "XJB",
"label": "FAMILLE B+X OVER",
"group": "007"
},
"tcu": {
"code": "AIVCT",
"label": "AVEC BOITIER CONNECT AIVC",
"group": "E70"
},
"navigationAssistanceLevel": {
"code": "",
"label": "",
"group": ""
},
"battery": {
"code": "SANBAT",
"label": "SANS BATTERIE",
"group": "968"
},
"radioType": {
"code": "NA406",
"label": "A-IVIMINDL, 2BO + 2BI + 2T, MICRO-DOUBLE, FM1/DAB+FM2",
"group": "425"
},
"registrationCountry": {
"code": "BE"
},
"brand": {
"label": "RENAULT"
},
"model": {
"code": "XJB1SU",
"label": "CAPTUR II",
"group": "971"
},
"gearbox": {
"code": "BVA7",
"label": "BOITE DE VITESSE AUTOMATIQUE 7 RAPPORTS",
"group": "427"
},
"version": {
"code": "ITAMFHA 6TH"
},
"energy": {
"code": "ESS",
"label": "ESSENCE",
"group": "019"
},
"registrationNumber": "REG-NUMBER",
"vcd": "ADR00/DLIGM2/PGPRT2/FEUAR3/CDVIT1/SKTPOU/SKTPGR/SSCCPC/SSPREM/FDIU2/MAPSTD/RCALL/MET04/DANGMO/ECOMOD/SSRCAR/AIVCT/AVGSI/TPRPE/TSGNE/2TON/ITPK7/MLEXP1/SPERTA/SSPERG/SPERTP/VOLCHA/SREACT/AVOSP1/SWALBO/DWGE01/AVC1A/1234Y/AEBS07/PRAHL/AVCAM/STANDA/XJB/HJB/EA3/MF/ESS/DG/TEMP/TR4X2/AFURGE/RVDIST/ABS/SBARTO/CA02/TOPAN/PBNCH/LAC/VSTLAR/CPE/RET04/2RVLG/RALU17/CEAVRH/AIRBA2/SERIE/DRA/DRAP05/HARM01/ATAR03/SGAV02/SGAR02/BIXPE/BANAL/KM/TPRM3/AVREPL/SSDECA/SFIRBA/ABLAVI/ESPHSA/FPAS2/ALEVA/SCACBA/SOP03C/SSADPC/STHPLG/SKTGRV/VLCUIR/RETIN2/TRSEV1/REPNTC/LVAVIP/LVAREI/SASURV/KTGREP/SGACHA/BEL01/APL03/FSTPO/ALOUC5/CMAR3P/FIPOU2/NA406/BVA7/ECLHB4/RDIF10/PNSTRD/ISOFIX/ENPH01/HRGM01/SANFLT/CSRGAC/SANACF/SDPCLV/TLRP00/SPRODI/SAN613/AVFAP/AIRBDE/CHC03/E06T/SAN806/SSPTLP/SANCML/SSFLEX/SDRQAR/SEXTIN/M2019/PHAS1/SPRTQT/SAN913/STHABT/SSTYAD/HYB01/SSCABA/SANBAT/VEC012/XJB1SU/SSNBT/H5H",
"assets": [
{
"assetType": "PICTURE",
"renditions": [
{
"resolutionType": "ONE_MYRENAULT_LARGE",
"url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_LARGE"
},
{
"resolutionType": "ONE_MYRENAULT_SMALL",
"url": "https: //3dv2.renault.com/ImageFromBookmark?configuration=ADR00%2FDLIGM2%2FPGPRT2%2FFEUAR3%2FCDVIT1%2FSSCCPC%2FRCALL%2FMET04%2FDANGMO%2FSSRCAR%2FAVGSI%2FITPK7%2FMLEXP1%2FSPERTA%2FSSPERG%2FSPERTP%2FVOLCHA%2FSREACT%2FDWGE01%2FAVCAM%2FHJB%2FEA3%2FESS%2FDG%2FTEMP%2FTR4X2%2FRVDIST%2FSBARTO%2FCA02%2FTOPAN%2FPBNCH%2FVSTLAR%2FCPE%2FRET04%2FRALU17%2FDRA%2FDRAP05%2FHARM01%2FBIXPE%2FKM%2FSSDECA%2FESPHSA%2FFPAS2%2FALEVA%2FSOP03C%2FSSADPC%2FVLCUIR%2FRETIN2%2FREPNTC%2FLVAVIP%2FLVAREI%2FSGACHA%2FALOUC5%2FNA406%2FBVA7%2FECLHB4%2FRDIF10%2FCSRGAC%2FSANACF%2FTLRP00%2FAIRBDE%2FCHC03%2FSSPTLP%2FSPRTQT%2FSAN913%2FHYB01%2FH5H&databaseId=3e814da7-766d-4039-ac69-f001a1f738c8&bookmarkSet=RSITE&bookmark=EXT_34_DESSUS&profile=HELIOS_OWNERSERVICES_SMALL_V2"
}
]
}
],
"yearsOfMaintenance": 12,
"connectivityTechnology": "NONE",
"easyConnectStore": false,
"electrical": false,
"rlinkStore": false,
"deliveryDate": "2020-06-17",
"retrievedFromDhs": false,
"engineEnergyType": "OTHER",
"radioCode": "1234"
}
}
]
}

View File

@ -2,11 +2,15 @@
from collections.abc import Generator
import datetime
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from renault_api.kamereon.exceptions import QuotaLimitException
from renault_api.kamereon.exceptions import (
AccessDeniedException,
NotSupportedException,
QuotaLimitException,
)
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntry
@ -241,3 +245,89 @@ async def test_sensor_throttling_after_init(
assert not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert "Renault API throttled" not in caplog.text
assert "Renault hub currently throttled: scan skipped" not in caplog.text
# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS
# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour
# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_40", 1, 300), # 5 coordinators => 5 minutes interval
("captur_fuel", 1, 240), # 4 coordinators => 4 minutes interval
("multi", 2, 540), # 9 coordinators => 9 minutes interval
],
indirect=["vehicle_type"],
)
async def test_dynamic_scan_interval(
hass: HomeAssistant,
config_entry: ConfigEntry,
vehicle_count: int,
scan_interval: int,
freezer: FrozenDateTimeFactory,
fixtures_with_data: dict[str, AsyncMock],
) -> None:
"""Test scan interval."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds before the expected scan interval > not called
freezer.tick(datetime.timedelta(seconds=scan_interval - 2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds after the expected scan interval > called
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2
# scan interval in seconds = (3600 * num_calls) / MAX_CALLS_PER_HOURS
# MAX_CALLS_PER_HOURS being a constant, for now 60 calls per hour
# num_calls = num_coordinator_car_0 + num_coordinator_car_1 + ... + num_coordinator_car_n
@pytest.mark.parametrize(
("vehicle_type", "vehicle_count", "scan_interval"),
[
("zoe_40", 1, 240), # (5-1) coordinators => 4 minutes interval
("captur_fuel", 1, 180), # (4-1) coordinators => 3 minutes interval
("multi", 2, 420), # (9-2) coordinators => 7 minutes interval
],
indirect=["vehicle_type"],
)
async def test_dynamic_scan_interval_failed_coordinator(
hass: HomeAssistant,
config_entry: ConfigEntry,
vehicle_count: int,
scan_interval: int,
freezer: FrozenDateTimeFactory,
fixtures_with_data: dict[str, AsyncMock],
) -> None:
"""Test scan interval."""
fixtures_with_data["battery_status"].side_effect = NotSupportedException(
"err.tech.501",
"This feature is not technically supported by this gateway",
)
fixtures_with_data["lock_status"].side_effect = AccessDeniedException(
"err.func.403",
"Access is denied for this resource",
)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds before the expected scan interval > not called
freezer.tick(datetime.timedelta(seconds=scan_interval - 2))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count
# 2 seconds after the expected scan interval > called
freezer.tick(datetime.timedelta(seconds=4))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert fixtures_with_data["cockpit"].call_count == vehicle_count * 2

View File

@ -107,7 +107,9 @@ def mock_smartthings() -> Generator[AsyncMock]:
"centralite",
"da_ref_normal_000001",
"da_ref_normal_01011",
"da_ref_normal_01001",
"vd_network_audio_002s",
"vd_network_audio_003s",
"vd_sensor_light_2023",
"iphone",
"da_sac_ehs_000001_sub",

View File

@ -0,0 +1,929 @@
{
"components": {
"pantry-01": {
"samsungce.foodDefrost": {
"supportedOptions": {
"value": null
},
"foodType": {
"value": null
},
"weight": {
"value": null
},
"operationTime": {
"value": null
},
"remainingTime": {
"value": null
}
},
"samsungce.fridgePantryInfo": {
"name": {
"value": null
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": ["samsungce.meatAging", "samsungce.foodDefrost"],
"timestamp": "2022-02-07T10:54:05.580Z"
}
},
"samsungce.meatAging": {
"zoneInfo": {
"value": null
},
"supportedMeatTypes": {
"value": null
},
"supportedAgingMethods": {
"value": null
},
"status": {
"value": null
}
},
"samsungce.fridgePantryMode": {
"mode": {
"value": null
},
"supportedModes": {
"value": null
}
}
},
"icemaker": {
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2022-02-07T10:54:05.580Z"
}
},
"switch": {
"switch": {
"value": "on",
"timestamp": "2025-02-07T12:01:52.528Z"
}
}
},
"scale-10": {
"samsungce.connectionState": {
"connectionState": {
"value": null
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2022-02-07T10:54:05.580Z"
}
},
"samsungce.weightMeasurement": {
"weight": {
"value": null
}
},
"samsungce.scaleSettings": {
"enabled": {
"value": null
}
},
"samsungce.weightMeasurementCalibration": {}
},
"scale-11": {
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2022-02-07T10:54:05.580Z"
}
},
"samsungce.weightMeasurement": {
"weight": {
"value": null
}
}
},
"camera-01": {
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": ["switch"],
"timestamp": "2023-12-17T11:19:18.845Z"
}
},
"switch": {
"switch": {
"value": null
}
}
},
"cooler": {
"contactSensor": {
"contact": {
"value": "closed",
"timestamp": "2025-02-09T00:23:41.655Z"
}
},
"samsungce.unavailableCapabilities": {
"unavailableCommands": {
"value": [],
"timestamp": "2024-11-06T12:35:50.411Z"
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2024-06-17T06:16:33.918Z"
}
},
"temperatureMeasurement": {
"temperatureRange": {
"value": null
},
"temperature": {
"value": 37,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
},
"custom.thermostatSetpointControl": {
"minimumSetpoint": {
"value": 34,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
},
"maximumSetpoint": {
"value": 44,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
},
"thermostatCoolingSetpoint": {
"coolingSetpointRange": {
"value": {
"minimum": 34,
"maximum": 44,
"step": 1
},
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
},
"coolingSetpoint": {
"value": 37,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
}
},
"freezer": {
"contactSensor": {
"contact": {
"value": "closed",
"timestamp": "2025-02-09T00:00:44.267Z"
}
},
"samsungce.unavailableCapabilities": {
"unavailableCommands": {
"value": [],
"timestamp": "2024-11-06T12:35:50.411Z"
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": ["samsungce.freezerConvertMode"],
"timestamp": "2024-11-06T09:00:29.743Z"
}
},
"temperatureMeasurement": {
"temperatureRange": {
"value": null
},
"temperature": {
"value": 0,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
},
"custom.thermostatSetpointControl": {
"minimumSetpoint": {
"value": -8,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
},
"maximumSetpoint": {
"value": 5,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
},
"samsungce.freezerConvertMode": {
"supportedFreezerConvertModes": {
"value": [],
"timestamp": "2025-02-01T19:39:00.448Z"
},
"freezerConvertMode": {
"value": null
}
},
"thermostatCoolingSetpoint": {
"coolingSetpointRange": {
"value": {
"minimum": -8,
"maximum": 5,
"step": 1
},
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
},
"coolingSetpoint": {
"value": 0,
"unit": "F",
"timestamp": "2025-02-01T19:39:00.493Z"
}
}
},
"main": {
"contactSensor": {
"contact": {
"value": "closed",
"timestamp": "2025-02-09T00:23:41.655Z"
}
},
"samsungce.viewInside": {
"supportedFocusAreas": {
"value": ["mainShelves"],
"timestamp": "2025-02-01T19:39:00.946Z"
},
"contents": {
"value": [
{
"fileId": "d3e1f875-f8b3-a031-737b-366eaa227773",
"mimeType": "image/jpeg",
"expiredTime": "2025-01-20T16:17:04Z",
"focusArea": "mainShelves"
},
{
"fileId": "9fccb6b4-e71f-6c7f-9935-f6082bb6ccfe",
"mimeType": "image/jpeg",
"expiredTime": "2025-01-20T16:17:04Z",
"focusArea": "mainShelves"
},
{
"fileId": "20b57a4d-b7fc-17fc-3a03-0fb84fb4efab",
"mimeType": "image/jpeg",
"expiredTime": "2025-01-20T16:17:05Z",
"focusArea": "mainShelves"
}
],
"timestamp": "2025-01-20T16:07:05.423Z"
},
"lastUpdatedTime": {
"value": "2025-02-07T12:01:52Z",
"timestamp": "2025-02-07T12:01:52.585Z"
}
},
"samsungce.fridgeFoodList": {
"outOfSyncChanges": {
"value": null
},
"refreshResult": {
"value": null
}
},
"samsungce.deviceIdentification": {
"micomAssayCode": {
"value": null
},
"modelName": {
"value": null
},
"serialNumber": {
"value": null
},
"serialNumberExtra": {
"value": null
},
"modelClassificationCode": {
"value": null
},
"description": {
"value": null
},
"releaseYear": {
"value": 19,
"timestamp": "2024-11-06T09:00:29.743Z"
},
"binaryId": {
"value": "24K_REF_LCD_FHUB9.0",
"timestamp": "2025-02-07T12:01:53.067Z"
}
},
"samsungce.quickControl": {
"version": {
"value": "1.0",
"timestamp": "2025-02-01T19:39:01.848Z"
}
},
"custom.fridgeMode": {
"fridgeModeValue": {
"value": null
},
"fridgeMode": {
"value": null
},
"supportedFridgeModes": {
"value": null
}
},
"ocf": {
"st": {
"value": "2024-11-08T11:56:59Z",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mndt": {
"value": "",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnfv": {
"value": "20240616.213423",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnhw": {
"value": "",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"di": {
"value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnsl": {
"value": "",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"dmv": {
"value": "res.1.1.0,sh.1.1.0",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"n": {
"value": "Family Hub",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnmo": {
"value": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"vid": {
"value": "DA-REF-NORMAL-01001",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnmn": {
"value": "Samsung Electronics",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnml": {
"value": "",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnpv": {
"value": "7.0",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"mnos": {
"value": "Tizen",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"pi": {
"value": "7d3feb98-8a36-4351-c362-5e21ad3a78dd",
"timestamp": "2025-01-02T12:37:43.756Z"
},
"icv": {
"value": "core.1.1.0",
"timestamp": "2025-01-02T12:37:43.756Z"
}
},
"samsungce.fridgeVacationMode": {
"vacationMode": {
"value": null
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [
"thermostatCoolingSetpoint",
"temperatureMeasurement",
"custom.fridgeMode",
"custom.deviceReportStateConfiguration",
"samsungce.fridgeFoodList",
"samsungce.runestoneHomeContext",
"demandResponseLoadControl",
"samsungce.fridgeVacationMode",
"samsungce.sabbathMode"
],
"timestamp": "2025-02-08T23:57:45.739Z"
}
},
"samsungce.driverVersion": {
"versionNumber": {
"value": 24090102,
"timestamp": "2024-11-06T09:00:29.743Z"
}
},
"sec.diagnosticsInformation": {
"logType": {
"value": ["errCode", "dump"],
"timestamp": "2025-02-01T19:39:00.523Z"
},
"endpoint": {
"value": "SSM",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"minVersion": {
"value": "1.0",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"signinPermission": {
"value": null
},
"setupId": {
"value": "500",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"protocolType": {
"value": "wifi_https",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"tsId": {
"value": null
},
"mnId": {
"value": "0AJT",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"dumpType": {
"value": "file",
"timestamp": "2025-02-01T19:39:00.523Z"
}
},
"temperatureMeasurement": {
"temperatureRange": {
"value": null
},
"temperature": {
"value": null
}
},
"custom.deviceReportStateConfiguration": {
"reportStateRealtimePeriod": {
"value": null
},
"reportStateRealtime": {
"value": {
"state": "disabled"
},
"timestamp": "2025-02-01T19:39:00.345Z"
},
"reportStatePeriod": {
"value": "enabled",
"timestamp": "2025-02-01T19:39:00.345Z"
}
},
"thermostatCoolingSetpoint": {
"coolingSetpointRange": {
"value": null
},
"coolingSetpoint": {
"value": null
}
},
"custom.disabledComponents": {
"disabledComponents": {
"value": [
"icemaker-02",
"icemaker-03",
"pantry-01",
"camera-01",
"scale-10",
"scale-11"
],
"timestamp": "2025-02-07T12:01:52.638Z"
}
},
"demandResponseLoadControl": {
"drlcStatus": {
"value": {
"drlcType": 1,
"drlcLevel": 0,
"duration": 0,
"override": false
},
"timestamp": "2025-02-01T19:38:59.899Z"
}
},
"samsungce.sabbathMode": {
"supportedActions": {
"value": null
},
"status": {
"value": null
}
},
"powerConsumptionReport": {
"powerConsumption": {
"value": {
"energy": 4381422,
"deltaEnergy": 27,
"power": 144,
"powerEnergy": 27.01890500307083,
"persistedEnergy": 0,
"energySaved": 0,
"start": "2025-02-09T00:13:39Z",
"end": "2025-02-09T00:25:23Z"
},
"timestamp": "2025-02-09T00:25:23.843Z"
}
},
"refresh": {},
"samsungce.runestoneHomeContext": {
"supportedContexts": {
"value": [
{
"context": "HOME_IN",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "ASLEEP",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "AWAKE",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "COOKING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_COOKING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "EATING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_EATING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "DOING_LAUNDRY",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_DOING_LAUNDRY",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "CLEANING_HOUSE",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_CLEANING_HOUSE",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "MUSIC_LISTENING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_MUSIC_LISTENING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "AIR_CONDITIONING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_AIR_CONDITIONING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "WASHING_DISHES",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_WASHING_DISHES",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "CARING_CLOTHING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_CARING_CLOTHING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "WATCHING_TV",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_WATCHING_TV",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "BEFORE_BEDTIME",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "BEFORE_COOKING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "BEFORE_HOME_OUT",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "ORDERING_DELIVERY_FOOD",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_ORDERING_DELIVERY_FOOD",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "ONLINE_GROCERY_SHOPPING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
},
{
"context": "FINISH_ONLINE_GROCERY_SHOPPING",
"place": "HOME",
"startTime": "99:99",
"endTime": "99:99"
}
],
"timestamp": "2025-02-01T19:39:02.150Z"
}
},
"execute": {
"data": {
"value": {
"payload": {
"rt": ["x.com.samsung.da.fridge"],
"if": ["oic.if.a"],
"x.com.samsung.da.rapidFridge": "Off",
"x.com.samsung.da.rapidFreezing": "Off"
}
},
"data": {
"href": "/refrigeration/vs/0"
},
"timestamp": "2024-03-26T09:06:17.169Z"
}
},
"sec.wifiConfiguration": {
"autoReconnection": {
"value": true,
"timestamp": "2025-02-01T19:39:01.951Z"
},
"minVersion": {
"value": "1.0",
"timestamp": "2025-02-01T19:39:01.951Z"
},
"supportedWiFiFreq": {
"value": ["2.4G", "5G"],
"timestamp": "2025-02-01T19:39:01.951Z"
},
"supportedAuthType": {
"value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK"],
"timestamp": "2025-02-01T19:39:01.951Z"
},
"protocolType": {
"value": ["helper_hotspot"],
"timestamp": "2025-02-01T19:39:01.951Z"
}
},
"refrigeration": {
"defrost": {
"value": "off",
"timestamp": "2025-02-01T19:38:59.276Z"
},
"rapidCooling": {
"value": "off",
"timestamp": "2025-02-01T19:39:00.497Z"
},
"rapidFreezing": {
"value": "off",
"timestamp": "2025-02-01T19:39:00.497Z"
}
},
"samsungce.powerCool": {
"activated": {
"value": false,
"timestamp": "2025-02-01T19:39:00.497Z"
}
},
"custom.energyType": {
"energyType": {
"value": "2.0",
"timestamp": "2022-02-07T10:54:05.580Z"
},
"energySavingSupport": {
"value": false,
"timestamp": "2022-02-07T10:57:35.490Z"
},
"drMaxDuration": {
"value": 1440,
"unit": "min",
"timestamp": "2022-02-07T11:50:40.228Z"
},
"energySavingLevel": {
"value": null
},
"energySavingInfo": {
"value": null
},
"supportedEnergySavingLevels": {
"value": null
},
"energySavingOperation": {
"value": null
},
"notificationTemplateID": {
"value": null
},
"energySavingOperationSupport": {
"value": false,
"timestamp": "2022-02-07T11:50:40.228Z"
}
},
"samsungce.softwareUpdate": {
"targetModule": {
"value": {},
"timestamp": "2025-02-01T19:39:00.200Z"
},
"otnDUID": {
"value": "2DCEZFTFQZPMO",
"timestamp": "2025-02-01T19:39:00.523Z"
},
"lastUpdatedDate": {
"value": null
},
"availableModules": {
"value": [],
"timestamp": "2025-02-01T19:39:00.523Z"
},
"newVersionAvailable": {
"value": false,
"timestamp": "2025-02-01T19:39:00.200Z"
},
"operatingState": {
"value": null
},
"progress": {
"value": null
}
},
"samsungce.powerFreeze": {
"activated": {
"value": false,
"timestamp": "2025-02-01T19:39:00.497Z"
}
},
"custom.waterFilter": {
"waterFilterUsageStep": {
"value": 1,
"timestamp": "2025-02-01T19:38:59.973Z"
},
"waterFilterResetType": {
"value": ["replaceable"],
"timestamp": "2025-02-01T19:38:59.973Z"
},
"waterFilterCapacity": {
"value": null
},
"waterFilterLastResetDate": {
"value": null
},
"waterFilterUsage": {
"value": 52,
"timestamp": "2025-02-08T05:06:45.769Z"
},
"waterFilterStatus": {
"value": "normal",
"timestamp": "2025-02-01T19:38:59.973Z"
}
}
},
"cvroom": {
"custom.fridgeMode": {
"fridgeModeValue": {
"value": null
},
"fridgeMode": {
"value": "CV_FDR_DELI",
"timestamp": "2025-02-01T19:39:00.448Z"
},
"supportedFridgeModes": {
"value": [
"CV_FDR_WINE",
"CV_FDR_DELI",
"CV_FDR_BEVERAGE",
"CV_FDR_MEAT"
],
"timestamp": "2025-02-01T19:39:00.448Z"
}
},
"contactSensor": {
"contact": {
"value": "closed",
"timestamp": "2025-02-08T23:22:04.631Z"
}
},
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2021-07-27T01:19:43.145Z"
}
}
},
"icemaker-02": {
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2022-07-28T18:47:07.039Z"
}
},
"switch": {
"switch": {
"value": null
}
}
},
"icemaker-03": {
"custom.disabledCapabilities": {
"disabledCapabilities": {
"value": [],
"timestamp": "2023-12-15T01:05:09.803Z"
}
},
"switch": {
"switch": {
"value": null
}
}
}
}
}

View File

@ -0,0 +1,231 @@
{
"components": {
"main": {
"samsungvd.soundFrom": {
"mode": {
"value": 29,
"timestamp": "2025-04-05T13:51:47.865Z"
},
"detailName": {
"value": "None",
"timestamp": "2025-04-05T13:51:50.230Z"
}
},
"audioVolume": {
"volume": {
"value": 6,
"unit": "%",
"timestamp": "2025-04-17T11:17:25.272Z"
}
},
"samsungvd.audioGroupInfo": {
"role": {
"value": null
},
"channel": {
"value": null
},
"status": {
"value": null
}
},
"refresh": {},
"audioNotification": {},
"execute": {
"data": {
"value": null
}
},
"samsungvd.audioInputSource": {
"supportedInputSources": {
"value": ["D.IN", "BT", "WIFI"],
"timestamp": "2025-03-18T19:11:54.071Z"
},
"inputSource": {
"value": "D.IN",
"timestamp": "2025-04-17T11:18:02.048Z"
}
},
"switch": {
"switch": {
"value": "off",
"timestamp": "2025-04-17T14:42:04.704Z"
}
},
"sec.wifiConfiguration": {
"autoReconnection": {
"value": true,
"timestamp": "2025-03-18T19:11:54.484Z"
},
"minVersion": {
"value": "1.0",
"timestamp": "2025-03-18T19:11:54.484Z"
},
"supportedWiFiFreq": {
"value": ["2.4G", "5G"],
"timestamp": "2025-03-18T19:11:54.484Z"
},
"supportedAuthType": {
"value": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"EAP",
"SAE",
"OWE",
"FT-PSK"
],
"timestamp": "2025-03-18T19:11:54.484Z"
},
"protocolType": {
"value": ["ble_ocf"],
"timestamp": "2025-03-18T19:11:54.484Z"
}
},
"ocf": {
"st": {
"value": "1970-01-01T00:00:47Z",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mndt": {
"value": "2024-01-01",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnfv": {
"value": "SAT-MT8532D24WWC-1016.0",
"timestamp": "2025-02-21T16:47:38.134Z"
},
"mnhw": {
"value": "",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"di": {
"value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnsl": {
"value": "",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"dmv": {
"value": "res.1.1.0,sh.1.1.0",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"n": {
"value": "Soundbar",
"timestamp": "2025-02-21T16:47:38.134Z"
},
"mnmo": {
"value": "HW-S60D",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"vid": {
"value": "VD-NetworkAudio-003S",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnmn": {
"value": "Samsung Electronics",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnml": {
"value": "",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnpv": {
"value": "8.0",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"mnos": {
"value": "Tizen",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"pi": {
"value": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6",
"timestamp": "2025-02-21T15:09:52.348Z"
},
"icv": {
"value": "core.1.1.0",
"timestamp": "2025-02-21T15:09:52.348Z"
}
},
"samsungvd.supportsFeatures": {
"mediaOutputSupported": {
"value": null
},
"imeAdvSupported": {
"value": null
},
"wifiUpdateSupport": {
"value": true,
"timestamp": "2025-03-18T19:11:53.853Z"
},
"executableServiceList": {
"value": null
},
"remotelessSupported": {
"value": null
},
"artSupported": {
"value": null
},
"mobileCamSupported": {
"value": null
}
},
"sec.diagnosticsInformation": {
"logType": {
"value": ["errCode", "dump"],
"timestamp": "2025-03-18T19:11:54.336Z"
},
"endpoint": {
"value": "PIPER",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"minVersion": {
"value": "3.0",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"signinPermission": {
"value": null
},
"setupId": {
"value": "301",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"protocolType": {
"value": "ble_ocf",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"tsId": {
"value": "VD02",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"mnId": {
"value": "0AJK",
"timestamp": "2025-03-18T19:11:54.336Z"
},
"dumpType": {
"value": "file",
"timestamp": "2025-03-18T19:11:54.336Z"
}
},
"audioMute": {
"mute": {
"value": "muted",
"timestamp": "2025-04-17T11:36:04.814Z"
}
},
"samsungvd.thingStatus": {
"updatedTime": {
"value": 1744900925,
"timestamp": "2025-04-17T14:42:04.770Z"
},
"status": {
"value": "Idle",
"timestamp": "2025-03-18T19:11:54.101Z"
}
}
}
}
}

View File

@ -0,0 +1,433 @@
{
"items": [
{
"deviceId": "7d3feb98-8a36-4351-c362-5e21ad3a78dd",
"name": "Family Hub",
"label": "Refrigerator",
"manufacturerName": "Samsung Electronics",
"presentationId": "DA-REF-NORMAL-01001",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "2487472a-06c4-4bce-8f4c-700c5f8644f8",
"ownerId": "b603d7e8-6066-4e10-8102-afa752a63816",
"roomId": "acaa060a-7c19-4579-8a4a-5ad891a2f0c1",
"deviceTypeName": "Samsung OCF Refrigerator",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "contactSensor",
"version": 1
},
{
"id": "execute",
"version": 1
},
{
"id": "ocf",
"version": 1
},
{
"id": "powerConsumptionReport",
"version": 1
},
{
"id": "demandResponseLoadControl",
"version": 1
},
{
"id": "refresh",
"version": 1
},
{
"id": "refrigeration",
"version": 1
},
{
"id": "temperatureMeasurement",
"version": 1
},
{
"id": "thermostatCoolingSetpoint",
"version": 1
},
{
"id": "custom.deviceReportStateConfiguration",
"version": 1
},
{
"id": "custom.energyType",
"version": 1
},
{
"id": "custom.fridgeMode",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.disabledComponents",
"version": 1
},
{
"id": "custom.waterFilter",
"version": 1
},
{
"id": "samsungce.fridgeFoodList",
"version": 1
},
{
"id": "samsungce.softwareUpdate",
"version": 1
},
{
"id": "samsungce.deviceIdentification",
"version": 1
},
{
"id": "samsungce.driverVersion",
"version": 1
},
{
"id": "samsungce.fridgeVacationMode",
"version": 1
},
{
"id": "samsungce.powerCool",
"version": 1
},
{
"id": "samsungce.powerFreeze",
"version": 1
},
{
"id": "samsungce.sabbathMode",
"version": 1
},
{
"id": "samsungce.viewInside",
"version": 1
},
{
"id": "samsungce.runestoneHomeContext",
"version": 1
},
{
"id": "samsungce.quickControl",
"version": 1
},
{
"id": "sec.diagnosticsInformation",
"version": 1
},
{
"id": "sec.wifiConfiguration",
"version": 1
}
],
"categories": [
{
"name": "Refrigerator",
"categoryType": "manufacturer"
}
]
},
{
"id": "freezer",
"label": "freezer",
"capabilities": [
{
"id": "contactSensor",
"version": 1
},
{
"id": "temperatureMeasurement",
"version": 1
},
{
"id": "thermostatCoolingSetpoint",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.thermostatSetpointControl",
"version": 1
},
{
"id": "samsungce.freezerConvertMode",
"version": 1
},
{
"id": "samsungce.unavailableCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "cooler",
"label": "cooler",
"capabilities": [
{
"id": "contactSensor",
"version": 1
},
{
"id": "temperatureMeasurement",
"version": 1
},
{
"id": "thermostatCoolingSetpoint",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.thermostatSetpointControl",
"version": 1
},
{
"id": "samsungce.unavailableCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "cvroom",
"label": "cvroom",
"capabilities": [
{
"id": "contactSensor",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
},
{
"id": "custom.fridgeMode",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "icemaker",
"label": "icemaker",
"capabilities": [
{
"id": "switch",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "icemaker-02",
"label": "icemaker-02",
"capabilities": [
{
"id": "switch",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "icemaker-03",
"label": "icemaker-03",
"capabilities": [
{
"id": "switch",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "scale-10",
"label": "scale-10",
"capabilities": [
{
"id": "samsungce.weightMeasurement",
"version": 1
},
{
"id": "samsungce.weightMeasurementCalibration",
"version": 1
},
{
"id": "samsungce.connectionState",
"version": 1
},
{
"id": "samsungce.scaleSettings",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "scale-11",
"label": "scale-11",
"capabilities": [
{
"id": "samsungce.weightMeasurement",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "pantry-01",
"label": "pantry-01",
"capabilities": [
{
"id": "samsungce.fridgePantryInfo",
"version": 1
},
{
"id": "samsungce.fridgePantryMode",
"version": 1
},
{
"id": "samsungce.meatAging",
"version": 1
},
{
"id": "samsungce.foodDefrost",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
},
{
"id": "camera-01",
"label": "camera-01",
"capabilities": [
{
"id": "switch",
"version": 1
},
{
"id": "custom.disabledCapabilities",
"version": 1
}
],
"categories": [
{
"name": "Other",
"categoryType": "manufacturer"
}
]
}
],
"createTime": "2021-07-27T01:19:42.051Z",
"profile": {
"id": "4c654f1b-8ef4-35b0-920e-c12568554213"
},
"ocf": {
"ocfDeviceType": "oic.d.refrigerator",
"name": "Family Hub",
"specVersion": "core.1.1.0",
"verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0",
"manufacturerName": "Samsung Electronics",
"modelNumber": "24K_REF_LCD_FHUB9.0|00113141|0002034e051324200103000000000000",
"platformVersion": "7.0",
"platformOS": "Tizen",
"hwVersion": "",
"firmwareVersion": "20240616.213423",
"vendorId": "DA-REF-NORMAL-01001",
"vendorResourceClientServerVersion": "4.0.22",
"locale": "",
"lastSignupTime": "2021-07-27T01:19:40.244392Z",
"transferCandidate": false,
"additionalAuthCodeRequired": false
},
"type": "OCF",
"restrictionTier": 0,
"allowed": [],
"executionContext": "CLOUD"
}
],
"_links": {}
}

View File

@ -0,0 +1,115 @@
{
"items": [
{
"deviceId": "a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6",
"name": "Soundbar",
"label": "Soundbar",
"manufacturerName": "Samsung Electronics",
"presentationId": "VD-NetworkAudio-003S",
"deviceManufacturerCode": "Samsung Electronics",
"locationId": "6bdf6730-8167-488b-8645-d0c5046ff763",
"ownerId": "15f0ae72-da51-14e2-65cf-ef59ae867e7f",
"roomId": "3b0fe9a8-51d6-49cf-b64a-8a719013c0a7",
"deviceTypeName": "Samsung OCF Network Audio Player",
"components": [
{
"id": "main",
"label": "main",
"capabilities": [
{
"id": "ocf",
"version": 1
},
{
"id": "execute",
"version": 1
},
{
"id": "refresh",
"version": 1
},
{
"id": "switch",
"version": 1
},
{
"id": "audioVolume",
"version": 1
},
{
"id": "audioMute",
"version": 1
},
{
"id": "samsungvd.audioInputSource",
"version": 1
},
{
"id": "audioNotification",
"version": 1
},
{
"id": "samsungvd.soundFrom",
"version": 1
},
{
"id": "sec.diagnosticsInformation",
"version": 1
},
{
"id": "samsungvd.thingStatus",
"version": 1
},
{
"id": "samsungvd.supportsFeatures",
"version": 1
},
{
"id": "sec.wifiConfiguration",
"version": 1
},
{
"id": "samsungvd.audioGroupInfo",
"version": 1,
"ephemeral": true
}
],
"categories": [
{
"name": "NetworkAudio",
"categoryType": "manufacturer"
}
],
"optional": false
}
],
"createTime": "2025-02-21T14:25:21.843Z",
"profile": {
"id": "25504ad5-8563-3b07-8770-e52ad29a9c5a"
},
"ocf": {
"ocfDeviceType": "oic.d.networkaudio",
"name": "Soundbar",
"specVersion": "core.1.1.0",
"verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0",
"manufacturerName": "Samsung Electronics",
"modelNumber": "HW-S60D",
"platformVersion": "8.0",
"platformOS": "Tizen",
"hwVersion": "",
"firmwareVersion": "SAT-MT8532D24WWC-1016.0",
"vendorId": "VD-NetworkAudio-003S",
"vendorResourceClientServerVersion": "4.0.26",
"lastSignupTime": "2025-03-18T19:11:51.176292902Z",
"transferCandidate": false,
"additionalAuthCodeRequired": false
},
"type": "OCF",
"restrictionTier": 0,
"allowed": null,
"executionContext": "CLOUD",
"relationships": []
}
],
"_links": {}
}

View File

@ -761,6 +761,150 @@
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.refrigerator_cooler_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Cooler door',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'cooler_door',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cooler_contactSensor_contact_contact',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_cooler_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'Refrigerator Cooler door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.refrigerator_cooler_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.refrigerator_coolselect_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'CoolSelect+ door',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'cool_select_plus_door',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_cvroom_contactSensor_contact_contact',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_coolselect_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'Refrigerator CoolSelect+ door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.refrigerator_coolselect_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.refrigerator_freezer_door',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.DOOR: 'door'>,
'original_icon': None,
'original_name': 'Freezer door',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'freezer_door',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_freezer_contactSensor_contact_contact',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'door',
'friendly_name': 'Refrigerator Freezer door',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.refrigerator_freezer_door',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -187,3 +187,50 @@
'state': 'unknown',
})
# ---
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.refrigerator_reset_water_filter',
'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': 'Reset water filter',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'reset_water_filter',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_resetWaterFilter',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[da_ref_normal_01001][button.refrigerator_reset_water_filter-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Refrigerator Reset water filter',
}),
'context': <ANY>,
'entity_id': 'button.refrigerator_reset_water_filter',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -629,6 +629,39 @@
'via_device_id': None,
})
# ---
# name: test_devices[da_ref_normal_01001]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '',
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'7d3feb98-8a36-4351-c362-5e21ad3a78dd',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Samsung Electronics',
'model': '24K_REF_LCD_FHUB9.0',
'model_id': None,
'name': 'Refrigerator',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': '20240616.213423',
'via_device_id': None,
})
# ---
# name: test_devices[da_ref_normal_01011]
DeviceRegistryEntrySnapshot({
'area_id': None,
@ -1652,6 +1685,39 @@
'via_device_id': None,
})
# ---
# name: test_devices[vd_network_audio_003s]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://account.smartthings.com',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': '',
'id': <ANY>,
'identifiers': set({
tuple(
'smartthings',
'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Samsung Electronics',
'model': 'HW-S60D',
'model_id': None,
'name': 'Soundbar',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': 'SAT-MT8532D24WWC-1016.0',
'via_device_id': None,
})
# ---
# name: test_devices[vd_sensor_light_2023]
DeviceRegistryEntrySnapshot({
'area_id': None,

View File

@ -231,6 +231,56 @@
'state': 'on',
})
# ---
# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.soundbar',
'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': None,
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': <MediaPlayerEntityFeature: 1420>,
'translation_key': None,
'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[vd_network_audio_003s][media_player.soundbar-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'speaker',
'friendly_name': 'Soundbar',
'supported_features': <MediaPlayerEntityFeature: 1420>,
}),
'context': <ANY>,
'entity_id': 'media_player.soundbar',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -4049,6 +4049,283 @@
'state': '0.0135559777781698',
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energy_meter',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Refrigerator Energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4381.422',
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_energy_difference',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy difference',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_difference',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_difference-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Refrigerator Energy difference',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_energy_difference',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.027',
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_energy_saved',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Energy saved',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'energy_saved',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_energySaved_meter',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy_saved-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Refrigerator Energy saved',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_energy_saved',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_power',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.POWER: 'power'>,
'original_icon': None,
'original_name': 'Power',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_power_meter',
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'power',
'friendly_name': 'Refrigerator Power',
'power_consumption_end': '2025-02-09T00:25:23Z',
'power_consumption_start': '2025-02-09T00:13:39Z',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_power',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '144',
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_power_energy',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
'original_name': 'Power energy',
'platform': 'smartthings',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'power_energy',
'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_powerConsumptionReport_powerConsumption_powerEnergy_meter',
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
})
# ---
# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_power_energy-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'energy',
'friendly_name': 'Refrigerator Power energy',
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
'unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_power_energy',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0270189050030708',
})
# ---
# name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

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