Create a UUID from given LG soundbar device name (#81918)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Fixes https://github.com/home-assistant/core/issues/77524
fixes undefined
pull/82986/head
Christopher McCurdy 2022-11-30 06:53:49 -05:00 committed by GitHub
parent 98f263c289
commit 4167edc52d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 320 additions and 111 deletions

View File

@ -672,6 +672,7 @@ omit =
homeassistant/components/led_ble/__init__.py
homeassistant/components/led_ble/light.py
homeassistant/components/lg_netcast/media_player.py
homeassistant/components/lg_soundbar/__init__.py
homeassistant/components/lg_soundbar/media_player.py
homeassistant/components/lidarr/__init__.py
homeassistant/components/lidarr/coordinator.py

View File

@ -1,5 +1,6 @@
"""Config flow to configure the LG Soundbar integration."""
from queue import Full, Queue
import logging
from queue import Empty, Full, Queue
import socket
import temescal
@ -7,6 +8,7 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.data_entry_flow import FlowResult
from .const import DEFAULT_PORT, DOMAIN
@ -14,50 +16,64 @@ DATA_SCHEMA = {
vol.Required(CONF_HOST): str,
}
_LOGGER = logging.getLogger(__name__)
QUEUE_TIMEOUT = 10
def test_connect(host, port):
"""LG Soundbar config flow test_connect."""
uuid_q = Queue(maxsize=1)
name_q = Queue(maxsize=1)
def check_msg_response(response, msgs, attr):
msg = response["msg"]
if msg == msgs or msg in msgs:
if "data" in response and attr in response["data"]:
return True
_LOGGER.debug(
"[%s] msg did not contain expected attr [%s]: %s", msg, attr, response
)
return False
def queue_add(attr_q, data):
try:
attr_q.put_nowait(data)
except Full:
pass
_LOGGER.debug("attempted to add [%s] to full queue", data)
def msg_callback(response):
if (
response["msg"] in ["MAC_INFO_DEV", "PRODUCT_INFO"]
and "s_uuid" in response["data"]
):
if check_msg_response(response, ["MAC_INFO_DEV", "PRODUCT_INFO"], "s_uuid"):
queue_add(uuid_q, response["data"]["s_uuid"])
if (
response["msg"] == "SPK_LIST_VIEW_INFO"
and "s_user_name" in response["data"]
):
if check_msg_response(response, "SPK_LIST_VIEW_INFO", "s_user_name"):
queue_add(name_q, response["data"]["s_user_name"])
details = {}
try:
connection = temescal.temescal(host, port=port, callback=msg_callback)
connection.get_info()
connection.get_mac_info()
if uuid_q.empty():
connection.get_product_info()
connection.get_info()
details = {"name": name_q.get(timeout=10), "uuid": uuid_q.get(timeout=10)}
return details
details["name"] = name_q.get(timeout=QUEUE_TIMEOUT)
details["uuid"] = uuid_q.get(timeout=QUEUE_TIMEOUT)
except Empty:
pass
except socket.timeout as err:
raise ConnectionError(f"Connection timeout with server: {host}:{port}") from err
except OSError as err:
raise ConnectionError(f"Cannot resolve hostname: {host}") from err
return details
class LGSoundbarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""LG Soundbar config flow."""
VERSION = 1
async def async_step_user(self, user_input=None):
async def async_step_user(self, user_input=None) -> FlowResult:
"""Handle a flow initiated by the user."""
if user_input is None:
return self._show_form()
@ -70,13 +86,19 @@ class LGSoundbarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
except ConnectionError:
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(details["uuid"])
self._abort_if_unique_id_configured()
info = {
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: DEFAULT_PORT,
}
return self.async_create_entry(title=details["name"], data=info)
if len(details) != 0:
info = {
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: DEFAULT_PORT,
}
if "uuid" in details:
unique_id = details["uuid"]
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
else:
self._async_abort_entries_match(info)
return self.async_create_entry(title=details["name"], data=info)
errors["base"] = "no_data"
return self._show_form(errors)

View File

@ -25,7 +25,7 @@ async def async_setup_entry(
LGDevice(
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.unique_id,
config_entry.unique_id or config_entry.entry_id,
)
]
)
@ -82,7 +82,7 @@ class LGDevice(MediaPlayerEntity):
def handle_event(self, response):
"""Handle responses from the speakers."""
data = response["data"]
data = response["data"] if "data" in response else {}
if response["msg"] == "EQ_VIEW_INFO":
if "i_bass" in data:
self._bass = data["i_bass"]

View File

@ -8,7 +8,8 @@
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_data": "Device did not return any data required to an entry."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

@ -1,7 +1,8 @@
{
"config": {
"abort": {
"already_configured": "Device is already configured"
"already_configured": "Device is already configured",
"no_uuid": "Device missing unique identification required for discovery."
},
"error": {
"cannot_connect": "Failed to connect"
@ -14,4 +15,4 @@
}
}
}
}
}

View File

@ -1,5 +1,10 @@
"""Test the lg_soundbar config flow."""
from unittest.mock import DEFAULT, MagicMock, Mock, call, patch
from __future__ import annotations
from collections.abc import Callable
import socket
from typing import Any
from unittest.mock import DEFAULT, patch
from homeassistant import config_entries
from homeassistant.components.lg_soundbar.const import DEFAULT_PORT, DOMAIN
@ -8,6 +13,43 @@ from homeassistant.const import CONF_HOST, CONF_PORT
from tests.common import MockConfigEntry
def setup_mock_temescal(
hass, mock_temescal, mac_info_dev=None, product_info=None, info=None
):
"""Set up a mock of the temescal object to craft our expected responses."""
tmock = mock_temescal.temescal
instance = tmock.return_value
def create_temescal_response(msg: str, data: dict | None = None) -> dict[str, Any]:
response: dict[str, Any] = {"msg": msg}
if data is not None:
response["data"] = data
return response
def temescal_side_effect(
addr: str, port: int, callback: Callable[[dict[str, Any]], None]
):
mac_info_response = create_temescal_response(
msg="MAC_INFO_DEV", data=mac_info_dev
)
product_info_response = create_temescal_response(
msg="PRODUCT_INFO", data=product_info
)
info_response = create_temescal_response(msg="SPK_LIST_VIEW_INFO", data=info)
instance.get_mac_info.side_effect = lambda: hass.add_job(
callback, mac_info_response
)
instance.get_product_info.side_effect = lambda: hass.add_job(
callback, product_info_response
)
instance.get_info.side_effect = lambda: hass.add_job(callback, info_response)
return DEFAULT
tmock.side_effect = temescal_side_effect
async def test_form(hass):
"""Test we get the form."""
@ -18,14 +60,16 @@ async def test_form(hass):
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal",
return_value=MagicMock(),
), patch(
"homeassistant.components.lg_soundbar.config_flow.test_connect",
return_value={"uuid": "uuid", "name": "name"},
), patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
mac_info_dev={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@ -36,6 +80,7 @@ async def test_form(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id == "uuid"
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
@ -43,8 +88,8 @@ async def test_form(hass):
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_uuid_missing_from_mac_info(hass):
"""Test we get the form, but uuid is missing from the initial get_mac_info function call."""
async def test_form_mac_info_response_empty(hass):
"""Test we get the form, but response from the initial get_mac_info function call is empty."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
@ -53,23 +98,16 @@ async def test_form_uuid_missing_from_mac_info(hass):
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal", return_value=Mock()
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
tmock = mock_temescal.temescal
tmock.return_value = Mock()
instance = tmock.return_value
def temescal_side_effect(addr, port, callback):
product_info = {"msg": "PRODUCT_INFO", "data": {"s_uuid": "uuid"}}
instance.get_product_info.side_effect = lambda: callback(product_info)
info = {"msg": "SPK_LIST_VIEW_INFO", "data": {"s_user_name": "name"}}
instance.get_info.side_effect = lambda: callback(info)
return DEFAULT
tmock.side_effect = temescal_side_effect
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
mac_info_dev={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@ -80,6 +118,7 @@ async def test_form_uuid_missing_from_mac_info(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id == "uuid"
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
@ -99,35 +138,18 @@ async def test_form_uuid_present_in_both_functions_uuid_q_empty(hass):
assert result["type"] == "form"
assert result["errors"] == {}
mock_uuid_q = MagicMock()
mock_name_q = MagicMock()
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal", return_value=Mock()
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.config_flow.Queue",
return_value=MagicMock(),
) as mock_q, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
mock_q.side_effect = [mock_uuid_q, mock_name_q]
mock_uuid_q.empty.return_value = True
mock_uuid_q.get.return_value = "uuid"
mock_name_q.get.return_value = "name"
tmock = mock_temescal.temescal
tmock.return_value = Mock()
instance = tmock.return_value
def temescal_side_effect(addr, port, callback):
mac_info = {"msg": "MAC_INFO_DEV", "data": {"s_uuid": "uuid"}}
instance.get_mac_info.side_effect = lambda: callback(mac_info)
product_info = {"msg": "PRODUCT_INFO", "data": {"s_uuid": "uuid"}}
instance.get_product_info.side_effect = lambda: callback(product_info)
info = {"msg": "SPK_LIST_VIEW_INFO", "data": {"s_user_name": "name"}}
instance.get_info.side_effect = lambda: callback(info)
return DEFAULT
tmock.side_effect = temescal_side_effect
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
mac_info_dev={"s_uuid": "uuid"},
product_info={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -139,14 +161,12 @@ async def test_form_uuid_present_in_both_functions_uuid_q_empty(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id == "uuid"
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
}
assert len(mock_setup_entry.mock_calls) == 1
mock_uuid_q.empty.assert_called_once()
mock_uuid_q.put_nowait.has_calls([call("uuid"), call("uuid")])
mock_uuid_q.get.assert_called_once()
async def test_form_uuid_present_in_both_functions_uuid_q_not_empty(hass):
@ -161,33 +181,21 @@ async def test_form_uuid_present_in_both_functions_uuid_q_not_empty(hass):
assert result["type"] == "form"
assert result["errors"] == {}
mock_uuid_q = MagicMock()
mock_name_q = MagicMock()
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal", return_value=Mock()
"homeassistant.components.lg_soundbar.config_flow.QUEUE_TIMEOUT",
new=0.1,
), patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.config_flow.Queue",
return_value=MagicMock(),
) as mock_q, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
mock_q.side_effect = [mock_uuid_q, mock_name_q]
mock_uuid_q.empty.return_value = False
mock_uuid_q.get.return_value = "uuid"
mock_name_q.get.return_value = "name"
tmock = mock_temescal.temescal
tmock.return_value = Mock()
instance = tmock.return_value
def temescal_side_effect(addr, port, callback):
mac_info = {"msg": "MAC_INFO_DEV", "data": {"s_uuid": "uuid"}}
instance.get_mac_info.side_effect = lambda: callback(mac_info)
info = {"msg": "SPK_LIST_VIEW_INFO", "data": {"s_user_name": "name"}}
instance.get_info.side_effect = lambda: callback(info)
return DEFAULT
tmock.side_effect = temescal_side_effect
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
mac_info_dev={"s_uuid": "uuid"},
product_info={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -199,26 +207,196 @@ async def test_form_uuid_present_in_both_functions_uuid_q_not_empty(hass):
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id == "uuid"
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
}
assert len(mock_setup_entry.mock_calls) == 1
mock_uuid_q.empty.assert_called_once()
mock_uuid_q.put_nowait.assert_called_once()
mock_uuid_q.get.assert_called_once()
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
async def test_form_uuid_missing_from_mac_info(hass):
"""Test we get the form, but uuid is missing from the initial get_mac_info function call."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
product_info={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id == "uuid"
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_uuid_not_provided_by_api(hass):
"""Test we get the form, but uuid is missing from the all API messages."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.QUEUE_TIMEOUT",
new=0.1,
), patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
product_info={"i_model_no": "8", "i_model_type": 0},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
},
)
await hass.async_block_till_done()
assert result2["type"] == "create_entry"
assert result2["title"] == "name"
assert result2["result"].unique_id is None
assert result2["data"] == {
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
}
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_both_queues_empty(hass):
"""Test we get the form, but none of the data we want is provided by the API."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.QUEUE_TIMEOUT",
new=0.1,
), patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal, patch(
"homeassistant.components.lg_soundbar.async_setup_entry", return_value=True
) as mock_setup_entry:
setup_mock_temescal(hass=hass, mock_temescal=mock_temescal)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
},
)
await hass.async_block_till_done()
assert result2["type"] == "form"
assert result2["errors"] == {"base": "no_data"}
assert len(mock_setup_entry.mock_calls) == 0
async def test_no_uuid_host_already_configured(hass):
"""Test we handle if the device has no UUID and the host has already been configured."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "1.1.1.1",
CONF_PORT: DEFAULT_PORT,
},
)
mock_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.lg_soundbar.config_flow.QUEUE_TIMEOUT",
new=0.1,
), patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal:
setup_mock_temescal(
hass=hass, mock_temescal=mock_temescal, info={"s_user_name": "name"}
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
},
)
assert result2["type"] == "abort"
assert result2["reason"] == "already_configured"
async def test_form_socket_timeout(hass):
"""Test we handle socket.timeout error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.lg_soundbar.config_flow.test_connect",
side_effect=ConnectionError,
):
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal:
mock_temescal.temescal.side_effect = socket.timeout
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: "1.1.1.1",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
async def test_form_os_error(hass):
"""Test we handle OSError."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal:
mock_temescal.temescal.side_effect = OSError
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
@ -247,9 +425,15 @@ async def test_form_already_configured(hass):
)
with patch(
"homeassistant.components.lg_soundbar.config_flow.test_connect",
return_value={"uuid": "uuid", "name": "name"},
):
"homeassistant.components.lg_soundbar.config_flow.temescal"
) as mock_temescal:
setup_mock_temescal(
hass=hass,
mock_temescal=mock_temescal,
mac_info_dev={"s_uuid": "uuid"},
info={"s_user_name": "name"},
)
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{