add teltonika integration (#157539)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Karl Beecken
2026-02-18 11:18:50 +01:00
committed by GitHub
parent fdd753e70c
commit 294a3e5360
26 changed files with 2333 additions and 0 deletions

2
CODEOWNERS generated
View File

@@ -1671,6 +1671,8 @@ build.json @home-assistant/supervisor
/tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77

View File

@@ -0,0 +1,70 @@
"""The Teltonika integration."""
from __future__ import annotations
import logging
from teltasync import Teltasync
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import TeltonikaDataUpdateCoordinator
from .util import normalize_url
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.SENSOR]
type TeltonikaConfigEntry = ConfigEntry[TeltonikaDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: TeltonikaConfigEntry) -> bool:
"""Set up Teltonika from a config entry."""
host = entry.data[CONF_HOST]
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
validate_ssl = entry.data.get(CONF_VERIFY_SSL, False)
session = async_get_clientsession(hass)
base_url = normalize_url(host)
client = Teltasync(
base_url=f"{base_url}/api",
username=username,
password=password,
session=session,
verify_ssl=validate_ssl,
)
# Create coordinator
coordinator = TeltonikaDataUpdateCoordinator(hass, client, entry, base_url)
# Fetch initial data and set up device info
await coordinator.async_config_entry_first_refresh()
assert coordinator.device_info is not None
# Store runtime data
entry.runtime_data = coordinator
# Set up platforms
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: TeltonikaConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.client.close()
return unload_ok

View File

@@ -0,0 +1,231 @@
"""Config flow for the Teltonika integration."""
from __future__ import annotations
import logging
from typing import Any
from teltasync import Teltasync, TeltonikaAuthenticationError, TeltonikaConnectionError
import voluptuous as vol
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.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN
from .util import get_url_variants
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_VERIFY_SSL, default=False): bool,
}
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
session = async_get_clientsession(hass)
host = data[CONF_HOST]
last_error: Exception | None = None
for base_url in get_url_variants(host):
client = Teltasync(
base_url=f"{base_url}/api",
username=data[CONF_USERNAME],
password=data[CONF_PASSWORD],
session=session,
verify_ssl=data.get(CONF_VERIFY_SSL, True),
)
try:
device_info = await client.get_device_info()
auth_valid = await client.validate_credentials()
except TeltonikaConnectionError as err:
_LOGGER.debug(
"Failed to connect to Teltonika device at %s: %s", base_url, err
)
last_error = err
continue
except TeltonikaAuthenticationError as err:
_LOGGER.error("Authentication failed: %s", err)
raise InvalidAuth from err
finally:
await client.close()
if not auth_valid:
raise InvalidAuth
return {
"title": device_info.device_name,
"device_id": device_info.device_identifier,
"host": base_url,
}
_LOGGER.error("Cannot connect to device after trying all schemas")
raise CannotConnect from last_error
class TeltonikaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Teltonika."""
VERSION = 1
MINOR_VERSION = 1
_discovered_host: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
# Set unique ID to prevent duplicates
await self.async_set_unique_id(info["device_id"])
self._abort_if_unique_id_configured()
data_to_store = dict(user_input)
if "host" in info:
data_to_store[CONF_HOST] = info["host"]
return self.async_create_entry(
title=info["title"],
data=data_to_store,
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
host = discovery_info.ip
# Store discovered host for later use
self._discovered_host = host
# Try to get device info without authentication to get device identifier and name
session = async_get_clientsession(self.hass)
for base_url in get_url_variants(host):
client = Teltasync(
base_url=f"{base_url}/api",
username="", # No credentials yet
password="",
session=session,
verify_ssl=False, # Teltonika devices use self-signed certs by default
)
try:
# Get device info from unauthorized endpoint
device_info = await client.get_device_info()
device_name = device_info.device_name
device_id = device_info.device_identifier
break
except TeltonikaConnectionError:
# Connection failed, try next URL variant
continue
finally:
await client.close()
else:
# 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})
# Store discovery info for the user step
self.context["title_placeholders"] = {
"name": device_name,
"host": host,
}
# Proceed to confirmation step to get credentials
return await self.async_step_dhcp_confirm()
async def async_step_dhcp_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm DHCP discovery and get credentials."""
errors: dict[str, str] = {}
if user_input is not None:
# Get the host from the discovery
host = getattr(self, "_discovered_host", "")
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)
# 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()
return self.async_create_entry(
title=info["title"],
data={
CONF_HOST: info["host"],
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
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",
data_schema=vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
}
),
errors=errors,
description_placeholders=self.context.get("title_placeholders", {}),
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Teltonika integration."""
DOMAIN = "teltonika"

View File

@@ -0,0 +1,98 @@
"""DataUpdateCoordinator for Teltonika."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponseError, ContentTypeError
from teltasync import Teltasync, TeltonikaAuthenticationError, TeltonikaConnectionError
from teltasync.modems import Modems
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import TeltonikaConfigEntry
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
class TeltonikaDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching Teltonika data."""
device_info: DeviceInfo
def __init__(
self,
hass: HomeAssistant,
client: Teltasync,
config_entry: TeltonikaConfigEntry,
base_url: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name="Teltonika",
update_interval=SCAN_INTERVAL,
config_entry=config_entry,
)
self.client = client
self.base_url = base_url
async def _async_setup(self) -> None:
"""Set up the coordinator - authenticate and fetch device info."""
try:
await self.client.get_device_info()
system_info_response = await self.client.get_system_info()
except TeltonikaAuthenticationError as err:
raise ConfigEntryError(f"Authentication failed: {err}") from err
except (ClientResponseError, ContentTypeError) as err:
if isinstance(err, ClientResponseError) and err.status in (401, 403):
raise ConfigEntryError(f"Authentication failed: {err}") from err
if isinstance(err, ContentTypeError) and err.status == 403:
raise ConfigEntryError(f"Authentication failed: {err}") from err
raise ConfigEntryNotReady(f"Failed to connect to device: {err}") from err
except TeltonikaConnectionError as err:
raise ConfigEntryNotReady(f"Failed to connect to device: {err}") from err
# Store device info for use by entities
self.device_info = DeviceInfo(
identifiers={(DOMAIN, system_info_response.mnf_info.serial)},
name=system_info_response.static.device_name,
manufacturer="Teltonika",
model=system_info_response.static.model,
sw_version=system_info_response.static.fw_version,
serial_number=system_info_response.mnf_info.serial,
configuration_url=self.base_url,
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from Teltonika device."""
modems = Modems(self.client.auth)
try:
# Get modems data using the teltasync library
modems_response = await modems.get_status()
except TeltonikaConnectionError as err:
raise UpdateFailed(f"Error communicating with device: {err}") from err
# Return only modems which are online
modem_data: dict[str, Any] = {}
if modems_response.data:
modem_data.update(
{
modem.id: modem
for modem in modems_response.data
if Modems.is_online(modem)
}
)
return modem_data

