mirror of
https://github.com/home-assistant/core.git
synced 2026-05-31 13:13:26 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a676072e0d | |||
| 0ebcbf33ba | |||
| cb544f2f67 | |||
| 26c5c37f53 | |||
| b9ed8e91df | |||
| 33a721245c | |||
| 840243db9c | |||
| 740778f00b | |||
| 1ec5e25b6b | |||
| 83c35b8b4d | |||
| 02b760f142 | |||
| 0c10c2c16b |
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -66,6 +67,15 @@ 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."""
|
||||
|
||||
@@ -150,6 +160,7 @@ 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 (
|
||||
@@ -165,11 +176,10 @@ 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: label}
|
||||
{disc.address: _discovery_label(disc)}
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -178,10 +188,7 @@ class AveaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_ADDRESS): vol.In(
|
||||
{
|
||||
service_info.address: (
|
||||
f"{service_info.name or service_info.address}"
|
||||
f" ({service_info.address})"
|
||||
)
|
||||
service_info.address: _discovery_label(service_info)
|
||||
for service_info in self._discovered_devices.values()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
|
||||
from habluetooth import (
|
||||
BaseHaRemoteScanner,
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBluetoothConnector,
|
||||
@@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api
|
||||
from .api import (
|
||||
_get_manager,
|
||||
async_address_present,
|
||||
async_address_reachability_diagnostics,
|
||||
async_ble_device_from_address,
|
||||
async_clear_address_from_match_history,
|
||||
async_clear_advertisement_history,
|
||||
@@ -108,12 +110,14 @@ __all__ = [
|
||||
"BluetoothCallback",
|
||||
"BluetoothCallbackMatcher",
|
||||
"BluetoothChange",
|
||||
"BluetoothReachabilityIntent",
|
||||
"BluetoothScannerDevice",
|
||||
"BluetoothScanningMode",
|
||||
"BluetoothServiceInfo",
|
||||
"BluetoothServiceInfoBleak",
|
||||
"HaBluetoothConnector",
|
||||
"async_address_present",
|
||||
"async_address_reachability_diagnostics",
|
||||
"async_ble_device_from_address",
|
||||
"async_clear_address_from_match_history",
|
||||
"async_clear_advertisement_history",
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast
|
||||
from bleak import BleakScanner
|
||||
from habluetooth import (
|
||||
BaseHaScanner,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScannerDevice,
|
||||
BluetoothScanningMode,
|
||||
HaBleakScannerWrapper,
|
||||
@@ -108,6 +109,14 @@ def async_ble_device_from_address(
|
||||
return _get_manager(hass).async_ble_device_from_address(address, connectable)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_address_reachability_diagnostics(
|
||||
hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent
|
||||
) -> str:
|
||||
"""Return a human readable explanation of why an address may be unreachable."""
|
||||
return _get_manager(hass).async_address_reachability_diagnostics(address, intent)
|
||||
|
||||
|
||||
@hass_callback
|
||||
def async_scanner_devices_by_address(
|
||||
hass: HomeAssistant, address: str, connectable: bool = True
|
||||
|
||||
@@ -284,6 +284,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
||||
UpdateDeviceClass, static_info.device_class
|
||||
)
|
||||
|
||||
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
|
||||
"""Return True if latest_version is newer than installed_version.
|
||||
|
||||
ESPHome project versions can carry a build suffix (e.g.
|
||||
2025.11.5_c51f7548) that AwesomeVersion cannot parse. Without stripping
|
||||
it the base comparison raises and the entity is forced on for every
|
||||
build mismatch. Drop the suffix so the versions compare cleanly and we
|
||||
only report genuinely newer firmware.
|
||||
"""
|
||||
return super().version_is_newer(
|
||||
latest_version.partition("_")[0], installed_version.partition("_")[0]
|
||||
)
|
||||
|
||||
@property
|
||||
@esphome_state_property
|
||||
def installed_version(self) -> str:
|
||||
|
||||
@@ -938,15 +938,3 @@ class AvmWrapper(FritzBoxTools):
|
||||
"X_AVM-DE_WakeOnLANByMACAddress",
|
||||
NewMACAddress=mac_address,
|
||||
)
|
||||
|
||||
async def async_get_firmware_extra_infos(self) -> dict[str, Any]:
|
||||
"""Return extra infos for firmware."""
|
||||
return await self._async_service_call("UserInterface", "1", "X_AVM-DE_GetInfo")
|
||||
|
||||
async def async_get_device_uptime_hours(self) -> int:
|
||||
"""Get device uptime in hours."""
|
||||
|
||||
def _get_uptime_hours() -> int:
|
||||
return int(self.fritz_status.device_uptime // 3600)
|
||||
|
||||
return await self.hass.async_add_executor_job(_get_uptime_hours)
|
||||
|
||||
@@ -24,11 +24,9 @@ async def async_get_config_entry_diagnostics(
|
||||
"unique_id": avm_wrapper.unique_id.replace(
|
||||
avm_wrapper.unique_id[6:11], "XX:XX"
|
||||
),
|
||||
"device_uptime_hours": await avm_wrapper.async_get_device_uptime_hours(),
|
||||
"current_firmware": avm_wrapper.current_firmware,
|
||||
"latest_firmware": avm_wrapper.latest_firmware,
|
||||
"update_available": avm_wrapper.update_available,
|
||||
"firmware_extra_infos": await avm_wrapper.async_get_firmware_extra_infos(),
|
||||
"connection_type": avm_wrapper.device_conn_type,
|
||||
"is_router": avm_wrapper.device_is_router,
|
||||
"mesh_role": avm_wrapper.mesh_role,
|
||||
|
||||
@@ -53,8 +53,8 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
|
||||
"requirements": [
|
||||
"aiolifx==1.2.1",
|
||||
"aiolifx==1.2.2",
|
||||
"aiolifx-effects==0.3.2",
|
||||
"aiolifx-themes==1.0.2"
|
||||
"aiolifx-themes==1.0.4"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
from typing import TypedDict
|
||||
|
||||
import aiohttp
|
||||
from aiohttp.web import Request
|
||||
from loqedAPI import loqed
|
||||
|
||||
@@ -160,14 +161,20 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]):
|
||||
|
||||
_LOGGER.debug("Webhook URL: %s", webhook_url)
|
||||
|
||||
webhooks = await self.lock.getWebhooks()
|
||||
try:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
async def async_cloudhook_generate_url(
|
||||
|
||||
@@ -530,7 +530,7 @@ class MqttAttributesMixin(Entity):
|
||||
self._attributes_message_received,
|
||||
{
|
||||
"_attr_extra_state_attributes",
|
||||
"_attr_gps_accuracy",
|
||||
"_attr_location_accuracy",
|
||||
"_attr_latitude",
|
||||
"_attr_location_name",
|
||||
"_attr_longitude",
|
||||
|
||||
@@ -79,14 +79,14 @@ TOKEN_SCHEMA = vol.Schema(
|
||||
|
||||
def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Validate the user input and fetch data (sync, for executor)."""
|
||||
auth_kwargs = {
|
||||
"password": data.get(CONF_PASSWORD),
|
||||
}
|
||||
if data.get(CONF_TOKEN):
|
||||
auth_kwargs = {
|
||||
auth_kwargs = (
|
||||
{
|
||||
"token_name": data[CONF_TOKEN_ID],
|
||||
"token_value": data[CONF_TOKEN_SECRET],
|
||||
}
|
||||
if data.get(CONF_TOKEN)
|
||||
else {"password": data.get(CONF_PASSWORD)}
|
||||
)
|
||||
data = sanitize_config_entry(data)
|
||||
try:
|
||||
client = ProxmoxAPI(
|
||||
@@ -122,6 +122,9 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ProxmoxConnectionError from err
|
||||
|
||||
if not nodes:
|
||||
raise ProxmoxNoNodesFound("No nodes found")
|
||||
|
||||
nodes_data: list[dict[str, Any]] = []
|
||||
for node in nodes:
|
||||
if node.get("status") != NODE_ONLINE:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.7.5"],
|
||||
"requirements": ["python-otbr-api==2.10.0", "pyroute2==0.9.6"],
|
||||
"single_config_entry": true,
|
||||
"zeroconf": ["_meshcop._udp.local."]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from tuya_device_handlers.devices import TUYA_QUIRKS_REGISTRY, register_tuya_quirks
|
||||
from tuya_device_handlers import TUYA_QUIRKS_REGISTRY
|
||||
from tuya_device_handlers.devices import register_tuya_quirks
|
||||
from tuya_sharing import (
|
||||
CustomerDevice,
|
||||
Manager,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.21",
|
||||
"tuya-device-handlers==0.0.22",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -112,3 +112,8 @@ 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()
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"universal_silabs_flasher",
|
||||
"serialx"
|
||||
],
|
||||
"requirements": ["zha==1.4.0"],
|
||||
"requirements": ["zha==1.4.1"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
Generated
+5
-5
@@ -318,10 +318,10 @@ aiolichess==1.3.0
|
||||
aiolifx-effects==0.3.2
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx-themes==1.0.2
|
||||
aiolifx-themes==1.0.4
|
||||
|
||||
# homeassistant.components.lifx
|
||||
aiolifx==1.2.1
|
||||
aiolifx==1.2.2
|
||||
|
||||
# homeassistant.components.lookin
|
||||
aiolookin==1.0.0
|
||||
@@ -2492,7 +2492,7 @@ pyrisco==0.8.0
|
||||
pyrituals==0.0.7
|
||||
|
||||
# homeassistant.components.thread
|
||||
pyroute2==0.7.5
|
||||
pyroute2==0.9.6
|
||||
|
||||
# homeassistant.components.rympro
|
||||
pyrympro==0.0.9
|
||||
@@ -3207,7 +3207,7 @@ ttls==1.8.3
|
||||
ttn_client==1.3.0
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.21
|
||||
tuya-device-handlers==0.0.22
|
||||
|
||||
# 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.0
|
||||
zha==1.4.1
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.avea.const import DOMAIN
|
||||
from homeassistant.components.avea.const import AVEA_SERVICE_UUID, 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,7 +12,11 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import AVEA_DISCOVERY_INFO, NOT_AVEA_DISCOVERY_INFO
|
||||
|
||||
from tests.components.bluetooth import inject_bluetooth_service_info
|
||||
from tests.components.bluetooth import (
|
||||
generate_advertisement_data,
|
||||
generate_ble_device,
|
||||
inject_bluetooth_service_info,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.usefixtures("enable_bluetooth")
|
||||
|
||||
@@ -35,13 +39,22 @@ 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)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
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}
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -67,14 +80,62 @@ 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)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
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}
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -10,9 +10,11 @@ from homeassistant.components.bluetooth import (
|
||||
MONOTONIC_TIME,
|
||||
BaseHaRemoteScanner,
|
||||
BluetoothChange,
|
||||
BluetoothReachabilityIntent,
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfo,
|
||||
HaBluetoothConnector,
|
||||
async_address_reachability_diagnostics,
|
||||
async_clear_advertisement_history,
|
||||
async_request_active_scan,
|
||||
async_scanner_by_source,
|
||||
@@ -112,6 +114,57 @@ async def test_async_scanner_devices_by_address_connectable(
|
||||
cancel()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_async_address_reachability_diagnostics(hass: HomeAssistant) -> None:
|
||||
"""Test the address reachability diagnostics passthrough."""
|
||||
# An address that was never seen reports as unknown.
|
||||
assert "unknown" in async_address_reachability_diagnostics(
|
||||
hass, "44:44:33:11:23:99", BluetoothReachabilityIntent.CONNECTION
|
||||
)
|
||||
|
||||
manager = _get_manager()
|
||||
|
||||
class FakeInjectableScanner(BaseHaRemoteScanner):
|
||||
def inject_advertisement(
|
||||
self, device: BLEDevice, advertisement_data: AdvertisementData
|
||||
) -> None:
|
||||
"""Inject an advertisement."""
|
||||
self._async_on_advertisement(
|
||||
device.address,
|
||||
advertisement_data.rssi,
|
||||
device.name,
|
||||
advertisement_data.service_uuids,
|
||||
advertisement_data.service_data,
|
||||
advertisement_data.manufacturer_data,
|
||||
advertisement_data.tx_power,
|
||||
{"scanner_specific_data": "test"},
|
||||
MONOTONIC_TIME(),
|
||||
)
|
||||
|
||||
connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True)
|
||||
scanner = FakeInjectableScanner("esp32", "esp32", connector, True)
|
||||
unsetup = scanner.async_setup()
|
||||
cancel = manager.async_register_scanner(scanner)
|
||||
switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {})
|
||||
switchbot_device_adv = generate_advertisement_data(local_name="wohand", rssi=-80)
|
||||
scanner.inject_advertisement(switchbot_device, switchbot_device_adv)
|
||||
|
||||
connection_diag = async_address_reachability_diagnostics(
|
||||
hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.CONNECTION
|
||||
)
|
||||
assert "in connectable history" in connection_diag
|
||||
assert "esp32" in connection_diag
|
||||
|
||||
# An advertisement intent does not report connectable paths or slots.
|
||||
advertisement_diag = async_address_reachability_diagnostics(
|
||||
hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT
|
||||
)
|
||||
assert "advertising" in advertisement_diag
|
||||
|
||||
unsetup()
|
||||
cancel()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_bluetooth")
|
||||
async def test_async_scanner_devices_by_address_non_connectable(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from aioesphomeapi import APIClient, UpdateCommand, UpdateInfo, UpdateState
|
||||
from awesomeversion import AwesomeVersion
|
||||
from awesomeversion.exceptions import AwesomeVersionCompareException
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.esphome.dashboard import async_get_dashboard
|
||||
@@ -547,6 +549,128 @@ async def test_generic_device_update_entity_has_update(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_version", "latest_version"),
|
||||
[
|
||||
("2025.11.5_c51f7548", "2025.11.6_aabbccdd"),
|
||||
("2025.11.5_c51f7548", "2025.11.5_aabbccdd"),
|
||||
("2025.11.6_aabbccdd", "2025.11.5_c51f7548"),
|
||||
],
|
||||
ids=["newer_base", "same_base_new_build", "older_base"],
|
||||
)
|
||||
def test_awesomeversion_cannot_compare_project_versions(
|
||||
current_version: str, latest_version: str
|
||||
) -> None:
|
||||
"""Prove AwesomeVersion raises on ESPHome project versions.
|
||||
|
||||
ESPHome project versions carry a build suffix (e.g. 2025.11.5_c51f7548).
|
||||
AwesomeVersion cannot parse these, so the base UpdateEntity comparison would
|
||||
raise and force the entity on, which is why ESPHomeUpdateEntity mirrors the
|
||||
device by comparing with a plain string inequality instead.
|
||||
"""
|
||||
with pytest.raises(AwesomeVersionCompareException):
|
||||
assert AwesomeVersion(latest_version) > current_version
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_version", "latest_version", "expected_state"),
|
||||
[
|
||||
("2025.11.5_c51f7548", "2025.11.6_aabbccdd", STATE_ON),
|
||||
("2025.11.5_c51f7548", "2025.11.5_aabbccdd", STATE_OFF),
|
||||
("2025.11.6_aabbccdd", "2025.11.5_c51f7548", STATE_OFF),
|
||||
("2025.11.5_c51f7548", "2025.11.5_c51f7548", STATE_OFF),
|
||||
],
|
||||
ids=["newer_base", "same_base_new_build", "older_base", "identical"],
|
||||
)
|
||||
async def test_generic_device_update_entity_project_version(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_generic_device_entry: MockGenericDeviceEntryType,
|
||||
current_version: str,
|
||||
latest_version: str,
|
||||
expected_state: str,
|
||||
) -> None:
|
||||
"""Test version comparison for ESPHome project versions.
|
||||
|
||||
AwesomeVersion cannot parse the build suffix, so the entity strips it and
|
||||
compares the real versions: only a genuinely newer base version is offered;
|
||||
a different build of the same version or an older version is not.
|
||||
"""
|
||||
entity_info = [
|
||||
UpdateInfo(
|
||||
object_id="myupdate",
|
||||
key=1,
|
||||
name="my update",
|
||||
)
|
||||
]
|
||||
states = [
|
||||
UpdateState(
|
||||
key=1,
|
||||
current_version=current_version,
|
||||
latest_version=latest_version,
|
||||
title="ESPHome Project",
|
||||
release_summary=RELEASE_SUMMARY,
|
||||
release_url=RELEASE_URL,
|
||||
)
|
||||
]
|
||||
await mock_generic_device_entry(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == expected_state
|
||||
|
||||
|
||||
async def test_generic_device_update_entity_clears_after_ota(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test a project version update clears once the device runs the new build."""
|
||||
entity_info = [
|
||||
UpdateInfo(
|
||||
object_id="myupdate",
|
||||
key=1,
|
||||
name="my update",
|
||||
)
|
||||
]
|
||||
states = [
|
||||
UpdateState(
|
||||
key=1,
|
||||
current_version="2025.11.5_c51f7548",
|
||||
latest_version="2025.11.6_aabbccdd",
|
||||
title="ESPHome Project",
|
||||
release_summary=RELEASE_SUMMARY,
|
||||
release_url=RELEASE_URL,
|
||||
)
|
||||
]
|
||||
mock_device = await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_ON
|
||||
|
||||
mock_device.set_state(
|
||||
UpdateState(
|
||||
key=1,
|
||||
current_version="2025.11.6_aabbccdd",
|
||||
latest_version="2025.11.6_aabbccdd",
|
||||
title="ESPHome Project",
|
||||
release_summary=RELEASE_SUMMARY,
|
||||
release_url=RELEASE_URL,
|
||||
)
|
||||
)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
async def test_update_entity_release_notes(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
|
||||
@@ -97,14 +97,6 @@ MOCK_FB_SERVICES: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"UserInterface1": {
|
||||
"GetInfo": {},
|
||||
"X_AVM-DE_GetInfo": {
|
||||
"NewX_AVM-DE_AutoUpdateMode": "notify",
|
||||
"NewX_AVM-DE_UpdateTime": "2026-05-17T18:54:37+02:00",
|
||||
"NewX_AVM-DE_LastFwVersion": "256.08.20,124233",
|
||||
"NewX_AVM-DE_LastInfoUrl": "http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_en.txt",
|
||||
"NewX_AVM-DE_CurrentFwVersion": "256.08.25",
|
||||
"NewX_AVM-DE_UpdateSuccessful": "succeeded",
|
||||
},
|
||||
},
|
||||
"WANCommonIFC1": {
|
||||
"GetCommonLinkProperties": {
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
'NAS': 'none',
|
||||
'Phone': 'readwrite',
|
||||
}),
|
||||
'device_uptime_hours': 699,
|
||||
'discovered_services': list([
|
||||
'DeviceInfo1',
|
||||
'Hosts1',
|
||||
@@ -44,14 +43,6 @@
|
||||
'X_AVM-DE_HostFilter1',
|
||||
'X_AVM-DE_UPnP1',
|
||||
]),
|
||||
'firmware_extra_infos': dict({
|
||||
'NewX_AVM-DE_AutoUpdateMode': 'notify',
|
||||
'NewX_AVM-DE_CurrentFwVersion': '256.08.25',
|
||||
'NewX_AVM-DE_LastFwVersion': '256.08.20,124233',
|
||||
'NewX_AVM-DE_LastInfoUrl': 'http://download.avm.de/fritzbox/fritzbox-7530-ax/deutschland/fritz.os/info_en.txt',
|
||||
'NewX_AVM-DE_UpdateSuccessful': 'succeeded',
|
||||
'NewX_AVM-DE_UpdateTime': '2026-05-17T18:54:37+02:00',
|
||||
}),
|
||||
'is_router': True,
|
||||
'last_exception': None,
|
||||
'last_update success': True,
|
||||
|
||||
@@ -1488,11 +1488,6 @@
|
||||
'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,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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
|
||||
@@ -214,3 +215,22 @@ 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
|
||||
|
||||
@@ -367,6 +367,33 @@ async def test_form_no_nodes_exception(
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
async def test_form_no_nodes_empty_list(
|
||||
hass: HomeAssistant,
|
||||
mock_proxmox_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test we handle no nodes found exception when empty list is returned."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
mock_proxmox_client.nodes.get.return_value = []
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=MOCK_USER_STEP
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user_auth"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=MOCK_USER_AUTH_STEP_PASSWORD
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "no_nodes_found"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_setup_entry")
|
||||
async def test_duplicate_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -8,6 +8,10 @@ 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,
|
||||
@@ -25,6 +29,8 @@ 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
|
||||
|
||||
@@ -186,3 +192,44 @@ 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"
|
||||
|
||||
Reference in New Issue
Block a user