Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions homeassistant/components/anova/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class AnovaSensorEntityDescription(SensorEntityDescription):
key="cook_time",
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="cook_time",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time,
Expand Down Expand Up @@ -62,6 +63,7 @@ class AnovaSensorEntityDescription(SensorEntityDescription):
AnovaSensorEntityDescription(
key="cook_time_remaining",
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
translation_key="cook_time_remaining",
device_class=SensorDeviceClass.DURATION,
value_fn=lambda data: data.cook_time_remaining,
Expand Down
16 changes: 5 additions & 11 deletions homeassistant/components/assist_satellite/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from dataclasses import dataclass, field
from enum import StrEnum
import logging
import time
from typing import Any, Literal, final, override

from hassil import Intents, recognize
Expand Down Expand Up @@ -442,6 +441,8 @@ async def async_accept_pipeline_from_satellite(
start_stage: PipelineStage = PipelineStage.STT,
end_stage: PipelineStage = PipelineStage.TTS,
wake_word_phrase: str | None = None,
*,
context: Context | None = None,
) -> None:
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
Expand Down Expand Up @@ -485,15 +486,8 @@ async def async_accept_pipeline_from_satellite(

device_id = self.registry_entry.device_id if self.registry_entry else None

# Refresh context if necessary
if (
(self._context is None)
or (self._context_set is None)
or ((time.time() - self._context_set) > entity.CONTEXT_RECENT_TIME_SECONDS)
):
self.async_set_context(Context())

assert self._context is not None
context = context or Context()
self.async_set_context(context)

# Set entity state based on pipeline events
self._run_has_tts = False
Expand All @@ -511,7 +505,7 @@ async def async_accept_pipeline_from_satellite(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
context=context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
Expand Down
3 changes: 1 addition & 2 deletions homeassistant/components/voip/assist_satellite.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,6 @@ async def _run_pipeline(self) -> None:
"""Run a pipeline with STT input and TTS output."""
_LOGGER.debug("Starting pipeline")

self.async_set_context(Context(user_id=self.config_entry.data["user"]))

async def stt_stream():
retry: bool = True
while True:
Expand All @@ -455,6 +453,7 @@ async def stt_stream():
try:
await self.async_accept_pipeline_from_satellite(
audio_stream=stt_stream(),
context=Context(user_id=self.config_entry.data["user"]),
)

if self._pipeline_had_error:
Expand Down
45 changes: 40 additions & 5 deletions homeassistant/components/zwave_js/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
)
from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_IGNORE,
SOURCE_USB,
SOURCE_ZEROCONF,
ConfigEntry,
Expand Down Expand Up @@ -235,6 +236,9 @@ def __init__(self) -> None:
self._recommended_install = False
self._rf_region: str | None = None
self._entry_unloaded_by_flow = False
# Set if the flow unique id is a placeholder that must be replaced
# with the home ID before a config entry is created.
self._unique_id_is_placeholder = False

async def async_step_install_addon(
self, user_input: dict[str, Any] | None = None
Expand Down Expand Up @@ -571,10 +575,13 @@ async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResu
await self.async_set_unique_id(
f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
)
# We don't need to check if the unique_id is already configured
# since we will update the unique_id before finishing the flow.
# The unique_id set above is just a temporary value to avoid
# duplicate discovery flows.
# The unique id set above is a placeholder that is replaced with the
# home ID before an entry is created, so only check ignored entries.
if any(
entry.source == SOURCE_IGNORE and entry.unique_id == self.unique_id
for entry in self._async_current_entries(include_ignore=True)
):
return self.async_abort(reason="already_configured")
dev_path = discovery_info.device
self.usb_path = dev_path
if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2":
Expand Down Expand Up @@ -1025,7 +1032,11 @@ async def async_step_finish_addon_setup_user(
discovery_info = await self._async_get_addon_discovery_info()
self.ws_address = f"ws://{discovery_info['host']}:{discovery_info['port']}"

if not self.unique_id or self.source == SOURCE_USB:
if (
not self.unique_id
or self.source == SOURCE_USB
or self._unique_id_is_placeholder
):
if not self.version_info:
try:
self.version_info = await async_get_version_info(
Expand All @@ -1037,6 +1048,7 @@ async def async_step_finish_addon_setup_user(
await self.async_set_unique_id(
str(self.version_info.home_id), raise_on_progress=False
)
self._unique_id_is_placeholder = False

if (
existing_entry := next(
Expand Down Expand Up @@ -1659,6 +1671,11 @@ async def async_step_esphome(
if not is_hassio(self.hass):
return self.async_abort(reason="not_hassio")

# The adapter may first be discovered without a home ID and get the
# placeholder unique id below, then report a home ID on a later
# discovery. Track the placeholder id so such a discovery can be
# deduplicated against a pending prompt or an ignored entry.
placeholder_unique_id = f"esphome_{discovery_info.name}"
if discovery_info.zwave_home_id:
existing_entry: ConfigEntry | None = None
if (
Expand Down Expand Up @@ -1706,6 +1723,11 @@ async def async_step_esphome(
)
return self.async_abort(reason="already_configured")

if any(
flow["context"].get("unique_id") == placeholder_unique_id
for flow in self._async_in_progress()
):
return self.async_abort(reason="already_in_progress")
# We are not aborting if home ID configured
# here, we just want to make sure that it's set
# We will update a USB based config entry
Expand All @@ -1714,6 +1736,19 @@ async def async_step_esphome(
await self.async_set_unique_id(
str(discovery_info.zwave_home_id), raise_on_progress=False
)
else:
# Set a placeholder unique id so the discovery can be ignored
# also when the adapter doesn't report a home ID yet.
# It is replaced with the home ID before an entry is created.
self._unique_id_is_placeholder = True
await self.async_set_unique_id(placeholder_unique_id)

if any(
entry.source == SOURCE_IGNORE
and entry.unique_id in (self.unique_id, placeholder_unique_id)
for entry in self._async_current_entries(include_ignore=True)
):
return self.async_abort(reason="already_configured")

self.socket_path = discovery_info.socket_path
home_id_display = format_home_id_for_display(discovery_info.zwave_home_id)
Expand Down
10 changes: 8 additions & 2 deletions tests/components/anova/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ async def test_sensors(hass: HomeAssistant, anova_api: AnovaApi) -> None:
assert len(hass.states.async_all("sensor")) == 8
assert (
hass.states.get("sensor.anova_precision_cooker_cook_time_remaining").state
== "0"
== "0.0"
)
assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0.0"
assert (
hass.states.get("sensor.anova_precision_cooker_cook_time").attributes[
"unit_of_measurement"
]
== "h"
)
assert hass.states.get("sensor.anova_precision_cooker_cook_time").state == "0"
assert (
hass.states.get("sensor.anova_precision_cooker_heater_temperature").state
== "22.37"
Expand Down
27 changes: 16 additions & 11 deletions tests/components/assist_satellite/test_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,10 @@ async def test_entity_state(
context = Context()
audio_stream = object()

entity.async_set_context(context)

with patch(
"homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream"
) as mock_start_pipeline:
await entity.async_accept_pipeline_from_satellite(audio_stream)
await entity.async_accept_pipeline_from_satellite(audio_stream, context=context)

assert mock_start_pipeline.called
kwargs = mock_start_pipeline.call_args[1]
Expand Down Expand Up @@ -466,22 +464,28 @@ async def async_announce(announcement):
)


async def test_context_refresh(
async def test_context_not_inherited(
hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite
) -> None:
"""Test that the context will be automatically refreshed."""
"""Test that audio from the satellite does not inherit an existing context."""
audio_stream = object()

# Remove context
entity._context = None
# A previous action targeting the entity, such as an announce service call
previous_context = Context(user_id="12345")
entity.async_set_context(previous_context)

with patch(
"homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream"
):
) as mock_start_pipeline:
await entity.async_accept_pipeline_from_satellite(audio_stream)

# Context should have been refreshed
assert entity._context is not None
# The speaker is unknown, so the pipeline must not run as the previous user
context = mock_start_pipeline.call_args[1]["context"]
assert context is not previous_context
assert context.user_id is None

# The pipeline drives the entity state from here, so it owns the context
assert entity._context is context


async def test_pipeline_entity(
Expand Down Expand Up @@ -936,6 +940,8 @@ async def speech_to_text(self, *args, **kwargs):
async def async_start_conversation(start_announcement):
# Verify state change
assert entity.state == AssistSatelliteState.RESPONDING
# The question is asked on behalf of the caller
assert hass.states.get(entity_id).context is context
assert (
start_announcement.preannounce_media_id is not None
) is should_preannounce
Expand Down Expand Up @@ -987,7 +993,6 @@ async def async_start_conversation(start_announcement):
)
assert entity.state == AssistSatelliteState.IDLE
assert response == asdict(expected_answer)
assert hass.states.get(entity_id).context is context


async def test_ask_question_requires_entity_permission(
Expand Down
Loading
Loading