View File

@@ -0,0 +1,19 @@
{
"domain": "teltonika",
"name": "Teltonika",
"codeowners": ["@karlbeecken"],
"config_flow": true,
"dhcp": [
{
"macaddress": "209727*"
},
{
"macaddress": "001E42*"
}
],
"documentation": "https://www.home-assistant.io/integrations/teltonika",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["teltasync==0.1.3"]
}

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions registered.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions registered.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: No custom events registered.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No custom actions registered.
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,187 @@
"""Teltonika sensor platform."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from teltasync.modems import ModemStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TeltonikaConfigEntry, TeltonikaDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class TeltonikaSensorEntityDescription(SensorEntityDescription):
"""Describes Teltonika sensor entity."""
value_fn: Callable[[ModemStatus], StateType]
SENSOR_DESCRIPTIONS: tuple[TeltonikaSensorEntityDescription, ...] = (
TeltonikaSensorEntityDescription(
key="rssi",
translation_key="rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
suggested_display_precision=0,
value_fn=lambda modem: modem.rssi,
),
TeltonikaSensorEntityDescription(
key="rsrp",
translation_key="rsrp",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
suggested_display_precision=0,
value_fn=lambda modem: modem.rsrp,
),
TeltonikaSensorEntityDescription(
key="rsrq",
translation_key="rsrq",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
suggested_display_precision=0,
value_fn=lambda modem: modem.rsrq,
),
TeltonikaSensorEntityDescription(
key="sinr",
translation_key="sinr",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
suggested_display_precision=0,
value_fn=lambda modem: modem.sinr,
),
TeltonikaSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=0,
value_fn=lambda modem: modem.temperature,
),
TeltonikaSensorEntityDescription(
key="operator",
translation_key="operator",
value_fn=lambda modem: modem.operator,
),
TeltonikaSensorEntityDescription(
key="connection_type",
translation_key="connection_type",
value_fn=lambda modem: modem.conntype,
),
TeltonikaSensorEntityDescription(
key="band",
translation_key="band",
value_fn=lambda modem: modem.band,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TeltonikaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Teltonika sensor platform."""
coordinator = entry.runtime_data
# Track known modems to detect new ones
known_modems: set[str] = set()
@callback
def _async_add_new_modems() -> None:
"""Add sensors for newly discovered modems."""
current_modems = set(coordinator.data.keys())
new_modems = current_modems - known_modems
if new_modems:
entities = [
TeltonikaSensorEntity(
coordinator,
coordinator.device_info,
description,
modem_id,
coordinator.data[modem_id],
)
for modem_id in new_modems
for description in SENSOR_DESCRIPTIONS
]
async_add_entities(entities)
known_modems.update(new_modems)
# Add sensors for initial modems
_async_add_new_modems()
# Listen for new modems
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_modems))
class TeltonikaSensorEntity(
CoordinatorEntity[TeltonikaDataUpdateCoordinator], SensorEntity
):
"""Teltonika sensor entity."""
_attr_has_entity_name = True
entity_description: TeltonikaSensorEntityDescription
def __init__(
self,
coordinator: TeltonikaDataUpdateCoordinator,
device_info: DeviceInfo,
description: TeltonikaSensorEntityDescription,
modem_id: str,
modem: ModemStatus,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._modem_id = modem_id
self._attr_device_info = device_info
# Create unique ID using entry unique identifier, modem ID, and sensor type
assert coordinator.config_entry is not None
entry_unique_id = (
coordinator.config_entry.unique_id or coordinator.config_entry.entry_id
)
self._attr_unique_id = f"{entry_unique_id}_{modem_id}_{description.key}"
# Use translation key for proper naming
modem_name = modem.name or f"Modem {modem_id}"
self._modem_name = modem_name
self._attr_translation_key = description.translation_key
self._attr_translation_placeholders = {"modem_name": modem_name}
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self._modem_id in self.coordinator.data
@property
def native_value(self) -> StateType:
"""Handle updated data from the coordinator."""
return self.entity_description.value_fn(self.coordinator.data[self._modem_id])

View File

@@ -0,0 +1,69 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The device does not match the existing configuration."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"dhcp_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password to authenticate with the device.",
"username": "The username to authenticate with the device."
},
"description": "A Teltonika device ({name}) was discovered at {host}. Enter the credentials to add it to Home Assistant.",
"title": "Discovered Teltonika device"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"host": "The hostname or IP address of your Teltonika device.",
"password": "The password to authenticate with the device.",
"username": "The username to authenticate with the device.",
"verify_ssl": "Whether to validate the SSL certificate when using HTTPS."
},
"description": "Enter the connection details for your Teltonika device.",
"title": "Set up Teltonika device"
}
}
},
"entity": {
"sensor": {
"band": {
"name": "{modem_name} Band"
},
"connection_type": {
"name": "{modem_name} Connection type"
},
"operator": {
"name": "{modem_name} Operator"
},
"rsrp": {
"name": "{modem_name} RSRP"
},
"rsrq": {
"name": "{modem_name} RSRQ"
},
"rssi": {
"name": "{modem_name} RSSI"
},
"sinr": {
"name": "{modem_name} SINR"
}
}
}
}

