mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
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:
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -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
|
||||
|
||||
70
homeassistant/components/teltonika/__init__.py
Normal file
70
homeassistant/components/teltonika/__init__.py
Normal 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
|
||||
231
homeassistant/components/teltonika/config_flow.py
Normal file
231
homeassistant/components/teltonika/config_flow.py
Normal 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", {}),
|
||||
)
|
||||
3
homeassistant/components/teltonika/const.py
Normal file
3
homeassistant/components/teltonika/const.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Constants for the Teltonika integration."""
|
||||
|
||||
DOMAIN = "teltonika"
|
||||
98
homeassistant/components/teltonika/coordinator.py
Normal file
98
homeassistant/components/teltonika/coordinator.py
Normal 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
|
||||
19
homeassistant/components/teltonika/manifest.json
Normal file
19
homeassistant/components/teltonika/manifest.json
Normal 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"]
|
||||
}
|
||||
68
homeassistant/components/teltonika/quality_scale.yaml
Normal file
68
homeassistant/components/teltonika/quality_scale.yaml
Normal 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
|
||||
187
homeassistant/components/teltonika/sensor.py
Normal file
187
homeassistant/components/teltonika/sensor.py
Normal 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])
|
||||
69
homeassistant/components/teltonika/strings.json
Normal file
69
homeassistant/components/teltonika/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
homeassistant/components/teltonika/util.py
Normal file
39
homeassistant/components/teltonika/util.py
Normal 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]
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -701,6 +701,7 @@ FLOWS = {
|
||||
"tedee",
|
||||
"telegram_bot",
|
||||
"tellduslive",
|
||||
"teltonika",
|
||||
"tesla_fleet",
|
||||
"tesla_wall_connector",
|
||||
"teslemetry",
|
||||
|
||||
8
homeassistant/generated/dhcp.py
generated
8
homeassistant/generated/dhcp.py
generated
@@ -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_*",
|
||||
|
||||
@@ -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
3
requirements_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
3
requirements_test_all.txt
generated
3
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
|
||||
1
tests/components/teltonika/__init__.py
Normal file
1
tests/components/teltonika/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for Teltonika."""
|
||||
111
tests/components/teltonika/conftest.py
Normal file
111
tests/components/teltonika/conftest.py
Normal 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
|
||||
55
tests/components/teltonika/fixtures/device_data.json
Normal file
55
tests/components/teltonika/fixtures/device_data.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
12
tests/components/teltonika/fixtures/device_info.json
Normal file
12
tests/components/teltonika/fixtures/device_info.json
Normal 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."
|
||||
}
|
||||
}
|
||||
236
tests/components/teltonika/fixtures/system_info.json
Normal file
236
tests/components/teltonika/fixtures/system_info.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
32
tests/components/teltonika/snapshots/test_init.ambr
Normal file
32
tests/components/teltonika/snapshots/test_init.ambr
Normal 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,
|
||||
})
|
||||
# ---
|
||||
433
tests/components/teltonika/snapshots/test_sensor.ambr
Normal file
433
tests/components/teltonika/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
408
tests/components/teltonika/test_config_flow.py
Normal file
408
tests/components/teltonika/test_config_flow.py
Normal 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"}
|
||||
107
tests/components/teltonika/test_init.py
Normal file
107
tests/components/teltonika/test_init.py
Normal 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
|
||||
93
tests/components/teltonika/test_sensor.py
Normal file
93
tests/components/teltonika/test_sensor.py
Normal 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"
|
||||
38
tests/components/teltonika/test_util.py
Normal file
38
tests/components/teltonika/test_util.py
Normal 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",
|
||||
]
|
||||
Reference in New Issue
Block a user