Allow more device types for Vodafone Station (#153990)

This commit is contained in:
Simone Chemelli
2025-10-14 16:18:16 +02:00
committed by GitHub
parent 3e20c506f4
commit 080a7dcfa7
12 changed files with 271 additions and 89 deletions

View File

@@ -1,8 +1,12 @@
"""Vodafone Station integration."""
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from aiohttp import ClientSession, CookieJar
from aiovodafone.api import VodafoneStationCommonApi
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from .const import _LOGGER, CONF_DEVICE_DETAILS, DEVICE_TYPE, DEVICE_URL
from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
from .utils import async_client_session
@@ -14,9 +18,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) ->
session = await async_client_session(hass)
coordinator = VodafoneStationRouter(
hass,
entry.data[CONF_HOST],
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
entry,
session,
)
@@ -30,6 +31,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) ->
return True
async def async_migrate_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version == 1 and entry.minor_version == 1:
_LOGGER.debug(
"Migrating from version %s.%s", entry.version, entry.minor_version
)
jar = CookieJar(unsafe=True, quote_cookie=False)
session = ClientSession(cookie_jar=jar)
try:
device_type, url = await VodafoneStationCommonApi.get_device_type(
entry.data[CONF_HOST],
session,
)
finally:
await session.close()
# Save device details to config entry
new_data = entry.data.copy()
new_data.update(
{
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: device_type,
DEVICE_URL: str(url),
}
},
)
hass.config_entries.async_update_entry(
entry, data=new_data, version=1, minor_version=2
)
_LOGGER.info(
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):

View File

