Compare commits

..

4 Commits

Author SHA1 Message Date
J. Nick Koston deff0e15cc Merge branch 'dev' into switchbot-reachability-diagnostics 2026-05-30 08:50:34 -05:00
J. Nick Koston ddf5613ac3 Explain why a Switchbot device could not be found 2026-05-29 11:11:27 -05:00
J. Nick Koston ec0bd39b7c Add async_address_reachability_diagnostics to bluetooth API 2026-05-29 10:53:33 -05:00
J. Nick Koston c6844a8223 Bump habluetooth to 6.8.0 2026-05-29 10:46:38 -05:00
19 changed files with 58 additions and 187 deletions
+6 -13
View File
@@ -8,7 +8,6 @@ import avea
from bleak.exc import BleakError
import voluptuous as vol
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
@@ -67,15 +66,6 @@ def _is_avea_discovery(discovery_info: BluetoothServiceInfoBleak) -> bool:
return AVEA_SERVICE_UUID in discovery_info.service_uuids
def _discovery_label(discovery_info: BluetoothServiceInfoBleak) -> str:
"""Return a label for a discovered Avea bulb."""
if (
name := _normalize_name(discovery_info.name)
) and name != discovery_info.address:
return f"{name} ({discovery_info.address})"
return discovery_info.address
class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Avea."""
@@ -160,7 +150,6 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
await bluetooth.async_request_active_scan(self.hass)
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
@@ -176,10 +165,11 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
if self._discovery_info:
disc = self._discovery_info
label = f"{disc.name or disc.address} ({disc.address})"
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS, default=disc.address): vol.In(
{disc.address: _discovery_label(disc)}
{disc.address: label}
)
}
)
@@ -188,7 +178,10 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: _discovery_label(service_info)
service_info.address: (
f"{service_info.name or service_info.address}"
f" ({service_info.address})"
)
for service_info in self._discovered_devices.values()
}
),
@@ -294,9 +294,6 @@
"vacuum_raw_get_positions_not_supported": {
"message": "Retrieving the positions of the chargers and the device itself is not supported"
},
"vacuum_send_command_not_supported": {
"message": "The {command} command is not supported by {name}"
},
"vacuum_send_command_params_dict": {
"message": "Params must be a dictionary and not a list"
},
+3 -2
View File
@@ -353,10 +353,11 @@ class EcovacsVacuum(
if self._capability.clean.action.area is None:
info = self._device.device_info
name = info.get("nick", info["name"])
# pylint: disable-next=home-assistant-exception-translation-key-missing
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="vacuum_send_command_not_supported",
translation_placeholders={"command": command, "name": name},
translation_key="vacuum_send_command_area_not_supported",
translation_placeholders={"name": name},
)
if command == "spot_area":
+2 -2
View File
@@ -53,8 +53,8 @@
"iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [
"aiolifx==1.2.2",
"aiolifx==1.2.1",
"aiolifx-effects==0.3.2",
"aiolifx-themes==1.0.4"
"aiolifx-themes==1.0.2"
]
}
+6 -13
View File
@@ -4,7 +4,6 @@ import asyncio
import logging
from typing import TypedDict
import aiohttp
from aiohttp.web import Request
from loqedAPI import loqed
@@ -161,20 +160,14 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]):
_LOGGER.debug("Webhook URL: %s", webhook_url)
try:
webhooks = await self.lock.getWebhooks()
webhooks = await self.lock.getWebhooks()
webhook_index = next(
(x["id"] for x in webhooks if x["url"] == webhook_url), None
)
webhook_index = next(
(x["id"] for x in webhooks if x["url"] == webhook_url), None
)
if webhook_index:
await self.lock.deleteWebhook(webhook_index)
except (TimeoutError, aiohttp.ClientError) as err:
_LOGGER.warning(
"Could not remove webhook from LOQED bridge; the bridge may be offline. Continuing to unload the entry anyway: %s",
err,
)
if webhook_index:
await self.lock.deleteWebhook(webhook_index)
async def async_cloudhook_generate_url(
+1 -1
View File
@@ -530,7 +530,7 @@ class MqttAttributesMixin(Entity):
self._attributes_message_received,
{
"_attr_extra_state_attributes",
"_attr_location_accuracy",
"_attr_gps_accuracy",
"_attr_latitude",
"_attr_location_name",
"_attr_longitude",
@@ -96,7 +96,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
# DomesticHotWaterProduction/WaterHeatingSystem
OverkizBinarySensorDescription(
key=OverkizState.IO_OPERATING_MODE_CAPABILITIES,
name="Energy demand status",
name="Energy Demand Status",
device_class=BinarySensorDeviceClass.HEAT,
value_fn=lambda state: (
cast(dict, state).get(OverkizCommandParam.ENERGY_DEMAND_STATUS) == 1
+17 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
import switchbot
from homeassistant.components import bluetooth
from homeassistant.components.bluetooth import BluetoothReachabilityIntent
from homeassistant.components.sensor import ConfigType
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -310,6 +311,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
translation_placeholders={
"sensor_type": entry.data[CONF_SENSOR_TYPE],
"address": entry.data[CONF_ADDRESS],
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
entry.data[CONF_ADDRESS].upper(),
BluetoothReachabilityIntent.CONNECTION,
),
},
)
@@ -331,7 +337,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found_error",
translation_placeholders={"sensor_type": sensor_type, "address": address},
translation_placeholders={
"sensor_type": sensor_type,
"address": address,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION
if connectable
else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT,
),
},
)
cls = CLASS_BY_DEVICE.get(sensor_type, switchbot.SwitchbotDevice)
@@ -384,7 +384,7 @@
"message": "The device ID {device_id} does not belong to SwitchBot integration."
},
"device_not_found_error": {
"message": "Could not find Switchbot {sensor_type} with address {address}"
"message": "Could not find Switchbot {sensor_type} with address {address}: {reason}"
},
"device_without_config_entry": {
"message": "The device ID {device_id} is not associated with a config entry."
+1 -2
View File
@@ -3,8 +3,7 @@
from pathlib import Path
from typing import Any
from tuya_device_handlers import TUYA_QUIRKS_REGISTRY
from tuya_device_handlers.devices import register_tuya_quirks
from tuya_device_handlers.devices import TUYA_QUIRKS_REGISTRY, register_tuya_quirks
from tuya_sharing import (
CustomerDevice,
Manager,
+1 -1
View File
@@ -44,7 +44,7 @@
"iot_class": "cloud_push",
"loggers": ["tuya_sharing"],
"requirements": [
"tuya-device-handlers==0.0.22",
"tuya-device-handlers==0.0.21",
"tuya-device-sharing-sdk==0.2.8"
]
}
-5
View File
@@ -112,8 +112,3 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity):
version = cast(str, self.latest_version)
await self.coordinator.wled.upgrade(version=version)
await self.coordinator.async_refresh()
async def async_update(self) -> None:
"""Update the entity."""
await super().async_update()
await self.releases_coordinator.async_request_refresh()
+1 -1
View File
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.4.1"],
"requirements": ["zha==1.4.0"],
"usb": [
{
"description": "*2652*",
+4 -4
View File
@@ -318,10 +318,10 @@ aiolichess==1.3.0
aiolifx-effects==0.3.2
# homeassistant.components.lifx
aiolifx-themes==1.0.4
aiolifx-themes==1.0.2
# homeassistant.components.lifx
aiolifx==1.2.2
aiolifx==1.2.1
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -3207,7 +3207,7 @@ ttls==1.8.3
ttn_client==1.3.0
# homeassistant.components.tuya
tuya-device-handlers==0.0.22
tuya-device-handlers==0.0.21
# homeassistant.components.tuya
tuya-device-sharing-sdk==0.2.8
@@ -3442,7 +3442,7 @@ zeroconf==0.149.16
zeversolar==0.3.2
# homeassistant.components.zha
zha==1.4.1
zha==1.4.0
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
+1 -1
View File
@@ -27,7 +27,7 @@ pytest-aiohttp==1.1.0
pytest-cov==7.1.0
pytest-freezer==0.4.9
pytest-github-actions-annotate-failures==0.4.0
pytest-socket==0.8.0
pytest-socket==0.7.0
pytest-sugar==1.1.1
pytest-timeout==2.4.0
pytest-unordered==0.7.0
+8 -69
View File
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.avea.const import AVEA_SERVICE_UUID, DOMAIN
from homeassistant.components.avea.const import DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant
@@ -12,11 +12,7 @@ from homeassistant.data_entry_flow import FlowResultType
from . import AVEA_DISCOVERY_INFO, NOT_AVEA_DISCOVERY_INFO
from tests.components.bluetooth import (
generate_advertisement_data,
generate_ble_device,
inject_bluetooth_service_info,
)
from tests.components.bluetooth import inject_bluetooth_service_info
pytestmark = pytest.mark.usefixtures("enable_bluetooth")
@@ -39,22 +35,13 @@ async def test_user_step_success(hass: HomeAssistant) -> None:
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
with patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
) as mock_request_active_scan:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
assert result["data_schema"].schema[CONF_ADDRESS].container == {
AVEA_DISCOVERY_INFO.address: (
f"{AVEA_DISCOVERY_INFO.name} ({AVEA_DISCOVERY_INFO.address})"
)
}
mock_request_active_scan.assert_awaited_once_with(hass)
with (
patch(
@@ -80,62 +67,14 @@ async def test_user_step_no_devices_found(hass: HomeAssistant) -> None:
inject_bluetooth_service_info(hass, NOT_AVEA_DISCOVERY_INFO)
await hass.async_block_till_done(wait_background_tasks=True)
with patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_user_step_unnamed_device_label(hass: HomeAssistant) -> None:
"""Test unnamed discovered devices are shown without duplicating the address."""
discovery_info = type(AVEA_DISCOVERY_INFO)(
name=AVEA_DISCOVERY_INFO.address,
address=AVEA_DISCOVERY_INFO.address,
rssi=-60,
manufacturer_data={},
service_uuids=[AVEA_SERVICE_UUID],
service_data={},
source="local",
device=generate_ble_device(
address=AVEA_DISCOVERY_INFO.address, name=AVEA_DISCOVERY_INFO.address
),
advertisement=generate_advertisement_data(
local_name=AVEA_DISCOVERY_INFO.address,
manufacturer_data={},
service_data={},
service_uuids=[AVEA_SERVICE_UUID],
),
time=0,
connectable=True,
tx_power=-127,
)
with (
patch(
"homeassistant.components.avea.config_flow.async_discovered_service_info",
return_value=[discovery_info],
),
patch(
"homeassistant.components.avea.config_flow.bluetooth.async_request_active_scan"
) as mock_request_active_scan,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["data_schema"].schema[CONF_ADDRESS].container == {
AVEA_DISCOVERY_INFO.address: AVEA_DISCOVERY_INFO.address
}
mock_request_active_scan.assert_awaited_once_with(hass)
async def test_user_step_cannot_connect_recovers(hass: HomeAssistant) -> None:
"""Test the user step recovers after a cannot connect error."""
inject_bluetooth_service_info(hass, AVEA_DISCOVERY_INFO)
@@ -1488,6 +1488,11 @@
'infrared': False,
'matrix': False,
'max_kelvin': 9000,
'min_ext_mz_firmware': 1532997580,
'min_ext_mz_firmware_components': list([
2,
77,
]),
'min_kelvin': 1500,
'multizone': True,
'relays': False,
-20
View File
@@ -8,7 +8,6 @@ from unittest.mock import AsyncMock, patch
import aiohttp
from freezegun.api import FrozenDateTimeFactory
from loqedAPI import loqed
import pytest
from homeassistant.components.loqed.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
@@ -215,22 +214,3 @@ async def test_unload_entry_fails(
lock.deleteWebhook = AsyncMock(side_effect=Exception)
assert not await hass.config_entries.async_unload(integration.entry_id)
@pytest.mark.parametrize("error", [aiohttp.ClientError, TimeoutError])
async def test_unload_entry_with_unreachable_bridge(
hass: HomeAssistant,
integration: MockConfigEntry,
lock: loqed.Lock,
error: type[Exception],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test entry still unloads when the bridge is unreachable."""
lock.getWebhooks = AsyncMock(side_effect=error)
assert await hass.config_entries.async_unload(integration.entry_id)
await hass.async_block_till_done()
assert integration.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
assert "Could not remove webhook from LOQED bridge" in caplog.text
-47
View File
@@ -8,10 +8,6 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from wled import Releases, WLEDError
from homeassistant.components.homeassistant import (
DOMAIN as HOME_ASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.update import (
ATTR_INSTALLED_VERSION,
ATTR_LATEST_VERSION,
@@ -29,8 +25,6 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.update_coordinator import REQUEST_REFRESH_DEFAULT_COOLDOWN
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -192,44 +186,3 @@ async def test_update_stay_beta(
)
assert mock_wled.upgrade.call_count == 1
mock_wled.upgrade.assert_called_with(version="1.0.0b5")
async def test_update_entities(
hass: HomeAssistant,
mock_wled: MagicMock,
mock_wled_releases: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test update entity async_update method."""
await async_setup_component(hass, HOME_ASSISTANT_DOMAIN, {})
assert (state := hass.states.get("update.wled_rgb_light_firmware"))
assert state.state == STATE_ON
assert state.attributes[ATTR_LATEST_VERSION] == "0.99.0"
mock_wled_releases.releases.assert_called_once()
mock_wled_releases.releases.return_value = Releases(
beta="1.0.0b5",
nightly=None,
repo="wled/WLED",
stable="16.0.0",
)
await hass.services.async_call(
HOME_ASSISTANT_DOMAIN,
SERVICE_UPDATE_ENTITY,
{ATTR_ENTITY_ID: state.entity_id},
blocking=True,
)
# Ensure we pass the debouncer interval to allow async_request_refresh to execute
freezer.tick(REQUEST_REFRESH_DEFAULT_COOLDOWN)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# releases() should be called twice: once on setup, once for the manual update
assert mock_wled_releases.releases.call_count == 2
assert (state := hass.states.get("update.wled_rgb_light_firmware"))
assert state.state == STATE_ON
assert state.attributes[ATTR_LATEST_VERSION] == "16.0.0"