View File

@@ -0,0 +1,39 @@
"""Utility helpers for the Teltonika integration."""
from __future__ import annotations
from yarl import URL
def normalize_url(host: str) -> str:
"""Normalize host input to a base URL without path.
Returns just the scheme://host part, without /api.
Ensures the URL has a scheme (defaults to HTTPS).
"""
host_input = host.strip().rstrip("/")
# Parse or construct URL
if host_input.startswith(("http://", "https://")):
url = URL(host_input)
else:
# handle as scheme-relative URL and add HTTPS scheme by default
url = URL(f"//{host_input}").with_scheme("https")
# Return base URL without path, only including scheme, host and port
return str(url.origin())
def get_url_variants(host: str) -> list[str]:
"""Get URL variants to try during setup (HTTPS first, then HTTP fallback)."""
normalized = normalize_url(host)
url = URL(normalized)
# If user specified a scheme, only try that
if host.strip().startswith(("http://", "https://")):
return [normalized]
# Otherwise try HTTPS first, then HTTP
https_url = str(url.with_scheme("https"))
http_url = str(url.with_scheme("http"))
return [https_url, http_url]

View File

@@ -701,6 +701,7 @@ FLOWS = {
"tedee",
"telegram_bot",
"tellduslive",
"teltonika",
"tesla_fleet",
"tesla_wall_connector",
"teslemetry",

View File

@@ -857,6 +857,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "tailwind",
"registered_devices": True,
},
{
"domain": "teltonika",
"macaddress": "209727*",
},
{
"domain": "teltonika",
"macaddress": "001E42*",
},
{
"domain": "tesla_wall_connector",
"hostname": "teslawallconnector_*",

View File

@@ -6870,6 +6870,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
"teltonika": {
"name": "Teltonika",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"temper": {
"name": "TEMPer",
"integration_type": "hub",

3
requirements_all.txt generated
View File

@@ -3027,6 +3027,9 @@ tellcore-py==1.1.2
# homeassistant.components.tellduslive
tellduslive==0.10.12
# homeassistant.components.teltonika
teltasync==0.1.3
# homeassistant.components.lg_soundbar
temescal==0.5

View File

@@ -2539,6 +2539,9 @@ tailscale==0.6.2
# homeassistant.components.tellduslive
tellduslive==0.10.12
# homeassistant.components.teltonika
teltasync==0.1.3
# homeassistant.components.lg_soundbar
temescal==0.5

View File

@@ -0,0 +1 @@
"""Tests for Teltonika."""

View File

@@ -0,0 +1,111 @@
"""Fixtures for Teltonika tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from teltasync.modems import ModemStatusFull
from teltasync.system import DeviceStatusData
from teltasync.unauthorized import UnauthorizedStatusData
from homeassistant.components.teltonika.const import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock setting up a config entry."""
with patch(
"homeassistant.components.teltonika.async_setup_entry",
return_value=True,
) as mock_setup:
yield mock_setup
@pytest.fixture
def mock_teltasync() -> Generator[MagicMock]:
"""Mock Teltasync client for both config flow and init."""
with (
patch(
"homeassistant.components.teltonika.config_flow.Teltasync",
autospec=True,
) as mock_teltasync_class,
patch(
"homeassistant.components.teltonika.Teltasync",
new=mock_teltasync_class,
),
):
shared_client = mock_teltasync_class.return_value
device_info = load_json_object_fixture("device_info.json", DOMAIN)
shared_client.get_device_info.return_value = UnauthorizedStatusData(
**device_info
)
system_info = load_json_object_fixture("system_info.json", DOMAIN)
shared_client.get_system_info.return_value = DeviceStatusData(**system_info)
yield mock_teltasync_class
@pytest.fixture
def mock_teltasync_client(mock_teltasync: MagicMock) -> MagicMock:
"""Return the client instance from mock_teltasync."""
return mock_teltasync.return_value
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return the default mocked config entry."""
device_data = load_json_object_fixture("device_data.json", DOMAIN)
return MockConfigEntry(
domain=DOMAIN,
title="RUTX50 Test",
data={
CONF_HOST: "192.168.1.1",
CONF_USERNAME: "admin",
CONF_PASSWORD: "test_password",
},
unique_id=device_data["system_info"]["mnf_info"]["serial"],
)
@pytest.fixture
def mock_modems() -> Generator[AsyncMock]:
"""Mock Modems class."""
with patch(
"homeassistant.components.teltonika.coordinator.Modems",
autospec=True,
) as mock_modems_class:
mock_modems_instance = mock_modems_class.return_value
# Load device data to get modem info
device_data = load_json_object_fixture("device_data.json", DOMAIN)
# Create response object with data attribute
response_mock = MagicMock()
response_mock.data = [
ModemStatusFull(**modem) for modem in device_data["modems_data"]
]
mock_modems_instance.get_status.return_value = response_mock
# Mock is_online to return True for the modem
mock_modems_class.is_online = MagicMock(return_value=True)
yield mock_modems_instance
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_teltasync: MagicMock,
mock_modems: MagicMock,
) -> MockConfigEntry:
"""Set up the Teltonika integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry

View File

@@ -0,0 +1,55 @@
{
"device_info": {
"lang": "en",
"filename": null,
"device_name": "RUTX50 Test",
"device_model": "RUTX50",
"api_version": "1.9.2",
"device_identifier": "abcd1234567890ef1234567890abcdef",
"serial": "1234567890",
"model": "RUTX50"
},
"system_info": {
"mnf_info": {
"mac_eth": "001122334455",
"name": "RUTX5000XXXX",
"hw_ver": "0202",
"batch": "0024",
"serial": "1234567890",
"mac": "001122334456",
"bl_ver": "3.0"
},
"static": {
"fw_version": "RUTX_R_00.07.17.3",
"kernel": "6.6.96",
"system": "ARMv7 Processor rev 5 (v7l)",
"device_name": "RUTX50 Test",
"hostname": "RUTX50",
"cpu_count": 4,
"model": "RUTX50"
}
},
"modems_data": [
{
"id": "2-1",
"imei": "123456789012345",
"model": "RG501Q-EU",
"name": "Internal modem",
"temperature": 42,
"signal": -63,
"operator": "test.operator",
"conntype": "5G (NSA)",
"state": "Connected",
"rssi": -63,
"rsrp": -93,
"rsrq": -10,
"sinr": 15,
"band": "5G N3",
"active_sim": 1,
"simstate": "Inserted",
"data_conn_state": "Connected",
"txbytes": 215863700781,
"rxbytes": 445573412885
}
]
}

View File

@@ -0,0 +1,12 @@
{
"lang": "en",
"filename": null,
"device_name": "RUTX50 Test",
"device_model": "RUTX50",
"api_version": "1.9.2",
"device_identifier": "1234567890",
"security_banner": {
"title": "Unauthorized access prohibited",
"message": "This system is for authorized use only. All activities on this system are logged and monitored. By using this system, you consent to such monitoring. Unauthorized access or misuse may result in disciplinary action, civil and criminal penalties, or both.\n\nIf you are not authorized to use this system, disconnect immediately."
}
}

View File

@@ -0,0 +1,236 @@
{
"mnf_info": {
"mac_eth": "001122334455",
"name": "RUTX5000XXXX",
"hw_ver": "0202",
"batch": "0024",
"serial": "1234567890",
"mac": "001122334456",
"bl_ver": "3.0"
},
"static": {
"fw_version": "RUTX_R_00.07.17.3",
"kernel": "6.6.96",
"system": "ARMv7 Processor rev 5 (v7l)",
"device_name": "RUTX50 Test",
"hostname": "RUTX50",
"cpu_count": 4,
"release": {
"distribution": "OpenWrt",
"revision": "r16279-5cc0535800",
"version": "21.02.0",
"target": "ipq40xx/generic",
"description": "OpenWrt 21.02.0 r16279-5cc0535800"
},
"fw_build_date": "2025-09-04 14:49:05",
"model": "RUTX50",
"board_name": "teltonika,rutx"
},
"features": {
"ipv6": true
},
"board": {
"modems": [
{
"id": "2-1",
"num": "1",
"builtin": true,
"sim_count": 2,
"gps_out": true,
"primary": true,
"revision": "RG501QEUAAR12A11M4G_04.202.04.202",
"modem_func_id": 2,
"multi_apn": true,
"operator_scan": true,
"dhcp_filter": true,
"dynamic_mtu": true,
"ipv6": true,
"volte": true,
"csd": false,
"band_list": [
"WCDMA_850",
"WCDMA_900",
"WCDMA_2100",
"LTE_B1",
"LTE_B3",
"LTE_B5",
"LTE_B7",
"LTE_B8",
"LTE_B20",
"LTE_B28",
"LTE_B32",
"LTE_B38",
"LTE_B40",
"LTE_B41",
"LTE_B42",
"LTE_B43",
"NSA_5G_N1",
"NSA_5G_N3",
"NSA_5G_N5",
"NSA_5G_N7",
"NSA_5G_N8",
"NSA_5G_N20",
"NSA_5G_N28",
"NSA_5G_N38",
"NSA_5G_N40",
"NSA_5G_N41",
"NSA_5G_N77",
"NSA_5G_N78",
"5G_N1",
"5G_N3",
"5G_N5",
"5G_N7",
"5G_N8",
"5G_N20",
"5G_N28",
"5G_N38",
"5G_N40",
"5G_N41",
"5G_N77",
"5G_N78"
],
"product": "0800",
"vendor": "2c7c",
"gps": "1",
"stop_bits": "8",
"baudrate": "115200",
"type": "gobinet",
"desc": "Quectel RG50X",
"control": "2"
}
],
"network": {
"wan": {
"proto": "dhcp",
"device": "eth1",
"default_ip": null
},
"lan": {
"proto": "static",
"device": "eth0",
"default_ip": "192.168.1.1"
}
},
"model": {
"id": "teltonika,rutx",
"platform": "RUTX",
"name": "RUTX50"
},
"usb_jack": "/usb3/3-1/",
"network_options": {
"readonly_vlans": 2,
"max_mtu": 9000,
"vlans": 128
},
"switch": {
"switch0": {
"enable": true,
"roles": [
{
"ports": "1 2 3 4 0",
"role": "lan",
"device": "eth0"
},
{
"ports": "5 0",
"role": "wan",
"device": "eth1"
}
],
"ports": [
{
"device": "eth0",
"num": 0,
"want_untag": true,
"need_tag": false,
"role": null,
"index": null
},
{
"device": null,
"num": 1,
"want_untag": null,
"need_tag": null,
"role": "lan",
"index": null
},
{
"device": null,
"num": 2,
"want_untag": null,
"need_tag": null,
"role": "lan",
"index": null
},
{
"device": null,
"num": 3,
"want_untag": null,
"need_tag": null,
"role": "lan",
"index": null
},
{
"device": null,
"num": 4,
"want_untag": null,
"need_tag": null,
"role": "lan",
"index": null
},
{
"device": "eth1",
"num": 0,
"want_untag": true,
"need_tag": false,
"role": null,
"index": null
},
{
"device": null,
"num": 5,
"want_untag": null,
"need_tag": null,
"role": "wan",
"index": null
}
],
"reset": true
}
},
"hw_info": {
"wps": false,
"rs232": false,
"nat_offloading": true,
"dual_sim": true,
"bluetooth": false,
"soft_port_mirror": false,
"vcert": null,
"micro_usb": false,
"wifi": true,
"sd_card": false,
"multi_tag": true,
"dual_modem": false,
"sfp_switch": null,
"dsa": false,
"hw_nat": false,
"sw_rst_on_init": null,
"at_sim": true,
"port_link": true,
"ios": true,
"usb": true,
"console": false,
"dual_band_ssid": true,
"gps": true,
"ethernet": true,
"sfp_port": false,
"rs485": false,
"mobile": true,
"poe": false,
"gigabit_port": true,
"field_2_5_gigabit_port": false,
"esim": false,
"modem_reset": null
}
}
}

View File

@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_registry_creation
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': 'https://192.168.1.1',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'teltonika',
'1234567890',
),
}),
'labels': set({
}),
'manufacturer': 'Teltonika',
'model': 'RUTX50',
'model_id': None,
'name': 'RUTX50 Test',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234567890',
'sw_version': 'RUTX_R_00.07.17.3',
'via_device_id': None,
})
# ---

