Files
esphome/tests/unit_tests/test_mqtt.py
T

943 lines
31 KiB
Python

"""Unit tests for esphome.mqtt module."""
from __future__ import annotations
from collections.abc import Callable
import json
import logging
from pathlib import Path
import ssl
import threading
import time
from unittest.mock import MagicMock, call, patch
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
import pytest
from esphome.components.const import CONF_VERIFY_SSL
from esphome.components.mqtt import CONF_TLS
from esphome.const import (
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE_AUTHORITY,
CONF_CLIENT_CERTIFICATE,
CONF_CLIENT_CERTIFICATE_KEY,
CONF_ESPHOME,
CONF_LOG_TOPIC,
CONF_MQTT,
CONF_NAME,
CONF_NAME_ADD_MAC_SUFFIX,
CONF_PAYLOAD,
CONF_PORT,
CONF_RETAIN,
CONF_SKIP_CERT_CN_CHECK,
CONF_TOPIC,
CONF_TOPIC_PREFIX,
)
from esphome.core import EsphomeError
from esphome.mqtt import (
clear_topic,
get_esphome_device_ip,
prepare,
show_discover,
show_logs,
)
@pytest.mark.parametrize(
"birth",
[
None,
{CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "online", CONF_RETAIN: False},
{CONF_TOPIC: "test-device/status", CONF_PAYLOAD: "", CONF_RETAIN: True},
],
ids=["no birth message", "unretained birth message", "empty birth payload"],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_asks_on_connect(
mock_initialize: MagicMock, birth: dict | None
) -> None:
"""Without a retained birth message nothing tells a later CLI the device is up, so it asks on connect."""
config = _discovery_config()
if birth is not None:
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = birth
show_logs(config)
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0, None)
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
if birth is None or not birth[CONF_PAYLOAD]:
assert mock_initialize.call_args.args[1] == ["test-device/debug"]
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_requests_crash_log_when_device_reports_online(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""With a retained birth message the request follows the device coming online, not the CLI connecting."""
config = _discovery_config()
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: "test-device/status",
CONF_PAYLOAD: "online",
CONF_RETAIN: True,
}
show_logs(config)
assert mock_initialize.call_args.args[1] == [
"test-device/debug",
"test-device/status",
]
on_message = mock_initialize.call_args.args[2]
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0, None)
client.publish.assert_not_called()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"offline"))
client.publish.assert_not_called()
# The birth payload on another topic is only a log line
on_message(client, None, MagicMock(topic="test-device/debug", payload=b"online"))
client.publish.assert_not_called()
mock_print.reset_mock()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"online"))
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
mock_print.assert_not_called()
on_message(
client, None, MagicMock(topic="test-device/debug", payload=b"[I][app]: hi")
)
assert mock_print.call_args.args[0].endswith("[I][app]: hi")
client.publish.assert_called_once()
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_prints_a_birth_message_inside_the_log_subscription(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""A birth topic covered by the log topic is still printed, and still requests the crash log."""
config = _discovery_config()
config[CONF_MQTT][CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: "test-device/status",
CONF_PAYLOAD: "online",
CONF_RETAIN: True,
}
show_logs(config, topic="test-device/#")
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
on_message(client, None, MagicMock(topic="test-device/status", payload=b"online"))
client.publish.assert_called_once_with(
"esphome/logs/test-device", None, retain=False
)
assert mock_print.call_args.args[0].endswith("online")
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_logs_follows_the_mac_suffix_of_default_topics(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""With name_add_mac_suffix the default topics carry a MAC the CLI cannot know, so any suffix matches."""
show_logs(_mac_config(log_topic="test-device/debug"))
assert mock_initialize.call_args.args[1] == ["+/debug", "+/status"]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
for topic in (
"other-device/status",
"test-device/status",
"test-device-abc/status",
):
_online(on_message, client, topic)
client.publish.assert_not_called()
_online(on_message, client, "test-device-a1b2c3/status")
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
on_message(client, None, MagicMock(topic="other-device/debug", payload=b"no"))
mock_print.assert_not_called()
on_message(
client, None, MagicMock(topic="test-device-a1b2c3/debug", payload=b"[I]: hi")
)
assert mock_print.call_args.args[0].endswith("[test-device-a1b2c3][I]: hi")
@pytest.mark.parametrize(
("mac_suffix", "options", "cli_topic", "subscriptions"),
[
pytest.param(
True,
{"log_topic": "custom/debug", "birth_topic": None},
None,
["custom/debug"],
id="custom log topic with the mac suffix",
),
pytest.param(
True,
{"birth_topic": None},
"test-device/debug",
["test-device/debug"],
id="command line topic as written",
),
pytest.param(
False,
{"prefix": "custom", "birth_topic": None},
None,
["custom/debug"],
id="logs under the topic prefix",
),
pytest.param(
True,
{"prefix": "custom", "log_topic": "test-device/debug"},
None,
["test-device/debug", "test-device/status"],
id="written out topics under a custom prefix",
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_subscribes_to_literal_topics(
mock_initialize: MagicMock,
mac_suffix: bool,
options: dict,
cli_topic: str | None,
subscriptions: list[str],
) -> None:
"""Topics the firmware does not build from the MAC-suffixed name are subscribed to as written."""
config = _mac_config(**options)
config[CONF_ESPHOME][CONF_NAME_ADD_MAC_SUFFIX] = mac_suffix
show_logs(config, topic=cli_topic)
assert mock_initialize.call_args.args[1] == subscriptions
@patch("esphome.mqtt.initialize")
def test_show_logs_asks_only_the_board_a_command_line_topic_shows(
mock_initialize: MagicMock,
) -> None:
"""Replaying a crash clears it, so boards whose logs are not shown are left alone."""
show_logs(_mac_config(), topic="test-device-a1b2c3/debug")
assert mock_initialize.call_args.args[1] == [
"test-device-a1b2c3/debug",
"+/status",
]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
_online(on_message, client, "test-device-d4e5f6/status")
client.publish.assert_not_called()
_online(on_message, client, "test-device-a1b2c3/status")
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
@pytest.mark.parametrize(
("cli_topic", "asked"),
[
pytest.param(None, True, id="no command line topic"),
pytest.param("custom/debug", True, id="the shared custom topic"),
pytest.param(
"test-device-a1b2c3/debug", False, id="a topic the board never uses"
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_matches_a_command_line_topic_to_the_custom_log_topic(
mock_initialize: MagicMock, cli_topic: str | None, asked: bool
) -> None:
"""Every board's crash replay goes to the custom log topic, so --topic must show it for a board to be asked."""
show_logs(_mac_config(log_topic="custom/debug"), topic=cli_topic)
assert mock_initialize.call_args.args[1] == [
cli_topic or "custom/debug",
"+/status",
]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
_online(on_message, client, "test-device-a1b2c3/status")
if asked:
client.publish.assert_called_once_with(
"esphome/logs/test-device-a1b2c3", None, retain=False
)
else:
client.publish.assert_not_called()
@pytest.mark.parametrize(
("mqtt_config", "message"),
[
pytest.param(None, "MQTT isn't setup", id="no mqtt"),
pytest.param({CONF_LOG_TOPIC: None}, "set to null", id="null log topic"),
pytest.param(
{CONF_LOG_TOPIC: {}}, "not available", id="log topic without topic"
),
],
)
@patch("esphome.mqtt.initialize")
def test_show_logs_without_a_log_topic(
mock_initialize: MagicMock,
mqtt_config: dict | None,
message: str,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Without a log topic there is nothing to follow."""
config = _discovery_config()
if mqtt_config is None:
del config[CONF_MQTT]
else:
config[CONF_MQTT].update(mqtt_config)
assert show_logs(config) == 1
assert message in caplog.text
mock_initialize.assert_not_called()
@patch("esphome.mqtt.initialize")
def test_show_logs_skips_crash_log_requests_no_suffixed_board_hears(
mock_initialize: MagicMock,
) -> None:
"""A board with the MAC suffix only hears requests named after its own birth message."""
show_logs(_mac_config(birth_topic="custom/status", retain=False))
on_message = mock_initialize.call_args.args[2]
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
on_connect(client, None, None, 0, None)
_online(on_message, client, "custom/status")
client.publish.assert_not_called()
def test_get_esphome_device_ip_empty_broker() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when broker is empty."""
config = {
CONF_MQTT: {
CONF_BROKER: "",
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the broker is not configured",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_none_broker() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when broker is None."""
config = {
CONF_MQTT: {
CONF_BROKER: None,
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the broker is not configured",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_mqtt() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when mqtt config is missing."""
config = {
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the mqtt:",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_esphome() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when esphome config is missing."""
config = {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the device name:",
):
get_esphome_device_ip(config)
def test_get_esphome_device_ip_missing_name() -> None:
"""Test that get_esphome_device_ip raises EsphomeError when device name is missing."""
config = {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
CONF_ESPHOME: {},
}
with pytest.raises(
EsphomeError,
match="Cannot discover IP via MQTT as the config does not include the device name:",
):
get_esphome_device_ip(config)
def _mac_config(
log_topic: str | None = None,
birth_topic: str | None = "test-device/status",
retain: bool = True,
prefix: str | None = None,
) -> dict:
"""A config with name_add_mac_suffix on and the given mqtt topics."""
config = _discovery_config()
config[CONF_ESPHOME][CONF_NAME_ADD_MAC_SUFFIX] = True
mqtt_conf = config[CONF_MQTT]
if prefix is not None:
mqtt_conf[CONF_TOPIC_PREFIX] = prefix
if log_topic is not None:
mqtt_conf[CONF_LOG_TOPIC] = {CONF_TOPIC: log_topic}
if birth_topic is not None:
mqtt_conf[CONF_BIRTH_MESSAGE] = {
CONF_TOPIC: birth_topic,
CONF_PAYLOAD: "online",
CONF_RETAIN: retain,
}
return config
def _online(on_message: Callable[..., None], client: MagicMock, topic: str) -> None:
"""Deliver the birth payload on a topic."""
on_message(client, None, MagicMock(topic=topic, payload=b"online"))
def _discovery_config() -> dict:
return {
CONF_MQTT: {
CONF_BROKER: "mqtt.local",
},
CONF_ESPHOME: {
CONF_NAME: "test-device",
},
}
def _disconnect_reason(name: str) -> ReasonCode:
return ReasonCode(PacketTypes.DISCONNECT, name)
def _deliver_on_loop_start(mock_prepare, client, payload: bytes) -> None:
"""Deliver a discovery answer as soon as the network loop starts."""
def deliver(*args, **kwargs):
msg = MagicMock()
msg.payload = payload
mock_prepare.call_args.args[2](client, None, msg)
client.loop_start.side_effect = deliver
def test_get_esphome_device_ip_success() -> None:
"""A device answer on the discovery topic returns its IPs."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps(
{"name": "test-device", "ip": "10.0.0.5", "ip1": "10.0.0.6"}
).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5", "10.0.0.6"]
client.loop_stop.assert_called_once_with()
# Once from on_message on receiving the answer, once from the finally
assert client.disconnect.call_count == 2
def test_get_esphome_device_ip_preset_stop_event_skips_lookup() -> None:
"""A stop event set before the call returns [] without touching the broker."""
stop_event = threading.Event()
stop_event.set()
with patch("esphome.mqtt.prepare") as mock_prepare:
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
assert result == []
mock_prepare.assert_not_called()
def test_get_esphome_device_ip_stop_event_aborts_wait() -> None:
"""A stop event set mid-wait exits quietly with no addresses."""
stop_event = threading.Event()
client = MagicMock()
# Simulate teardown starting right after the network loop spins up
client.loop_start.side_effect = stop_event.set
start = time.monotonic()
with patch("esphome.mqtt.prepare", return_value=client):
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
# An abort is not a failure and must be nowhere near the 25s timeout
assert result == []
assert time.monotonic() - start < 5
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_timeout_raises() -> None:
"""No answer within the timeout raises EsphomeError (default stop event path)."""
client = MagicMock()
with (
patch("esphome.mqtt.prepare", return_value=client),
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
):
get_esphome_device_ip(_discovery_config(), timeout=0.25)
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_stop_during_connect_skips_wait() -> None:
"""A stop event set while the broker connect is in flight still cleans up."""
stop_event = threading.Event()
client = MagicMock()
def prepare_and_stop(*args):
stop_event.set()
return client
with patch("esphome.mqtt.prepare", side_effect=prepare_and_stop):
result = get_esphome_device_ip(_discovery_config(), stop_event=stop_event)
assert result == []
client.loop_start.assert_not_called()
client.disconnect.assert_called_once_with()
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_replaces_reconnect_handler(
caplog: pytest.LogCaptureFixture,
) -> None:
"""The one-shot discovery client must not inherit the reconnect-forever
handler, which would make loop_stop() join the network thread forever;
its replacement still reports a broker-initiated disconnect."""
client = MagicMock()
prepare_handler = MagicMock()
client.on_disconnect = prepare_handler
with (
patch("esphome.mqtt.prepare", return_value=client),
pytest.raises(EsphomeError, match="Failed to find IP via MQTT"),
):
get_esphome_device_ip(_discovery_config(), timeout=0.25)
assert client.on_disconnect is not prepare_handler
flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=True)
client.on_disconnect(
client, None, flags, _disconnect_reason("Normal disconnection"), None
)
assert "Disconnected from MQTT broker" not in caplog.text
client.on_disconnect(
client, None, flags, _disconnect_reason("Not authorized"), None
)
assert "Disconnected from MQTT broker (Not authorized, rc=135)" in caplog.text
def test_get_esphome_device_ip_answer_without_ip_fails_fast(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A device answer with no IP fields fails promptly, not at the timeout."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare, client, json.dumps({"name": "test-device"}).encode()
)
start = time.monotonic()
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=5)
assert time.monotonic() - start < 1
assert "Device answer did not include an IP address" in caplog.text
@pytest.mark.parametrize("payload", [b"not json {", b"123", b"null"])
def test_get_esphome_device_ip_unparsable_payload_ignored(
caplog: pytest.LogCaptureFixture,
payload: bytes,
) -> None:
"""Garbage on the discovery topic must not kill paho's network thread."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(mock_prepare, client, payload)
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=0)
assert "Ignoring unparsable discovery payload" in caplog.text
def test_get_esphome_device_ip_broker_disconnect_fails_fast(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A broker-initiated disconnect aborts the wait instead of timing out."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client):
def drop_connection(*args, **kwargs):
flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=True)
client.on_disconnect(
client, None, flags, _disconnect_reason("Not authorized"), None
)
client.loop_start.side_effect = drop_connection
start = time.monotonic()
with pytest.raises(EsphomeError, match="Failed to find IP via MQTT"):
get_esphome_device_ip(_discovery_config(), timeout=5)
assert time.monotonic() - start < 1
assert "Disconnected from MQTT broker (Not authorized, rc=135)" in caplog.text
def test_get_esphome_device_ip_sends_discovery_ping() -> None:
"""Connecting publishes the discovery ping for the device."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
def connect_then_answer(*args, **kwargs):
on_connect = mock_prepare.call_args.args[3]
on_connect(client, None, None, 0, None)
msg = MagicMock()
msg.payload = json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode()
mock_prepare.call_args.args[2](client, None, msg)
client.loop_start.side_effect = connect_then_answer
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
client.publish.assert_called_once_with(
"esphome/ping/test-device", None, retain=False
)
def test_get_esphome_device_ip_disconnect_error_does_not_mask_result(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A cleanup failure must not replace the discovery result."""
client = MagicMock()
# First disconnect (from on_message) succeeds; the finally's fails
client.disconnect.side_effect = [None, OSError("socket already closed")]
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps({"name": "test-device", "ip": "10.0.0.5"}).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
client.loop_stop.assert_called_once_with()
def test_get_esphome_device_ip_invalid_address_values_skipped(
caplog: pytest.LogCaptureFixture,
) -> None:
"""Non-string or non-printable ip values are skipped, valid ones kept."""
client = MagicMock()
with patch("esphome.mqtt.prepare", return_value=client) as mock_prepare:
_deliver_on_loop_start(
mock_prepare,
client,
json.dumps(
{
"name": "test-device",
"ip": 1234,
"ip1": "x\n[00:00:00][I][forged] fake line",
"ip2": " 10.0.0.5 ",
}
).encode(),
)
result = get_esphome_device_ip(_discovery_config())
assert result == ["10.0.0.5"]
assert caplog.text.count("Ignoring invalid address in discovery answer") == 2
assert "forged" not in "".join(
r.getMessage() for r in caplog.records if "Found IP" in r.getMessage()
)
@pytest.mark.parametrize(
("transport", "ws_path", "expect_tls", "expect_path"),
[
(None, None, False, None),
("ws", None, False, "/mqtt"),
("wss", "/ws", True, "/ws"),
],
ids=["tcp", "ws with the default path", "wss with a path"],
)
@patch("esphome.mqtt.ssl.create_default_context")
@patch("esphome.mqtt.mqtt.Client")
def test_prepare_follows_the_firmware_transport(
mock_client_class: MagicMock,
mock_context: MagicMock,
transport: str | None,
ws_path: str | None,
expect_tls: bool,
expect_path: str | None,
) -> None:
"""The CLI client speaks WebSocket, and TLS over wss, when the firmware is configured to."""
config = _discovery_config()
config[CONF_MQTT][CONF_PORT] = 1883
if transport is not None:
config[CONF_MQTT]["transport"] = transport
if ws_path is not None:
config[CONF_MQTT]["ws_path"] = ws_path
if expect_tls:
# Validation switches tls on for wss
config[CONF_MQTT][CONF_TLS] = True
client = mock_client_class.return_value
assert prepare(config, [], None, None, None, None, None) is client
mock_client_class.assert_called_once_with(
mqtt.CallbackAPIVersion.VERSION2,
"",
transport="websockets" if expect_path else "tcp",
)
if expect_path is None:
client.ws_set_options.assert_not_called()
else:
client.ws_set_options.assert_called_once_with(path=expect_path)
if expect_tls:
mock_context.assert_called_once_with(cadata=None)
client.tls_set_context.assert_called_once_with(mock_context.return_value)
else:
client.tls_set_context.assert_not_called()
client.connect.assert_called_once_with("mqtt.local", 1883)
@patch("esphome.mqtt.mqtt.Client")
def test_prepare_subscribes_on_connect_then_runs_the_caller_hook(
mock_client_class: MagicMock,
) -> None:
"""On CONNACK the client subscribes to every topic before the caller's on_connect runs."""
config = _discovery_config()
config[CONF_MQTT][CONF_PORT] = 1883
on_connect = MagicMock()
client = prepare(config, ["a/#", "b"], None, on_connect, None, None, None)
flags = mqtt.ConnectFlags(session_present=False)
reason = ReasonCode(PacketTypes.CONNACK, "Success")
client.on_connect(client, None, flags, reason, None)
assert client.subscribe.call_args_list == [call("a/#"), call("b")]
on_connect.assert_called_once_with(client, None, flags, reason, None)
@patch("esphome.mqtt.time.sleep")
@patch("esphome.mqtt.mqtt.Client")
def test_prepare_reconnects_with_backoff_after_an_unexpected_disconnect(
mock_client_class: MagicMock,
mock_sleep: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A clean disconnect is left alone; any other reason retries with backoff until the broker answers."""
caplog.set_level(logging.INFO)
config = _discovery_config()
config[CONF_MQTT][CONF_PORT] = 1883
client = prepare(config, [], None, None, None, None, None)
flags = mqtt.DisconnectFlags(is_disconnect_packet_from_server=False)
client.on_disconnect(
client, None, flags, _disconnect_reason("Normal disconnection"), None
)
client.reconnect.assert_not_called()
client.reconnect.side_effect = [OSError("down"), 1, 0]
client.on_disconnect(
client, None, flags, _disconnect_reason("Unspecified error"), None
)
assert client.reconnect.call_count == 3
assert mock_sleep.call_args_list == [call(1), call(2)]
assert (
caplog.text.count(
"Disconnected from MQTT (Unspecified error, rc=128). Trying to reconnect"
)
== 2
)
assert "Successfully reconnected to the MQTT server" in caplog.text
@patch("esphome.mqtt.safe_print")
@patch("esphome.mqtt.initialize")
def test_show_discover_broadcasts_on_connect_and_prints_answers(
mock_initialize: MagicMock, mock_print: MagicMock
) -> None:
"""The discover listener sends the broadcast once connected and prints every non-empty answer."""
show_discover(_discovery_config())
assert mock_initialize.call_args.args[1] == ["esphome/discover/#"]
on_message = mock_initialize.call_args.args[2]
on_connect = mock_initialize.call_args.args[3]
client = MagicMock()
flags = mqtt.ConnectFlags(session_present=False)
on_connect(client, None, flags, ReasonCode(PacketTypes.CONNACK, "Success"), None)
client.publish.assert_called_once_with("esphome/discover", None, retain=False)
on_message(client, None, MagicMock(payload=b""))
mock_print.assert_not_called()
on_message(client, None, MagicMock(payload=b"test-device 10.0.0.5"))
assert mock_print.call_args.args[0].endswith(" test-device 10.0.0.5")
def _mqtt_config(options: dict | None = None) -> dict:
return {
CONF_MQTT: {CONF_BROKER: "broker.local", CONF_PORT: 8883, **(options or {})}
}
def _prepare_with_mocks(config: dict) -> tuple[MagicMock, MagicMock, MagicMock]:
"""Runs prepare() against a mocked paho client and SSL context factory."""
with (
patch("esphome.mqtt.mqtt.Client") as client_cls,
patch("esphome.mqtt.ssl.create_default_context") as create_context,
):
client = client_cls.return_value
context = create_context.return_value
# A real context verifies hostnames unless prepare() turns that off
context.check_hostname = True
prepare(config, [], None, None, None, None, None)
return client, create_context, context
def test_prepare_without_tls_connects_in_plaintext() -> None:
client, create_context, _ = _prepare_with_mocks(_mqtt_config({CONF_PORT: 1883}))
create_context.assert_not_called()
client.tls_set_context.assert_not_called()
client.connect.assert_called_once_with("broker.local", 1883)
def test_prepare_tls_without_ca_uses_system_trust_store() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config({CONF_TLS: True})
)
create_context.assert_called_once_with(cadata=None)
assert context.check_hostname is True
client.tls_set_context.assert_called_once_with(context)
def test_prepare_tls_with_ca_and_skip_cn_check() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config(
{
CONF_TLS: True,
CONF_CERTIFICATE_AUTHORITY: "---cert---",
CONF_SKIP_CERT_CN_CHECK: True,
}
)
)
create_context.assert_called_once_with(cadata="---cert---")
assert context.check_hostname is False
client.tls_set_context.assert_called_once_with(context)
def test_prepare_verify_ssl_false_disables_verification() -> None:
client, create_context, context = _prepare_with_mocks(
_mqtt_config({CONF_TLS: True, CONF_VERIFY_SSL: False})
)
create_context.assert_called_once_with()
assert context.check_hostname is False
assert context.verify_mode == ssl.CERT_NONE
client.tls_set_context.assert_called_once_with(context)
def test_prepare_loads_client_certificate() -> None:
_, _, context = _prepare_with_mocks(
_mqtt_config(
{
CONF_TLS: True,
CONF_CLIENT_CERTIFICATE: "---client cert---",
CONF_CLIENT_CERTIFICATE_KEY: "---client key---",
}
)
)
context.load_cert_chain.assert_called_once()
cert_file, key_file = context.load_cert_chain.call_args.args
assert not Path(cert_file).exists()
assert not Path(key_file).exists()
@patch("esphome.mqtt.initialize")
def test_clear_topic_clears_retained_messages_under_the_device(
mock_initialize: MagicMock,
) -> None:
"""Without a topic the device's discovery subtree is cleared, one empty retained publish per retained message."""
clear_topic(_discovery_config(), None)
assert mock_initialize.call_args.args[1] == ["homeassistant/+/test-device/#"]
on_message = mock_initialize.call_args.args[2]
client = MagicMock()
on_message(
client,
None,
MagicMock(
topic="homeassistant/sensor/test-device/t", payload=b"1", retain=False
),
)
on_message(
client,
None,
MagicMock(topic="homeassistant/sensor/test-device/t", payload=b"", retain=True),
)
client.publish.assert_not_called()
on_message(
client,
None,
MagicMock(
topic="homeassistant/sensor/test-device/t", payload=b"1", retain=True
),
)
client.publish.assert_called_once_with(
"homeassistant/sensor/test-device/t", None, retain=True
)