Add Linkplay mTLS/HTTPS and improve logging (#124307)

* Work

* Implement 0.0.8 changes, fixup tests

* Cleanup

* Implement new playmodes, close clientsession upon ha close

* Implement new playmodes, close clientsession upon ha close

* Add test for zeroconf bridge failure

* Bump 0.0.9
Address old comments in 113940

* Exact _async_register_default_clientsession_shutdown
This commit is contained in:
Philip Vanloo 2024-09-03 13:34:47 +02:00 committed by Bram Kragten
parent c7d1ad27f0
commit 009989d7ae
10 changed files with 128 additions and 38 deletions

View File

@ -1,17 +1,22 @@
"""Support for LinkPlay devices."""
from dataclasses import dataclass
from aiohttp import ClientSession
from linkplay.bridge import LinkPlayBridge
from linkplay.discovery import linkplay_factory_bridge
from linkplay.discovery import linkplay_factory_httpapi_bridge
from linkplay.exceptions import LinkPlayRequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import PLATFORMS
from .utils import async_get_client_session
@dataclass
class LinkPlayData:
"""Data for LinkPlay."""
@ -24,16 +29,17 @@ type LinkPlayConfigEntry = ConfigEntry[LinkPlayData]
async def async_setup_entry(hass: HomeAssistant, entry: LinkPlayConfigEntry) -> bool:
"""Async setup hass config entry. Called when an entry has been setup."""
session = async_get_clientsession(hass)
if (
bridge := await linkplay_factory_bridge(entry.data[CONF_HOST], session)
) is None:
session: ClientSession = await async_get_client_session(hass)
bridge: LinkPlayBridge | None = None
try:
bridge = await linkplay_factory_httpapi_bridge(entry.data[CONF_HOST], session)
except LinkPlayRequestException as exception:
raise ConfigEntryNotReady(
f"Failed to connect to LinkPlay device at {entry.data[CONF_HOST]}"
)
) from exception
entry.runtime_data = LinkPlayData()
entry.runtime_data.bridge = bridge
entry.runtime_data = LinkPlayData(bridge=bridge)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -1,16 +1,22 @@
"""Config flow to configure LinkPlay component."""
import logging
from typing import Any
from linkplay.discovery import linkplay_factory_bridge
from aiohttp import ClientSession
from linkplay.bridge import LinkPlayBridge
from linkplay.discovery import linkplay_factory_httpapi_bridge
from linkplay.exceptions import LinkPlayRequestException
import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MODEL
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .utils import async_get_client_session
_LOGGER = logging.getLogger(__name__)
class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
@ -25,10 +31,15 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle Zeroconf discovery."""
session = async_get_clientsession(self.hass)
bridge = await linkplay_factory_bridge(discovery_info.host, session)
session: ClientSession = await async_get_client_session(self.hass)
bridge: LinkPlayBridge | None = None
if bridge is None:
try:
bridge = await linkplay_factory_httpapi_bridge(discovery_info.host, session)
except LinkPlayRequestException:
_LOGGER.exception(
"Failed to connect to LinkPlay device at %s", discovery_info.host
)
return self.async_abort(reason="cannot_connect")
self.data[CONF_HOST] = discovery_info.host
@ -66,14 +77,26 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
bridge = await linkplay_factory_bridge(user_input[CONF_HOST], session)
session: ClientSession = await async_get_client_session(self.hass)
bridge: LinkPlayBridge | None = None
try:
bridge = await linkplay_factory_httpapi_bridge(
user_input[CONF_HOST], session
)
except LinkPlayRequestException:
_LOGGER.exception(
"Failed to connect to LinkPlay device at %s", user_input[CONF_HOST]
)
errors["base"] = "cannot_connect"
if bridge is not None:
self.data[CONF_HOST] = user_input[CONF_HOST]
self.data[CONF_MODEL] = bridge.device.name
await self.async_set_unique_id(bridge.device.uuid)
await self.async_set_unique_id(
bridge.device.uuid, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.data[CONF_HOST]}
)
@ -83,7 +106,6 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_HOST: self.data[CONF_HOST]},
)
errors["base"] = "cannot_connect"
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): str}),

View File

@ -4,3 +4,4 @@ from homeassistant.const import Platform
DOMAIN = "linkplay"
PLATFORMS = [Platform.MEDIA_PLAYER]
CONF_SESSION = "session"

View File

@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/linkplay",
"integration_type": "hub",
"iot_class": "local_polling",
"requirements": ["python-linkplay==0.0.8"],
"requirements": ["python-linkplay==0.0.9"],
"zeroconf": ["_linkplay._tcp.local."]
}

View File

@ -48,6 +48,17 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.XLR: "XLR",
PlayingMode.HDMI: "HDMI",
PlayingMode.OPTICAL_2: "Optical 2",
PlayingMode.EXTERN_BLUETOOTH: "External Bluetooth",
PlayingMode.PHONO: "Phono",
PlayingMode.ARC: "ARC",
PlayingMode.COAXIAL_2: "Coaxial 2",
PlayingMode.TF_CARD_1: "SD Card 1",
PlayingMode.TF_CARD_2: "SD Card 2",
PlayingMode.CD: "CD",
PlayingMode.DAB: "DAB Radio",
PlayingMode.FM: "FM Radio",
PlayingMode.RCA: "RCA",
PlayingMode.UDISK: "USB",
}
SOURCE_MAP_INV: dict[str, PlayingMode] = {v: k for k, v in SOURCE_MAP.items()}

View File

@ -2,6 +2,14 @@
from typing import Final
from aiohttp import ClientSession
from linkplay.utils import async_create_unverified_client_session
from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
from homeassistant.core import Event, HomeAssistant, callback
from .const import CONF_SESSION, DOMAIN
MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
MANUFACTURER_ARYLIC: Final[str] = "Arylic"
MANUFACTURER_IEAST: Final[str] = "iEAST"
@ -44,3 +52,22 @@ def get_info_from_project(project: str) -> tuple[str, str]:
return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
case _:
return MANUFACTURER_GENERIC, MODELS_GENERIC
async def async_get_client_session(hass: HomeAssistant) -> ClientSession:
"""Get a ClientSession that can be used with LinkPlay devices."""
hass.data.setdefault(DOMAIN, {})
if CONF_SESSION not in hass.data[DOMAIN]:
clientsession: ClientSession = await async_create_unverified_client_session()
@callback
def _async_close_websession(event: Event) -> None:
"""Close websession."""
clientsession.detach()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession)
hass.data[DOMAIN][CONF_SESSION] = clientsession
return clientsession
session: ClientSession = hass.data[DOMAIN][CONF_SESSION]
return session

View File

@ -2316,7 +2316,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.7.2
# homeassistant.components.linkplay
python-linkplay==0.0.8
python-linkplay==0.0.9
# homeassistant.components.lirc
# python-lirc==1.2.3

View File

@ -1834,7 +1834,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.7.2
# homeassistant.components.linkplay
python-linkplay==0.0.8
python-linkplay==0.0.9
# homeassistant.components.matter
python-matter-server==6.3.0

View File

@ -3,6 +3,7 @@
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from aiohttp import ClientSession
from linkplay.bridge import LinkPlayBridge, LinkPlayDevice
import pytest
@ -14,11 +15,15 @@ NAME = "Smart Zone 1_54B9"
@pytest.fixture
def mock_linkplay_factory_bridge() -> Generator[AsyncMock]:
"""Mock for linkplay_factory_bridge."""
"""Mock for linkplay_factory_httpapi_bridge."""
with (
patch(
"homeassistant.components.linkplay.config_flow.linkplay_factory_bridge"
"homeassistant.components.linkplay.config_flow.async_get_client_session",
return_value=AsyncMock(spec=ClientSession),
),
patch(
"homeassistant.components.linkplay.config_flow.linkplay_factory_httpapi_bridge",
) as factory,
):
bridge = AsyncMock(spec=LinkPlayBridge)

View File

@ -3,6 +3,9 @@
from ipaddress import ip_address
from unittest.mock import AsyncMock
from linkplay.exceptions import LinkPlayRequestException
import pytest
from homeassistant.components.linkplay.const import DOMAIN
from homeassistant.components.zeroconf import ZeroconfServiceInfo
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
@ -47,10 +50,9 @@ ZEROCONF_DISCOVERY_RE_ENTRY = ZeroconfServiceInfo(
)
@pytest.mark.usefixtures("mock_linkplay_factory_bridge", "mock_setup_entry")
async def test_user_flow(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user setup config flow."""
result = await hass.config_entries.flow.async_init(
@ -74,10 +76,9 @@ async def test_user_flow(
assert result["result"].unique_id == UUID
@pytest.mark.usefixtures("mock_linkplay_factory_bridge")
async def test_user_flow_re_entry(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test user setup config flow when an entry with the same unique id already exists."""
@ -105,10 +106,9 @@ async def test_user_flow_re_entry(
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_linkplay_factory_bridge", "mock_setup_entry")
async def test_zeroconf_flow(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test Zeroconf flow."""
result = await hass.config_entries.flow.async_init(
@ -133,10 +133,9 @@ async def test_zeroconf_flow(
assert result["result"].unique_id == UUID
@pytest.mark.usefixtures("mock_linkplay_factory_bridge")
async def test_zeroconf_flow_re_entry(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test Zeroconf flow when an entry with the same unique id already exists."""
@ -160,16 +159,35 @@ async def test_zeroconf_flow_re_entry(
assert result["reason"] == "already_configured"
async def test_flow_errors(
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_flow_errors(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
) -> None:
"""Test flow when the device discovered through Zeroconf cannot be reached."""
# Temporarily make the mock_linkplay_factory_bridge throw an exception
mock_linkplay_factory_bridge.side_effect = (LinkPlayRequestException("Error"),)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_errors(
hass: HomeAssistant,
mock_linkplay_factory_bridge: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test flow when the device cannot be reached."""
# Temporarily store bridge in a separate variable and set factory to return None
bridge = mock_linkplay_factory_bridge.return_value
mock_linkplay_factory_bridge.return_value = None
# Temporarily make the mock_linkplay_factory_bridge throw an exception
mock_linkplay_factory_bridge.side_effect = (LinkPlayRequestException("Error"),)
result = await hass.config_entries.flow.async_init(
DOMAIN,
@ -188,8 +206,8 @@ async def test_flow_errors(
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
# Make linkplay_factory_bridge return a mock bridge again
mock_linkplay_factory_bridge.return_value = bridge
# Make mock_linkplay_factory_bridge_exception no longer throw an exception
mock_linkplay_factory_bridge.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],