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 GitHub
parent f34b449f61
commit b9db9eeab2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 128 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -48,6 +48,17 @@ SOURCE_MAP: dict[PlayingMode, str] = {
PlayingMode.XLR: "XLR", PlayingMode.XLR: "XLR",
PlayingMode.HDMI: "HDMI", PlayingMode.HDMI: "HDMI",
PlayingMode.OPTICAL_2: "Optical 2", 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()} 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 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_ARTSOUND: Final[str] = "ArtSound"
MANUFACTURER_ARYLIC: Final[str] = "Arylic" MANUFACTURER_ARYLIC: Final[str] = "Arylic"
MANUFACTURER_IEAST: Final[str] = "iEAST" 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 return MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5
case _: case _:
return MANUFACTURER_GENERIC, MODELS_GENERIC 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

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

View File

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

View File

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

View File

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