View File

@@ -0,0 +1,433 @@
# serializer version: 1
# name: test_sensors[sensor.rutx50_test_internal_modem_band-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_band',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem Band',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Internal modem Band',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'band',
'unique_id': '1234567890_2-1_band',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_band-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'RUTX50 Test Internal modem Band',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_band',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5G N3',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_connection_type-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_connection_type',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem Connection type',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Internal modem Connection type',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'connection_type',
'unique_id': '1234567890_2-1_connection_type',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_connection_type-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'RUTX50 Test Internal modem Connection type',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_connection_type',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5G (NSA)',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_operator-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_operator',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem Operator',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Internal modem Operator',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'operator',
'unique_id': '1234567890_2-1_operator',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_operator-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'RUTX50 Test Internal modem Operator',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_operator',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'test.operator',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rsrp-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_rsrp',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem RSRP',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Internal modem RSRP',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'rsrp',
'unique_id': '1234567890_2-1_rsrp',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rsrp-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'RUTX50 Test Internal modem RSRP',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_rsrp',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-93',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rsrq-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_rsrq',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem RSRQ',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Internal modem RSRQ',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'rsrq',
'unique_id': '1234567890_2-1_rsrq',
'unit_of_measurement': 'dB',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rsrq-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'RUTX50 Test Internal modem RSRQ',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dB',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_rsrq',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-10',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rssi-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_rssi',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem RSSI',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Internal modem RSSI',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'rssi',
'unique_id': '1234567890_2-1_rssi',
'unit_of_measurement': 'dBm',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_rssi-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'RUTX50 Test Internal modem RSSI',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_rssi',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-63',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_sinr-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_internal_modem_sinr',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Internal modem SINR',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'original_icon': None,
'original_name': 'Internal modem SINR',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sinr',
'unique_id': '1234567890_2-1_sinr',
'unit_of_measurement': 'dB',
})
# ---
# name: test_sensors[sensor.rutx50_test_internal_modem_sinr-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'signal_strength',
'friendly_name': 'RUTX50 Test Internal modem SINR',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dB',
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_internal_modem_sinr',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '15',
})
# ---
# name: test_sensors[sensor.rutx50_test_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.rutx50_test_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'teltonika',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1234567890_2-1_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.rutx50_test_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'RUTX50 Test Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.rutx50_test_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '42',
})
# ---

