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

View File

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

View File

@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self._status.input_id is not None: if self._status.input_id is not None:
for input_ in self._inputs: 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 return input_.text
for preset in self._presets: for preset in self._presets:

View File

@ -197,6 +197,7 @@ class ChatLog:
( (
"?", "?",
";", # Greek question mark ";", # 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 Returns True if the position was set, False if there is no
supported method for setting the position. supported method for setting the position.
""" """
if target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features: if CoverEntityFeature.SET_POSITION 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:
await service_call( await service_call(
SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position} 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: else:
# Requested a position but the cover doesn't support it # Requested a position but the cover doesn't support it
return False 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 Returns True if the tilt position was set, False if there is no
supported method for setting the tilt position. supported method for setting the tilt position.
""" """
if target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features: if CoverEntityFeature.SET_TILT_POSITION 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:
await service_call( await service_call(
SERVICE_SET_COVER_TILT_POSITION, SERVICE_SET_COVER_TILT_POSITION,
service_data | {ATTR_TILT_POSITION: target_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: else:
# Requested a tilt position but the cover doesn't support it # Requested a tilt position but the cover doesn't support it
return False return False
@ -183,12 +185,12 @@ async def _async_reproduce_state(
current_attrs = cur_state.attributes current_attrs = cur_state.attributes
target_attrs = state.attributes target_attrs = state.attributes
current_position = current_attrs.get(ATTR_CURRENT_POSITION) current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION)
target_position = target_attrs.get(ATTR_CURRENT_POSITION) target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION)
position_matches = current_position == target_position position_matches = current_position == target_position
current_tilt_position = current_attrs.get(ATTR_CURRENT_TILT_POSITION) current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION)
target_tilt_position = target_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 tilt_position_matches = current_tilt_position == target_tilt_position
state_matches = cur_state.state == target_state state_matches = cur_state.state == target_state
@ -214,20 +216,12 @@ async def _async_reproduce_state(
) )
service_data = {ATTR_ENTITY_ID: entity_id} service_data = {ATTR_ENTITY_ID: entity_id}
set_position = ( set_position = target_position is not None and await _async_set_position(
not position_matches
and target_position is not None
and await _async_set_position(
service_call, service_data, features, target_position service_call, service_data, features, target_position
) )
) set_tilt = target_tilt_position is not None and await _async_set_tilt_position(
set_tilt = (
not tilt_position_matches
and target_tilt_position is not None
and await _async_set_tilt_position(
service_call, service_data, features, target_tilt_position service_call, service_data, features, target_tilt_position
) )
)
if target_state in CLOSING_STATES: if target_state in CLOSING_STATES:
await _async_close_cover( await _async_close_cover(

View File

@ -23,6 +23,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf from homeassistant.components import zeroconf
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_REAUTH, SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry, ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
@ -44,6 +45,7 @@ from .const import (
CONF_SUBSCRIBE_LOGS, CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS, DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DEFAULT_PORT,
DOMAIN, DOMAIN,
) )
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
@ -63,6 +65,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
_reauth_entry: ConfigEntry _reauth_entry: ConfigEntry
_reconfig_entry: ConfigEntry
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize flow.""" """Initialize flow."""
@ -88,7 +91,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
fields: dict[Any, type] = OrderedDict() fields: dict[Any, type] = OrderedDict()
fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str 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 = {} errors = {}
if error is not None: if error is not None:
@ -140,7 +143,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle reauthorization flow when encryption was removed.""" """Handle reauthorization flow when encryption was removed."""
if user_input is not None: if user_input is not None:
self._noise_psk = 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( return self.async_show_form(
step_id="reauth_encryption_removed_confirm", step_id="reauth_encryption_removed_confirm",
@ -172,6 +175,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders={"name": self._name}, 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 @property
def _name(self) -> str: def _name(self) -> str:
return self.__name or "ESPHome" return self.__name or "ESPHome"
@ -230,7 +245,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_authenticate() return await self.async_step_authenticate()
self._password = "" self._password = ""
return await self._async_get_entry_or_resolve_conflict() return await self._async_validated_connection()
async def async_step_discovery_confirm( async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -270,13 +285,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self._async_validate_mac_abort_configured( await self._async_validate_mac_abort_configured(
mac_address, self._host, self._port mac_address, self._host, self._port
) )
return await self.async_step_discovery_confirm() return await self.async_step_discovery_confirm()
async def _async_validate_mac_abort_configured( async def _async_validate_mac_abort_configured(
self, formatted_mac: str, host: str, port: int | None self, formatted_mac: str, host: str, port: int | None
) -> None: ) -> None:
"""Validate if the MAC address is already configured.""" """Validate if the MAC address is already configured."""
assert self.unique_id is not None
if not ( if not (
entry := self.hass.config_entries.async_entry_for_domain_unique_id( entry := self.hass.config_entries.async_entry_for_domain_unique_id(
self.handler, formatted_mac self.handler, formatted_mac
@ -393,7 +408,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
data={ data={
**self._entry_with_name_conflict.data, **self._entry_with_name_conflict.data,
CONF_HOST: self._host, CONF_HOST: self._host,
CONF_PORT: self._port or 6053, CONF_PORT: self._port or DEFAULT_PORT,
CONF_PASSWORD: self._password or "", CONF_PASSWORD: self._password or "",
CONF_NOISE_PSK: self._noise_psk or "", CONF_NOISE_PSK: self._noise_psk or "",
}, },
@ -417,20 +432,24 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.config_entries.async_remove( await self.hass.config_entries.async_remove(
self._entry_with_name_conflict.entry_id self._entry_with_name_conflict.entry_id
) )
return self._async_get_entry() return self._async_create_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()
@callback @callback
def _async_get_entry(self) -> ConfigFlowResult: def _async_create_entry(self) -> ConfigFlowResult:
config_data = { """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_HOST: self._host,
CONF_PORT: self._port, CONF_PORT: self._port,
# The API uses protobuf, so empty string denotes absence # 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_NOISE_PSK: self._noise_psk or "",
CONF_DEVICE_NAME: self._device_name, 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: 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( 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 async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
return self.async_create_entry( """Handle reconfigure validated connection."""
title=self._name, assert self._reconfig_entry.unique_id is not None
data=config_data, assert self._host is not None
options=config_options, 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( async def async_step_encryption_key(
@ -481,7 +580,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
error = await self.try_login() error = await self.try_login()
if error: if error:
return await self.async_step_authenticate(error=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 = {} errors = {}
if error is not None: if error is not None:
@ -501,12 +600,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
zeroconf_instance = await zeroconf.async_get_instance(self.hass) zeroconf_instance = await zeroconf.async_get_instance(self.hass)
cli = APIClient( cli = APIClient(
host, host,
port or 6053, port or DEFAULT_PORT,
"", "",
zeroconf_instance=zeroconf_instance, zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk, noise_psk=noise_psk,
) )
try: try:
await cli.connect() await cli.connect()
self._device_info = await cli.device_info() self._device_info = await cli.device_info()
@ -541,9 +639,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
assert self._device_info is not None assert self._device_info is not None
mac_address = format_mac(self._device_info.mac_address) mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False) 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( 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 return None

View File

@ -1,5 +1,7 @@
"""ESPHome constants.""" """ESPHome constants."""
from typing import Final
from awesomeversion import AwesomeVersion from awesomeversion import AwesomeVersion
DOMAIN = "esphome" DOMAIN = "esphome"
@ -13,6 +15,7 @@ CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
DEFAULT_ALLOW_SERVICE_CALLS = True DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_PORT: Final = 6053
STABLE_BLE_VERSION_STR = "2025.2.2" STABLE_BLE_VERSION_STR = "2025.2.2"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR) 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.""" """Define a base esphome entity."""
_attr_should_poll = False _attr_should_poll = False
_attr_has_entity_name = True
_static_info: _InfoT _static_info: _InfoT
_state: _StateT _state: _StateT
_has_state: bool _has_state: bool
@ -223,24 +224,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} 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}" self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:

View File

@ -520,6 +520,15 @@ class ESPHomeManager:
if device_info.name: if device_info.name:
reconnect_logic.name = 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) self.device_id = _async_setup_device_registry(hass, entry, entry_data)
entry_data.async_update_device_state() entry_data.async_update_device_state()
@ -756,7 +765,7 @@ def _async_setup_device_registry(
config_entry_id=entry.entry_id, config_entry_id=entry.entry_id,
configuration_url=configuration_url, configuration_url=configuration_url,
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}, 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, manufacturer=manufacturer,
model=model, model=model,
sw_version=sw_version, sw_version=sw_version,

View File

@ -10,7 +10,11 @@
"mqtt_missing_api": "Missing API port in MQTT properties.", "mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties.", "mqtt_missing_ip": "Missing IP address in MQTT properties.",
"mqtt_missing_payload": "Missing MQTT Payload.", "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": { "error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", "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 is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
if user_input[CONF_LLM_HASS_API] == "none": if not user_input.get(CONF_LLM_HASS_API):
user_input.pop(CONF_LLM_HASS_API) user_input.pop(CONF_LLM_HASS_API, None)
if not ( 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 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 # 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: ) -> dict:
"""Return a schema for Google Generative AI completion options.""" """Return a schema for Google Generative AI completion options."""
hass_apis: list[SelectOptionDict] = [ hass_apis: list[SelectOptionDict] = [
SelectOptionDict(
label="No control",
value="none",
)
]
hass_apis.extend(
SelectOptionDict( SelectOptionDict(
label=api.name, label=api.name,
value=api.id, value=api.id,
) )
for api in llm.async_get_apis(hass) 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 = { schema = {
vol.Optional( vol.Optional(
@ -237,9 +235,8 @@ async def google_generative_ai_config_option_schema(
): TemplateSelector(), ): TemplateSelector(),
vol.Optional( vol.Optional(
CONF_LLM_HASS_API, CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)}, description={"suggested_value": suggested_llm_apis},
default="none", ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
): SelectSelector(SelectSelectorConfig(options=hass_apis)),
vol.Required( vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool, ): bool,

View File

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

View File

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

View File

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

View File

@ -197,6 +197,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
super().__init__(config_entry, description) super().__init__(config_entry, description)
self._attrs: dict[str, str] = {} 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: async def async_update(self) -> None:
"""Update the state of the sensor.""" """Update the state of the sensor."""
now = dt_util.now() now = dt_util.now()

View File

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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,9 @@ from dataclasses import dataclass
from typing import Any, cast from typing import Any, cast
from pylamarzocco import LaMarzoccoMachine from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import WidgetType from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.exceptions import RequestNotSuccessful
from pylamarzocco.models import CoffeeBoiler from pylamarzocco.models import CoffeeBoiler, PreBrewing
from homeassistant.components.number import ( from homeassistant.components.number import (
NumberDeviceClass, NumberDeviceClass,
@ -77,6 +77,123 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
), ),
native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes, 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": { "water_tank": {
"name": "Water tank empty" "name": "Water tank empty"
},
"websocket_connected": {
"name": "WebSocket connected"
} }
}, },
"button": { "button": {
@ -101,6 +104,15 @@
}, },
"smart_standby_time": { "smart_standby_time": {
"name": "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": { "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": { "switch": {
"auto_on_off": { "auto_on_off": {
"name": "Auto on/off ({id})" "name": "Auto on/off ({id})"

View File

@ -1,9 +1,10 @@
"""Support for La Marzocco update entities.""" """Support for La Marzocco update entities."""
import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from pylamarzocco.const import FirmwareType from pylamarzocco.const import FirmwareType, UpdateCommandStatus
from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.update import ( from homeassistant.components.update import (
@ -22,6 +23,7 @@ from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1 PARALLEL_UPDATES = 1
MAX_UPDATE_WAIT = 150
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -71,7 +73,11 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"""Entity representing the update state.""" """Entity representing the update state."""
entity_description: LaMarzoccoUpdateEntityDescription entity_description: LaMarzoccoUpdateEntityDescription
_attr_supported_features = UpdateEntityFeature.INSTALL _attr_supported_features = (
UpdateEntityFeature.INSTALL
| UpdateEntityFeature.PROGRESS
| UpdateEntityFeature.RELEASE_NOTES
)
@property @property
def installed_version(self) -> str: def installed_version(self) -> str:
@ -94,15 +100,40 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"""Return the release notes URL.""" """Return the release notes URL."""
return "https://support-iot.lamarzocco.com/firmware-updates/" 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( async def async_install(
self, version: str | None, backup: bool, **kwargs: Any self, version: str | None, backup: bool, **kwargs: Any
) -> None: ) -> None:
"""Install an update.""" """Install an update."""
self._attr_in_progress = True self._attr_in_progress = True
self.async_write_ha_state() self.async_write_ha_state()
counter = 0
def _raise_timeout_error() -> None: # to avoid TRY301
raise TimeoutError("Update timed out")
try: try:
await self.coordinator.device.update_firmware() 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( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="update_failed", translation_key="update_failed",
@ -110,5 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"key": self.entity_description.key, "key": self.entity_description.key,
}, },
) from exc ) from exc
finally:
self._attr_in_progress = False self._attr_in_progress = False
await self.coordinator.async_request_refresh() 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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval 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 .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator
from .mqtt import ThinQMQTT from .mqtt import ThinQMQTT
@ -137,7 +137,15 @@ async def async_setup_mqtt(
entry.runtime_data.mqtt_client = mqtt_client entry.runtime_data.mqtt_client = mqtt_client
# Try to connect. # Try to connect.
try:
result = await mqtt_client.async_connect() 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: if not result:
_LOGGER.error("Failed to set up mqtt connection") _LOGGER.error("Failed to set up mqtt connection")
return return

View File

@ -43,7 +43,7 @@ class ThinQMQTT:
async def async_connect(self) -> bool: async def async_connect(self) -> bool:
"""Create a mqtt client and then try to connect.""" """Create a mqtt client and then try to connect."""
try:
self.client = await ThinQMQTTClient( self.client = await ThinQMQTTClient(
self.thinq_api, self.client_id, self.on_message_received self.thinq_api, self.client_id, self.on_message_received
) )
@ -52,9 +52,6 @@ class ThinQMQTT:
# Connect to server and create certificate. # Connect to server and create certificate.
return await self.client.async_prepare_mqtt() 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: async def async_disconnect(self, event: Event | None = None) -> None:
"""Unregister client and disconnects handlers.""" """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] subscriptions: list[CALLBACK_TYPE]
end_time_unsub: CALLBACK_TYPE | None = None end_time_unsub: CALLBACK_TYPE | None = None
task: asyncio.Task | None = None task: asyncio.Task | None = None
wait_sync_task: asyncio.Task | None = None wait_sync_future: asyncio.Future[None] | None = None
@callback @callback
@ -329,8 +329,8 @@ async def ws_event_stream(
subscriptions.clear() subscriptions.clear()
if live_stream.task: if live_stream.task:
live_stream.task.cancel() live_stream.task.cancel()
if live_stream.wait_sync_task: if live_stream.wait_sync_future:
live_stream.wait_sync_task.cancel() live_stream.wait_sync_future.cancel()
if live_stream.end_time_unsub: if live_stream.end_time_unsub:
live_stream.end_time_unsub() live_stream.end_time_unsub()
live_stream.end_time_unsub = None live_stream.end_time_unsub = None
@ -399,10 +399,12 @@ async def ws_event_stream(
) )
) )
live_stream.wait_sync_task = create_eager_task( if sync_future := get_instance(hass).async_get_commit_future():
get_instance(hass).async_block_till_done() # Set the future so we can cancel it if the client
) # unsubscribes before the commit is done so we don't
await live_stream.wait_sync_task # 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 # Fetch any events from the database that have

View File

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

View File

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

View File

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

View File

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

View File

@ -1307,11 +1307,17 @@ class Recorder(threading.Thread):
async def async_block_till_done(self) -> None: async def async_block_till_done(self) -> None:
"""Async version of block_till_done.""" """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: if self._queue.empty() and not self._event_session_has_pending_writes:
return return None
event = asyncio.Event() future: asyncio.Future[None] = self.hass.loop.create_future()
self.queue_task(SynchronizeTask(event)) self.queue_task(SynchronizeTask(future))
await event.wait() return future
def block_till_done(self) -> None: def block_till_done(self) -> None:
"""Block till all events processed. """Block till all events processed.

View File

@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask):
"""Ensure all pending data has been committed.""" """Ensure all pending data has been committed."""
# commit_before is the default # commit_before is the default
event: asyncio.Event future: asyncio.Future
def run(self, instance: Recorder) -> None: def run(self, instance: Recorder) -> None:
"""Handle the task.""" """Handle the task."""
# Does not use a tracked task to avoid # Does not use a tracked task to avoid
# blocking shutdown if the recorder is broken # 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) @dataclass(slots=True)

View File

@ -7,7 +7,9 @@ DOMAIN = "renault"
CONF_LOCALE = "locale" CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id" 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 # If throttled time to pause the updates, in seconds
COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes

View File

@ -32,9 +32,9 @@ from time import time
from .const import ( from .const import (
CONF_KAMEREON_ACCOUNT_ID, CONF_KAMEREON_ACCOUNT_ID,
COOLING_UPDATES_SECONDS, 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__) LOGGER = logging.getLogger(__name__)
@ -82,7 +82,6 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None: async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy.""" """Set up proxy."""
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID] 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) self._account = await self._client.get_api_account(account_id)
vehicles = await self._account.get_vehicles() vehicles = await self._account.get_vehicles()
@ -94,6 +93,12 @@ class RenaultHub:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers" "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) device_registry = dr.async_get(self._hass)
await asyncio.gather( 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( async def async_initialise_vehicle(
self, self,
vehicle_link: KamereonVehiclesLink, vehicle_link: KamereonVehiclesLink,

View File

@ -91,6 +91,13 @@ class RenaultVehicleProxy:
self._scan_interval = scan_interval self._scan_interval = scan_interval
self._hub = hub 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 @property
def details(self) -> models.KamereonVehicleDetails: def details(self) -> models.KamereonVehicleDetails:
"""Return the specs of the vehicle.""" """Return the specs of the vehicle."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,8 +14,6 @@ import mimetypes
import os import os
import re import re
import secrets import secrets
import subprocess
import tempfile
from time import monotonic from time import monotonic
from typing import Any, Final from typing import Any, Final
@ -309,80 +307,73 @@ async def _async_convert_audio(
) -> AsyncGenerator[bytes]: ) -> AsyncGenerator[bytes]:
"""Convert audio to a preferred format using ffmpeg.""" """Convert audio to a preferred format using ffmpeg."""
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) 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 = [ command = [
ffmpeg_binary, ffmpeg_manager.binary,
"-y", # overwrite temp file "-hide_banner",
"-loglevel",
"error",
"-f", "-f",
from_extension, from_extension,
"-i", "-i",
"pipe:", # input from stdin "pipe:",
"-f",
to_extension,
] ]
# output
command.extend(["-f", to_extension])
if to_sample_rate is not None: if to_sample_rate is not None:
command.extend(["-ar", str(to_sample_rate)]) command.extend(["-ar", str(to_sample_rate)])
if to_sample_channels is not None: if to_sample_channels is not None:
command.extend(["-ac", str(to_sample_channels)]) command.extend(["-ac", str(to_sample_channels)])
if to_extension == "mp3": if to_extension == "mp3":
# Max quality for MP3 # Max quality for MP3.
command.extend(["-q:a", "0"]) command.extend(["-q:a", "0"])
if to_sample_bytes == 2: if to_sample_bytes == 2:
# 16-bit samples # 16-bit samples.
command.extend(["-sample_fmt", "s16"]) 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,
with subprocess.Popen( stdin=asyncio.subprocess.PIPE,
command, stdin=subprocess.PIPE, stderr=subprocess.PIPE stdout=asyncio.subprocess.PIPE,
) as proc: stderr=asyncio.subprocess.PIPE,
_stdout, stderr = proc.communicate(input=audio_bytes)
if proc.returncode != 0:
_LOGGER.error(stderr.decode())
raise RuntimeError(
f"Unexpected error while running ffmpeg with arguments: {command}."
"See log for details."
) )
output_file.seek(0) async def write_input() -> None:
return output_file.read() 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."
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:

View File

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

View File

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

View File

@ -629,6 +629,10 @@ class _ScriptRun:
self, script: Script, *, parallel: bool = False self, script: Script, *, parallel: bool = False
) -> None: ) -> None:
"""Execute a script.""" """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( result = await self._async_run_long_action(
self._hass.async_create_task_internal( self._hass.async_create_task_internal(
script.async_run( script.async_run(
@ -1442,8 +1446,12 @@ class Script:
script_mode: str = DEFAULT_SCRIPT_MODE, script_mode: str = DEFAULT_SCRIPT_MODE,
top_level: bool = True, top_level: bool = True,
variables: ScriptVariables | None = None, variables: ScriptVariables | None = None,
enabled: bool = True,
) -> None: ) -> 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)): if not (all_scripts := hass.data.get(DATA_SCRIPTS)):
all_scripts = hass.data[DATA_SCRIPTS] = [] all_scripts = hass.data[DATA_SCRIPTS] = []
hass.bus.async_listen_once( hass.bus.async_listen_once(
@ -1462,6 +1470,7 @@ class Script:
self.name = name self.name = name
self.unique_id = f"{domain}.{name}-{id(self)}" self.unique_id = f"{domain}.{name}-{id(self)}"
self.domain = domain self.domain = domain
self.enabled = enabled
self.running_description = running_description or f"{domain} script" self.running_description = running_description or f"{domain} script"
self._change_listener = change_listener self._change_listener = change_listener
self._change_listener_job = ( self._change_listener_job = (
@ -2002,6 +2011,7 @@ class Script:
max_runs=self.max_runs, max_runs=self.max_runs,
logger=self._logger, logger=self._logger,
top_level=False, top_level=False,
enabled=parallel_script.get(CONF_ENABLED, True),
) )
parallel_script.change_listener = partial( parallel_script.change_listener = partial(
self._chain_change_listener, parallel_script self._chain_change_listener, parallel_script

View File

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

6
requirements_all.txt generated
View File

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

View File

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

View File

@ -1530,7 +1530,7 @@ pybalboa==1.1.3
pyblackbird==0.6 pyblackbird==0.6
# homeassistant.components.bluesound # homeassistant.components.bluesound
pyblu==2.0.0 pyblu==2.0.1
# homeassistant.components.neato # homeassistant.components.neato
pybotvac==0.0.26 pybotvac==0.0.26
@ -1904,7 +1904,7 @@ pysmartthings==3.0.4
pysmarty2==0.10.2 pysmarty2==0.10.2
# homeassistant.components.smhi # homeassistant.components.smhi
pysmhi==1.0.1 pysmhi==1.0.2
# homeassistant.components.edl21 # homeassistant.components.edl21
pysml==0.0.12 pysml==0.0.12
@ -2554,7 +2554,7 @@ zeroconf==0.146.5
zeversolar==0.3.2 zeversolar==0.3.2
# homeassistant.components.zha # homeassistant.components.zha
zha==0.0.55 zha==0.0.56
# homeassistant.components.zwave_js # homeassistant.components.zwave_js
zwave-js-server-python==0.62.0 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_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla", CONF_PROMPT: "bla",
}, },
{ {
CONF_RECOMMENDED: False, CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate", CONF_PROMPT: "Speak like a pirate",
CONF_TEMPERATURE: 0.3, CONF_TEMPERATURE: 0.3,
CONF_LLM_HASS_API: [],
}, },
{ {
CONF_RECOMMENDED: False, CONF_RECOMMENDED: False,
@ -224,15 +224,32 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non
}, },
{ {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "", CONF_PROMPT: "",
}, },
{ {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "", 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( async def test_options_switching(

View File

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

View File

@ -178,6 +178,22 @@ async def test_reproducing_states(
| CoverEntityFeature.OPEN, | 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( hass.states.async_set(
"cover.tilt_only_open", "cover.tilt_only_open",
CoverState.OPEN, CoverState.OPEN,
@ -249,6 +265,14 @@ async def test_reproducing_states(
await async_reproduce_state( await async_reproduce_state(
hass, 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.entity_close", CoverState.CLOSED),
State("cover.closed_only_supports_close_open", CoverState.CLOSED), State("cover.closed_only_supports_close_open", CoverState.CLOSED),
State("cover.closed_only_supports_tilt_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( await async_reproduce_state(
hass, 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.entity_close", CoverState.OPEN),
State( State(
"cover.closed_only_supports_close_open", "cover.closed_only_supports_close_open",
@ -458,7 +487,6 @@ async def test_reproducing_states(
valid_close_calls = [ valid_close_calls = [
{"entity_id": "cover.entity_open"}, {"entity_id": "cover.entity_open"},
{"entity_id": "cover.entity_open_attr"}, {"entity_id": "cover.entity_open_attr"},
{"entity_id": "cover.entity_entirely_open"},
{"entity_id": "cover.open_only_supports_close_open"}, {"entity_id": "cover.open_only_supports_close_open"},
{"entity_id": "cover.open_missing_all_features"}, {"entity_id": "cover.open_missing_all_features"},
] ]
@ -481,11 +509,8 @@ async def test_reproducing_states(
valid_open_calls.remove(call.data) valid_open_calls.remove(call.data)
valid_close_tilt_calls = [ 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.tilt_only_open"},
{"entity_id": "cover.entity_open_attr"}, {"entity_id": "cover.entity_open_attr"},
{"entity_id": "cover.tilt_only_tilt_position_100"},
{"entity_id": "cover.open_only_supports_tilt_close_open"}, {"entity_id": "cover.open_only_supports_tilt_close_open"},
] ]
assert len(close_tilt_calls) == len(valid_close_tilt_calls) 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_close_tilt_calls.remove(call.data)
valid_open_tilt_calls = [ valid_open_tilt_calls = [
{"entity_id": "cover.entity_close_tilt"},
{"entity_id": "cover.tilt_only_closed"}, {"entity_id": "cover.tilt_only_closed"},
{"entity_id": "cover.tilt_only_tilt_position_0"},
{"entity_id": "cover.closed_only_supports_tilt_close_open"}, {"entity_id": "cover.closed_only_supports_tilt_close_open"},
] ]
assert len(open_tilt_calls) == len(valid_open_tilt_calls) 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", "entity_id": "cover.open_only_supports_position",
ATTR_POSITION: 0, 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) assert len(position_calls) == len(valid_position_calls)
for call in 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", "entity_id": "cover.tilt_partial_open_only_supports_tilt_position",
ATTR_TILT_POSITION: 70, 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) assert len(position_tilt_calls) == len(valid_position_tilt_calls)
for call in position_tilt_calls: for call in position_tilt_calls:
assert call.domain == "cover" assert call.domain == "cover"

View File

@ -813,12 +813,15 @@ async def test_reauth_confirm_valid(
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, 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) entry.add_to_hass(hass)
result = await entry.start_reauth_flow(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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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 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") @pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_fixed_via_dashboard( async def test_reauth_fixed_via_dashboard(
hass: HomeAssistant, hass: HomeAssistant,
@ -845,10 +890,13 @@ async def test_reauth_fixed_via_dashboard(
CONF_PASSWORD: "", CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test", CONF_DEVICE_NAME: "test",
}, },
unique_id="11:22:33:44:55:aa",
) )
entry.add_to_hass(hass) 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( 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.""" """Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = ( mock_client.device_info.side_effect = (
InvalidAuthAPIError, 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( mock_dashboard["configured"].append(
@ -917,7 +965,9 @@ async def test_reauth_fixed_via_remove_password(
mock_setup_entry: None, mock_setup_entry: None,
) -> None: ) -> None:
"""Test reauth fixed automatically by seeing password removed.""" """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) 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_PASSWORD: "",
CONF_DEVICE_NAME: "test", CONF_DEVICE_NAME: "test",
}, },
unique_id="11:22:33:44:55:aa",
) )
entry.add_to_hass(hass) 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) result = await entry.start_reauth_flow(hass)
@ -984,6 +1037,7 @@ async def test_reauth_confirm_invalid(
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, 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) entry.add_to_hass(hass)
@ -1000,7 +1054,9 @@ async def test_reauth_confirm_invalid(
assert result["errors"]["base"] == "invalid_psk" assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock( 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: ""}, 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) entry.add_to_hass(hass)
@ -1036,7 +1092,9 @@ async def test_reauth_confirm_invalid_with_unique_id(
assert result["errors"]["base"] == "invalid_psk" assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock( 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 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} 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") @pytest.mark.usefixtures("mock_zeroconf")
async def test_reauth_encryption_key_removed( 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: ) -> None:
"""Test reauth when the encryption key was removed.""" """Test reauth when the encryption key was removed."""
entry = MockConfigEntry( entry = MockConfigEntry(
@ -1060,7 +1118,7 @@ async def test_reauth_encryption_key_removed(
CONF_PASSWORD: "", CONF_PASSWORD: "",
CONF_NOISE_PSK: VALID_NOISE_PSK, CONF_NOISE_PSK: VALID_NOISE_PSK,
}, },
unique_id="test", unique_id="11:22:33:44:55:aa",
) )
entry.add_to_hass(hass) 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["type"] is FlowResultType.ABORT
assert result["reason"] == "name_conflict_migrated" 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 == { assert existing_entry.data == {
CONF_HOST: "127.0.0.1", CONF_HOST: "127.0.0.1",
CONF_PORT: 6053, CONF_PORT: 6053,
@ -1715,3 +1777,321 @@ async def test_user_flow_name_conflict_overwrite(
CONF_DEVICE_NAME: "test", CONF_DEVICE_NAME: "test",
} }
assert result["context"]["unique_id"] == "11:22:33:44:55:aa" 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.""" """Test config entries waiting for reauth are triggered."""
mock_client.device_info.side_effect = ( mock_client.device_info.side_effect = (
InvalidAuthAPIError, InvalidAuthAPIError,
DeviceInfo(uses_password=False, name="test"), DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"),
) )
with patch( with patch(

View File

@ -500,6 +500,6 @@ async def test_esphome_device_without_friendly_name(
states=states, states=states,
device_info={"friendly_name": None}, 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 is not None
assert state.state == STATE_ON 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() await hass.async_block_till_done()
assert entry.data[CONF_BLUETOOTH_MAC_ADDRESS] == "AA:BB:CC:DD:EE:FC" 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_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla", CONF_PROMPT: "bla",
}, },
{ {
@ -162,12 +161,12 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
}, },
{ {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "", CONF_PROMPT: "",
}, },
{ {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "", CONF_PROMPT: "",
}, },
None, None,
@ -235,7 +234,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
{ {
CONF_RECOMMENDED: False, CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate", CONF_PROMPT: "Speak like a pirate",
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_TEMPERATURE: 0.3, CONF_TEMPERATURE: 0.3,
CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL,
CONF_TOP_P: RECOMMENDED_TOP_P, 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_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") @pytest.mark.usefixtures("mock_init_component")

View File

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

View File

@ -1,57 +1 @@
"""Tests for the jewish_calendar component.""" """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.""" """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 unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import pytest 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 from tests.common import MockConfigEntry
@pytest.fixture class _LocationData(NamedTuple):
def mock_config_entry() -> MockConfigEntry: timezone: str
"""Return the default mocked config entry.""" diaspora: bool
return MockConfigEntry( lat: float
title=DEFAULT_NAME, lng: float
domain=DOMAIN, 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 @pytest.fixture
@ -26,3 +43,109 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.jewish_calendar.async_setup_entry", return_value=True "homeassistant.components.jewish_calendar.async_setup_entry", return_value=True
) as mock_setup_entry: ) as mock_setup_entry:
yield 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.""" """The tests for the Jewish calendar binary sensors."""
from datetime import datetime as dt, timedelta 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 import pytest
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.jewish_calendar.const import ( from homeassistant.components.jewish_calendar.const import DOMAIN
CONF_CANDLE_LIGHT_MINUTES, from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
CONF_DIASPORA,
CONF_HAVDALAH_OFFSET_MINUTES,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.const import CONF_LANGUAGE, CONF_PLATFORM, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component 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 = [ MELACHA_PARAMS = [
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 1, 16, 0), dt(2018, 9, 1, 16, 0),
{ {"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF},
"state": STATE_ON, id="currently_first_shabbat",
"update": dt(2018, 9, 1, 20, 14),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 1, 20, 21), dt(2018, 9, 1, 20, 21),
{ {"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF},
"state": STATE_OFF, id="after_first_shabbat",
"update": dt(2018, 9, 2, 6, 21),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 7, 13, 1), dt(2018, 9, 7, 13, 1),
{ {"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON},
"state": STATE_OFF, id="friday_upcoming_shabbat",
"update": dt(2018, 9, 7, 19, 4),
"new_state": STATE_ON,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 8, 21, 25), dt(2018, 9, 8, 21, 25),
{ {"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF},
"state": STATE_OFF, id="upcoming_rosh_hashana",
"update": dt(2018, 9, 9, 6, 27),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 9, 21, 25), dt(2018, 9, 9, 21, 25),
{ {"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON},
"state": STATE_ON, id="currently_rosh_hashana",
"update": dt(2018, 9, 10, 6, 28),
"new_state": STATE_ON,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 10, 21, 25), dt(2018, 9, 10, 21, 25),
{ {"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON},
"state": STATE_ON, id="second_day_rosh_hashana_night",
"update": dt(2018, 9, 11, 6, 29),
"new_state": STATE_ON,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 11, 11, 25), dt(2018, 9, 11, 11, 25),
{ {"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF},
"state": STATE_ON, id="second_day_rosh_hashana_day",
"update": dt(2018, 9, 11, 19, 57),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 29, 16, 25), dt(2018, 9, 29, 16, 25),
{ {"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF},
"state": STATE_ON, id="currently_shabbat_chol_hamoed",
"update": dt(2018, 9, 29, 19, 25),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 29, 21, 25), dt(2018, 9, 29, 21, 25),
{ {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF},
"state": STATE_OFF, id="upcoming_two_day_yomtov_in_diaspora",
"update": dt(2018, 9, 30, 6, 48),
"new_state": STATE_OFF,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 30, 21, 25), dt(2018, 9, 30, 21, 25),
{ {"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON},
"state": STATE_ON, id="currently_first_day_of_two_day_yomtov_in_diaspora",
"update": dt(2018, 10, 1, 6, 49),
"new_state": STATE_ON,
},
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 10, 1, 21, 25), dt(2018, 10, 1, 21, 25),
{ {"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON},
"state": STATE_ON, id="currently_second_day_of_two_day_yomtov_in_diaspora",
"update": dt(2018, 10, 2, 6, 50),
"new_state": STATE_ON,
},
), ),
make_jerusalem_test_params( pytest.param(
"Jerusalem",
dt(2018, 9, 29, 21, 25), dt(2018, 9, 29, 21, 25),
{ {"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF},
"state": STATE_OFF, id="upcoming_one_day_yom_tov_in_israel",
"update": dt(2018, 9, 30, 6, 29),
"new_state": STATE_OFF,
},
), ),
make_jerusalem_test_params( pytest.param(
"Jerusalem",
dt(2018, 10, 1, 11, 25), dt(2018, 10, 1, 11, 25),
{ {"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF},
"state": STATE_ON, id="currently_one_day_yom_tov_in_israel",
"update": dt(2018, 10, 1, 19, 2),
"new_state": STATE_OFF,
},
), ),
make_jerusalem_test_params( pytest.param(
"Jerusalem",
dt(2018, 10, 1, 21, 25), dt(2018, 10, 1, 21, 25),
{ {"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF},
"state": STATE_OFF, id="after_one_day_yom_tov_in_israel",
"update": dt(2018, 10, 2, 6, 31),
"new_state": STATE_OFF,
},
), ),
] ]
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( @pytest.mark.parametrize(
( ("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
MELACHA_PARAMS,
ids=MELACHA_TEST_IDS,
) )
@pytest.mark.usefixtures("setup_at_time")
async def test_issur_melacha_sensor( async def test_issur_melacha_sensor(
hass: HomeAssistant, hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any]
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
) -> None: ) -> None:
"""Test Issur Melacha sensor output.""" """Test Issur Melacha sensor output."""
time_zone = dt_util.get_time_zone(tzname) sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
test_time = now.replace(tzinfo=time_zone) assert hass.states.get(sensor_id).state == results["state"]
await hass.config.async_set_time_zone(tzname) freezer.move_to(results["update"])
hass.config.latitude = latitude async_fire_time_changed(hass)
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)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get(sensor_id).state == results["new_state"]
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"]
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
( ("location_data", "test_time", "results"),
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
[ [
make_nyc_test_params( ("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]),
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]),
),
make_nyc_test_params(
dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]
),
], ],
ids=["before_candle_lighting", "before_havdalah"], ids=["before_candle_lighting", "before_havdalah"],
indirect=True,
) )
@pytest.mark.usefixtures("setup_at_time")
async def test_issur_melacha_sensor_update( async def test_issur_melacha_sensor_update(
hass: HomeAssistant, hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str]
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
) -> None: ) -> None:
"""Test Issur Melacha sensor output.""" """Test Issur Melacha sensor output."""
time_zone = dt_util.get_time_zone(tzname) sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
test_time = now.replace(tzinfo=time_zone) assert hass.states.get(sensor_id).state == results[0]
await hass.config.async_set_time_zone(tzname) freezer.tick(timedelta(microseconds=1))
hass.config.latitude = latitude async_fire_time_changed(hass)
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)
await hass.async_block_till_done() await hass.async_block_till_done()
assert ( assert hass.states.get(sensor_id).state == results[1]
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]
)
async def test_no_discovery_info( 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( async def test_single_instance_allowed(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test we abort if already setup.""" """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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
@ -70,11 +70,11 @@ async def test_single_instance_allowed(
assert result.get("reason") == "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.""" """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["type"] is FlowResultType.FORM
assert result["step_id"] == "init" assert result["step_id"] == "init"
@ -95,16 +95,16 @@ async def test_options(hass: HomeAssistant, mock_config_entry: MockConfigEntry)
async def test_options_reconfigure( async def test_options_reconfigure(
hass: HomeAssistant, mock_config_entry: MockConfigEntry hass: HomeAssistant, config_entry: MockConfigEntry
) -> None: ) -> None:
"""Test that updating the options of the Jewish Calendar integration triggers a value update.""" """Test that updating the options of the Jewish Calendar integration triggers a value update."""
mock_config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() 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 # 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 = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],
user_input={ user_input={
@ -114,21 +114,17 @@ async def test_options_reconfigure(
assert result["result"] assert result["result"]
# The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value # The value of the "upcoming_shabbat_candle_lighting" sensor should be the new value
assert ( assert config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1
mock_config_entry.options[CONF_CANDLE_LIGHT_MINUTES] == DEFAULT_CANDLE_LIGHT + 1
)
async def test_reconfigure( async def test_reconfigure(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test starting a reconfigure flow.""" """Test starting a reconfigure flow."""
mock_config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
# init user flow # 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["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure" assert result["step_id"] == "reconfigure"
@ -141,4 +137,4 @@ async def test_reconfigure(
) )
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful" 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( async def test_migrate_unique_id(
hass: HomeAssistant, hass: HomeAssistant,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
config_entry: MockConfigEntry,
old_key: str, old_key: str,
new_key: str, new_key: str,
) -> None: ) -> None:
"""Test unique id migration.""" """Test unique id migration."""
entry = MockConfigEntry(domain=DOMAIN, data={}) config_entry.add_to_hass(hass)
entry.add_to_hass(hass)
entity: er.RegistryEntry = entity_registry.async_get_or_create( entity: er.RegistryEntry = entity_registry.async_get_or_create(
domain=SENSOR_DOMAIN, domain=SENSOR_DOMAIN,
platform=DOMAIN, platform=DOMAIN,
unique_id=f"{entry.entry_id}-{old_key}", unique_id=f"{config_entry.entry_id}-{old_key}",
config_entry=entry, config_entry=config_entry,
) )
assert entity.unique_id.endswith(f"-{old_key}") 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() await hass.async_block_till_done()
entity_migrated = entity_registry.async_get(entity.entity_id) entity_migrated = entity_registry.async_get(entity.entity_id)
assert entity_migrated 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.""" """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.holidays import HolidayDatabase
from hdate.parasha import Parasha from hdate.parasha import Parasha
import pytest import pytest
from homeassistant.components.jewish_calendar.const import ( from homeassistant.components.jewish_calendar.const import DOMAIN
CONF_CANDLE_LIGHT_MINUTES,
CONF_DIASPORA,
CONF_HAVDALAH_OFFSET_MINUTES,
DEFAULT_NAME,
DOMAIN,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_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.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import make_jerusalem_test_params, make_nyc_test_params from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
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.""" """Test minimum jewish calendar configuration."""
entry = MockConfigEntry(title=DEFAULT_NAME, domain=DOMAIN, data={}) config_entry.add_to_hass(hass)
entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id)
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)
await hass.async_block_till_done() await hass.async_block_till_done()
assert hass.states.get("sensor.jewish_calendar_date") is not None assert hass.states.get("sensor.jewish_calendar_date") is not None
TEST_PARAMS = [ TEST_PARAMS = [
( pytest.param(
"Jerusalem",
dt(2018, 9, 3), dt(2018, 9, 3),
"UTC", {"state": "23 Elul 5778"},
31.778,
35.235,
"english", "english",
"date", "date",
False, id="date_output",
"23 Elul 5778",
None,
), ),
( pytest.param(
"Jerusalem",
dt(2018, 9, 3), dt(2018, 9, 3),
"UTC", {"state": 'כ"ג אלול ה\' תשע"ח'},
31.778,
35.235,
"hebrew", "hebrew",
"date", "date",
False, id="date_output_hebrew",
'כ"ג אלול ה\' תשע"ח',
None,
), ),
( pytest.param(
"Jerusalem",
dt(2018, 9, 10), dt(2018, 9, 10),
"UTC", {"state": "א' ראש השנה"},
31.778,
35.235,
"hebrew", "hebrew",
"holiday", "holiday",
False, id="holiday",
"א' ראש השנה",
None,
), ),
( pytest.param(
"Jerusalem",
dt(2018, 9, 10), dt(2018, 9, 10),
"UTC",
31.778,
35.235,
"english",
"holiday",
False,
"Rosh Hashana I",
{ {
"state": "Rosh Hashana I",
"attr": {
"device_class": "enum", "device_class": "enum",
"friendly_name": "Jewish Calendar Holiday", "friendly_name": "Jewish Calendar Holiday",
"icon": "mdi:calendar-star", "icon": "mdi:calendar-star",
@ -96,17 +64,17 @@ TEST_PARAMS = [
"type": "YOM_TOV", "type": "YOM_TOV",
"options": HolidayDatabase(False).get_all_names("english"), "options": HolidayDatabase(False).get_all_names("english"),
}, },
), },
(
dt(2024, 12, 31),
"UTC",
31.778,
35.235,
"english", "english",
"holiday", "holiday",
False, id="holiday_english",
"Chanukah, Rosh Chodesh", ),
pytest.param(
"Jerusalem",
dt(2024, 12, 31),
{ {
"state": "Chanukah, Rosh Chodesh",
"attr": {
"device_class": "enum", "device_class": "enum",
"friendly_name": "Jewish Calendar Holiday", "friendly_name": "Jewish Calendar Holiday",
"icon": "mdi:calendar-star", "icon": "mdi:calendar-star",
@ -114,169 +82,103 @@ TEST_PARAMS = [
"type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH", "type": "MELACHA_PERMITTED_HOLIDAY, ROSH_CHODESH",
"options": HolidayDatabase(False).get_all_names("english"), "options": HolidayDatabase(False).get_all_names("english"),
}, },
},
"english",
"holiday",
id="holiday_multiple",
), ),
( pytest.param(
"Jerusalem",
dt(2018, 9, 8), dt(2018, 9, 8),
"UTC",
31.778,
35.235,
"hebrew",
"parshat_hashavua",
False,
"נצבים",
{ {
"state": "נצבים",
"attr": {
"device_class": "enum", "device_class": "enum",
"friendly_name": "Jewish Calendar Parshat Hashavua", "friendly_name": "Jewish Calendar Parshat Hashavua",
"icon": "mdi:book-open-variant", "icon": "mdi:book-open-variant",
"options": list(Parasha), "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", "hebrew",
"parshat_hashavua", "parshat_hashavua",
False, id="torah_reading",
"לך לך",
None,
), ),
( 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), dt(2018, 10, 14, 17, 0, 0),
"Asia/Jerusalem", {"state": "ה' מרחשוון ה' תשע\"ט"},
31.778,
35.235,
"hebrew", "hebrew",
"date", "date",
False, id="date_before_sunset",
"ה' מרחשוון ה' תשע\"ט",
None,
), ),
( pytest.param(
"Jerusalem",
dt(2018, 10, 14, 19, 0, 0), dt(2018, 10, 14, 19, 0, 0),
"Asia/Jerusalem",
31.778,
35.235,
"hebrew",
"date",
False,
"ו' מרחשוון ה' תשע\"ט",
{ {
"state": "ו' מרחשוון ה' תשע\"ט",
"attr": {
"hebrew_year": "5779", "hebrew_year": "5779",
"hebrew_month_name": "מרחשוון", "hebrew_month_name": "מרחשוון",
"hebrew_day": "6", "hebrew_day": "6",
"icon": "mdi:star-david", "icon": "mdi:star-david",
"friendly_name": "Jewish Calendar Date", "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( @pytest.mark.parametrize(
( ("location_data", "test_time", "results", "language", "sensor"),
"now",
"tzname",
"latitude",
"longitude",
"language",
"sensor",
"diaspora",
"result",
"attrs",
),
TEST_PARAMS, 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( async def test_jewish_calendar_sensor(
hass: HomeAssistant, hass: HomeAssistant, results: dict[str, Any], sensor: str
now,
tzname,
latitude,
longitude,
language,
sensor,
diaspora,
result,
attrs,
) -> None: ) -> None:
"""Test Jewish calendar sensor output.""" """Test Jewish calendar sensor output."""
time_zone = dt_util.get_time_zone(tzname) result = results["state"]
test_time = now.replace(tzinfo=time_zone) if isinstance(result, dt):
result = dt_util.as_utc(result).isoformat()
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
)
sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}") sensor_object = hass.states.get(f"sensor.jewish_calendar_{sensor}")
assert sensor_object.state == result assert sensor_object.state == result
if attrs: if attrs := results.get("attr"):
assert sensor_object.attributes == attrs assert sensor_object.attributes == attrs
SHABBAT_PARAMS = [ SHABBAT_PARAMS = [
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 1, 16, 0), dt(2018, 9, 1, 16, 0),
{ {
"english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -286,8 +188,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo", "english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא", "hebrew_parshat_hashavua": "כי תבוא",
}, },
None,
id="currently_first_shabbat",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 1, 16, 0), dt(2018, 9, 1, 16, 0),
{ {
"english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12), "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -297,9 +202,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo", "english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא", "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), dt(2018, 9, 1, 20, 0),
{ {
"english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12), "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 12),
@ -309,8 +216,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Ki Tavo", "english_parshat_hashavua": "Ki Tavo",
"hebrew_parshat_hashavua": "כי תבוא", "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), dt(2018, 9, 1, 20, 21),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 7, 19), "english_upcoming_candle_lighting": dt(2018, 9, 7, 19),
@ -320,8 +230,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Nitzavim", "english_parshat_hashavua": "Nitzavim",
"hebrew_parshat_hashavua": "נצבים", "hebrew_parshat_hashavua": "נצבים",
}, },
None,
id="after_first_shabbat",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 7, 13, 1), dt(2018, 9, 7, 13, 1),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 7, 19), "english_upcoming_candle_lighting": dt(2018, 9, 7, 19),
@ -331,8 +244,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Nitzavim", "english_parshat_hashavua": "Nitzavim",
"hebrew_parshat_hashavua": "נצבים", "hebrew_parshat_hashavua": "נצבים",
}, },
None,
id="friday_upcoming_shabbat",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 8, 21, 25), dt(2018, 9, 8, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -344,8 +260,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Erev Rosh Hashana", "english_holiday": "Erev Rosh Hashana",
"hebrew_holiday": "ערב ראש השנה", "hebrew_holiday": "ערב ראש השנה",
}, },
None,
id="upcoming_rosh_hashana",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 9, 21, 25), dt(2018, 9, 9, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -357,8 +276,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana I", "english_holiday": "Rosh Hashana I",
"hebrew_holiday": "א' ראש השנה", "hebrew_holiday": "א' ראש השנה",
}, },
None,
id="currently_rosh_hashana",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 10, 21, 25), dt(2018, 9, 10, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57), "english_upcoming_candle_lighting": dt(2018, 9, 9, 18, 57),
@ -370,8 +292,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana II", "english_holiday": "Rosh Hashana II",
"hebrew_holiday": "ב' ראש השנה", "hebrew_holiday": "ב' ראש השנה",
}, },
None,
id="second_day_rosh_hashana",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 28, 21, 25), dt(2018, 9, 28, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25), "english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 25),
@ -381,8 +306,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "none", "english_parshat_hashavua": "none",
"hebrew_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), dt(2018, 9, 29, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -394,8 +322,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Hoshana Raba", "english_holiday": "Hoshana Raba",
"hebrew_holiday": "הושענא רבה", "hebrew_holiday": "הושענא רבה",
}, },
None,
id="upcoming_two_day_yomtov_in_diaspora",
), ),
make_nyc_test_params( pytest.param(
"New York",
dt(2018, 9, 30, 21, 25), dt(2018, 9, 30, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -407,8 +338,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shmini Atzeret", "english_holiday": "Shmini Atzeret",
"hebrew_holiday": "שמיני עצרת", "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), dt(2018, 10, 1, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22), "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 22),
@ -420,11 +354,14 @@ SHABBAT_PARAMS = [
"english_holiday": "Simchat Torah", "english_holiday": "Simchat Torah",
"hebrew_holiday": "שמחת תורה", "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), 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_havdalah": dt(2018, 10, 1, 19, 1),
"english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39),
"english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54),
@ -433,11 +370,14 @@ SHABBAT_PARAMS = [
"english_holiday": "Hoshana Raba", "english_holiday": "Hoshana Raba",
"hebrew_holiday": "הושענא רבה", "hebrew_holiday": "הושענא רבה",
}, },
None,
id="upcoming_one_day_yom_tov_in_israel",
), ),
make_jerusalem_test_params( pytest.param(
"Jerusalem",
dt(2018, 9, 30, 21, 25), 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_havdalah": dt(2018, 10, 1, 19, 1),
"english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 17, 39),
"english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54), "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 54),
@ -446,8 +386,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shmini Atzeret, Simchat Torah", "english_holiday": "Shmini Atzeret, Simchat Torah",
"hebrew_holiday": "שמיני עצרת, שמחת תורה", "hebrew_holiday": "שמיני עצרת, שמחת תורה",
}, },
None,
id="currently_one_day_yom_tov_in_israel",
), ),
make_jerusalem_test_params( pytest.param(
"Jerusalem",
dt(2018, 10, 1, 21, 25), dt(2018, 10, 1, 21, 25),
{ {
"english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39), "english_upcoming_candle_lighting": dt(2018, 10, 5, 17, 39),
@ -457,8 +400,11 @@ SHABBAT_PARAMS = [
"english_parshat_hashavua": "Bereshit", "english_parshat_hashavua": "Bereshit",
"hebrew_parshat_hashavua": "בראשית", "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), dt(2016, 6, 11, 8, 25),
{ {
"english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9),
@ -470,8 +416,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Erev Shavuot", "english_holiday": "Erev Shavuot",
"hebrew_holiday": "ערב שבועות", "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), dt(2016, 6, 12, 8, 25),
{ {
"english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9), "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 9),
@ -483,8 +432,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Shavuot", "english_holiday": "Shavuot",
"hebrew_holiday": "שבועות", "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), dt(2017, 9, 21, 8, 25),
{ {
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -496,8 +448,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana I", "english_holiday": "Rosh Hashana I",
"hebrew_holiday": "א' ראש השנה", "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), dt(2017, 9, 22, 8, 25),
{ {
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -509,8 +464,11 @@ SHABBAT_PARAMS = [
"english_holiday": "Rosh Hashana II", "english_holiday": "Rosh Hashana II",
"hebrew_holiday": "ב' ראש השנה", "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), dt(2017, 9, 23, 8, 25),
{ {
"english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58), "english_upcoming_candle_lighting": dt(2017, 9, 20, 17, 58),
@ -522,179 +480,70 @@ SHABBAT_PARAMS = [
"english_holiday": "", "english_holiday": "",
"hebrew_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("language", ["english", "hebrew"])
@pytest.mark.parametrize( @pytest.mark.parametrize(
( ("location_data", "test_time", "results", "havdalah_offset"),
"now",
"candle_lighting",
"havdalah",
"diaspora",
"tzname",
"latitude",
"longitude",
"result",
),
SHABBAT_PARAMS, 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( async def test_shabbat_times_sensor(
hass: HomeAssistant, hass: HomeAssistant, results: dict[str, Any], language: str
language,
now,
candle_lighting,
havdalah,
diaspora,
tzname,
latitude,
longitude,
result,
) -> None: ) -> None:
"""Test sensor output for upcoming shabbat/yomtov times.""" """Test sensor output for upcoming shabbat/yomtov times."""
time_zone = dt_util.get_time_zone(tzname) for sensor_type, result_value in results.items():
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():
if not sensor_type.startswith(language): if not sensor_type.startswith(language):
continue continue
sensor_type = sensor_type.replace(f"{language}_", "") sensor_type = sensor_type.replace(f"{language}_", "")
result_value = ( if isinstance(result_value, dt):
dt_util.as_utc(result_value).isoformat() result_value = dt_util.as_utc(result_value).isoformat()
if isinstance(result_value, dt)
else result_value
)
assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str( assert hass.states.get(f"sensor.jewish_calendar_{sensor_type}").state == str(
result_value result_value
), f"Value for {sensor_type}" ), f"Value for {sensor_type}"
OMER_PARAMS = [ @pytest.mark.parametrize(
(dt(2019, 4, 21, 0), "1"), ("test_time", "results"),
(dt(2019, 4, 21, 23), "2"), [
(dt(2019, 5, 23, 0), "33"), pytest.param(dt(2019, 4, 21, 0), "1", id="first_day_of_omer"),
(dt(2019, 6, 8, 0), "49"), pytest.param(dt(2019, 4, 21, 23), "2", id="first_day_of_omer_after_tzeit"),
(dt(2019, 6, 9, 0), "0"), pytest.param(dt(2019, 5, 23, 0), "33", id="lag_baomer"),
(dt(2019, 1, 1, 0), "0"), 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"),
OMER_TEST_IDS = [ pytest.param(dt(2019, 1, 1, 0), "0", id="jan_1st_no_omer"),
"first_day_of_omer", ],
"first_day_of_omer_after_tzeit", indirect=True,
"lag_baomer", )
"last_day_of_omer", @pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
"shavuot_no_omer", async def test_omer_sensor(hass: HomeAssistant, results: str) -> None:
"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:
"""Test Omer Count sensor output.""" """Test Omer Count sensor output."""
test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) assert hass.states.get("sensor.jewish_calendar_day_of_the_omer").state == results
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
DAFYOMI_PARAMS = [ @pytest.mark.parametrize(
(dt(2014, 4, 28, 0), "Beitzah 29"), ("test_time", "results"),
(dt(2020, 1, 4, 0), "Niddah 73"), [
(dt(2020, 1, 5, 0), "Berachos 2"), pytest.param(dt(2014, 4, 28, 0), "Beitzah 29", id="randomly_picked_date"),
(dt(2020, 3, 7, 0), "Berachos 64"), pytest.param(dt(2020, 1, 4, 0), "Niddah 73", id="end_of_cycle13"),
(dt(2020, 3, 8, 0), "Shabbos 2"), 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"),
DAFYOMI_TEST_IDS = [ pytest.param(dt(2020, 3, 8, 0), "Shabbos 2", id="cycle14_start_of_shabbos"),
"randomly_picked_date", ],
"end_of_cycle13", indirect=True,
"start_of_cycle14", )
"cycle14_end_of_berachos", @pytest.mark.usefixtures("entity_registry_enabled_by_default", "setup_at_time")
"cycle14_start_of_shabbos", async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None:
]
@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:
"""Test Daf Yomi sensor output.""" """Test Daf Yomi sensor output."""
test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results
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
async def test_no_discovery_info( async def test_no_discovery_info(

View File

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

View File

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

View File

@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.exceptions import RequestNotSuccessful
import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform 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 from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_binary_sensors( async def test_binary_sensors(
hass: HomeAssistant, hass: HomeAssistant,
mock_config_entry: MockConfigEntry, 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.const import CONF_ADDRESS, CONF_MAC, CONF_PASSWORD, CONF_TOKEN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from . import USER_INPUT, async_init_integration, get_bluetooth_service_info 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( async def test_bluetooth_discovery(
hass: HomeAssistant, hass: HomeAssistant,
mock_lamarzocco: MagicMock, mock_lamarzocco: MagicMock,

View File

@ -3,7 +3,12 @@
from typing import Any from typing import Any
from unittest.mock import MagicMock from unittest.mock import MagicMock
from pylamarzocco.const import SmartStandByType from pylamarzocco.const import (
ModelName,
PreExtractionMode,
SmartStandByType,
WidgetType,
)
from pylamarzocco.exceptions import RequestNotSuccessful from pylamarzocco.exceptions import RequestNotSuccessful
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -85,6 +90,140 @@ async def test_general_numbers(
mock_func.assert_called_once_with(**kwargs) 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") @pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_number_error( async def test_number_error(
hass: HomeAssistant, 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.""" """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.exceptions import RequestNotSuccessful
from pylamarzocco.models import UpdateDetails
import pytest import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
@ -15,6 +23,17 @@ from homeassistant.helpers import entity_registry as er
from . import async_init_integration from . import async_init_integration
from tests.common import MockConfigEntry, snapshot_platform 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( async def test_update(
@ -29,17 +48,51 @@ async def test_update(
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_update_entites( async def test_update_process(
hass: HomeAssistant, hass: HomeAssistant,
mock_lamarzocco: MagicMock, mock_lamarzocco: MagicMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test the La Marzocco update entities.""" """Test the La Marzocco update entities."""
serial_number = mock_lamarzocco.serial_number 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) 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( await hass.services.async_call(
UPDATE_DOMAIN, UPDATE_DOMAIN,
SERVICE_INSTALL, SERVICE_INSTALL,
@ -76,3 +129,35 @@ async def test_update_error(
blocking=True, blocking=True,
) )
assert exc_info.value.translation_key == "update_failed" 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 @pytest.fixture
def mock_thinq_api(mock_thinq_mqtt_client: AsyncMock) -> Generator[AsyncMock]: def mock_config_thinq_api() -> Generator[AsyncMock]:
"""Mock a thinq api.""" """Mock a thinq api."""
with ( with (
patch("homeassistant.components.lg_thinq.ThinQApi", autospec=True) as mock_api, 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, 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 = mock_api.return_value
thinq_api.async_get_device_list.return_value = [ thinq_api.async_get_device_list.return_value = [
load_json_object_fixture("air_conditioner/device.json", DOMAIN) 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 @pytest.fixture
def mock_thinq_mqtt_client() -> Generator[AsyncMock]: def mock_thinq_mqtt_client() -> Generator[AsyncMock]:
"""Mock a thinq api.""" """Mock a thinq mqtt client."""
with patch( with patch(
"homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient", autospec=True "homeassistant.components.lg_thinq.mqtt.ThinQMQTTClient",
) as mock_api: autospec=True,
yield mock_api return_value=True,
):
yield
@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

View File

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

View File

@ -1,22 +1,29 @@
"""Tests for the LG ThinQ integration.""" """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.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_load_unload_entry( async def test_load_unload_entry(
hass: HomeAssistant, hass: HomeAssistant,
mock_thinq_api: AsyncMock, mock_thinq_api: AsyncMock,
mock_thinq_mqtt_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test load and unload entry.""" """Test load and unload entry."""
mock_config_entry.add_to_hass(hass) with patch(
assert await hass.config_entries.async_setup(mock_config_entry.entry_id) "homeassistant.components.lg_thinq.ThinQMQTT.async_connect",
await hass.async_block_till_done() return_value=True,
):
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED assert mock_config_entry.state is ConfigEntryState.LOADED
@ -24,3 +31,21 @@ async def test_load_unload_entry(
await hass.async_block_till_done() await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED 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_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate", CONF_PROMPT: "Speak like a pirate",
CONF_CHAT_MODEL: "o1-mini", CONF_CHAT_MODEL: "o1-mini",
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
}, },
) )
await hass.async_block_till_done() 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_RECOMMENDED: True,
CONF_LLM_HASS_API: "none",
CONF_PROMPT: "bla", 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_CONTEXT_SIZE: "medium",
CONF_WEB_SEARCH_USER_LOCATION: False, 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_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", 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_RECOMMENDED: True,
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "",
},
{
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: ["assist"],
CONF_PROMPT: "", CONF_PROMPT: "",
}, },
), ),
@ -338,7 +354,7 @@ async def test_options_web_search_unsupported_model(
CONF_RECOMMENDED: False, CONF_RECOMMENDED: False,
CONF_PROMPT: "Speak like a pirate", CONF_PROMPT: "Speak like a pirate",
CONF_CHAT_MODEL: "o1-pro", CONF_CHAT_MODEL: "o1-pro",
CONF_LLM_HASS_API: "assist", CONF_LLM_HASS_API: ["assist"],
CONF_WEB_SEARCH: True, 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 from collections.abc import Generator
import datetime import datetime
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from renault_api.kamereon.exceptions import QuotaLimitException from renault_api.kamereon.exceptions import (
AccessDeniedException,
NotSupportedException,
QuotaLimitException,
)
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntry 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 not hass.states.get(entity_id).attributes.get(ATTR_ASSUMED_STATE)
assert "Renault API throttled" not in caplog.text assert "Renault API throttled" not in caplog.text
assert "Renault hub currently throttled: scan skipped" 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", "centralite",
"da_ref_normal_000001", "da_ref_normal_000001",
"da_ref_normal_01011", "da_ref_normal_01011",
"da_ref_normal_01001",
"vd_network_audio_002s", "vd_network_audio_002s",
"vd_network_audio_003s",
"vd_sensor_light_2023", "vd_sensor_light_2023",
"iphone", "iphone",
"da_sac_ehs_000001_sub", "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', '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] # name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_cooler_door-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -187,3 +187,50 @@
'state': 'unknown', '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, '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] # name: test_devices[da_ref_normal_01011]
DeviceRegistryEntrySnapshot({ DeviceRegistryEntrySnapshot({
'area_id': None, 'area_id': None,
@ -1652,6 +1685,39 @@
'via_device_id': None, '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] # name: test_devices[vd_sensor_light_2023]
DeviceRegistryEntrySnapshot({ DeviceRegistryEntrySnapshot({
'area_id': None, 'area_id': None,

View File

@ -231,6 +231,56 @@
'state': 'on', '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] # name: test_all_entities[vd_stv_2017_k][media_player.tv_samsung_8_series_49-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

View File

@ -4049,6 +4049,283 @@
'state': '0.0135559777781698', '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] # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry]
EntityRegistryEntrySnapshot({ EntityRegistryEntrySnapshot({
'aliases': set({ 'aliases': set({

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