Compare commits

..

1 Commits

Author SHA1 Message Date
J. Nick Koston 434d5b54ae Fix ESPHome update entity stuck on for project versions with build suffix 2026-05-29 09:35:37 -05:00
20 changed files with 469 additions and 282 deletions
+2 -2
View File
@@ -380,7 +380,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -394,7 +394,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -27,7 +27,6 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME
from habluetooth import (
BaseHaRemoteScanner,
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBluetoothConnector,
@@ -56,7 +55,6 @@ 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,
@@ -110,14 +108,12 @@ __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,7 +11,6 @@ from typing import TYPE_CHECKING, cast
from bleak import BleakScanner
from habluetooth import (
BaseHaScanner,
BluetoothReachabilityIntent,
BluetoothScannerDevice,
BluetoothScanningMode,
HaBleakScannerWrapper,
@@ -109,14 +108,6 @@ 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
@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.6.4",
"bluetooth-data-tools==1.29.18",
"dbus-fast==5.0.16",
"habluetooth==6.8.0"
"habluetooth==6.7.9"
]
}
@@ -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:
+20 -43
View File
@@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DISCOVERY,
CONF_PLATFORM,
CONF_PORT,
CONF_PROTOCOL,
SERVICE_RELOAD,
)
@@ -51,7 +50,6 @@ from .client import (
async_subscribe_internal,
publish,
subscribe,
try_connection,
)
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
from .config_integration import CONFIG_SCHEMA_BASE
@@ -81,15 +79,14 @@ from .const import (
CONFIG_ENTRY_VERSION,
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_RETAIN,
DOMAIN,
ENTITY_PLATFORMS,
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
PROTOCOL_5,
PROTOCOL_311,
TEMPLATE_ERRORS,
Platform,
@@ -499,45 +496,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load a config entry."""
mqtt_data: MqttData
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5:
# Automatically migrate the broker protocol to v5 if possible
# Can be removed with HA Core 2027.1
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
# And update the protocol if successful
if await hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
hass.config_entries.async_update_entry(
entry,
data=new_entry_data,
)
ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration")
_LOGGER.info(
"The MQTT protocol version was successfully updated to version 5"
)
else:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=False,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
"#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
broker: str = entry.data[CONF_BROKER]
async_create_issue(
hass,
DOMAIN,
"protocol_5_migration",
issue_domain=DOMAIN,
is_fixable=True,
breaks_in_ha_version="2027.1.0",
severity=IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
data={
"entry_id": entry.entry_id,
"broker": broker,
"protocol": protocol,
},
translation_placeholders={"broker": broker, "protocol": protocol},
translation_key="protocol_5_migration",
)
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
"""Set up the MQTT client."""
-37
View File
@@ -9,7 +9,6 @@ from functools import lru_cache, partial
from itertools import chain, groupby
import logging
from operator import attrgetter
import queue
import socket
import ssl
import time
@@ -93,8 +92,6 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl
_LOGGER = logging.getLogger(__name__)
MQTT_TIMEOUT = 5
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB
@@ -436,40 +433,6 @@ class MqttClientSetup:
return self._client
def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
_mqttc: mqtt.Client,
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
result.put(not reason_code.is_failure)
client.on_connect = on_connect
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
client.loop_start()
try:
return result.get(timeout=MQTT_TIMEOUT)
except queue.Empty:
return False
finally:
client.disconnect()
client.loop_stop()
class MQTT:
"""Home Assistant MQTT client."""
+39 -1
View File
@@ -8,6 +8,7 @@ from dataclasses import dataclass
from enum import IntEnum
import json
import logging
import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, cast
@@ -21,6 +22,7 @@ from cryptography.hazmat.primitives.serialization import (
load_pem_private_key,
)
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import paho.mqtt.client as mqtt
import voluptuous as vol
import yaml
@@ -141,7 +143,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from homeassistant.util.unit_conversion import TemperatureConverter
from .addon import get_addon_manager
from .client import try_connection
from .client import MqttClientSetup
from .const import (
ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
ATTR_PAYLOAD,
@@ -442,6 +444,8 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
MQTT_TIMEOUT = 5
ADVANCED_OPTIONS = "advanced_options"
SET_CA_CERT = "set_ca_cert"
SET_CLIENT_CERT = "set_client_cert"
@@ -5577,6 +5581,40 @@ async def async_get_broker_settings(
return False
def try_connection(
user_input: dict[str, Any],
) -> bool:
"""Test if we can connect to an MQTT broker."""
mqtt_client_setup = MqttClientSetup(user_input)
mqtt_client_setup.setup()
client = mqtt_client_setup.client
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
_mqttc: mqtt.Client,
_userdata: None,
_connect_flags: mqtt.ConnectFlags,
reason_code: mqtt.ReasonCode,
_properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
result.put(not reason_code.is_failure)
client.on_connect = on_connect
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
client.loop_start()
try:
return result.get(timeout=MQTT_TIMEOUT)
except queue.Empty:
return False
finally:
client.disconnect()
client.loop_stop()
def check_certicate_chain() -> str | None:
"""Check the MQTT certificates."""
if client_certificate := get_file_path(CONF_CLIENT_CERT):
+56 -1
View File
@@ -5,10 +5,12 @@ from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
from .config_flow import try_connection
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
URL_MQTT_BROKER_CONFIGURATION = (
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
@@ -53,6 +55,55 @@ class MQTTDeviceEntryMigration(RepairsFlow):
)
class MQTTProtocolV5Migration(RepairsFlow):
"""Handler to migrate to MQTT protocol version 5."""
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.broker = broker
self.protocol = protocol
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> RepairsFlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
new_entry_data = entry.data.copy()
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
# Try the connection with protocol version 5
if await self.hass.async_add_executor_job(
try_connection,
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
):
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
return self.async_create_entry(data={})
return self.async_abort(
reason="mqtt_broker_migration_to_v5_failed",
description_placeholders={
"broker": self.broker,
"protocol": self.protocol,
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
},
)
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"broker": self.broker, "protocol": self.protocol},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
@@ -62,6 +113,10 @@ async def async_create_fix_flow(
if TYPE_CHECKING:
assert data is not None
entry_id: str = data["entry_id"] # type: ignore[assignment]
if issue_id == "protocol_5_migration":
broker: str = data["broker"] # type: ignore[assignment]
protocol: str = data["protocol"] # type: ignore[assignment]
return MQTTProtocolV5Migration(entry_id, broker, protocol)
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
name: str = data["name"] # type: ignore[assignment]
return MQTTDeviceEntryMigration(
+13 -3
View File
@@ -56,7 +56,7 @@
"keepalive": "A value less than 90 seconds is advised.",
"password": "The password to log in to your MQTT broker.",
"port": "The port your MQTT broker listens to. For example 1883.",
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
@@ -1134,8 +1134,18 @@
"title": "Invalid config found for MQTT {domain} item"
},
"protocol_5_migration": {
"description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.",
"title": "MQTT protocol migration failed"
"fix_flow": {
"abort": {
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
},
"step": {
"confirm": {
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
"title": "MQTT protocol change required"
}
}
},
"title": "Deprecated MQTT protocol {protocol} in use"
},
"subentry_migration_discovery": {
"fix_flow": {
+1 -17
View File
@@ -6,7 +6,6 @@ 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 (
@@ -311,11 +310,6 @@ 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,
),
},
)
@@ -337,17 +331,7 @@ 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,
"reason": bluetooth.async_address_reachability_diagnostics(
hass,
address.upper(),
BluetoothReachabilityIntent.CONNECTION
if connectable
else BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT,
),
},
translation_placeholders={"sensor_type": sensor_type, "address": address},
)
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}: {reason}"
"message": "Could not find Switchbot {sensor_type} with address {address}"
},
"device_without_config_entry": {
"message": "The device ID {device_id} is not associated with a config entry."
+1 -1
View File
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
fnv-hash-fast==2.0.3
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==6.8.0
habluetooth==6.7.9
hass-nabucasa==2.2.0
hassil==3.5.0
home-assistant-bluetooth==2.0.0
+1 -1
View File
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
habiticalib==0.4.7
# homeassistant.components.bluetooth
habluetooth==6.8.0
habluetooth==6.7.9
# homeassistant.components.hanna
hanna-cloud==0.0.7
+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,
+1 -19
View File
@@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator, Generator
from pathlib import Path
from random import getrandbits
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, patch
import pytest
@@ -33,24 +33,6 @@ def patch_hass_config(mock_hass_config: None) -> None:
"""Patch configuration.yaml."""
@pytest.fixture
def mock_v5_protocol_check() -> bool:
"""Fixture to mock a v5 protocol test result."""
return True
@pytest.fixture(autouse=True)
def mock_try_connection_protocol_check(
hass: HomeAssistant, mock_v5_protocol_check: bool
) -> Generator[MagicMock]:
"""Patch try_connection."""
with patch(
"homeassistant.components.mqtt.try_connection",
return_value=mock_v5_protocol_check,
) as mock_try_connection:
yield mock_try_connection
@pytest.fixture
def temp_dir_prefix() -> str:
"""Set an alternate temp dir prefix."""
+4 -11
View File
@@ -230,7 +230,6 @@ async def test_publish(
assert publish_mock.call_args[0][4].json() == {"MessageExpiryInterval": 60}
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_options", "mqtt_config_entry_data", "protocol"),
[
@@ -1258,12 +1257,7 @@ async def test_restore_subscriptions_on_reconnect(
@pytest.mark.parametrize(
("mqtt_config_entry_data", "mqtt_config_entry_options"),
[
(
{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_PROTOCOL: "5"},
{mqtt.CONF_DISCOVERY: False},
)
],
[({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})],
)
async def test_restore_all_active_subscriptions_on_reconnect(
hass: HomeAssistant,
@@ -1283,7 +1277,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
# the subscription with the highest QoS should survive
expected = [
call([("test/state", 2)], properties=ANY),
call([("test/state", 2)], properties=None),
]
assert mqtt_client_mock.subscribe.mock_calls == expected
@@ -1297,7 +1291,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
# wait for cooldown
await mock_debouncer.wait()
expected.append(call([("test/state", 1)], properties=ANY))
expected.append(call([("test/state", 1)], properties=None))
for expected_call in expected:
assert mqtt_client_mock.subscribe.hass_call(expected_call)
@@ -1555,7 +1549,6 @@ async def test_handle_message_callback(
assert callbacks[0].payload == "test-payload"
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "protocol", "clean_session"),
[
@@ -1589,6 +1582,7 @@ async def test_handle_message_callback(
async def test_setup_mqtt_client_clean_session_and_protocol(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_client_mock: MqttMockPahoClient,
protocol: int,
clean_session: bool | None,
) -> None:
@@ -1603,7 +1597,6 @@ async def test_setup_mqtt_client_clean_session_and_protocol(
assert mock_client.call_args[1]["protocol"] == protocol
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "connect_args"),
[
+1 -3
View File
@@ -130,7 +130,6 @@ MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n"
MOCK_ENTRY_DATA = {
mqtt.CONF_BROKER: "test-broker",
CONF_PROTOCOL: "5",
CONF_PORT: 1234,
CONF_USERNAME: "user",
CONF_PASSWORD: "pass",
@@ -274,7 +273,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]:
# Patch prevent waiting 5 sec for a timeout
with (
patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client,
patch("homeassistant.components.mqtt.client.MQTT_TIMEOUT", 0),
patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0),
):
mock_client().loop_start = lambda *args: 1
yield mock_client()
@@ -1757,7 +1756,6 @@ async def test_step_hassio_reauth(
mock_try_connection.assert_called_once_with(
{
"broker": "core-mosquitto",
CONF_PROTOCOL: "5",
"port": 1883,
"username": "mock-user",
"password": "mock-pass",
-125
View File
@@ -5,7 +5,6 @@ from copy import deepcopy
from datetime import datetime, timedelta
from functools import partial
import json
import logging
import time
from typing import Any, TypedDict
from unittest.mock import ANY, MagicMock, Mock, mock_open, patch
@@ -29,7 +28,6 @@ from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import (
ATTR_ASSUMED_STATE,
CONF_PORT,
CONF_PROTOCOL,
SERVICE_RELOAD,
STATE_UNAVAILABLE,
@@ -55,7 +53,6 @@ from tests.common import (
MockEntity,
MockEntityPlatform,
MockMqttReasonCode,
async_capture_events,
async_fire_mqtt_message,
async_fire_time_changed,
mock_restore_cache,
@@ -2473,125 +2470,3 @@ async def test_yaml_config_with_active_mqtt_config_entry(
state = hass.states.get("sensor.mqtt_sensor")
assert state is not None
assert issue is None
@pytest.mark.parametrize(
"mqtt_config_entry_data",
[
{
mqtt.CONF_BROKER: "mock-broker",
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_successful_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the silent MQTT protocol migration is successful."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
with caplog.at_level(logging.INFO):
await mqtt_mock_entry()
assert len(events) == 0
assert (
"The MQTT protocol version was successfully updated to version 5"
in caplog.text
)
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data[mqtt.CONF_PROTOCOL] == mqtt.PROTOCOL_5
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_failed_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
current_protocol: str,
) -> None:
"""Test failed silent MQTT protocol migration creates a repair issue."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert (
"The MQTT protocol version was successfully updated to version 5"
not in caplog.text
)
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data.get(mqtt.CONF_PROTOCOL, mqtt.PROTOCOL_311) == current_protocol
+191 -3
View File
@@ -1,18 +1,19 @@
"""Test repairs for MQTT."""
from collections.abc import Coroutine
from collections.abc import Coroutine, Generator
from copy import deepcopy
from typing import Any
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData
from homeassistant.const import SERVICE_RELOAD
from homeassistant.const import CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.setup import async_setup_component
from homeassistant.util.yaml import parse_yaml
from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message
@@ -27,6 +28,13 @@ from tests.conftest import ClientSessionGenerator
from tests.typing import MqttMockHAClientGenerator
@pytest.fixture
def mock_try_connection() -> Generator[MagicMock]:
"""Mock the try connection method."""
with patch("homeassistant.components.mqtt.repairs.try_connection") as mock_try:
yield mock_try
async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None:
"""Help to set up an exported MQTT device via YAML."""
with patch(
@@ -177,3 +185,183 @@ async def test_subentry_reconfigure_export_settings(
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
assert device.config_entries_subentries[config_entry.entry_id] == {None}
assert device is not None
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_successful_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
hass_client: ClientSessionGenerator,
mqtt_config_entry_data: dict[str, Any],
current_protocol: str,
mock_try_connection: MagicMock,
) -> None:
"""Test the MQTT protocol migration repair flow is successful."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
await async_process_repairs_platforms(hass)
client = await hass_client()
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert data["step_id"] == "confirm"
mock_try_connection.side_effect = lambda x: True
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "create_entry"
expected_entry_data: dict[str, Any] = mqtt_config_entry_data | {CONF_PROTOCOL: "5"}
mock_try_connection.assert_called_once_with(expected_entry_data | {CONF_PORT: 1883})
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
assert entry.data == expected_entry_data
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.mark.parametrize(
("mqtt_config_entry_data", "current_protocol"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
CONF_PORT: 1883,
},
"3.1.1",
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
CONF_PORT: 1883,
},
"3.1",
),
],
ids=[
"entry_without_protocol_without_port",
"entry_without_protocol_with_port",
"entry_with_protocol_3.1.1",
"entry_with_protocol_3.1",
],
)
async def test_mqtt_protocol_failed_migration_to_v5(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
hass_client: ClientSessionGenerator,
current_protocol: str,
mock_try_connection: MagicMock,
) -> None:
"""Test the MQTT protocol migration repair flow fails."""
assert await async_setup_component(hass, "repairs", {})
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await mqtt_mock_entry()
assert len(events) == 1
assert events[0].data["issue_id"] == "protocol_5_migration"
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
assert issue is not None
assert issue.translation_key == "protocol_5_migration"
assert issue.translation_placeholders == {
"broker": "mock-broker",
"protocol": current_protocol,
}
await async_process_repairs_platforms(hass)
client = await hass_client()
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
flow_id = data["flow_id"]
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
}
assert data["step_id"] == "confirm"
mock_try_connection.side_effect = lambda x: False
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "abort"
assert data["reason"] == "mqtt_broker_migration_to_v5_failed"
assert data["description_placeholders"] == {
"broker": "mock-broker",
"protocol": current_protocol,
"url_mqtt_broker_configuration": "https://www.home-assistant.io/integrations/mqtt/#broker-configuration",
}
await hass.async_block_till_done(wait_background_tasks=True)