From 91f4c1aafc232a2114423f0d7f3bdc425f31fa72 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 14:16:55 +0300 Subject: [PATCH 01/10] Log entry runtime data once only, not for each entity --- custom_components/extron/__init__.py | 4 ++++ custom_components/extron/button.py | 1 - custom_components/extron/media_player.py | 2 +- custom_components/extron/sensor.py | 1 - 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/custom_components/extron/__init__.py b/custom_components/extron/__init__.py index 6feda95..b480403 100644 --- a/custom_components/extron/__init__.py +++ b/custom_components/extron/__init__.py @@ -1,5 +1,7 @@ """The Extron integration.""" +import logging + from dataclasses import dataclass from homeassistant.config_entries import ConfigEntry @@ -11,6 +13,7 @@ from custom_components.extron.extron import AuthenticationError, ExtronDevice PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON] +_LOGGER = logging.getLogger(__name__) @dataclass @@ -59,6 +62,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_information = await get_device_information(device) entry.runtime_data = ExtronConfigEntryRuntimeData(device, device_information) + _LOGGER.info(f"Initializing entry with runtime data: {entry.runtime_data}") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/custom_components/extron/button.py b/custom_components/extron/button.py index 6641637..6892480 100644 --- a/custom_components/extron/button.py +++ b/custom_components/extron/button.py @@ -14,7 +14,6 @@ async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): runtime_data: ExtronConfigEntryRuntimeData = entry.runtime_data device = runtime_data.device device_information = runtime_data.device_information - logger.info(device_information) # Add entities async_add_entities([ExtronRebootButton(device, device_information)]) diff --git a/custom_components/extron/media_player.py b/custom_components/extron/media_player.py index fabe697..bdaf7d7 100644 --- a/custom_components/extron/media_player.py +++ b/custom_components/extron/media_player.py @@ -16,7 +16,7 @@ async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): runtime_data: ExtronConfigEntryRuntimeData = entry.runtime_data device = runtime_data.device device_information = runtime_data.device_information - logger.info(device_information) + input_names = runtime_data.input_names # Add entities if entry.data[CONF_DEVICE_TYPE] == DeviceType.SURROUND_SOUND_PROCESSOR.value: diff --git a/custom_components/extron/sensor.py b/custom_components/extron/sensor.py index 77160bf..386aa56 100644 --- a/custom_components/extron/sensor.py +++ b/custom_components/extron/sensor.py @@ -20,7 +20,6 @@ async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): runtime_data: ExtronConfigEntryRuntimeData = entry.runtime_data device = runtime_data.device device_information = runtime_data.device_information - logger.info(device_information) # Add entities if entry.data[CONF_DEVICE_TYPE] == DeviceType.SURROUND_SOUND_PROCESSOR.value: From cd355286e1a15ad2f12aac704be36d3ec7529047 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 20:22:10 +0300 Subject: [PATCH 02/10] Remove clashing type-hint PyCharm complains that the expected type should be NamedTuple :shrug: --- custom_components/extron/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/extron/config_flow.py b/custom_components/extron/config_flow.py index d8b06e6..92b8348 100644 --- a/custom_components/extron/config_flow.py +++ b/custom_components/extron/config_flow.py @@ -38,7 +38,7 @@ class ExtronConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: + async def async_step_user(self, user_input: dict[str, Any] | None = None): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: From c2651402dad75828a701c4a48162e0ee9505cbe2 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 20:23:15 +0300 Subject: [PATCH 03/10] Add an option flow where the user can configure custom input names --- custom_components/extron/__init__.py | 15 +++++++-- custom_components/extron/config_flow.py | 31 +++++++++++++++++-- custom_components/extron/const.py | 2 ++ custom_components/extron/media_player.py | 17 +++++----- custom_components/extron/strings.json | 11 +++++++ custom_components/extron/translations/en.json | 11 +++++++ 6 files changed, 76 insertions(+), 11 deletions(-) diff --git a/custom_components/extron/__init__.py b/custom_components/extron/__init__.py index b480403..50af244 100644 --- a/custom_components/extron/__init__.py +++ b/custom_components/extron/__init__.py @@ -10,6 +10,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from custom_components.extron.const import OPTION_INPUT_NAMES from custom_components.extron.extron import AuthenticationError, ExtronDevice PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.BUTTON] @@ -27,6 +28,7 @@ class DeviceInformation: class ExtronConfigEntryRuntimeData: device: ExtronDevice device_information: DeviceInformation + input_names: list[str] async def get_device_information(device: ExtronDevice) -> DeviceInformation: @@ -58,9 +60,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except Exception as e: raise ConfigEntryNotReady("Unable to connect") from e - # Store the device and information about as runtime data in the entry + # Store runtime information device_information = await get_device_information(device) - entry.runtime_data = ExtronConfigEntryRuntimeData(device, device_information) + input_names = entry.options.get(OPTION_INPUT_NAMES, []) + entry.runtime_data = ExtronConfigEntryRuntimeData(device, device_information, input_names) + + # Register a listener for option updates + entry.async_on_unload(entry.add_update_listener(entry_update_listener)) _LOGGER.info(f"Initializing entry with runtime data: {entry.runtime_data}") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -71,3 +77,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def entry_update_listener(hass: HomeAssistant, config_entry: ConfigEntry): + # Reload the entry when options have been changed + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/custom_components/extron/config_flow.py b/custom_components/extron/config_flow.py index 92b8348..c7dade7 100644 --- a/custom_components/extron/config_flow.py +++ b/custom_components/extron/config_flow.py @@ -6,10 +6,10 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.helpers.selector import selector -from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PASSWORD, CONF_PORT, DOMAIN +from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PASSWORD, CONF_PORT, DOMAIN, OPTION_INPUT_NAMES from .extron import AuthenticationError, DeviceType, ExtronDevice _LOGGER = logging.getLogger(__name__) @@ -61,3 +61,30 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): return self.async_create_entry(title=title, data=user_input) return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors) + + @staticmethod + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Create the options flow""" + return ExtronOptionsFlowHandler(config_entry) + + +class ExtronOptionsFlowHandler(OptionsFlow): + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input: dict[str, Any] | None = None): + """Manage optional settings for the entry.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + OPTION_INPUT_NAMES, default=self.config_entry.options.get(OPTION_INPUT_NAMES) + ): selector({"text": {"multiple": True}}), + } + ), + ) diff --git a/custom_components/extron/const.py b/custom_components/extron/const.py index 8f64176..5961c04 100644 --- a/custom_components/extron/const.py +++ b/custom_components/extron/const.py @@ -6,3 +6,5 @@ CONF_PORT = "port" CONF_PASSWORD = "password" CONF_DEVICE_TYPE = "device_type" + +OPTION_INPUT_NAMES = "input_names" diff --git a/custom_components/extron/media_player.py b/custom_components/extron/media_player.py index bdaf7d7..40af035 100644 --- a/custom_components/extron/media_player.py +++ b/custom_components/extron/media_player.py @@ -21,16 +21,17 @@ async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): # Add entities if entry.data[CONF_DEVICE_TYPE] == DeviceType.SURROUND_SOUND_PROCESSOR.value: ssp = SurroundSoundProcessor(device) - async_add_entities([ExtronSurroundSoundProcessor(ssp, device_information)]) + async_add_entities([ExtronSurroundSoundProcessor(ssp, device_information, input_names)]) elif entry.data[CONF_DEVICE_TYPE] == DeviceType.HDMI_SWITCHER.value: hdmi_switcher = HDMISwitcher(device) - async_add_entities([ExtronHDMISwitcher(hdmi_switcher, device_information)]) + async_add_entities([ExtronHDMISwitcher(hdmi_switcher, device_information, input_names)]) class AbstractExtronMediaPlayerEntity(MediaPlayerEntity): - def __init__(self, device: ExtronDevice, device_information: DeviceInformation) -> None: + def __init__(self, device: ExtronDevice, device_information: DeviceInformation, input_names: list[str]) -> None: self._device = device self._device_information = device_information + self._input_names = input_names self._device_class = "receiver" self._state = MediaPlayerState.PLAYING @@ -66,8 +67,8 @@ def name(self): class ExtronSurroundSoundProcessor(AbstractExtronMediaPlayerEntity): - def __init__(self, ssp: SurroundSoundProcessor, device_information: DeviceInformation): - super().__init__(ssp.get_device(), device_information) + def __init__(self, ssp: SurroundSoundProcessor, device_information: DeviceInformation, input_names: list[str]): + super().__init__(ssp.get_device(), device_information, input_names) self._ssp = ssp self._source = None @@ -129,8 +130,10 @@ async def async_volume_down(self) -> None: class ExtronHDMISwitcher(AbstractExtronMediaPlayerEntity): - def __init__(self, hdmi_switcher: HDMISwitcher, device_information: DeviceInformation) -> None: - super().__init__(hdmi_switcher.get_device(), device_information) + def __init__( + self, hdmi_switcher: HDMISwitcher, device_information: DeviceInformation, input_names: list[str] + ) -> None: + super().__init__(hdmi_switcher.get_device(), device_information, input_names) self._hdmi_switcher = hdmi_switcher self._state = MediaPlayerState.PLAYING diff --git a/custom_components/extron/strings.json b/custom_components/extron/strings.json index bd4127a..2f0ec2c 100644 --- a/custom_components/extron/strings.json +++ b/custom_components/extron/strings.json @@ -18,5 +18,16 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "options": { + "step": { + "init": { + "title": "Settings", + "description": "Here you can define custom names for your inputs", + "data": { + "input_names": "Input names" + } + } + } } } diff --git a/custom_components/extron/translations/en.json b/custom_components/extron/translations/en.json index e99ea39..5874223 100644 --- a/custom_components/extron/translations/en.json +++ b/custom_components/extron/translations/en.json @@ -18,5 +18,16 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "Settings", + "description": "Here you can define custom names for your inputs", + "data": { + "input_names": "Input names" + } + } + } } } \ No newline at end of file From 4165b0d0c6181e39aaf919d2c48ee8e35af20acb Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 20:27:00 +0300 Subject: [PATCH 04/10] Catch narrower list of errors when checking for connectivity --- custom_components/extron/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/extron/config_flow.py b/custom_components/extron/config_flow.py index c7dade7..03cc454 100644 --- a/custom_components/extron/config_flow.py +++ b/custom_components/extron/config_flow.py @@ -55,7 +55,7 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): await extron_device.disconnect() except AuthenticationError: errors["base"] = "invalid_auth" - except Exception: + except (BrokenPipeError, ConnectionError, OSError): # all technically OSError errors["base"] = "cannot_connect" else: return self.async_create_entry(title=title, data=user_input) From 8bcdf39f3dc370b163470211bd2c02231cb5c01b Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 20:45:10 +0300 Subject: [PATCH 05/10] Use a bidict to represent the source list --- custom_components/extron/manifest.json | 2 +- custom_components/extron/media_player.py | 36 ++++++++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/custom_components/extron/manifest.json b/custom_components/extron/manifest.json index 32a75e5..444f445 100644 --- a/custom_components/extron/manifest.json +++ b/custom_components/extron/manifest.json @@ -11,7 +11,7 @@ "documentation": "https://www.home-assistant.io/integrations/extron", "homekit": {}, "iot_class": "local_polling", - "requirements": [], + "requirements": ["bidict"], "ssdp": [], "zeroconf": [] } diff --git a/custom_components/extron/media_player.py b/custom_components/extron/media_player.py index 40af035..aa7f80f 100644 --- a/custom_components/extron/media_player.py +++ b/custom_components/extron/media_player.py @@ -1,5 +1,6 @@ import logging +from bidict import bidict from homeassistant.components.media_player import MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity import DeviceInfo @@ -11,6 +12,15 @@ logger = logging.getLogger(__name__) +def make_numeric_source_bidict(num_sources: int) -> bidict: + bd = bidict() + + for i in range(num_sources): + bd.put(i, str(i)) + + return bd + + async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): # Extract stored runtime data from the entry runtime_data: ExtronConfigEntryRuntimeData = entry.runtime_data @@ -72,7 +82,7 @@ def __init__(self, ssp: SurroundSoundProcessor, device_information: DeviceInform self._ssp = ssp self._source = None - self._source_list = ["1", "2", "3", "4", "5"] + self._source_bidict = self.make_source_bidict() self._volume = None self._muted = False @@ -87,7 +97,7 @@ def get_device_type(self): return DeviceType.SURROUND_SOUND_PROCESSOR async def async_update(self): - self._source = await self._ssp.view_input() + self._source = self._source_bidict.get(await self._ssp.view_input()) self._muted = await self._ssp.is_muted() volume = await self._ssp.get_volume_level() self._volume = volume / 100 @@ -110,10 +120,14 @@ def source(self): @property def source_list(self): - return self._source_list + return list(self._source_bidict.values()) + + @staticmethod + def make_source_bidict() -> bidict: + return make_numeric_source_bidict(5) async def async_select_source(self, source): - await self._ssp.select_input(int(source)) + await self._ssp.select_input(self._source_bidict.inverse.get(source)) self._source = source async def async_mute_volume(self, mute: bool) -> None: @@ -138,6 +152,7 @@ def __init__( self._state = MediaPlayerState.PLAYING self._source = None + self._source_bidict = self.make_source_bidict() _attr_supported_features = MediaPlayerEntityFeature.SELECT_SOURCE @@ -145,7 +160,7 @@ def get_device_type(self): return DeviceType.HDMI_SWITCHER async def async_update(self): - self._source = await self._hdmi_switcher.view_input() + self._source = self._source_bidict.get(await self._hdmi_switcher.view_input()) @property def source(self): @@ -153,17 +168,20 @@ def source(self): @property def source_list(self): + return list(self._source_bidict.values()) + + def make_source_bidict(self) -> bidict: model_name = self._device_information.model_name sw = model_name.split(" ")[0] if sw == "SW2": - return ["1", "2"] + return make_numeric_source_bidict(2) elif sw == "SW4": - return ["1", "2", "3", "4"] + return make_numeric_source_bidict(4) elif sw == "SW6": - return ["1", "2", "3", "4", "5", "6"] + return make_numeric_source_bidict(6) else: - return ["1", "2", "3", "4", "5", "6", "7", "8"] + return make_numeric_source_bidict(8) async def async_select_source(self, source: str): await self._hdmi_switcher.select_input(int(source)) From 6f5112782d186c67ccd5119b256feb92b0c688c4 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 21:10:26 +0300 Subject: [PATCH 06/10] Return int from view_input --- custom_components/extron/extron.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/extron/extron.py b/custom_components/extron/extron.py index 3ac2e7a..4e6e323 100644 --- a/custom_components/extron/extron.py +++ b/custom_components/extron/extron.py @@ -113,8 +113,8 @@ def __init__(self, device: ExtronDevice) -> None: def get_device(self) -> ExtronDevice: return self._device - async def view_input(self): - return await self._device.run_command("$") + async def view_input(self) -> int: + return int(await self._device.run_command("$")) async def select_input(self, input: int): await self._device.run_command(f"{str(input)}$") @@ -154,8 +154,8 @@ def __init__(self, device: ExtronDevice) -> None: def get_device(self) -> ExtronDevice: return self._device - async def view_input(self): - return await self._device.run_command("!") + async def view_input(self) -> int: + return int(await self._device.run_command("!")) async def select_input(self, input: int): await self._device.run_command(f"{str(input)}!") From 91d7c4cf1d6a2387ff7f2bbf822623e1cded69b1 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Thu, 12 Sep 2024 20:50:54 +0300 Subject: [PATCH 07/10] Replace numeric input names with user-defined names when available --- custom_components/extron/media_player.py | 31 +++++++++++------------- tests/test_media_player.py | 26 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 tests/test_media_player.py diff --git a/custom_components/extron/media_player.py b/custom_components/extron/media_player.py index aa7f80f..4726952 100644 --- a/custom_components/extron/media_player.py +++ b/custom_components/extron/media_player.py @@ -12,13 +12,9 @@ logger = logging.getLogger(__name__) -def make_numeric_source_bidict(num_sources: int) -> bidict: - bd = bidict() - - for i in range(num_sources): - bd.put(i, str(i)) - - return bd +def make_source_bidict(num_sources: int, input_names: list[str]) -> bidict: + # Use user-defined input name for the source when available + return bidict({i + 1: input_names[i] if i < len(input_names) else str(i + 1) for i in range(num_sources)}) async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): @@ -82,7 +78,7 @@ def __init__(self, ssp: SurroundSoundProcessor, device_information: DeviceInform self._ssp = ssp self._source = None - self._source_bidict = self.make_source_bidict() + self._source_bidict = self.create_source_bidict() self._volume = None self._muted = False @@ -122,9 +118,8 @@ def source(self): def source_list(self): return list(self._source_bidict.values()) - @staticmethod - def make_source_bidict() -> bidict: - return make_numeric_source_bidict(5) + def create_source_bidict(self) -> bidict: + return make_source_bidict(5, self._input_names) async def async_select_source(self, source): await self._ssp.select_input(self._source_bidict.inverse.get(source)) @@ -152,7 +147,7 @@ def __init__( self._state = MediaPlayerState.PLAYING self._source = None - self._source_bidict = self.make_source_bidict() + self._source_bidict = self.create_source_bidict() _attr_supported_features = MediaPlayerEntityFeature.SELECT_SOURCE @@ -170,18 +165,20 @@ def source(self): def source_list(self): return list(self._source_bidict.values()) - def make_source_bidict(self) -> bidict: + def create_source_bidict(self) -> bidict: model_name = self._device_information.model_name sw = model_name.split(" ")[0] if sw == "SW2": - return make_numeric_source_bidict(2) + num_sources = 2 elif sw == "SW4": - return make_numeric_source_bidict(4) + num_sources = 4 elif sw == "SW6": - return make_numeric_source_bidict(6) + num_sources = 6 else: - return make_numeric_source_bidict(8) + num_sources = 8 + + return make_source_bidict(num_sources, self._input_names) async def async_select_source(self, source: str): await self._hdmi_switcher.select_input(int(source)) diff --git a/tests/test_media_player.py b/tests/test_media_player.py new file mode 100644 index 0000000..99af611 --- /dev/null +++ b/tests/test_media_player.py @@ -0,0 +1,26 @@ +from unittest import TestCase + +from custom_components.extron.media_player import make_source_bidict + + +class TestSourceBidict(TestCase): + def test_make_source_bidict(self): + # No input names specified + bd = make_source_bidict(4, []) + self.assertEqual(4, len(bd.values())) + self.assertEqual("1", bd.get(1)) + self.assertEqual("4", bd.get(4)) + + # First two input names specified + bd = make_source_bidict(4, ["foo", "bar"]) + self.assertEqual(4, len(bd.values())) + self.assertEqual("foo", bd.get(1)) + self.assertEqual("bar", bd.get(2)) + self.assertEqual("3", bd.get(3)) + self.assertEqual("4", bd.get(4)) + + # Define one more input name than there are sources + bd = make_source_bidict(2, ["foo", "bar", "baz"]) + self.assertEqual(2, len(bd.values())) + self.assertEqual("foo", bd.get(1)) + self.assertEqual("bar", bd.get(2)) From f69b9b68514bb5578024d00158523062a50b771c Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 13 Sep 2024 09:44:39 +0300 Subject: [PATCH 08/10] Add GitHub action for running our tests --- .github/workflows/ruff.yaml | 4 ++-- .github/workflows/unittest.yaml | 22 ++++++++++++++++++++++ README.md | 8 ++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/unittest.yaml diff --git a/.github/workflows/ruff.yaml b/.github/workflows/ruff.yaml index 68bfa4a..d84e31b 100644 --- a/.github/workflows/ruff.yaml +++ b/.github/workflows/ruff.yaml @@ -1,4 +1,4 @@ -name: Ruff +name: Linting on: push: branches: [ main ] @@ -9,4 +9,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: chartboost/ruff-action@v1 \ No newline at end of file + - uses: chartboost/ruff-action@v1 diff --git a/.github/workflows/unittest.yaml b/.github/workflows/unittest.yaml new file mode 100644 index 0000000..4156e4c --- /dev/null +++ b/.github/workflows/unittest.yaml @@ -0,0 +1,22 @@ +name: Tests +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + unittest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v3 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install homeassistant bidict + - name: Run tests + run: | + python -m unittest discover -s tests/ -v diff --git a/README.md b/README.md index 0791c86..1d11e30 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ Obviously not every single feature can be controlled, only the basics: The communication is done using Python's `asyncio` and requires no external libraries +## Development + +### Tests + +```bash +python3 -m unittest discover -s tests/ -v +``` + ## License GNU GENERAL PUBLIC LICENSE version 3 From 5cd24f101f75659fd873f9beb01f5f0309f2879b Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 13 Sep 2024 09:44:56 +0300 Subject: [PATCH 09/10] Specify a unique ID for config flows to prevent adding the same device twice --- custom_components/extron/config_flow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/extron/config_flow.py b/custom_components/extron/config_flow.py index 03cc454..9050733 100644 --- a/custom_components/extron/config_flow.py +++ b/custom_components/extron/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.selector import selector from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PASSWORD, CONF_PORT, DOMAIN, OPTION_INPUT_NAMES @@ -51,6 +52,11 @@ async def async_step_user(self, user_input: dict[str, Any] | None = None): model_name = await extron_device.query_model_name() title = f"Extron {model_name}" + # Make a unique ID for the entry, prevent adding the same device twice + unique_id = format_mac(await extron_device.query_mac_address()) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + # Disconnect, we'll connect again later, this was just for validation await extron_device.disconnect() except AuthenticationError: From 91fa1c34284100dbf50f06e073f4c327f4ec46c6 Mon Sep 17 00:00:00 2001 From: Sam Stenvall Date: Fri, 13 Sep 2024 11:49:00 +0300 Subject: [PATCH 10/10] Add configuration_url to DeviceInfo so we get the "Visit" button visible --- custom_components/extron/__init__.py | 2 ++ custom_components/extron/extron.py | 3 +++ 2 files changed, 5 insertions(+) diff --git a/custom_components/extron/__init__.py b/custom_components/extron/__init__.py index 50af244..e9e0093 100644 --- a/custom_components/extron/__init__.py +++ b/custom_components/extron/__init__.py @@ -36,6 +36,7 @@ async def get_device_information(device: ExtronDevice) -> DeviceInformation: model_name = await device.query_model_name() firmware_version = await device.query_firmware_version() part_number = await device.query_part_number() + ip_address = await device.query_ip_address() device_info = DeviceInfo( identifiers={(DOMAIN, format_mac(mac_address))}, @@ -44,6 +45,7 @@ async def get_device_information(device: ExtronDevice) -> DeviceInformation: model=model_name, sw_version=firmware_version, serial_number=part_number, + configuration_url=f"http://{ip_address}/", ) return DeviceInformation(mac_address=format_mac(mac_address), model_name=model_name, device_info=device_info) diff --git a/custom_components/extron/extron.py b/custom_components/extron/extron.py index 4e6e323..957e2f8 100644 --- a/custom_components/extron/extron.py +++ b/custom_components/extron/extron.py @@ -102,6 +102,9 @@ async def query_part_number(self): async def query_mac_address(self): return await self.run_command("\x1b" + "CH") + async def query_ip_address(self): + return await self.run_command("\x1b" + "CI") + async def reboot(self): await self.run_command("\x1b" + "1BOOT")