Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions homeassistant/components/teltonika/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -59,6 +60,8 @@ 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()
if auth_valid and device_info.device_identifier is None:
system_info = await client.get_system_info()
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
except TeltonikaConnectionError as err:
_LOGGER.debug(
"Failed to connect to Teltonika device at %s: %s", base_url, err
Expand All @@ -76,7 +79,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_info.device_identifier or system_info.mnf_info.serial,
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
"host": base_url,
}
Comment thread
karlbeecken marked this conversation as resolved.

Expand Down Expand Up @@ -220,9 +223,30 @@ 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)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
if device_id is not None:
# Set unique ID and check for existing conf
await self.async_set_unique_id(device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
else:
# Older firmware (API v1.0) does not expose the serial without
# authentication, so we fall back to the DHCP MAC as the flow
# unique_id (suppresses parallel discovery flows for the same
# device) and look the device up in the registry by MAC.
mac = dr.format_mac(discovery_info.macaddress)
await self.async_set_unique_id(mac)
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
existing = dr.async_get(self.hass).async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac)}
)
if existing is not None:
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:
return self.async_update_reload_and_abort(
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
entry,
data_updates={CONF_HOST: host},
Comment thread
karlbeecken marked this conversation as resolved.
Outdated
reason="already_configured",
reload_even_if_entry_is_unchanged=False,
)
Comment thread
karlbeecken marked this conversation as resolved.
Outdated

# Store discovery info for the user step
self.context["title_placeholders"] = {
Expand Down
14 changes: 13 additions & 1 deletion homeassistant/components/teltonika/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/teltonika/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["teltasync==0.2.0"]
"requirements": ["teltasync==0.3.0"]
Comment thread
karlbeecken marked this conversation as resolved.
Comment thread
karlbeecken marked this conversation as resolved.
}
2 changes: 1 addition & 1 deletion requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions tests/components/teltonika/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 5 additions & 0 deletions tests/components/teltonika/fixtures/device_info_rut240.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lang": "en",
"device_name": "RUT240",
"api_version": "1.0"
}
4 changes: 2 additions & 2 deletions tests/components/teltonika/fixtures/system_info.json
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
karlbeecken marked this conversation as resolved.
"bl_ver": "3.0"
},
"static": {
Expand Down
8 changes: 8 additions & 0 deletions tests/components/teltonika/snapshots/test_init.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
121 changes: 121 additions & 0 deletions tests/components/teltonika/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -518,3 +519,123 @@ 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

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

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"


Comment thread
karlbeecken marked this conversation as resolved.
async def test_dhcp_discovery_rut240_already_configured(
hass: HomeAssistant,
mock_teltasync_client: MagicMock,
init_integration: MockConfigEntry,
rut240_device_info: UnauthorizedStatusData,
) -> None:
"""RUT240 DHCP discovery dedups via MAC against the device registry and updates host."""
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 init_integration.data[CONF_HOST] == "192.168.99.99"


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"
Loading