@@ -5,7 +5,8 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions
from aiovodafone import exceptions as aiovodafone_exceptions
from aiovodafone.api import VodafoneStationCommonApi, init_api_class
import voluptuous as vol
from homeassistant.components.device_tracker import (
@@ -20,7 +21,15 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN
from .const import (
_LOGGER,
CONF_DEVICE_DETAILS,
DEFAULT_HOST,
DEFAULT_USERNAME,
DEVICE_TYPE,
DEVICE_URL,
DOMAIN,
)
from .coordinator import VodafoneConfigEntry
from .utils import async_client_session
@@ -40,26 +49,37 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema:
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
session = await async_client_session(hass)
api = VodafoneStationSercommApi(
data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD], session
device_type, url = await VodafoneStationCommonApi.get_device_type(
data[CONF_HOST],
session,
)
api = init_api_class(url, device_type, data, session)
try:
await api.login()
finally:
await api.logout()
return {"title": data[CONF_HOST]}
return {
"title": data[CONF_HOST],
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: device_type,
DEVICE_URL: str(url),
},
}
class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Vodafone Station."""
VERSION = 1
MINOR_VERSION = 2
@staticmethod
@callback
@@ -97,7 +117,10 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_create_entry(
title=info["title"],
data=user_input | {CONF_DEVICE_DETAILS: info[CONF_DEVICE_DETAILS]},
)
return self.async_show_form(
step_id="user", data_schema=user_form_schema(user_input), errors=errors

View File

@@ -7,6 +7,10 @@ _LOGGER = logging.getLogger(__package__)
DOMAIN = "vodafone_station"
SCAN_INTERVAL = 30
CONF_DEVICE_DETAILS = "device_details"
DEVICE_URL = "device_url"
DEVICE_TYPE = "device_type"
DEFAULT_DEVICE_NAME = "Unknown device"
DEFAULT_HOST = "192.168.1.1"
DEFAULT_USERNAME = "vodafone"

View File

@@ -6,13 +6,16 @@ from json.decoder import JSONDecodeError
from typing import Any, cast
from aiohttp import ClientSession
from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions
from aiovodafone import exceptions
from aiovodafone.api import VodafoneStationDevice, init_api_class
from yarl import URL
from homeassistant.components.device_tracker import (
DEFAULT_CONSIDER_HOME,
DOMAIN as DEVICE_TRACKER_DOMAIN,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
@@ -20,7 +23,14 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN, SCAN_INTERVAL
from .const import (
_LOGGER,
CONF_DEVICE_DETAILS,
DEVICE_TYPE,
DEVICE_URL,
DOMAIN,
SCAN_INTERVAL,
)
from .helpers import cleanup_device_tracker
CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds()
@@ -53,16 +63,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
def __init__(
self,
hass: HomeAssistant,
host: str,
username: str,
password: str,
config_entry: VodafoneConfigEntry,
session: ClientSession,
) -> None:
"""Initialize the scanner."""
self._host = host
self.api = VodafoneStationSercommApi(host, username, password, session)
data = config_entry.data
self.api = init_api_class(
URL(data[CONF_DEVICE_DETAILS][DEVICE_URL]),
data[CONF_DEVICE_DETAILS][DEVICE_TYPE],
data,
session,
)
# Last resort as no MAC or S/N can be retrieved via API
self._id = config_entry.unique_id
@@ -70,7 +83,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
super().__init__(
hass=hass,
logger=_LOGGER,
name=f"{DOMAIN}-{host}-coordinator",
name=f"{DOMAIN}-{data[CONF_HOST]}-coordinator",
update_interval=timedelta(seconds=SCAN_INTERVAL),
config_entry=config_entry,
)
@@ -117,7 +130,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
async def _async_update_data(self) -> UpdateCoordinatorDataType:
"""Update router data."""
_LOGGER.debug("Polling Vodafone Station host: %s", self._host)
_LOGGER.debug("Polling Vodafone Station host: %s", self.api.base_url.host)
try:
await self.api.login()

View File

@@ -8,5 +8,5 @@
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
"quality_scale": "platinum",
"requirements": ["aiovodafone==1.2.1"]
"requirements": ["aiovodafone==2.0.1"]
}

2
requirements_all.txt generated
View File

@@ -429,7 +429,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==1.2.1
aiovodafone==2.0.1
# homeassistant.components.waqi
aiowaqi==3.1.0

View File

@@ -411,7 +411,7 @@ aiousbwatcher==1.1.1
aiovlc==0.5.1
# homeassistant.components.vodafone_station
aiovodafone==1.2.1
aiovodafone==2.0.1
# homeassistant.components.waqi
aiowaqi==3.1.0

View File

@@ -2,13 +2,28 @@
from datetime import UTC, datetime
from aiovodafone import VodafoneStationDevice
from aiovodafone.api import VodafoneStationCommonApi, VodafoneStationDevice
import pytest
from yarl import URL
from homeassistant.components.vodafone_station.const import DOMAIN
from homeassistant.components.vodafone_station.const import (
CONF_DEVICE_DETAILS,
DEVICE_TYPE,
DEVICE_URL,
DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from .const import DEVICE_1_HOST, DEVICE_1_MAC, DEVICE_2_MAC
from .const import (
DEVICE_1_HOST,
DEVICE_1_MAC,
DEVICE_2_MAC,
TEST_HOST,
TEST_PASSWORD,
TEST_TYPE,
TEST_URL,
TEST_USERNAME,
)
from tests.common import (
AsyncMock,
@@ -34,53 +49,71 @@ def mock_vodafone_station_router() -> Generator[AsyncMock]:
"""Mock a Vodafone Station router."""
with (
patch(
"homeassistant.components.vodafone_station.coordinator.VodafoneStationSercommApi",
"homeassistant.components.vodafone_station.coordinator.init_api_class",
autospec=True,
) as mock_router,
patch(
"homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi",
"homeassistant.components.vodafone_station.config_flow.init_api_class",
new=mock_router,
),
patch.object(
VodafoneStationCommonApi,
"get_device_type",
new=AsyncMock(return_value=(TEST_TYPE, URL(TEST_URL))),
),
):
router = mock_router.return_value
router.get_devices_data.return_value = {
DEVICE_1_MAC: VodafoneStationDevice(
connected=True,
connection_type="wifi",
ip_address="192.168.1.10",
name=DEVICE_1_HOST,
mac=DEVICE_1_MAC,
type="laptop",
wifi="2.4G",
),
DEVICE_2_MAC: VodafoneStationDevice(
connected=False,
connection_type="lan",
ip_address="192.168.1.11",
name="LanDevice1",
mac=DEVICE_2_MAC,
type="desktop",
wifi="",
),
}
router.get_sensor_data.return_value = load_json_object_fixture(
"get_sensor_data.json", DOMAIN
router.login = AsyncMock(return_value=True)
router.logout = AsyncMock(return_value=True)
router.get_devices_data = AsyncMock(
return_value={
DEVICE_1_MAC: VodafoneStationDevice(
connected=True,
connection_type="wifi",
ip_address="192.168.1.10",
name=DEVICE_1_HOST,
mac=DEVICE_1_MAC,
type="laptop",
wifi="2.4G",
),
DEVICE_2_MAC: VodafoneStationDevice(
connected=False,
connection_type="lan",
ip_address="192.168.1.11",
name="LanDevice1",
mac=DEVICE_2_MAC,
type="desktop",
wifi="",
),
}
)
router.get_sensor_data = AsyncMock(
return_value=load_json_object_fixture("get_sensor_data.json", DOMAIN)
)
router.convert_uptime.return_value = datetime(
2024, 11, 19, 20, 19, 0, tzinfo=UTC
)
router.base_url = "https://fake_host"
router.base_url = URL(TEST_URL)
router.restart_connection = AsyncMock(return_value=True)
router.restart_router = AsyncMock(return_value=True)
yield router
@pytest.fixture
def mock_config_entry() -> Generator[MockConfigEntry]:
def mock_config_entry() -> MockConfigEntry:
"""Mock a Vodafone Station config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: TEST_TYPE,
DEVICE_URL: TEST_URL,
},
},
version=1,
minor_version=2,
)

View File

@@ -4,3 +4,9 @@ DEVICE_1_HOST = "WifiDevice0"
DEVICE_1_MAC = "xx:xx:xx:xx:xx:xx"
DEVICE_2_HOST = "LanDevice1"
DEVICE_2_MAC = "yy:yy:yy:yy:yy:yy"
TEST_HOST = "fake_host"
TEST_PASSWORD = "fake_password"
TEST_TYPE = "Sercomm"
TEST_URL = f"https://{TEST_HOST}"
TEST_USERNAME = "fake_username"

