From 974843e91319023e6d39aa2b35be38e6dde5e8f8 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 10:54:41 +0300 Subject: [PATCH 1/9] Expand list of exceptions to consider "connection failed" --- custom_components/vinx/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/vinx/config_flow.py b/custom_components/vinx/config_flow.py index cd4b95d..1a80182 100644 --- a/custom_components/vinx/config_flow.py +++ b/custom_components/vinx/config_flow.py @@ -32,7 +32,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con # Disconnect, this was just for validation await lw3_device.disconnect() - except (ConnectionError, OSError): + except (BrokenPipeError, ConnectionError, OSError): # all technically OSError errors["base"] = "cannot_connect" else: return self.async_create_entry(title=title, data=user_input) From d2c2d491cb3912d309e0b3e18dec1ef941d5acc5 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 13:05:36 +0300 Subject: [PATCH 2/9] Expose a context manager getter that handles connecting/disconnecting from the underlying device --- custom_components/vinx/lw3.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/custom_components/vinx/lw3.py b/custom_components/vinx/lw3.py index ee228be..9418746 100644 --- a/custom_components/vinx/lw3.py +++ b/custom_components/vinx/lw3.py @@ -47,10 +47,13 @@ async def _read_until(self, phrase: str) -> str | None: if b.endswith(phrase.encode()): return b.decode() - async def connect(self): + def connection(self): + return LW3ConnectionContext(self) + + async def _connect(self): self._reader, self._writer = await asyncio.open_connection(self._hostname, self._port) - async def disconnect(self): + async def _disconnect(self): self._writer.close() await self._writer.wait_closed() @@ -99,3 +102,14 @@ async def get_property(self, path: str) -> PropertyResponse: async def set_property(self, path: str, value: str) -> PropertyResponse: return await asyncio.wait_for(self._run_set_property(path, value), self._timeout) + + +class LW3ConnectionContext: + def __init__(self, lw3: LW3): + self._lw3 = lw3 + + async def __aenter__(self): + await self._lw3._connect() + + async def __aexit__(self, *args): + await self._lw3._disconnect() From 7d5fb867fd9f2021ff4ebf9e5aee082c45832579 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 13:05:55 +0300 Subject: [PATCH 3/9] Query and store device information as runtime data --- custom_components/vinx/__init__.py | 50 ++++++++++++++++++++++---- custom_components/vinx/config_flow.py | 14 ++++---- custom_components/vinx/media_player.py | 6 ++-- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/custom_components/vinx/__init__.py b/custom_components/vinx/__init__.py index e7daa56..85c24f6 100644 --- a/custom_components/vinx/__init__.py +++ b/custom_components/vinx/__init__.py @@ -1,27 +1,65 @@ from __future__ import annotations +from dataclasses import dataclass + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from custom_components.vinx.const import DOMAIN from custom_components.vinx.lw3 import LW3 PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] +@dataclass +class DeviceInformation: + mac_address: str + product_name: str + device_info: DeviceInfo + + +@dataclass +class VinxRuntimeData: + lw3: LW3 + device_information: DeviceInformation + + +async def get_device_information(lw3: LW3) -> DeviceInformation: + async with lw3.connection(): + mac_address = str(await lw3.get_property("/.MacAddress")) + product_name = str(await lw3.get_property("/.ProductName")) + firmware_version = str(await lw3.get_property("/.FirmwareVersion")) + serial_number = str(await lw3.get_property("/.SerialNumber")) + + device_info = DeviceInfo( + identifiers={(DOMAIN, format_mac(mac_address))}, + name=product_name, + manufacturer="Lightware", + model=product_name, + sw_version=firmware_version, + serial_number=serial_number, + ) + + return DeviceInformation(mac_address, product_name, device_info) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - # Verify we can connect + lw3 = LW3(entry.data["host"], entry.data["port"]) + try: - device = LW3(entry.data["host"], entry.data["port"]) - await device.connect() + # Store runtime information + async with lw3.connection(): + device_information = await get_device_information(lw3) + + # Store the lw3 as runtime data in the entry + entry.runtime_data = VinxRuntimeData(lw3, device_information) except ConnectionError as e: raise ConfigEntryNotReady("Unable to connect") from e - # Store the device as runtime data in teh entry - entry.runtime_data = device - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/vinx/config_flow.py b/custom_components/vinx/config_flow.py index 1a80182..04af8b2 100644 --- a/custom_components/vinx/config_flow.py +++ b/custom_components/vinx/config_flow.py @@ -23,15 +23,13 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None) -> Con errors: dict[str, str] = {} if user_input is not None: try: - # Try to connect to the device - lw3_device = LW3(user_input["host"], user_input["port"]) - await lw3_device.connect() + # Query the device for enough information to make an entry title + lw3 = LW3(user_input["host"], user_input["port"]) + async with lw3.connection(): + product_name = await lw3.get_property("/.ProductName") + device_label = await lw3.get_property("/SYS/MB.DeviceLabel") - # Make a title for the entry - title = str(await lw3_device.get_property("/.ProductName")) - - # Disconnect, this was just for validation - await lw3_device.disconnect() + title = f"{device_label} ({product_name})" except (BrokenPipeError, ConnectionError, OSError): # all technically OSError errors["base"] = "cannot_connect" else: diff --git a/custom_components/vinx/media_player.py b/custom_components/vinx/media_player.py index 3ef3d28..e853c7c 100644 --- a/custom_components/vinx/media_player.py +++ b/custom_components/vinx/media_player.py @@ -2,15 +2,15 @@ from homeassistant.components.media_player import MediaPlayerEntity -from custom_components.vinx import LW3 +from custom_components.vinx import VinxRuntimeData _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, entry, async_add_entities): # Extract stored runtime data - lw3_device: LW3 = entry.runtime_data - _LOGGER.info(lw3_device) + runtime_data: VinxRuntimeData = entry.runtime_data + _LOGGER.info(f"Runtime data: {runtime_data}") class VinxEncoder(MediaPlayerEntity): From e59e50bbbffc8076f3371621ae11b53f0439b910 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 13:40:12 +0300 Subject: [PATCH 4/9] Add media player entities --- custom_components/vinx/media_player.py | 56 +++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/custom_components/vinx/media_player.py b/custom_components/vinx/media_player.py index e853c7c..3fb4c3e 100644 --- a/custom_components/vinx/media_player.py +++ b/custom_components/vinx/media_player.py @@ -1,8 +1,9 @@ import logging -from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState +from homeassistant.helpers.device_registry import DeviceInfo -from custom_components.vinx import VinxRuntimeData +from custom_components.vinx import LW3, DeviceInformation, VinxRuntimeData _LOGGER = logging.getLogger(__name__) @@ -12,10 +13,55 @@ async def async_setup_entry(hass, entry, async_add_entities): runtime_data: VinxRuntimeData = entry.runtime_data _LOGGER.info(f"Runtime data: {runtime_data}") + # Add entity to Home Assistant + product_name = runtime_data.device_information.product_name + if product_name.endswith("ENC"): + async_add_entities([VinxEncoder(runtime_data.lw3, runtime_data.device_information)]) + pass + elif product_name.endswith("DEC"): + async_add_entities([VinxDecoder(runtime_data.lw3, runtime_data.device_information)]) + pass + else: + _LOGGER.warning("Unknown device type, no entities will be added") -class VinxEncoder(MediaPlayerEntity): - pass + +class AbstractVinxDevice(MediaPlayerEntity): + def __init__(self, lw3: LW3, device_information: DeviceInformation) -> None: + self._lw3 = lw3 + self._device_information = device_information + + self._device_class = "receiver" + self._state = MediaPlayerState.IDLE + + @property + def device_class(self): + return self._device_class + + @property + def unique_id(self) -> str | None: + mac_address = self._device_information.mac_address + + return f"vinx_{mac_address}_media_player" + + @property + def state(self): + return self._state + + @property + def device_info(self) -> DeviceInfo: + return self._device_information.device_info + + @property + def name(self): + return "Media Player" + + +class VinxEncoder(AbstractVinxDevice): + def __init__(self, lw3: LW3, device_information: DeviceInformation) -> None: + super().__init__(lw3, device_information) + + _attr_supported_features = MediaPlayerEntityFeature.SELECT_SOURCE -class VinxDecoder(MediaPlayerEntity): +class VinxDecoder(AbstractVinxDevice): pass From 288dec4c0fe6599bf44f25663d7bbbed21f33330 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 13:42:46 +0300 Subject: [PATCH 5/9] Add configuration_url so we have an easy way of seeing the IP address --- custom_components/vinx/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/custom_components/vinx/__init__.py b/custom_components/vinx/__init__.py index 85c24f6..d574c9d 100644 --- a/custom_components/vinx/__init__.py +++ b/custom_components/vinx/__init__.py @@ -33,6 +33,7 @@ async def get_device_information(lw3: LW3) -> DeviceInformation: product_name = str(await lw3.get_property("/.ProductName")) firmware_version = str(await lw3.get_property("/.FirmwareVersion")) serial_number = str(await lw3.get_property("/.SerialNumber")) + ip_address = str(await lw3.get_property("/MANAGEMENT/NETWORK.IpAddress")) device_info = DeviceInfo( identifiers={(DOMAIN, format_mac(mac_address))}, @@ -41,6 +42,7 @@ async def get_device_information(lw3: LW3) -> DeviceInformation: model=product_name, sw_version=firmware_version, serial_number=serial_number, + configuration_url=f"http://{ip_address}/", ) return DeviceInformation(mac_address, product_name, device_info) From 4aee81aa0361c87f2f1d971cbb7bbe60b8c94910 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 13:43:56 +0300 Subject: [PATCH 6/9] Move SELECT_SOURCE capability to the decoder where it belongs --- custom_components/vinx/media_player.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/vinx/media_player.py b/custom_components/vinx/media_player.py index 3fb4c3e..55d89eb 100644 --- a/custom_components/vinx/media_player.py +++ b/custom_components/vinx/media_player.py @@ -57,11 +57,11 @@ def name(self): class VinxEncoder(AbstractVinxDevice): + pass + + +class VinxDecoder(AbstractVinxDevice): def __init__(self, lw3: LW3, device_information: DeviceInformation) -> None: super().__init__(lw3, device_information) _attr_supported_features = MediaPlayerEntityFeature.SELECT_SOURCE - - -class VinxDecoder(AbstractVinxDevice): - pass From d93345c4367019e10cd8b2c3f9a2a275cbe83316 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Mon, 9 Sep 2024 14:22:26 +0300 Subject: [PATCH 7/9] Include the "device label" in the device name too --- custom_components/vinx/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/vinx/__init__.py b/custom_components/vinx/__init__.py index d574c9d..ee197d3 100644 --- a/custom_components/vinx/__init__.py +++ b/custom_components/vinx/__init__.py @@ -31,13 +31,14 @@ async def get_device_information(lw3: LW3) -> DeviceInformation: async with lw3.connection(): mac_address = str(await lw3.get_property("/.MacAddress")) product_name = str(await lw3.get_property("/.ProductName")) + device_label = str(await lw3.get_property("/SYS/MB.DeviceLabel")) firmware_version = str(await lw3.get_property("/.FirmwareVersion")) serial_number = str(await lw3.get_property("/.SerialNumber")) ip_address = str(await lw3.get_property("/MANAGEMENT/NETWORK.IpAddress")) device_info = DeviceInfo( identifiers={(DOMAIN, format_mac(mac_address))}, - name=product_name, + name=f"{device_label} ({product_name})", manufacturer="Lightware", model=product_name, sw_version=firmware_version, From c46905cf597b65e4c2444eefa6b6452b4494efc2 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Tue, 10 Sep 2024 08:51:46 +0300 Subject: [PATCH 8/9] Remove unneeded __future__ import --- custom_components/vinx/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/vinx/__init__.py b/custom_components/vinx/__init__.py index ee197d3..799a063 100644 --- a/custom_components/vinx/__init__.py +++ b/custom_components/vinx/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from homeassistant.config_entries import ConfigEntry From 6d161e41adf4007987597c60b4a64bf8e187c2c9 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Tue, 10 Sep 2024 08:52:18 +0300 Subject: [PATCH 9/9] Check that config entry has the keys we expect --- custom_components/vinx/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/vinx/__init__.py b/custom_components/vinx/__init__.py index 799a063..42b8d83 100644 --- a/custom_components/vinx/__init__.py +++ b/custom_components/vinx/__init__.py @@ -49,7 +49,10 @@ async def get_device_information(lw3: LW3) -> DeviceInformation: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - lw3 = LW3(entry.data["host"], entry.data["port"]) + if "host" in entry.data and "port" in entry.data: + lw3 = LW3(entry.data["host"], entry.data["port"]) + else: + raise KeyError("Config entry is missing required parameters") try: # Store runtime information