View File

@@ -0,0 +1,408 @@
"""Test the Teltonika config flow."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from teltasync import TeltonikaAuthenticationError, TeltonikaConnectionError
from homeassistant import config_entries
from homeassistant.components.teltonika.const import DOMAIN
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.service_info.dhcp import DhcpServiceInfo
from tests.common import MockConfigEntry
async def test_form_user_flow(
hass: HomeAssistant, mock_teltasync: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form and can create an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
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"] == "RUTX50 Test"
assert result["data"] == {
CONF_HOST: "https://192.168.1.1",
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
CONF_VERIFY_SSL: False,
}
assert result["result"].unique_id == "1234567890"
@pytest.mark.parametrize(
("exception", "error_key"),
[
(TeltonikaAuthenticationError("Invalid credentials"), "invalid_auth"),
(TeltonikaConnectionError("Connection failed"), "cannot_connect"),
(ValueError("Unexpected error"), "unknown"),
],
ids=["invalid_auth", "cannot_connect", "unexpected_exception"],
)
async def test_form_error_with_recovery(
hass: HomeAssistant,
mock_teltasync_client: MagicMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error_key: str,
) -> None:
"""Test we handle errors in config form and can recover."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
# First attempt with error
mock_teltasync_client.get_device_info.side_effect = exception
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.FORM
assert result["errors"] == {"base": error_key}
# Recover with working connection
device_info = MagicMock()
device_info.device_name = "RUTX50 Test"
device_info.device_identifier = "1234567890"
mock_teltasync_client.get_device_info.side_effect = None
mock_teltasync_client.get_device_info.return_value = device_info
mock_teltasync_client.validate_credentials.return_value = True
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,
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "RUTX50 Test"
assert result["data"][CONF_HOST] == "https://192.168.1.1"
assert result["result"].unique_id == "1234567890"
async def test_form_duplicate_entry(
hass: HomeAssistant, mock_teltasync: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test duplicate config entry is handled."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.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.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("host_input", "expected_base_url", "expected_host"),
[
("192.168.1.1", "https://192.168.1.1/api", "https://192.168.1.1"),
("http://192.168.1.1", "http://192.168.1.1/api", "http://192.168.1.1"),
("https://192.168.1.1", "https://192.168.1.1/api", "https://192.168.1.1"),
("https://192.168.1.1/api", "https://192.168.1.1/api", "https://192.168.1.1"),
("device.local", "https://device.local/api", "https://device.local"),
],
)
async def test_host_url_construction(
hass: HomeAssistant,
mock_teltasync: MagicMock,
mock_teltasync_client: MagicMock,
mock_setup_entry: AsyncMock,
host_input: str,
expected_base_url: str,
expected_host: str,
) -> None:
"""Test that host URLs are constructed correctly."""
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: host_input,
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
CONF_VERIFY_SSL: False,
},
)
# Verify Teltasync was called with correct base URL
assert mock_teltasync_client.get_device_info.call_count == 1
call_args = mock_teltasync.call_args_list[0]
assert call_args.kwargs["base_url"] == expected_base_url
assert call_args.kwargs["verify_ssl"] is False
# Verify the result is a created entry with normalized host
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].data[CONF_HOST] == expected_host
async def test_form_user_flow_http_fallback(
hass: HomeAssistant, mock_teltasync_client: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test we fall back to HTTP when HTTPS fails."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
# First call (HTTPS) fails
https_client = MagicMock()
https_client.get_device_info.side_effect = TeltonikaConnectionError(
"HTTPS unavailable"
)
https_client.close = AsyncMock()
# Second call (HTTP) succeeds
device_info = MagicMock()
device_info.device_name = "RUTX50 Test"
device_info.device_identifier = "TESTFALLBACK"
http_client = MagicMock()
http_client.get_device_info = AsyncMock(return_value=device_info)
http_client.validate_credentials = AsyncMock(return_value=True)
http_client.close = AsyncMock()
mock_teltasync_client.get_device_info.side_effect = [
TeltonikaConnectionError("HTTPS unavailable"),
mock_teltasync_client.get_device_info.return_value,
]
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["data"][CONF_HOST] == "http://192.168.1.1"
assert mock_teltasync_client.get_device_info.call_count == 2
# HTTPS client should be closed before falling back
assert mock_teltasync_client.close.call_count == 2
async def test_dhcp_discovery(
hass: HomeAssistant, mock_teltasync_client: MagicMock, mock_setup_entry: AsyncMock
) -> None:
"""Test DHCP discovery flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip="192.168.1.50",
macaddress="209727112233",
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"]
# Configure device info for the actual setup
device_info = MagicMock()
device_info.device_name = "RUTX50 Discovered"
device_info.device_identifier = "DISCOVERED123"
mock_teltasync_client.get_device_info.return_value = device_info
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"] == "RUTX50 Discovered"
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 == "DISCOVERED123"
async def test_dhcp_discovery_already_configured(
hass: HomeAssistant, mock_teltasync: MagicMock, mock_config_entry: MockConfigEntry
) -> None:
"""Test DHCP discovery when device is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip="192.168.1.50", # Different IP
macaddress="209727112233",
hostname="teltonika",
),
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
# Verify IP was updated
assert mock_config_entry.data[CONF_HOST] == "192.168.1.50"
async def test_dhcp_discovery_cannot_connect(
hass: HomeAssistant, mock_teltasync_client: MagicMock
) -> None:
"""Test DHCP discovery when device is not reachable."""
# Simulate device not reachable via API
mock_teltasync_client.get_device_info.side_effect = TeltonikaConnectionError(
"Connection failed"
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip="192.168.1.50",
macaddress="209727112233",
hostname="teltonika",
),
)
# Should abort if device is not reachable
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
@pytest.mark.parametrize(
("exception", "error_key"),
[
(TeltonikaAuthenticationError("Invalid credentials"), "invalid_auth"),
(TeltonikaConnectionError("Connection failed"), "cannot_connect"),
(ValueError("Unexpected error"), "unknown"),
],
ids=["invalid_auth", "cannot_connect", "unexpected_exception"],
)
async def test_dhcp_confirm_error_with_recovery(
hass: HomeAssistant,
mock_teltasync_client: MagicMock,
mock_setup_entry: AsyncMock,
exception: Exception,
error_key: str,
) -> None:
"""Test DHCP confirmation handles errors and can recover."""
# Start the DHCP flow
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
data=DhcpServiceInfo(
ip="192.168.1.50",
macaddress="209727112233",
hostname="teltonika",
),
)
# First attempt with error
mock_teltasync_client.get_device_info.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error_key}
assert result["step_id"] == "dhcp_confirm"
# Recover with working connection
device_info = MagicMock()
device_info.device_name = "RUTX50 Discovered"
device_info.device_identifier = "DISCOVERED123"
mock_teltasync_client.get_device_info.side_effect = None
mock_teltasync_client.get_device_info.return_value = device_info
mock_teltasync_client.validate_credentials.return_value = True
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "admin",
CONF_PASSWORD: "password",
},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "RUTX50 Discovered"
assert result["data"][CONF_HOST] == "https://192.168.1.50"
assert result["result"].unique_id == "DISCOVERED123"
async def test_validate_credentials_false(
hass: HomeAssistant, mock_teltasync_client: MagicMock
) -> None:
"""Test config flow when validate_credentials returns False."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
device_info = MagicMock()
device_info.device_name = "Test Device"
device_info.device_identifier = "TEST123"
mock_teltasync_client.get_device_info.return_value = device_info
mock_teltasync_client.validate_credentials.return_value = False
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.FORM
assert result["errors"] == {"base": "invalid_auth"}

View File

@@ -0,0 +1,107 @@
"""Test the Teltonika integration."""
from unittest.mock import MagicMock
from aiohttp import ClientResponseError, ContentTypeError
import pytest
from syrupy.assertion import SnapshotAssertion
from teltasync import TeltonikaAuthenticationError, TeltonikaConnectionError
from homeassistant.components.teltonika.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
async def test_load_unload_config_entry(
hass: HomeAssistant,
init_integration: MockConfigEntry,
) -> None:
"""Test loading and unloading the integration."""
assert init_integration.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(init_integration.entry_id)
await hass.async_block_till_done()
assert init_integration.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(
TeltonikaConnectionError("Connection failed"),
ConfigEntryState.SETUP_RETRY,
),
(
ContentTypeError(
request_info=MagicMock(),
history=(),
status=403,
message="Attempt to decode JSON with unexpected mimetype: text/html",
headers={},
),
ConfigEntryState.SETUP_ERROR,
),
(
ClientResponseError(
request_info=MagicMock(),
history=(),
status=401,
message="Unauthorized",
headers={},
),
ConfigEntryState.SETUP_ERROR,
),
(
ClientResponseError(
request_info=MagicMock(),
history=(),
status=403,
message="Forbidden",
headers={},
),
ConfigEntryState.SETUP_ERROR,
),
(
TeltonikaAuthenticationError("Invalid credentials"),
ConfigEntryState.SETUP_ERROR,
),
],
ids=[
"connection_error",
"content_type_403",
"response_401",
"response_403",
"auth_error",
],
)
async def test_setup_errors(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_teltasync: MagicMock,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test various setup errors result in appropriate config entry states."""
mock_teltasync.return_value.get_device_info.side_effect = exception
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is expected_state
async def test_device_registry_creation(
hass: HomeAssistant,
init_integration: MockConfigEntry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test device registry creation."""
device = device_registry.async_get_device(identifiers={(DOMAIN, "1234567890")})
assert device is not None
assert device == snapshot

View File

@@ -0,0 +1,93 @@
"""Test Teltonika sensor platform."""
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from teltasync import TeltonikaConnectionError
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
init_integration: MockConfigEntry,
) -> None:
"""Test sensor entities match snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id)
async def test_sensor_modem_removed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
init_integration: MockConfigEntry,
mock_modems: MagicMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensor becomes unavailable when modem is removed."""
# Get initial sensor state
state = hass.states.get("sensor.rutx50_test_internal_modem_rssi")
assert state is not None
# Update coordinator with empty modem data
mock_response = MagicMock()
mock_response.data = [] # No modems
mock_modems.get_status.return_value = mock_response
freezer.tick(timedelta(seconds=31))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check that entity is marked as unavailable
state = hass.states.get("sensor.rutx50_test_internal_modem_rssi")
assert state is not None
# When modem is removed, entity should be marked as unavailable
# Verify through entity registry that entity exists but is unavailable
entity_entry = entity_registry.async_get("sensor.rutx50_test_internal_modem_rssi")
assert entity_entry is not None
# State should show unavailable when modem is removed
assert state.state == "unavailable"
async def test_sensor_update_failure_and_recovery(
hass: HomeAssistant,
mock_modems: AsyncMock,
init_integration: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensor becomes unavailable on update failure and recovers."""
# Get initial sensor state, here it should be available
state = hass.states.get("sensor.rutx50_test_internal_modem_rssi")
assert state is not None
assert state.state == "-63"
mock_modems.get_status.side_effect = TeltonikaConnectionError("Connection lost")
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Sensor should now be unavailable
state = hass.states.get("sensor.rutx50_test_internal_modem_rssi")
assert state is not None
assert state.state == "unavailable"
# Simulate recovery
mock_modems.get_status.side_effect = None
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Sensor should be available again with correct data
state = hass.states.get("sensor.rutx50_test_internal_modem_rssi")
assert state is not None
assert state.state == "-63"

