diff --git a/homeassistant/components/teltonika/config_flow.py b/homeassistant/components/teltonika/config_flow.py index 7b5195b80febd..44db9ddb0c062 100644 --- a/homeassistant/components/teltonika/config_flow.py +++ b/homeassistant/components/teltonika/config_flow.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo @@ -59,6 +60,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, try: device_info = await client.get_device_info() auth_valid = await client.validate_credentials() + device_id = device_info.device_identifier + if auth_valid and device_id is None: + system_info = await client.get_system_info() + device_id = system_info.mnf_info.serial except TeltonikaConnectionError as err: _LOGGER.debug( "Failed to connect to Teltonika device at %s: %s", base_url, err @@ -76,7 +81,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, return { "title": device_info.device_name, - "device_id": device_info.device_identifier, + "device_id": device_id, "host": base_url, } @@ -220,8 +225,29 @@ async def async_step_dhcp( # No URL variant worked, device not reachable, don't autodiscover return self.async_abort(reason="cannot_connect") - # Set unique ID and check for existing conf - await self.async_set_unique_id(device_id) + formatted_mac = dr.format_mac(discovery_info.macaddress) + + if device_id is None: + # FW with API v1.0 doesn't expose any unique identifier on the + # unauthorized endpoint. Match existing entries by MAC so it + # aborts without asking for credentials again. + device_reg = dr.async_get(self.hass) + if existing := device_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, formatted_mac)} + ): + for entry_id in existing.config_entries: + entry = self.hass.config_entries.async_get_entry(entry_id) + if ( + entry is not None + and entry.domain == DOMAIN + and entry.unique_id is not None + ): + device_id = entry.unique_id + break + + # Use the MAC as a placeholder unique_id when nothing matched, so + # parallel DHCP advertisements don't both reach dhcp_confirm. + await self.async_set_unique_id(device_id or formatted_mac) self._abort_if_unique_id_configured(updates={CONF_HOST: host}) # Store discovery info for the user step @@ -243,21 +269,27 @@ async def async_step_dhcp_confirm( # Get the host from the discovery host = getattr(self, "_discovered_host", "") + data = { + CONF_HOST: host, + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_VERIFY_SSL: False, + } try: - # Validate credentials with discovered host - data = { - CONF_HOST: host, - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_VERIFY_SSL: False, - } info = await validate_input(self.hass, data) - + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception during DHCP confirm") + errors["base"] = "unknown" + else: # Update unique ID to device identifier if we didn't get it during discovery await self.async_set_unique_id( info["device_id"], raise_on_progress=False ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: info["host"]}) return self.async_create_entry( title=info["title"], @@ -268,13 +300,6 @@ async def async_step_dhcp_confirm( CONF_VERIFY_SSL: False, }, ) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception during DHCP confirm") - errors["base"] = "unknown" return self.async_show_form( step_id="dhcp_confirm", diff --git a/homeassistant/components/teltonika/coordinator.py b/homeassistant/components/teltonika/coordinator.py index 8e1c239b1efa9..8cc79cbed97de 100644 --- a/homeassistant/components/teltonika/coordinator.py +++ b/homeassistant/components/teltonika/coordinator.py @@ -11,7 +11,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -73,6 +77,14 @@ async def _async_setup(self) -> None: # Store device info for use by entities self.device_info = DeviceInfo( identifiers={(DOMAIN, system_info_response.mnf_info.serial)}, + connections={ + (CONNECTION_NETWORK_MAC, format_mac(mac)) + for mac in ( + system_info_response.mnf_info.mac_eth, + system_info_response.mnf_info.mac, + ) + if mac + }, name=system_info_response.static.device_name, manufacturer="Teltonika", model=system_info_response.static.model, diff --git a/homeassistant/components/teltonika/manifest.json b/homeassistant/components/teltonika/manifest.json index e6359073e7037..747b563483265 100644 --- a/homeassistant/components/teltonika/manifest.json +++ b/homeassistant/components/teltonika/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["teltasync==0.2.0"] + "requirements": ["teltasync==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2a2b19ac296c3..ac2443498e2c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3104,7 +3104,7 @@ tellcore-py==1.1.2 tellduslive==0.10.12 # homeassistant.components.teltonika -teltasync==0.2.0 +teltasync==0.3.0 # homeassistant.components.lg_soundbar temescal==0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f5c0eae69cd0..bea49b7a18b32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2634,7 +2634,7 @@ tailscale==0.7.0 tellduslive==0.10.12 # homeassistant.components.teltonika -teltasync==0.2.0 +teltasync==0.3.0 # homeassistant.components.lg_soundbar temescal==0.5 diff --git a/tests/components/teltonika/conftest.py b/tests/components/teltonika/conftest.py index f33293847cbee..fbd778a9933d1 100644 --- a/tests/components/teltonika/conftest.py +++ b/tests/components/teltonika/conftest.py @@ -57,6 +57,14 @@ def mock_teltasync_client(mock_teltasync: MagicMock) -> MagicMock: return mock_teltasync.return_value +@pytest.fixture(name="rut240_device_info") +def fixture_rut240_device_info() -> UnauthorizedStatusData: + """Load the unauthorized payload returned by RUT240 firmware 7.6.""" + return UnauthorizedStatusData( + **load_json_object_fixture("device_info_rut240.json", DOMAIN) + ) + + @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/teltonika/fixtures/device_data.json b/tests/components/teltonika/fixtures/device_data.json index 15c8c070543c9..ab8a6595ae187 100644 --- a/tests/components/teltonika/fixtures/device_data.json +++ b/tests/components/teltonika/fixtures/device_data.json @@ -11,12 +11,12 @@ }, "system_info": { "mnf_info": { - "mac_eth": "001122334455", + "mac_eth": "209727aabbcc", "name": "RUTX5000XXXX", "hw_ver": "0202", "batch": "0024", "serial": "1234567890", - "mac": "001122334456", + "mac": "209727aabbcd", "bl_ver": "3.0" }, "static": { diff --git a/tests/components/teltonika/fixtures/device_info_rut240.json b/tests/components/teltonika/fixtures/device_info_rut240.json new file mode 100644 index 0000000000000..19263ae51df2c --- /dev/null +++ b/tests/components/teltonika/fixtures/device_info_rut240.json @@ -0,0 +1,5 @@ +{ + "lang": "en", + "device_name": "RUT240", + "api_version": "1.0" +} diff --git a/tests/components/teltonika/fixtures/system_info.json b/tests/components/teltonika/fixtures/system_info.json index 1d672daae5369..7540a3a2595e8 100644 --- a/tests/components/teltonika/fixtures/system_info.json +++ b/tests/components/teltonika/fixtures/system_info.json @@ -1,11 +1,11 @@ { "mnf_info": { - "mac_eth": "001122334455", + "mac_eth": "209727aabbcc", "name": "RUTX5000XXXX", "hw_ver": "0202", "batch": "0024", "serial": "1234567890", - "mac": "001122334456", + "mac": "209727aabbcd", "bl_ver": "3.0" }, "static": { diff --git a/tests/components/teltonika/snapshots/test_init.ambr b/tests/components/teltonika/snapshots/test_init.ambr index b12a01f3b1c8e..6280477ceeaa8 100644 --- a/tests/components/teltonika/snapshots/test_init.ambr +++ b/tests/components/teltonika/snapshots/test_init.ambr @@ -6,6 +6,14 @@ 'config_entries_subentries': , 'configuration_url': 'https://192.168.1.1', 'connections': set({ + tuple( + 'mac', + '20:97:27:aa:bb:cc', + ), + tuple( + 'mac', + '20:97:27:aa:bb:cd', + ), }), 'disabled_by': None, 'entry_type': None, diff --git a/tests/components/teltonika/test_config_flow.py b/tests/components/teltonika/test_config_flow.py index 582de543fcbc3..2155563e20c4a 100644 --- a/tests/components/teltonika/test_config_flow.py +++ b/tests/components/teltonika/test_config_flow.py @@ -4,6 +4,7 @@ import pytest from teltasync import TeltonikaAuthenticationError, TeltonikaConnectionError +from teltasync.unauthorized import UnauthorizedStatusData from homeassistant import config_entries from homeassistant.components.teltonika.const import DOMAIN @@ -11,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from tests.common import MockConfigEntry @@ -438,6 +440,37 @@ async def test_reauth_flow_success( assert mock_config_entry.data[CONF_HOST] == "192.168.1.1" +async def test_reauth_flow_success_rut240( + hass: HomeAssistant, + mock_teltasync_client: MagicMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """Reauth on a RUT240 falls back to mnf_info.serial for unique_id matching.""" + mock_teltasync_client.get_device_info.return_value = rut240_device_info + mock_teltasync_client.validate_credentials.return_value = True + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "new_password", + }, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "new_password" + + @pytest.mark.parametrize( ("side_effect", "expected_error"), [ @@ -518,3 +551,169 @@ async def test_reauth_flow_wrong_account( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +async def test_form_user_flow_rut240( + hass: HomeAssistant, + mock_teltasync_client: MagicMock, + mock_setup_entry: AsyncMock, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """RUT240 firmware omits device_identifier; the flow falls back to mnf_info.serial.""" + mock_teltasync_client.get_device_info.return_value = rut240_device_info + mock_teltasync_client.validate_credentials.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "192.168.1.1", + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + CONF_VERIFY_SSL: False, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "RUT240" + assert result["result"].unique_id == "1234567890" + + +async def test_dhcp_discovery_rut240( + hass: HomeAssistant, + mock_teltasync_client: MagicMock, + mock_setup_entry: AsyncMock, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """RUT240 DHCP discovery proceeds to confirmation when the MAC isn't yet known.""" + mock_teltasync_client.get_device_info.return_value = rut240_device_info + mock_teltasync_client.validate_credentials.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.1.50", + macaddress="209727aabbcc", + hostname="teltonika", + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + assert "name" in result["description_placeholders"] + assert "host" in result["description_placeholders"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "RUT240" + assert result["data"][CONF_HOST] == "https://192.168.1.50" + assert result["data"][CONF_USERNAME] == "admin" + assert result["data"][CONF_PASSWORD] == "password" + assert result["result"].unique_id == "1234567890" + + +async def test_dhcp_discovery_rut240_repeated_advertisement( + hass: HomeAssistant, + mock_teltasync_client: MagicMock, + mock_setup_entry: AsyncMock, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """A second DHCP advertisement before dhcp_confirm finishes is suppressed.""" + mock_teltasync_client.get_device_info.return_value = rut240_device_info + + discovery = DhcpServiceInfo( + ip="192.168.1.50", + macaddress="209727aabbcc", + hostname="teltonika", + ) + + first = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery + ) + assert first["type"] is FlowResultType.FORM + assert first["step_id"] == "dhcp_confirm" + + second = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery + ) + assert second["type"] is FlowResultType.ABORT + assert second["reason"] == "already_in_progress" + + +async def test_dhcp_discovery_rut240_already_configured_updates_host( + hass: HomeAssistant, + mock_teltasync_client: MagicMock, + mock_config_entry: MockConfigEntry, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """An already-configured RUT240 gets its host updated through dhcp_confirm.""" + mock_config_entry.add_to_hass(hass) + mock_teltasync_client.get_device_info.return_value = rut240_device_info + mock_teltasync_client.validate_credentials.return_value = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.99.99", + macaddress="209727aabbcc", + hostname="teltonika", + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "dhcp_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "password", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "https://192.168.99.99" + + +async def test_dhcp_discovery_apiv1_already_configured_aborts( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_teltasync_client: MagicMock, + mock_config_entry: MockConfigEntry, + rut240_device_info: UnauthorizedStatusData, +) -> None: + """An API v1.0 (e.g. RUT240) known to the device registry by MAC aborts before dhcp_confirm.""" + mock_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.unique_id)}, + connections={(dr.CONNECTION_NETWORK_MAC, "20:97:27:aa:bb:cc")}, + ) + mock_teltasync_client.get_device_info.return_value = rut240_device_info + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.99.99", + macaddress="209727aabbcc", + hostname="teltonika", + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "192.168.99.99" + mock_teltasync_client.validate_credentials.assert_not_called()