View File

@@ -27,6 +27,10 @@
}),
'entry': dict({
'data': dict({
'device_details': dict({
'device_type': 'Sercomm',
'device_url': 'https://fake_host',
}),
'host': 'fake_host',
'password': '**REDACTED**',
'username': '**REDACTED**',
@@ -35,7 +39,7 @@
'discovery_keys': dict({
}),
'domain': 'vodafone_station',
'minor_version': 1,
'minor_version': 2,
'options': dict({
}),
'pref_disable_new_entities': False,

View File

@@ -11,12 +11,19 @@ from aiovodafone import (
import pytest
from homeassistant.components.device_tracker import CONF_CONSIDER_HOME
from homeassistant.components.vodafone_station.const import DOMAIN
from homeassistant.components.vodafone_station.const import (
CONF_DEVICE_DETAILS,
DEVICE_TYPE,
DEVICE_URL,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -35,16 +42,20 @@ async def test_user(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: TEST_TYPE,
DEVICE_URL: TEST_URL,
},
}
assert not result["result"].unique_id
@@ -81,9 +92,9 @@ async def test_exception_connection(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
@@ -96,18 +107,22 @@ async def test_exception_connection(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "fake_host"
assert result["title"] == TEST_HOST
assert result["data"] == {
"host": "fake_host",
"username": "fake_username",
"password": "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: TEST_TYPE,
DEVICE_URL: TEST_URL,
},
}
@@ -127,9 +142,9 @@ async def test_duplicate_entry(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "fake_host",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: "fake_password",
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.ABORT
@@ -199,13 +214,13 @@ async def test_reauth_not_successful(
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PASSWORD: "fake_password",
CONF_PASSWORD: TEST_PASSWORD,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data[CONF_PASSWORD] == "fake_password"
assert mock_config_entry.data[CONF_PASSWORD] == TEST_PASSWORD
async def test_options_flow(
@@ -244,7 +259,7 @@ async def test_reconfigure_successful(
assert result["step_id"] == "reconfigure"
# original entry
assert mock_config_entry.data["host"] == "fake_host"
assert mock_config_entry.data[CONF_HOST] == TEST_HOST
new_host = "192.168.100.60"
@@ -252,8 +267,8 @@ async def test_reconfigure_successful(
result["flow_id"],
user_input={
CONF_HOST: new_host,
CONF_PASSWORD: "fake_password",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: TEST_PASSWORD,
CONF_USERNAME: TEST_USERNAME,
},
)
@@ -261,7 +276,7 @@ async def test_reconfigure_successful(
assert reconfigure_result["reason"] == "reconfigure_successful"
# changed entry
assert mock_config_entry.data["host"] == new_host
assert mock_config_entry.data[CONF_HOST] == new_host
@pytest.mark.parametrize(
@@ -294,8 +309,8 @@ async def test_reconfigure_fails(
result["flow_id"],
user_input={
CONF_HOST: "192.168.100.60",
CONF_PASSWORD: "fake_password",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: TEST_PASSWORD,
CONF_USERNAME: TEST_USERNAME,
},
)
@@ -309,8 +324,8 @@ async def test_reconfigure_fails(
result["flow_id"],
user_input={
CONF_HOST: "192.168.100.61",
CONF_PASSWORD: "fake_password",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: TEST_PASSWORD,
CONF_USERNAME: TEST_USERNAME,
},
)
@@ -318,6 +333,10 @@ async def test_reconfigure_fails(
assert reconfigure_result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == {
CONF_HOST: "192.168.100.61",
CONF_PASSWORD: "fake_password",
CONF_USERNAME: "fake_username",
CONF_PASSWORD: TEST_PASSWORD,
CONF_USERNAME: TEST_USERNAME,
CONF_DEVICE_DETAILS: {
DEVICE_TYPE: TEST_TYPE,
DEVICE_URL: TEST_URL,
},
}

View File

@@ -3,12 +3,19 @@
from unittest.mock import AsyncMock
from homeassistant.components.device_tracker import CONF_CONSIDER_HOME
from homeassistant.components.vodafone_station.const import DOMAIN
from homeassistant.components.vodafone_station.const import (
CONF_DEVICE_DETAILS,
DEVICE_TYPE,
DEVICE_URL,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from . import setup_integration
from .const import TEST_HOST, TEST_PASSWORD, TEST_TYPE, TEST_URL, TEST_USERNAME
from tests.common import MockConfigEntry
@@ -51,3 +58,35 @@ async def test_unload_entry(
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert not hass.data.get(DOMAIN)
async def test_migrate_entry(
hass: HomeAssistant,
mock_vodafone_station_router: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful migration of entry data."""
config_entry = MockConfigEntry(
domain=DOMAIN,
title=TEST_HOST,
data={
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
},
unique_id="vodafone",
version=1,
minor_version=1,
)
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.minor_version == 2
assert config_entry.data[CONF_DEVICE_DETAILS] == {
DEVICE_TYPE: TEST_TYPE,
DEVICE_URL: TEST_URL,
}