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
147 changes: 123 additions & 24 deletions homeassistant/components/bluetooth/websocket_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
from typing import Any

from habluetooth import (
BaseHaScanner,
BluetoothScanningMode,
HaBluetoothSlotAllocations,
HaScannerModeChange,
HaScannerRegistration,
HaScannerRegistrationEvent,
)
Expand All @@ -27,12 +29,54 @@
from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source


@callback
def _async_get_source_from_config_entry(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg_id: int,
config_entry_id: str | None,
validate_source: bool = True,
) -> str | None:
"""Get source from config entry id.

Returns None if no config_entry_id provided or on error (after sending error response).
If validate_source is True, also validates that the scanner exists.
"""
if not config_entry_id:
return None

if validate_source:
# Use the full validation that checks if scanner exists
try:
return config_entry_id_to_source(hass, config_entry_id)
except InvalidConfigEntryID as err:
connection.send_error(msg_id, "invalid_config_entry_id", str(err))
return None
except InvalidSource as err:
connection.send_error(msg_id, "invalid_source", str(err))
return None

# Just check if config entry exists and belongs to bluetooth
if (
not (entry := hass.config_entries.async_get_entry(config_entry_id))
or entry.domain != DOMAIN
):
connection.send_error(
msg_id,
"invalid_config_entry_id",
f"Config entry {config_entry_id} not found",
)
return None
return entry.unique_id


@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the bluetooth websocket API."""
websocket_api.async_register_command(hass, ws_subscribe_advertisements)
websocket_api.async_register_command(hass, ws_subscribe_connection_allocations)
websocket_api.async_register_command(hass, ws_subscribe_scanner_details)
websocket_api.async_register_command(hass, ws_subscribe_scanner_state)


@lru_cache(maxsize=1024)
Expand Down Expand Up @@ -180,16 +224,12 @@ async def ws_subscribe_connection_allocations(
) -> None:
"""Handle subscribe advertisements websocket command."""
ws_msg_id = msg["id"]
source: str | None = None
if config_entry_id := msg.get("config_entry_id"):
try:
source = config_entry_id_to_source(hass, config_entry_id)
except InvalidConfigEntryID as err:
connection.send_error(ws_msg_id, "invalid_config_entry_id", str(err))
return
except InvalidSource as err:
connection.send_error(ws_msg_id, "invalid_source", str(err))
return
config_entry_id = msg.get("config_entry_id")
source = _async_get_source_from_config_entry(
hass, connection, ws_msg_id, config_entry_id
)
if config_entry_id and source is None:
return # Error already sent by helper

def _async_allocations_changed(allocations: HaBluetoothSlotAllocations) -> None:
connection.send_message(
Expand Down Expand Up @@ -220,20 +260,12 @@ async def ws_subscribe_scanner_details(
) -> None:
"""Handle subscribe scanner details websocket command."""
ws_msg_id = msg["id"]
source: str | None = None
if config_entry_id := msg.get("config_entry_id"):
if (
not (entry := hass.config_entries.async_get_entry(config_entry_id))
or entry.domain != DOMAIN
):
connection.send_error(
ws_msg_id,
"invalid_config_entry_id",
f"Invalid config entry id: {config_entry_id}",
)
return
source = entry.unique_id
assert source is not None
config_entry_id = msg.get("config_entry_id")
source = _async_get_source_from_config_entry(
hass, connection, ws_msg_id, config_entry_id, validate_source=False
)
if config_entry_id and source is None:
return # Error already sent by helper

def _async_event_message(message: dict[str, Any]) -> None:
connection.send_message(
Expand All @@ -260,3 +292,70 @@ def _async_registration_changed(registration: HaScannerRegistration) -> None:
]
):
_async_event_message({"add": matching_scanners})


@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "bluetooth/subscribe_scanner_state",
vol.Optional("config_entry_id"): str,
}
)
@websocket_api.async_response
async def ws_subscribe_scanner_state(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle subscribe scanner state websocket command."""
ws_msg_id = msg["id"]
config_entry_id = msg.get("config_entry_id")
source = _async_get_source_from_config_entry(
hass, connection, ws_msg_id, config_entry_id, validate_source=False
)
if config_entry_id and source is None:
return # Error already sent by helper

@callback
def _async_send_scanner_state(
scanner: BaseHaScanner,
current_mode: BluetoothScanningMode | None,
requested_mode: BluetoothScanningMode | None,
) -> None:
payload = {
"source": scanner.source,
"adapter": scanner.adapter,
"current_mode": current_mode.value if current_mode else None,
"requested_mode": requested_mode.value if requested_mode else None,
}
connection.send_message(
json_bytes(
websocket_api.event_message(
ws_msg_id,
payload,
)
)
)

