mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
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:
co-authored by
Copilot Autofix powered by AI
parent
bacb8a8fea
commit
85faab5d5d
@@ -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"]
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user