View File

@@ -0,0 +1,38 @@
"""Test Teltonika utility helpers."""
from homeassistant.components.teltonika.util import get_url_variants, normalize_url
def test_normalize_url_adds_https_scheme() -> None:
"""Test normalize_url adds HTTPS scheme for bare hostnames."""
assert normalize_url("teltonika") == "https://teltonika"
def test_normalize_url_preserves_scheme() -> None:
"""Test normalize_url preserves explicitly provided scheme."""
assert normalize_url("http://teltonika") == "http://teltonika"
assert normalize_url("https://teltonika") == "https://teltonika"
def test_normalize_url_strips_path() -> None:
"""Test normalize_url removes any path component."""
assert normalize_url("https://teltonika/api") == "https://teltonika"
assert normalize_url("http://teltonika/other/path") == "http://teltonika"
def test_get_url_variants_with_https_scheme() -> None:
"""Test get_url_variants with explicit HTTPS scheme returns only HTTPS."""
assert get_url_variants("https://teltonika") == ["https://teltonika"]
def test_get_url_variants_with_http_scheme() -> None:
"""Test get_url_variants with explicit HTTP scheme returns only HTTP."""
assert get_url_variants("http://teltonika") == ["http://teltonika"]
def test_get_url_variants_without_scheme() -> None:
"""Test get_url_variants without scheme returns both HTTPS and HTTP."""
assert get_url_variants("teltonika") == [
"https://teltonika",
"http://teltonika",
]