Compare commits

..

12 Commits

Author SHA1 Message Date
Jan Bouwhuis a676072e0d Fix MQTT device_tracker not saving state on location accuracy changes (#172629) 2026-05-31 12:03:12 +02:00
epenet 0ebcbf33ba Bump tuya-device-handlers to 0.0.22 (#172648) 2026-05-31 11:57:11 +02:00
Kamil Breguła cb544f2f67 Refresh WLED firmware releases on manual entity update (#172517)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-31 11:55:30 +02:00
TheJulianJES 26c5c37f53 Bump ZHA to 1.4.1 (#172640) 2026-05-31 11:22:47 +02:00
epenet b9ed8e91df Cleanup incorrect import path in Tuya coordinator (#172330) 2026-05-31 11:10:22 +02:00
alexborro 33a721245c Catch network errors during Loqed config entry unload (#172617) 2026-05-31 11:04:40 +02:00
Sören 840243db9c Improve Avea Bluetooth discovery flow (#172623) 2026-05-30 09:49:36 -05:00
Avi Miller 740778f00b fix: bump aiolifx and aiolifx-themes (#172619)
Signed-off-by: Avi Miller <me@dje.li>
2026-05-30 16:56:20 +03:00
J. Nick Koston 1ec5e25b6b Fix ESPHome update entity stuck on for project versions with build suffix (#172571) 2026-05-30 08:50:26 -05:00
J. Nick Koston 83c35b8b4d Bump pyroute2 to 0.9.6 (#172521) 2026-05-30 08:50:16 -05:00
J. Nick Koston 02b760f142 Expose bluetooth address reachability diagnostics API (#172578) 2026-05-30 08:49:56 -05:00
Erwin Douna 0c10c2c16b Proxmox refactor config flow to support no nodes (#172615) 2026-05-30 15:45:45 +02:00
25 changed files with 418 additions and 73 deletions
+13 -6
View File
@@ -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,
+2 -2
View File
@@ -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"
]
}
+13 -6
View File
@@ -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(
+1 -1
View File
@@ -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."]
}
+2 -1
View File
@@ -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,
+1 -1
View File
@@ -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"
]
}
+5
View File
@@ -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()
+1 -1
View File
@@ -23,7 +23,7 @@
"universal_silabs_flasher",
"serialx"
],
"requirements": ["zha==1.4.0"],
"requirements": ["zha==1.4.1"],
"usb": [
{
"description": "*2652*",
+5 -5
View File
@@ -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
+69 -8
View File
@@ -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)
+53
View File
@@ -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,
+124
View File
@@ -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,
-8
View File
@@ -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,
+20
View File
@@ -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,
+47
View File
@@ -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"