Bump teltasync to 0.3.0, fix discovery for older devices (#169660)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Karl Beecken
2026-05-11 15:21:09 +01:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent bacb8a8fea
commit 85faab5d5d
11 changed files with 284 additions and 27 deletions
@@ -11,6 +11,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
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 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
# 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 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
# 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 @@ class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
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",
@@ -11,7 +11,11 @@ from teltasync.modems import Modems
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 @@ class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 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,
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["teltasync==0.2.0"]
"requirements": ["teltasync==0.3.0"]
}
+1 -1
View File
@@ -3107,7 +3107,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
+1 -1
View File
@@ -2637,7 +2637,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
+8
View File
@@ -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."""
@@ -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": {
@@ -0,0 +1,5 @@
{
"lang": "en",
"device_name": "RUT240",
"api_version": "1.0"
}
@@ -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": {
@@ -6,6 +6,14 @@
'config_entries_subentries': <ANY>,
'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,
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
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.config_entries import SOURCE_USER
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()