@callback
def _async_scanner_state_changed(mode_change: HaScannerModeChange) -> None:
_async_send_scanner_state(
mode_change.scanner,
mode_change.current_mode,
mode_change.requested_mode,
)

manager = _get_manager(hass)
connection.subscriptions[ws_msg_id] = (
manager.async_register_scanner_mode_change_callback(
_async_scanner_state_changed, source
)
)
connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id)))

# Send initial state for all matching scanners
for scanner in manager.async_current_scanners():
if source is None or scanner.source == source:
_async_send_scanner_state(
scanner,
scanner.current_mode,
scanner.requested_mode,
)
4 changes: 3 additions & 1 deletion homeassistant/components/lawn_mower/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent

from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING
from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING, LawnMowerEntityFeature

INTENT_LANW_MOWER_START_MOWING = "HassLawnMowerStartMowing"
INTENT_LANW_MOWER_DOCK = "HassLawnMowerDock"
Expand All @@ -20,6 +20,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Starts a lawn mower",
required_domains={DOMAIN},
platforms={DOMAIN},
required_features=LawnMowerEntityFeature.START_MOWING,
),
)
intent.async_register(
Expand All @@ -31,5 +32,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Sends a lawn mower to dock",
required_domains={DOMAIN},
platforms={DOMAIN},
required_features=LawnMowerEntityFeature.DOCK,
),
)
4 changes: 3 additions & 1 deletion homeassistant/components/vacuum/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent

from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START
from . import DOMAIN, SERVICE_RETURN_TO_BASE, SERVICE_START, VacuumEntityFeature

INTENT_VACUUM_START = "HassVacuumStart"
INTENT_VACUUM_RETURN_TO_BASE = "HassVacuumReturnToBase"
Expand All @@ -20,6 +20,7 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Starts a vacuum",
required_domains={DOMAIN},
platforms={DOMAIN},
required_features=VacuumEntityFeature.START,
),
)
intent.async_register(
Expand All @@ -31,5 +32,6 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
description="Returns a vacuum to base",
required_domains={DOMAIN},
platforms={DOMAIN},
required_features=VacuumEntityFeature.RETURN_HOME,
),
)
2 changes: 1 addition & 1 deletion homeassistant/components/volvo/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["volvocarsapi"],
"quality_scale": "silver",
"requirements": ["volvocarsapi==0.4.1"]
"requirements": ["volvocarsapi==0.4.2"]
}
4 changes: 4 additions & 0 deletions homeassistant/components/zwave_js/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@
"menu_options": {
"intent_migrate": "Migrate to a new adapter",
"intent_reconfigure": "Re-configure the current adapter"
},
"menu_option_descriptions": {
"intent_migrate": "This will move your Z-Wave network to a new adapter.",
"intent_reconfigure": "This will let you change the adapter configuration."
}
},
"instruct_unplug": {
Expand Down
5 changes: 2 additions & 3 deletions homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,5 @@ pymodbus==3.11.1
# Some packages don't support gql 4.0.0 yet
gql<4.0.0

# pytest-rerunfailures 16.0 breaks pytest, pin 15.1 until resolved
# https://github.com/pytest-dev/pytest-rerunfailures/issues/302
pytest-rerunfailures==15.1
# Pin pytest-rerunfailures to prevent accidental breaks
pytest-rerunfailures==16.0.1
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,8 @@
# Some packages don't support gql 4.0.0 yet
gql<4.0.0

# pytest-rerunfailures 16.0 breaks pytest, pin 15.1 until resolved
# https://github.com/pytest-dev/pytest-rerunfailures/issues/302
pytest-rerunfailures==15.1
# Pin pytest-rerunfailures to prevent accidental breaks
pytest-rerunfailures==16.0.1
"""

GENERATED_MESSAGE = (
Expand Down
3 changes: 3 additions & 0 deletions script/hassfest/translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ def gen_data_entry_schema(
vol.Optional("data"): {str: translation_value_validator},
vol.Optional("data_description"): {str: translation_value_validator},
vol.Optional("menu_options"): {str: translation_value_validator},
vol.Optional("menu_option_descriptions"): {
str: translation_value_validator
},
vol.Optional("submit"): translation_value_validator,
vol.Optional("sections"): {
str: {
Expand Down
Loading
Loading