diff --git a/CHANGELOG.md b/CHANGELOG.md index 5588bdbd..a0c7a8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## v0.59.0 + +- New Feature: implement reconfiguring of the Stick path via PR [407](https://github.com/plugwise/plugwise_usb-beta/pull/407) + ## v0.58.2 - 2026-01-29 - Update plugwise_usb to [v0.47.2](https://github.com/plugwise/python-plugwise-usb/releases/tag/v0.47.2) fixing a bug. diff --git a/custom_components/plugwise_usb/__init__.py b/custom_components/plugwise_usb/__init__.py index 8a35c6b5..295aac03 100644 --- a/custom_components/plugwise_usb/__init__.py +++ b/custom_components/plugwise_usb/__init__.py @@ -162,14 +162,20 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: PlugwiseUSBConfigEntry ) -> bool: """Unload the Plugwise USB stick connection.""" - config_entry.runtime_data[UNSUBSCRIBE_DISCOVERY]() - for coordinator in config_entry.runtime_data[NODES].values(): - await coordinator.unsubscribe_all_nodefeatures() - unload = await hass.config_entries.async_unload_platforms( + runtime_data = getattr(config_entry, "runtime_data", None) or {} + if runtime_data: + unsubscribe_discovery = runtime_data.get(UNSUBSCRIBE_DISCOVERY) + if callable(unsubscribe_discovery): + unsubscribe_discovery() + for coordinator in runtime_data.get(NODES, {}).values(): + await coordinator.unsubscribe_all_nodefeatures() + stick = runtime_data.get(STICK) + if stick is not None: + await stick.disconnect() + + return await hass.config_entries.async_unload_platforms( config_entry, PLUGWISE_USB_PLATFORMS ) - await config_entry.runtime_data[STICK].disconnect() - return unload async def async_remove_config_entry_device( diff --git a/custom_components/plugwise_usb/config_flow.py b/custom_components/plugwise_usb/config_flow.py index 654c1863..a1647b90 100644 --- a/custom_components/plugwise_usb/config_flow.py +++ b/custom_components/plugwise_usb/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components import usb -from homeassistant.config_entries import SOURCE_USER, ConfigFlow +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_BASE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -17,6 +17,12 @@ from .const import CONF_MANUAL_PATH, CONF_USB_PATH, DOMAIN, MANUAL_PATH +STICK_RECONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_USB_PATH): str, + } +) + @callback def plugwise_stick_entries(hass): @@ -28,7 +34,7 @@ def plugwise_stick_entries(hass): ] -async def validate_usb_connection(self, device_path=None) -> tuple[dict[str, str], str]: +async def validate_usb_connection(self, device_path=None) -> tuple[dict[str, str], str | None]: """Test if device_path is a real Plugwise USB-Stick.""" errors = {} @@ -93,6 +99,7 @@ async def async_step_user( return self.async_create_entry( title="Stick", data={CONF_USB_PATH: device_path} ) + return self.async_show_form( step_id=SOURCE_USER, data_schema=vol.Schema( @@ -112,7 +119,10 @@ async def async_step_manual_path( ) errors, mac_stick = await validate_usb_connection(self.hass, device_path) if not errors: - await self.async_set_unique_id(mac_stick) + await self.async_set_unique_id( + unique_id=mac_stick, raise_on_progress=False + ) + self._abort_if_unique_id_configured() return self.async_create_entry( title="Stick", data={CONF_USB_PATH: device_path} ) @@ -123,3 +133,35 @@ async def async_step_manual_path( ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + device_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, user_input.get(CONF_USB_PATH) + ) + errors, mac_stick = await validate_usb_connection(self.hass, device_path) + if not errors: + await self.async_set_unique_id( + unique_id=mac_stick, raise_on_progress=False + ) + self._abort_if_unique_id_mismatch(reason="not_the_same_stick") + return self.async_update_reload_and_abort( + reconfigure_entry, + data_updates={CONF_USB_PATH: device_path} + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STICK_RECONF_SCHEMA, + suggested_values=reconfigure_entry.data, + ), + description_placeholders={"title": reconfigure_entry.title}, + errors=errors, + ) diff --git a/custom_components/plugwise_usb/manifest.json b/custom_components/plugwise_usb/manifest.json index d822359d..50b1fe8b 100644 --- a/custom_components/plugwise_usb/manifest.json +++ b/custom_components/plugwise_usb/manifest.json @@ -10,5 +10,5 @@ "issue_tracker": "https://github.com/plugwise/python-plugwise-usb/issues", "loggers": ["plugwise_usb"], "requirements": ["plugwise-usb==0.47.2"], - "version": "0.58.2" + "version": "0.59.0" } diff --git a/custom_components/plugwise_usb/strings.json b/custom_components/plugwise_usb/strings.json index bb1ecefc..3f5dde0a 100644 --- a/custom_components/plugwise_usb/strings.json +++ b/custom_components/plugwise_usb/strings.json @@ -12,8 +12,18 @@ "data": { "usb_path": "USB-path" } + }, + "reconfigure": { + "data": { + "usb_path": "USB-path" + }, + "description": "Update USB-{title} device-path" } }, + "abort": { + "not_the_same_stick": "The configured Stick does not match with the Stick on the entered port", + "reconfigure_successful": "Reconfiguration successful" + }, "error": { "already_configured": "This device is already configured", "cannot_connect": "Failed to connect", @@ -29,7 +39,7 @@ "fields": { "mac": { "name": "MAC address", - "description": "The full 16 character MAC address of the plugwise device." + "description": "The full 16 character MAC address of the plugwise device" } } }, @@ -39,7 +49,7 @@ "fields": { "mac": { "name": "MAC address", - "description": "The full 16 character MAC address of the plugwise device." + "description": "The full 16 character MAC address of the plugwise device" } } } diff --git a/custom_components/plugwise_usb/translations/en.json b/custom_components/plugwise_usb/translations/en.json index c6517192..c46635f9 100644 --- a/custom_components/plugwise_usb/translations/en.json +++ b/custom_components/plugwise_usb/translations/en.json @@ -12,8 +12,18 @@ "data": { "usb_path": "USB-path" } + }, + "reconfigure": { + "data": { + "usb_path": "USB-path" + }, + "description": "Update USB-{title} device-path" } }, + "abort": { + "not_the_same_stick": "The configured Stick does not match with the Stick on the entered port", + "reconfigure_successful": "Reconfiguration successful" + }, "error": { "already_configured": "This device is already configured", "cannot_connect": "Failed to connect", @@ -29,7 +39,7 @@ "fields": { "mac": { "name": "MAC address", - "description": "The full 16 character MAC address of the plugwise device." + "description": "The full 16 character MAC address of the plugwise device" } } }, @@ -39,7 +49,7 @@ "fields": { "mac": { "name": "MAC address", - "description": "The full 16 character MAC address of the plugwise device." + "description": "The full 16 character MAC address of the plugwise device" } } } diff --git a/custom_components/plugwise_usb/translations/nl.json b/custom_components/plugwise_usb/translations/nl.json index 92436dcb..12148eb0 100644 --- a/custom_components/plugwise_usb/translations/nl.json +++ b/custom_components/plugwise_usb/translations/nl.json @@ -12,8 +12,18 @@ "data": { "usb_path": "USB-pad" } + }, + "reconfigure": { + "data": { + "usb_path": "USB-pad" + }, + "description": "USB-{title} pad bijwerken" } }, + "abort": { + "not_the_same_stick": "De geconfigureerde Stick matcht niet met de Stick van de ingegeven poort", + "reconfigure_successful": "Herconfiguratie succesvol" + }, "error": { "already_configured": "Dit apparaat is al geconfigureerd", "cannot_connect": "Verbinden is mislukt", @@ -29,7 +39,7 @@ "fields": { "mac": { "name": "MAC adres", - "description": "Het volledige MAC address (16 karakters) van het plugwise apparaat." + "description": "Het volledige MAC address (16 karakters) van het plugwise apparaat" } } }, @@ -39,7 +49,7 @@ "fields": { "mac": { "name": "MAC adres", - "description": "Het volledige MAC address (16 karakters) van het plugwise apparaat." + "description": "Het volledige MAC address (16 karakters) van het plugwise apparaat" } } } diff --git a/pyproject.toml b/pyproject.toml index 0d1e869e..3605b7b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "plugwise_usb-beta" -version = "0.58.2" +version = "0.59.0" description = "Plugwise USB custom_component (BETA)" readme = "README.md" requires-python = ">=3.13" diff --git a/tests/conftest.py b/tests/conftest.py index 4bd5335b..0ee28b20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,10 +11,12 @@ from custom_components.plugwise_usb.const import CONF_USB_PATH, DOMAIN from homeassistant.core import HomeAssistant + from pytest_homeassistant_custom_component.common import MockConfigEntry -TEST_MAC: Final[str] = "01:23:45:67:AB" STICK_IMPORT_MOCK: Final[str] = "custom_components.plugwise_usb.config_flow.Stick" +TEST_MAC: Final[str] = "01:23:45:67:AB" +TEST_USB_PATH: Final[str] = "/dev/ttyUSB1" @pytest.fixture @@ -27,17 +29,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup -TEST_USBPORT = "/dev/ttyUSB1" - - @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return a mocked v1.2 config entry.""" # pw-beta only return MockConfigEntry( domain=DOMAIN, - data={CONF_USB_PATH: TEST_USBPORT}, - title="plugwise_usb", - unique_id="TEST_USBPORT", + data={CONF_USB_PATH: TEST_USB_PATH}, + minor_version=0, + version=1, + unique_id=TEST_MAC, ) @@ -68,6 +68,21 @@ async def init_integration( # yield [port] +@pytest.fixture +def mock_usb_stick_not_setup() -> Generator[MagicMock]: + """Return a mocked usb_mock.""" + + with patch(STICK_IMPORT_MOCK, autospec=True) as mock_usb: + usb = mock_usb.return_value + + usb.connect = AsyncMock(return_value=None) + usb.initialize = AsyncMock(return_value=None) + usb.disconnect = AsyncMock(return_value=None) + usb.mac_stick = None + + yield usb + + @pytest.fixture def mock_usb_stick() -> Generator[MagicMock]: """Return a mocked usb_mock.""" @@ -113,12 +128,16 @@ def mock_usb_stick_init_error() -> Generator[MagicMock]: yield usb -async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> MockConfigEntry: """Set up the usb integration.""" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + return config_entry + @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0a4d4e3e..14a8b93f 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,28 +1,33 @@ """Test the Plugwise config flow.""" -from unittest.mock import MagicMock, patch +from typing import Final +from unittest.mock import AsyncMock, MagicMock, patch +from plugwise_usb.exceptions import StickError import pytest from custom_components.plugwise_usb.config_flow import CONF_MANUAL_PATH from custom_components.plugwise_usb.const import CONF_USB_PATH, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult from homeassistant.const import CONF_SOURCE +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType, InvalidData from pytest_homeassistant_custom_component.common import MockConfigEntry import serial.tools.list_ports -TEST_USBPORT = "/dev/ttyUSB1" -TEST_USBPORT2 = "/dev/ttyUSB2" +TEST_MAC: Final[str] = "01:23:45:67:AB" +TEST_MAC2: Final[str] = "02:23:45:67:AB" +TEST_USB_PATH: Final[str] = "/dev/ttyUSB1" +TEST_USB2_PATH: Final[str] = "/dev/ttyUSB2" def com_port(): """Mock of a serial port.""" - port = serial.tools.list_ports_common.ListPortInfo(TEST_USBPORT) + port = serial.tools.list_ports_common.ListPortInfo(TEST_USB_PATH) port.serial_number = "1234" port.manufacturer = "Virtual serial port" - port.device = TEST_USBPORT + port.device = TEST_USB_PATH port.description = "Some serial port" return port @@ -47,7 +52,7 @@ async def test_user_flow_select(hass, mock_usb_stick: MagicMock): ) await hass.async_block_till_done() assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("data") == {CONF_USB_PATH: TEST_USBPORT} + assert result.get("data") == {CONF_USB_PATH: TEST_USB_PATH} # Retry to ensure configuring the same port is not allowed result = await hass.config_entries.flow.async_init( @@ -77,7 +82,7 @@ async def test_user_flow_manual_selected_show_form(hass): async def test_user_flow_manual( - hass, mock_usb_stick: MagicMock, init_integration: MockConfigEntry + hass, mock_usb_stick_not_setup: MagicMock, init_integration: MockConfigEntry ): """Test user flow when USB-stick is manually entered.""" @@ -92,11 +97,11 @@ async def test_user_flow_manual( result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_USB_PATH: TEST_USBPORT2}, + user_input={CONF_USB_PATH: TEST_USB2_PATH}, ) await hass.async_block_till_done() assert result.get("type") is FlowResultType.CREATE_ENTRY - assert result.get("data") == {CONF_USB_PATH: TEST_USBPORT2} + assert result.get("data") == {CONF_USB_PATH: TEST_USB2_PATH} async def test_invalid_connection(hass): @@ -183,3 +188,87 @@ async def test_failed_initialization(hass, mock_usb_stick_init_error: MagicMock) await hass.async_block_till_done() assert result.get("type") is FlowResultType.FORM assert result.get("errors") == {"base": "stick_init"} + + +async def _start_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + device_path: str, +) -> ConfigFlowResult: + """Initialize a reconfigure flow.""" + mock_config_entry.add_to_hass(hass) + + reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass) + + assert reconfigure_result["type"] is FlowResultType.FORM + assert reconfigure_result["step_id"] == "reconfigure" + + return await hass.config_entries.flow.async_configure( + reconfigure_result["flow_id"], {CONF_USB_PATH: device_path} + ) + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_usb_stick: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_USB2_PATH) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data.get(CONF_USB_PATH) == TEST_USB2_PATH + + + +async def test_reconfigure_flow_same_path( + hass: HomeAssistant, + mock_usb_stick: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_USB_PATH) + + assert result["type"] is FlowResultType.FORM + assert result.get("errors") == {"base": "already_configured"} + + +async def test_reconfigure_flow_other_stick( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_usb_stick: AsyncMock, +) -> None: + """Test reconfigure flow aborts on other Smile ID.""" + mock_usb_stick.mac_stick = TEST_MAC2 + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_USB2_PATH) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_the_same_stick" + + +@pytest.mark.parametrize( + ("side_effect", "reason"),[(StickError, "cannot_connect")], +) +async def test_reconfigure_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_usb_stick: AsyncMock, + side_effect: Exception, + reason: str, +) -> None: + """Test we handle each reconfigure exception error.""" + + mock_usb_stick.connect.side_effect = side_effect + + result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_USB2_PATH) + + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": reason} + assert result.get("step_id") == "reconfigure"