Compare commits

..

9 Commits

Author SHA1 Message Date
Joakim Sørensen f18291259e Merge branch 'dev' into Add-devcontainer-lock.json-file 2026-05-24 09:27:10 +02:00
J. Nick Koston c056242390 Bump habluetooth to 6.7.1 (#172000) 2026-05-24 08:52:21 +02:00
J. Nick Koston 9cbb14bbde Bump inkbird-ble to 1.1.2 (#172011) 2026-05-24 08:41:11 +02:00
Allen Porter 6634c4ce78 Replace duplicate constant ATTR_ELEVATION in fitbit (#172018) 2026-05-24 08:40:32 +02:00
Allen Porter ae1355666b Remove positional message strings from roborock exceptions (#172016) 2026-05-23 22:14:12 -07:00
Allen Porter 2d0d202b80 Fix exception translation placeholder mismatch in roborock (#172014) 2026-05-23 22:14:02 -07:00
skye-harris 9fd48344f8 Reorder device location context towards the end of the Assist LLM instructions (#165136) 2026-05-23 20:51:17 -07:00
J. Nick Koston 7b4ed59861 Change default ESPHome bluetooth proxy scanning mode to Auto (#171996) 2026-05-23 18:21:37 -05:00
ludeeus 296d625121 Add devcontainer-lock.json file 2026-05-23 18:11:53 +00:00
19 changed files with 593 additions and 125 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {
"version": "1.1.0",
"resolved": "ghcr.io/devcontainers/features/github-cli@sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671",
"integrity": "sha256:d22f50b70ed75339b4eed1ba9ecde3a1791f90e88d37936517e3bace0bbad671"
}
}
}
@@ -70,7 +70,6 @@ from .api import (
async_register_callback,
async_register_scanner,
async_remove_scanner,
async_request_active_scan,
async_scanner_by_source,
async_scanner_count,
async_scanner_devices_by_address,
@@ -129,7 +128,6 @@ __all__ = [
"async_register_callback",
"async_register_scanner",
"async_remove_scanner",
"async_request_active_scan",
"async_scanner_by_source",
"async_scanner_count",
"async_scanner_devices_by_address",
-16
View File
@@ -284,19 +284,3 @@ def async_set_fallback_availability_interval(
) -> None:
"""Override the fallback availability timeout for a MAC address."""
_get_manager(hass).async_set_fallback_availability_interval(address, interval)
async def async_request_active_scan(
hass: HomeAssistant, duration: float | None = None
) -> None:
"""Run an on-demand active sweep across every AUTO scanner.
Intended for config-flow discovery and other one-shot probes that
need fresh advertisements without waiting for the periodic
rediscovery cadence. Awaits ``duration`` seconds so the caller can
then read newly discovered advertisements. Defaults to habluetooth's
on-demand sweep duration when ``duration`` is not provided; the
scheduler clamps the value to its allowed range. Concurrent callers
dedupe to a single bus-wide window.
"""
await _get_manager(hass).async_request_active_scan(duration)
+94 -18
View File
@@ -1,16 +1,33 @@
"""Bluetooth support for esphome."""
from functools import partial
import logging
from typing import TYPE_CHECKING
from aioesphomeapi import APIClient, DeviceInfo
from aioesphomeapi import (
APIClient,
APIVersion,
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerStateResponse,
DeviceInfo,
)
from bleak_esphome import connect_scanner
from homeassistant.components.bluetooth import async_register_scanner
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_register_scanner,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
from .const import DOMAIN
from .entry_data import RuntimeEntryData
from .const import CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE, DOMAIN
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
if TYPE_CHECKING:
from bleak_esphome.backend.scanner import ESPHomeScanner
_LOGGER = logging.getLogger(__name__)
_VALID_SCANNING_MODES = {mode.value for mode in BluetoothScanningMode}
@hass_callback
@@ -23,6 +40,7 @@ def _async_unload(unload_callbacks: list[CALLBACK_TYPE]) -> None:
@hass_callback
def async_connect_scanner(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
@@ -35,17 +53,75 @@ def async_connect_scanner(
scanner = client_data.scanner
if TYPE_CHECKING:
assert scanner is not None
return partial(
_async_unload,
[
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],
)
api_version = cli.api_version or APIVersion()
feature_flags = device_info.bluetooth_proxy_feature_flags_compat(api_version)
state_and_mode = bool(feature_flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
# Pin mode before async_register_scanner so habluetooth spawns the AUTO worker.
deferred_migration: CALLBACK_TYPE | None = None
if state_and_mode:
deferred_migration = _async_apply_scanning_mode(hass, entry, scanner, cli)
callbacks: list[CALLBACK_TYPE] = [
async_register_scanner(
hass,
scanner,
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
]
if deferred_migration is not None:
callbacks.append(deferred_migration)
return partial(_async_unload, callbacks)
@hass_callback
def _async_apply_scanning_mode(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
scanner: ESPHomeScanner,
cli: APIClient,
) -> CALLBACK_TYPE | None:
"""Apply saved scanning mode synchronously; migrate from configured_mode later."""
saved = entry.options.get(CONF_BLUETOOTH_SCANNING_MODE)
if saved is not None and saved not in _VALID_SCANNING_MODES:
_LOGGER.warning("%s: unknown scanning mode %r", entry.title, saved)
saved = None
initial_value = saved if saved is not None else DEFAULT_BLUETOOTH_SCANNING_MODE
scanner.async_set_scanning_mode(BluetoothScanningMode(initial_value))
if saved is not None:
return None
unsub_holder: list[CALLBACK_TYPE] = []
@hass_callback
def _migrate(state: BluetoothScannerStateResponse) -> None:
# proto3 unset enums decode to None; wait for a real value.
if (configured_pb := state.configured_mode) is None:
return
if unsub_holder:
unsub_holder.pop()()
if configured_pb is BluetoothScannerMode.PASSIVE:
new_mode = BluetoothScanningMode.PASSIVE
else:
new_mode = BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE)
hass.config_entries.async_update_entry(
entry,
options={
**entry.options,
CONF_BLUETOOTH_SCANNING_MODE: new_mode.value,
},
)
# AUTO -> AUTO is already pinned; only re-apply on a downgrade.
if new_mode is not BluetoothScanningMode(DEFAULT_BLUETOOTH_SCANNING_MODE):
scanner.async_set_scanning_mode(new_mode)
unsub_holder.append(cli.subscribe_bluetooth_scanner_state(_migrate))
@hass_callback
def _unsubscribe() -> None:
if unsub_holder:
unsub_holder.pop()()
return _unsubscribe
+59 -12
View File
@@ -9,6 +9,7 @@ from typing import Any, cast
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -20,6 +21,7 @@ import aiohttp
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.config_entries import (
SOURCE_ESPHOME,
SOURCE_IGNORE,
@@ -38,6 +40,11 @@ from homeassistant.data_entry_flow import AbortFlow, FlowResultType
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.importlib import async_import_module
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.esphome import ESPHomeServiceInfo
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
@@ -47,10 +54,12 @@ from homeassistant.util.json import json_loads_object
from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_SCANNING_MODE,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_BLUETOOTH_SCANNING_MODE,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DEFAULT_PORT,
DOMAIN,
@@ -68,6 +77,18 @@ _LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
DEFAULT_NAME = "ESPHome"
_BLUETOOTH_SCANNING_MODE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[
BluetoothScanningMode.AUTO.value,
BluetoothScanningMode.ACTIVE.value,
BluetoothScanningMode.PASSIVE.value,
],
translation_key="bluetooth_scanning_mode",
mode=SelectSelectorMode.DROPDOWN,
)
)
class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a esphome config flow."""
@@ -936,18 +957,44 @@ class OptionsFlowHandler(OptionsFlowWithReload):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
data_schema = vol.Schema(
{
options = self.config_entry.options
schema: dict[Any, Any] = {
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
if _entry_has_bluetooth_scanner(self.config_entry):
schema[
vol.Required(
CONF_ALLOW_SERVICE_CALLS,
default=self.config_entry.options.get(
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
CONF_BLUETOOTH_SCANNING_MODE,
default=options.get(
CONF_BLUETOOTH_SCANNING_MODE, DEFAULT_BLUETOOTH_SCANNING_MODE
),
): bool,
vol.Required(
CONF_SUBSCRIBE_LOGS,
default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
): bool,
}
)
] = _BLUETOOTH_SCANNING_MODE_SELECTOR
return self.async_show_form(step_id="init", data_schema=vol.Schema(schema))
@callback
def _entry_has_bluetooth_scanner(entry: ESPHomeConfigEntry) -> bool:
"""Return True if the entry exposes a bluetooth proxy scanner or has one saved."""
# Keep showing the option if it was previously saved, even when the
# device is offline or stops advertising the feature flag, so the
# saved value isn't silently dropped on the next options save.
if CONF_BLUETOOTH_SCANNING_MODE in entry.options:
return True
if entry.state is ConfigEntryState.LOADED and (
device_info := entry.runtime_data.device_info
):
flags = device_info.bluetooth_proxy_feature_flags_compat(
entry.runtime_data.api_version
)
return self.async_show_form(step_id="init", data_schema=data_schema)
return bool(flags & BluetoothProxyFeature.FEATURE_STATE_AND_MODE)
return False
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Final
from awesomeversion import AwesomeVersion
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
@@ -18,9 +19,11 @@ CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
CONF_BLUETOOTH_SCANNING_MODE = "bluetooth_scanning_mode"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
DEFAULT_BLUETOOTH_SCANNING_MODE = BluetoothScanningMode.AUTO.value
DEFAULT_PORT: Final = 6053
+1 -1
View File
@@ -669,7 +669,7 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
hass, self.entry, entry_data, cli, device_info, self.device_id
)
)
else:
@@ -209,13 +209,24 @@
"init": {
"data": {
"allow_service_calls": "Allow the device to perform Home Assistant actions.",
"bluetooth_scanning_mode": "Bluetooth scanning mode",
"subscribe_logs": "Subscribe to logs from the device."
},
"data_description": {
"allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions or send events. Only enable this if you trust the device.",
"bluetooth_scanning_mode": "Auto is recommended for most setups. It saves battery on your Bluetooth devices while still catching new devices and updates quickly.",
"subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
}
},
"selector": {
"bluetooth_scanning_mode": {
"options": {
"active": "Active (uses more device battery, fastest updates)",
"auto": "Auto (recommended, saves device battery)",
"passive": "Passive (lowest device battery use, some details may be missing)"
}
}
}
}
-2
View File
@@ -13,8 +13,6 @@ ATTR_LAST_SAVED_AT: Final = "last_saved_at"
ATTR_DURATION: Final = "duration"
ATTR_DISTANCE: Final = "distance"
# pylint: disable-next=home-assistant-duplicate-const
ATTR_ELEVATION: Final = "elevation"
ATTR_HEIGHT: Final = "height"
ATTR_WEIGHT: Final = "weight"
ATTR_BODY: Final = "body"
@@ -63,5 +63,5 @@
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["inkbird-ble==1.1.1"]
"requirements": ["inkbird-ble==1.1.2"]
}
@@ -95,9 +95,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
prefer_cache=False,
)
except RoborockInvalidCredentials as err:
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ConfigEntryAuthFailed(
"Invalid credentials",
translation_domain=DOMAIN,
translation_key="invalid_credentials",
) from err
@@ -118,9 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
) from err
except RoborockException as err:
_LOGGER.debug("Failed to get Roborock home data: %s", err)
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ConfigEntryNotReady(
"Failed to get Roborock home data",
translation_domain=DOMAIN,
translation_key="home_data_fail",
) from err
@@ -178,9 +174,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
len(v1_coords) + len(a01_coords) + len(b01_q7_coords) + len(b01_q10_coords) == 0
and enabled_devices
):
# pylint: disable-next=home-assistant-exception-message-with-translation
raise ConfigEntryNotReady(
"No devices were able to successfully setup",
translation_domain=DOMAIN,
translation_key="no_coordinators",
)
@@ -161,11 +161,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceState | None]):
_LOGGER.info("Home discovery skipped while device is busy/cleaning")
except RoborockException as err:
_LOGGER.debug("Failed to get maps: %s", err)
# pylint: disable-next=home-assistant-exception-placeholder-mismatch
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="map_failure",
translation_placeholders={"error": str(err)},
) from err
else:
# Force a map refresh on first setup
+39 -36
View File
@@ -71,6 +71,13 @@ NO_ENTITIES_PROMPT = (
"to their voice assistant in Home Assistant."
)
DEVICE_CONTROL_TOOL_USAGE_PROMPT = (
"When controlling Home Assistant always call the intent tools. "
"Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
"When controlling a device, prefer passing just name and domain. "
"When controlling an area, prefer passing just area name and domain."
)
DYNAMIC_CONTEXT_PROMPT = (
"You ARE equipped to answer questions about the"
" current state of\n"
@@ -496,27 +503,33 @@ class AssistAPI(API):
) -> str:
if not exposed_entities or not exposed_entities["entities"]:
return NO_ENTITIES_PROMPT
return "\n".join(
[
*self._async_get_preable(llm_context),
*self._async_get_exposed_entities_prompt(llm_context, exposed_entities),
]
)
# Collect all parts, filtering out any None values
prompt_parts = [
DEVICE_CONTROL_TOOL_USAGE_PROMPT,
DYNAMIC_CONTEXT_PROMPT,
*self._async_get_exposed_entities_prompt(exposed_entities),
self._async_get_voice_satellite_area_prompt(llm_context),
self._async_get_no_timer_prompt(llm_context),
]
# Filter out None and empty strings before joining
return "\n".join([part for part in prompt_parts if part])
@callback
def _async_get_preable(self, llm_context: LLMContext) -> list[str]:
"""Return the prompt for the API."""
def _async_get_no_timer_prompt(self, llm_context: LLMContext) -> str | None:
if not llm_context.device_id or not async_device_supports_timers(
self.hass, llm_context.device_id
):
return "This device is not able to start timers."
return None
prompt = [
(
"When controlling Home Assistant always call the intent tools. "
"Use HassTurnOn to lock and HassTurnOff to unlock a lock. "
"When controlling a device, prefer passing just name and domain. "
"When controlling an area, prefer passing just area name and domain."
)
]
area: ar.AreaEntry | None = None
@callback
def _async_get_voice_satellite_area_prompt(self, llm_context: LLMContext) -> str:
"""Return the area prompt for the voice satellite."""
floor: fr.FloorEntry | None = None
area: ar.AreaEntry | None = None
extra = ""
if llm_context.device_id:
device_reg = dr.async_get(self.hass)
device = device_reg.async_get(llm_context.device_id)
@@ -535,28 +548,18 @@ class AssistAPI(API):
)
if floor and area:
prompt.append(f"You are in area {area.name} (floor {floor.name}) {extra}")
elif area:
prompt.append(f"You are in area {area.name} {extra}")
else:
prompt.append(
"When a user asks to turn on all devices of a specific type, "
"ask user to specify an area, unless there"
" is only one device of that type."
)
if not llm_context.device_id or not async_device_supports_timers(
self.hass, llm_context.device_id
):
prompt.append("This device is not able to start timers.")
prompt.append(DYNAMIC_CONTEXT_PROMPT)
return prompt
return f"You are in area {area.name} (floor {floor.name}) {extra}".strip()
if area:
return f"You are in area {area.name} {extra}".strip()
return (
"When a user asks to turn on all devices of a specific type, "
"ask the user to specify an area, unless there"
" is only one device of that type."
)
@callback
def _async_get_exposed_entities_prompt(
self, llm_context: LLMContext, exposed_entities: dict | None
self, exposed_entities: dict | None
) -> list[str]:
"""Return the prompt for the API for exposed entities."""
prompt = []
+1 -1
View File
@@ -1359,7 +1359,7 @@ influxdb==5.3.1
infrared-protocols==5.4.0
# homeassistant.components.inkbird
inkbird-ble==1.1.1
inkbird-ble==1.1.2
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.6.2
-10
View File
@@ -14,7 +14,6 @@ from homeassistant.components.bluetooth import (
BluetoothServiceInfo,
HaBluetoothConnector,
async_clear_advertisement_history,
async_request_active_scan,
async_scanner_by_source,
async_scanner_devices_by_address,
)
@@ -279,12 +278,3 @@ async def test_clear_advertisement_history(hass: HomeAssistant) -> None:
assert len(callbacks) == 2
cancel()
@pytest.mark.usefixtures("enable_bluetooth")
async def test_async_request_active_scan(hass: HomeAssistant) -> None:
"""Test async_request_active_scan completes without error."""
await async_request_active_scan(hass, 0.01)
with pytest.raises(ValueError):
await async_request_active_scan(hass, 0)
+253 -3
View File
@@ -1,12 +1,30 @@
"""Test the ESPHome bluetooth integration."""
from unittest.mock import patch
from collections.abc import Callable
from typing import Any
from unittest.mock import MagicMock, patch
from aioesphomeapi import (
BluetoothProxyFeature,
BluetoothScannerMode,
BluetoothScannerState,
BluetoothScannerStateResponse,
)
from homeassistant.components import bluetooth
from homeassistant.core import HomeAssistant
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.esphome.const import CONF_BLUETOOTH_SCANNING_MODE
from homeassistant.core import HomeAssistant, callback as hass_callback
from homeassistant.helpers import device_registry as dr
from .conftest import MockESPHomeDevice
from .conftest import MockBluetoothEntryType, MockESPHomeDevice
_PROXY_WITH_STATE_AND_MODE = (
BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
)
async def test_bluetooth_connect_with_raw_adv(
@@ -94,3 +112,235 @@ async def test_bluetooth_cleanup_on_remove_entry(
await hass.async_block_till_done()
remove_mock.assert_called_once_with(hass, scanner.source)
async def test_scanning_mode_saved_option_applied(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""A saved CONF_BLUETOOTH_SCANNING_MODE is applied immediately to the proxy."""
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
hass.config_entries.async_update_entry(
device.entry,
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
)
set_mode_mock = MagicMock()
device.client.bluetooth_scanner_set_mode = set_mode_mock
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
async def test_scanning_mode_invalid_option_falls_back_to_default(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""A malformed saved value falls back to the AUTO default instead of raising."""
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
hass.config_entries.async_update_entry(
device.entry,
options={**device.entry.options, CONF_BLUETOOTH_SCANNING_MODE: "bogus"},
)
set_mode_mock = MagicMock()
device.client.bluetooth_scanner_set_mode = set_mode_mock
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
# AUTO maps to PASSIVE on the firmware.
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
async def test_scanning_mode_migration_passive_is_honored(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""Proxy configured PASSIVE in YAML is honored on first state update."""
set_mode_mock = MagicMock()
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
def _subscribe(
callback: Callable[[BluetoothScannerStateResponse], None],
) -> Callable[[], None]:
state_subscriptions.append(callback)
return lambda: state_subscriptions.remove(callback)
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
device.client.bluetooth_scanner_set_mode = set_mode_mock
device.client.subscribe_bluetooth_scanner_state = _subscribe
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
assert state_subscriptions
for callback in state_subscriptions[:]:
callback(
BluetoothScannerStateResponse(
state=BluetoothScannerState.RUNNING,
mode=BluetoothScannerMode.PASSIVE,
configured_mode=BluetoothScannerMode.PASSIVE,
)
)
await hass.async_block_till_done()
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
set_mode_mock.assert_any_call(BluetoothScannerMode.PASSIVE)
async def test_scanning_mode_migration_waits_for_known_configured_mode(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""An initial state with configured_mode=None must not commit a migration."""
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
set_mode_mock = MagicMock()
def _subscribe(
callback: Callable[[BluetoothScannerStateResponse], None],
) -> Callable[[], None]:
state_subscriptions.append(callback)
return lambda: state_subscriptions.remove(callback)
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
device.client.bluetooth_scanner_set_mode = set_mode_mock
device.client.subscribe_bluetooth_scanner_state = _subscribe
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
assert state_subscriptions
for callback in state_subscriptions[:]:
callback(
BluetoothScannerStateResponse(
state=BluetoothScannerState.RUNNING,
mode=None,
configured_mode=None,
)
)
await hass.async_block_till_done()
assert CONF_BLUETOOTH_SCANNING_MODE not in device.entry.options
# A second response with a real configured_mode commits the migration.
for callback in state_subscriptions[:]:
callback(
BluetoothScannerStateResponse(
state=BluetoothScannerState.RUNNING,
mode=BluetoothScannerMode.PASSIVE,
configured_mode=BluetoothScannerMode.PASSIVE,
)
)
await hass.async_block_till_done()
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "passive"
async def test_scanning_mode_pending_subscription_unsubscribes_on_unload(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""Unloading before the first state update cancels the migration subscription."""
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
unsub_calls: list[Callable[[BluetoothScannerStateResponse], None]] = []
def _subscribe(
callback: Callable[[BluetoothScannerStateResponse], None],
) -> Callable[[], None]:
state_subscriptions.append(callback)
def _unsub() -> None:
unsub_calls.append(callback)
state_subscriptions.remove(callback)
return _unsub
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
device.client.subscribe_bluetooth_scanner_state = _subscribe
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
# The migration subscription is pending; tear the entry down without
# firing a state update so _unsubscribe in bluetooth.py runs the
# cancellation arm.
assert state_subscriptions
await hass.config_entries.async_unload(device.entry.entry_id)
await hass.async_block_till_done()
assert unsub_calls
async def test_scanning_mode_migration_active_becomes_auto(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""Proxy configured ACTIVE migrates to AUTO on first state update."""
set_mode_mock = MagicMock()
state_subscriptions: list[Callable[[BluetoothScannerStateResponse], None]] = []
def _subscribe(
callback: Callable[[BluetoothScannerStateResponse], None],
) -> Callable[[], None]:
state_subscriptions.append(callback)
return lambda: state_subscriptions.remove(callback)
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
device.client.bluetooth_scanner_set_mode = set_mode_mock
device.client.subscribe_bluetooth_scanner_state = _subscribe
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
# AUTO was applied at setup before async_register_scanner so habluetooth's
# scheduler spawns a worker; AUTO maps to PASSIVE on the firmware.
assert set_mode_mock.call_args_list == [((BluetoothScannerMode.PASSIVE,), {})]
set_mode_mock.reset_mock()
assert state_subscriptions
for callback in state_subscriptions[:]:
callback(
BluetoothScannerStateResponse(
state=BluetoothScannerState.RUNNING,
mode=BluetoothScannerMode.ACTIVE,
configured_mode=BluetoothScannerMode.ACTIVE,
)
)
await hass.async_block_till_done()
assert device.entry.options[CONF_BLUETOOTH_SCANNING_MODE] == "auto"
# AUTO -> AUTO does not re-send a firmware command.
set_mode_mock.assert_not_called()
async def test_scanning_mode_default_pinned_before_register(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""The default AUTO is applied immediately so the AUTO worker spawns at register."""
set_mode_mock = MagicMock()
requested_at_register: list[BluetoothScanningMode | None] = []
real_register = bluetooth.async_register_scanner
@hass_callback
def _spy_register(*args: Any, **kwargs: Any) -> Callable[[], None]:
requested_at_register.append(args[1].requested_mode)
return real_register(*args, **kwargs)
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=_PROXY_WITH_STATE_AND_MODE
)
device.client.bluetooth_scanner_set_mode = set_mode_mock
with patch(
"homeassistant.components.esphome.bluetooth.async_register_scanner",
_spy_register,
):
await hass.config_entries.async_reload(device.entry.entry_id)
await hass.async_block_till_done()
# AUTO -> PASSIVE is sent before async_register_scanner, so the
# habluetooth auto-mode worker is spawned at registration time.
set_mode_mock.assert_called_once_with(BluetoothScannerMode.PASSIVE)
assert requested_at_register == [BluetoothScanningMode.AUTO]
+107 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aioesphomeapi import (
APIClient,
APIConnectionError,
BluetoothProxyFeature,
DeviceInfo,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
@@ -22,9 +23,11 @@ from homeassistant import config_entries
from homeassistant.components.esphome import dashboard
from homeassistant.components.esphome.const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_BLUETOOTH_SCANNING_MODE,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
CONF_SUBSCRIBE_LOGS,
DEFAULT_BLUETOOTH_SCANNING_MODE,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
DOMAIN,
)
@@ -43,7 +46,11 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import VALID_NOISE_PSK
from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType
from .conftest import (
MockBluetoothEntryType,
MockESPHomeDeviceType,
MockGenericDeviceEntryType,
)
from tests.common import MockConfigEntry
@@ -2078,6 +2085,105 @@ async def test_option_flow_subscribe_logs(
assert len(mock_reload.mock_calls) == 1
async def test_option_flow_shows_saved_scanning_mode_when_proxy_unavailable(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""A previously-saved mode keeps surfacing even if the proxy feature flag is gone."""
entry = await mock_generic_device_entry(mock_client=mock_client)
hass.config_entries.async_update_entry(
entry,
options={**entry.options, CONF_BLUETOOTH_SCANNING_MODE: "passive"},
)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE in result["data_schema"].schema
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: False,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: "auto",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "auto"
async def test_option_flow_unloaded_entry_without_saved_mode(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""An unloaded entry without a saved scanning mode hides the option."""
entry = await mock_generic_device_entry(mock_client=mock_client)
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
async def test_option_flow_hides_bluetooth_scanning_mode_without_proxy(
hass: HomeAssistant,
mock_client: APIClient,
mock_generic_device_entry: MockGenericDeviceEntryType,
) -> None:
"""Devices without a bluetooth proxy must not see the scanning mode option."""
entry = await mock_generic_device_entry(mock_client=mock_client)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data_schema"].schema
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_ALLOW_SERVICE_CALLS: False, CONF_SUBSCRIBE_LOGS: False},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert CONF_BLUETOOTH_SCANNING_MODE not in result["data"]
async def test_option_flow_bluetooth_scanning_mode(
hass: HomeAssistant,
mock_bluetooth_entry: MockBluetoothEntryType,
) -> None:
"""Bluetooth proxy devices with FEATURE_STATE_AND_MODE expose the option."""
device = await mock_bluetooth_entry(
bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN
| BluetoothProxyFeature.ACTIVE_CONNECTIONS
| BluetoothProxyFeature.RAW_ADVERTISEMENTS
| BluetoothProxyFeature.FEATURE_STATE_AND_MODE
)
entry = device.entry
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["data_schema"]({}) == {
CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: DEFAULT_BLUETOOTH_SCANNING_MODE,
}
with patch("homeassistant.components.esphome.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_ALLOW_SERVICE_CALLS: False,
CONF_SUBSCRIBE_LOGS: False,
CONF_BLUETOOTH_SCANNING_MODE: "passive",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_BLUETOOTH_SCANNING_MODE] == "passive"
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
async def test_user_discovers_name_no_dashboard(
hass: HomeAssistant,
+2 -2
View File
@@ -61,7 +61,7 @@ INITIALIZE_MESSAGE = {
}
EVENT_PREFIX = "event: "
DATA_PREFIX = "data: "
EXPECTED_PROMPT_SUFFIX = """
EXPECTED_PROMPT_ENTITY_DEFINITION = """
- names: Kitchen Light
domain: light
areas: Kitchen
@@ -481,7 +481,7 @@ async def test_prompt_get(
assert result.messages[0].role == "assistant"
assert result.messages[0].content.type == "text"
assert "When controlling Home Assistant" in result.messages[0].content.text
assert result.messages[0].content.text.endswith(EXPECTED_PROMPT_SUFFIX)
assert EXPECTED_PROMPT_ENTITY_DEFINITION in result.messages[0].content.text
async def test_get_unknown_prompt(
+13 -12
View File
@@ -419,6 +419,7 @@ async def test_assist_api_prompt(
device_id=None,
)
api = await llm.async_get_api(hass, "assist", llm_context)
assert api.api_prompt == (
"Only if the user wants to control a device, tell them to expose"
" entities to their "
@@ -677,7 +678,7 @@ Static Context: An overview of the areas and the devices in this smart home:
area_prompt = (
"When a user asks to turn on all devices of a specific type, "
"ask user to specify an area, unless there is only one device of that type."
"ask the user to specify an area, unless there is only one device of that type."
)
dynamic_context_prompt = (
"You ARE equipped to answer questions about the"
@@ -708,10 +709,10 @@ Static Context: An overview of the areas and the devices in this smart home:
api = await llm.async_get_api(hass, "assist", llm_context)
assert api.api_prompt == (
f"""{first_part_prompt}
{area_prompt}
{no_timer_prompt}
{dynamic_context_prompt}
{stateless_exposed_entities_prompt}"""
{stateless_exposed_entities_prompt}
{area_prompt}
{no_timer_prompt}"""
)
# Verify that the GetLiveContext tool returns the same results
@@ -733,10 +734,10 @@ Static Context: An overview of the areas and the devices in this smart home:
api = await llm.async_get_api(hass, "assist", llm_context)
assert api.api_prompt == (
f"""{first_part_prompt}
{area_prompt}
{no_timer_prompt}
{dynamic_context_prompt}
{stateless_exposed_entities_prompt}"""
{stateless_exposed_entities_prompt}
{area_prompt}
{no_timer_prompt}"""
)
# Add floor
@@ -750,10 +751,10 @@ Static Context: An overview of the areas and the devices in this smart home:
api = await llm.async_get_api(hass, "assist", llm_context)
assert api.api_prompt == (
f"""{first_part_prompt}
{area_prompt}
{no_timer_prompt}
{dynamic_context_prompt}
{stateless_exposed_entities_prompt}"""
{stateless_exposed_entities_prompt}
{area_prompt}
{no_timer_prompt}"""
)
# Register device for timers
@@ -763,9 +764,9 @@ Static Context: An overview of the areas and the devices in this smart home:
# The no_timer_prompt is gone
assert api.api_prompt == (
f"""{first_part_prompt}
{area_prompt}
{dynamic_context_prompt}
{stateless_exposed_entities_prompt}"""
{stateless_exposed_entities_prompt}
{area_prompt}"""
)