From d0eef57627dc7e5739f0e1c9bdcf26ba1982e68d Mon Sep 17 00:00:00 2001 From: Anatosun Date: Thu, 21 May 2026 17:08:34 +0200 Subject: [PATCH 1/9] Added authentication to Swisscom integration --- .../components/swisscom/device_tracker.py | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 902449c9f86474..540484c6713ac7 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -1,6 +1,7 @@ """Support for Swisscom routers (Internet-Box).""" from contextlib import suppress +import json import logging import requests @@ -11,7 +12,7 @@ PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -19,9 +20,14 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_IP = "192.168.1.1" +DEFAULT_USERNAME = "admin" PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string} + { + vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + } ) @@ -40,7 +46,10 @@ class SwisscomDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" self.host = config[CONF_HOST] + self.username = config[CONF_USERNAME] + self.password = config.get(CONF_PASSWORD) self.last_results = {} + self._context_id: str | None = None # Test the router is accessible. data = self.get_swisscom_data() @@ -76,10 +85,57 @@ def _update_info(self): self.last_results = active_clients return True + def _login(self) -> str | None: + """Authenticate against the router and return a context ID.""" + try: + response = requests.post( + f"http://{self.host}/ws", + headers={ + "Content-Type": "application/x-sah-ws-4-call+json", + "Authorization": "X-Sah-Login", + }, + data=json.dumps( + { + "service": "sah.Device.Information", + "method": "createContext", + "parameters": { + "applicationName": "webui", + "username": self.username, + "password": self.password, + }, + } + ), + timeout=10, + ) + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ConnectTimeout, + ): + _LOGGER.debug("Login request to Swisscom Internet Box failed") + return None + + if response.status_code != 200: + _LOGGER.warning( + "Authentication to Swisscom Internet Box failed (status %s)", + response.status_code, + ) + return None + + with suppress(ValueError, KeyError): + return response.json()["data"]["contextID"] + return None + def get_swisscom_data(self): """Retrieve data from Swisscom and return parsed result.""" + if self.password and self._context_id is None: + self._context_id = self._login() + url = f"http://{self.host}/ws" headers = {"Content-Type": "application/x-sah-ws-4-call+json"} + if self._context_id is not None: + headers["Authorization"] = f"X-Sah {self._context_id}" + headers["X-Context"] = self._context_id data = """ {"service":"Devices", "method":"get", "parameters":{"expression":"lan and not self"}}""" @@ -96,6 +152,23 @@ def get_swisscom_data(self): _LOGGER.debug("No response from Swisscom Internet Box") return devices + # Context may have expired; refresh it once and retry. + if request.status_code == 401 and self.password: + self._context_id = self._login() + if self._context_id is None: + return devices + headers["Authorization"] = f"X-Sah {self._context_id}" + headers["X-Context"] = self._context_id + try: + request = requests.post(url, headers=headers, data=data, timeout=10) + except ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ConnectTimeout, + ): + _LOGGER.debug("No response from Swisscom Internet Box") + return devices + if "status" not in request.json(): _LOGGER.debug("No status in response from Swisscom Internet Box") return devices From 61d01dd358b4f703746ceb4e40553d3d5798e504 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Fri, 22 May 2026 08:06:53 +0200 Subject: [PATCH 2/9] Upgraded Swisscom integration --- homeassistant/components/swisscom/__init__.py | 24 +- homeassistant/components/swisscom/api.py | 112 +++++++ .../components/swisscom/config_flow.py | 83 +++++ homeassistant/components/swisscom/const.py | 6 + .../components/swisscom/coordinator.py | 65 ++++ .../components/swisscom/device_tracker.py | 287 ++++++++---------- .../components/swisscom/manifest.json | 5 +- .../components/swisscom/strings.json | 26 ++ 8 files changed, 441 insertions(+), 167 deletions(-) create mode 100644 homeassistant/components/swisscom/api.py create mode 100644 homeassistant/components/swisscom/config_flow.py create mode 100644 homeassistant/components/swisscom/const.py create mode 100644 homeassistant/components/swisscom/coordinator.py create mode 100644 homeassistant/components/swisscom/strings.json diff --git a/homeassistant/components/swisscom/__init__.py b/homeassistant/components/swisscom/__init__.py index 5e0c11af090c3e..bb277c1e7c2b42 100644 --- a/homeassistant/components/swisscom/__init__.py +++ b/homeassistant/components/swisscom/__init__.py @@ -1 +1,23 @@ -"""The swisscom component.""" +"""The Swisscom Internet-Box integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool: + """Set up Swisscom Internet-Box from a config entry.""" + coordinator = SwisscomDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: SwisscomConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/swisscom/api.py b/homeassistant/components/swisscom/api.py new file mode 100644 index 00000000000000..cbf23eb4cde59c --- /dev/null +++ b/homeassistant/components/swisscom/api.py @@ -0,0 +1,112 @@ +"""Lightweight client for the Swisscom Internet-Box web-services endpoint.""" + +from typing import Any + +import aiohttp + +CONTENT_TYPE = "application/x-sah-ws-4-call+json" +REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=10) + + +class SwisscomError(Exception): + """Base error for the Swisscom Internet-Box client.""" + + +class SwisscomAuthError(SwisscomError): + """Authentication with the Internet-Box failed.""" + + +class SwisscomConnectionError(SwisscomError): + """Communication with the Internet-Box failed.""" + + +class SwisscomClient: + """Authenticated client for the Internet-Box web-services endpoint.""" + + def __init__( + self, + session: aiohttp.ClientSession, + host: str, + username: str, + password: str, + ) -> None: + """Initialize the client.""" + self._session = session + self._url = f"http://{host}/ws" + self._username = username + self._password = password + self._context_id: str | None = None + + async def _post( + self, payload: dict[str, Any], headers: dict[str, str] + ) -> dict[str, Any]: + """POST a request and return the parsed JSON body.""" + try: + async with self._session.post( + self._url, + json=payload, + headers={"Content-Type": CONTENT_TYPE, **headers}, + timeout=REQUEST_TIMEOUT, + ) as response: + if response.status == 401: + raise SwisscomAuthError("Unauthorized") + response.raise_for_status() + return await response.json(content_type=None) + except (TimeoutError, aiohttp.ClientError) as err: + raise SwisscomConnectionError(str(err)) from err + + async def login(self) -> None: + """Authenticate and store a context ID.""" + data = await self._post( + { + "service": "sah.Device.Information", + "method": "createContext", + "parameters": { + "applicationName": "webui", + "username": self._username, + "password": self._password, + }, + }, + {"Authorization": "X-Sah-Login"}, + ) + try: + self._context_id = data["data"]["contextID"] + except (KeyError, TypeError) as err: + raise SwisscomAuthError("Unexpected authentication response") from err + + async def get_device_info(self) -> dict[str, Any]: + """Return the box's `DeviceInfo` (unauthenticated).""" + data = await self._post( + {"service": "DeviceInfo", "method": "get", "parameters": {}}, {} + ) + return data.get("status", {}) + + async def get_devices(self) -> list[dict[str, Any]]: + """Return the list of LAN devices known to the box. + + Re-authenticates once on context expiry. + """ + if self._context_id is None: + await self.login() + + for attempt in range(2): + assert self._context_id is not None + try: + data = await self._post( + { + "service": "Devices", + "method": "get", + "parameters": {"expression": "lan and not self"}, + }, + { + "Authorization": f"X-Sah {self._context_id}", + "X-Context": self._context_id, + }, + ) + except SwisscomAuthError: + if attempt == 0: + await self.login() + continue + raise + return data.get("status", []) + return [] diff --git a/homeassistant/components/swisscom/config_flow.py b/homeassistant/components/swisscom/config_flow.py new file mode 100644 index 00000000000000..8699e71ff4360b --- /dev/null +++ b/homeassistant/components/swisscom/config_flow.py @@ -0,0 +1,83 @@ +"""Config flow for the Swisscom Internet-Box integration.""" + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac + +from .api import SwisscomAuthError, SwisscomClient, SwisscomConnectionError +from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST, default=DEFAULT_HOST): str, + vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class SwisscomConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Swisscom Internet-Box.""" + + VERSION = 1 + + async def _validate(self, data: dict[str, Any]) -> tuple[str | None, str]: + """Validate credentials and return (unique_id, title).""" + client = SwisscomClient( + async_get_clientsession(self.hass), + data[CONF_HOST], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + await client.login() + info = await client.get_device_info() + unique_id = format_mac(info["BaseMAC"]) if info.get("BaseMAC") else None + title = info.get("ModelName") or "Internet-Box" + return unique_id, title + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + unique_id, title = await self._validate(user_input) + except SwisscomAuthError: + errors["base"] = "invalid_auth" + except SwisscomConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception during Swisscom config flow") + errors["base"] = "unknown" + else: + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import a configuration from configuration.yaml.""" + try: + unique_id, title = await self._validate(import_data) + except SwisscomAuthError: + return self.async_abort(reason="invalid_auth") + except SwisscomConnectionError: + return self.async_abort(reason="cannot_connect") + + if unique_id: + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=import_data) diff --git a/homeassistant/components/swisscom/const.py b/homeassistant/components/swisscom/const.py new file mode 100644 index 00000000000000..9bc400a268e812 --- /dev/null +++ b/homeassistant/components/swisscom/const.py @@ -0,0 +1,6 @@ +"""Constants for the Swisscom Internet-Box integration.""" + +DOMAIN = "swisscom" + +DEFAULT_HOST = "192.168.1.1" +DEFAULT_USERNAME = "admin" diff --git a/homeassistant/components/swisscom/coordinator.py b/homeassistant/components/swisscom/coordinator.py new file mode 100644 index 00000000000000..b8845c9fca6e73 --- /dev/null +++ b/homeassistant/components/swisscom/coordinator.py @@ -0,0 +1,65 @@ +"""DataUpdateCoordinator for the Swisscom Internet-Box.""" + +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import SwisscomAuthError, SwisscomClient, SwisscomConnectionError +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=30) + +type SwisscomConfigEntry = ConfigEntry[SwisscomDataUpdateCoordinator] + + +class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): + """Poll the Internet-Box for the list of LAN devices.""" + + config_entry: SwisscomConfigEntry + + def __init__(self, hass: HomeAssistant, entry: SwisscomConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + config_entry=entry, + ) + self.client = SwisscomClient( + async_get_clientsession(hass), + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + async def _async_update_data(self) -> dict[str, dict[str, Any]]: + """Fetch device data from the box.""" + try: + raw = await self.client.get_devices() + except SwisscomAuthError as err: + raise ConfigEntryAuthFailed(str(err)) from err + except SwisscomConnectionError as err: + raise UpdateFailed(str(err)) from err + + devices: dict[str, dict[str, Any]] = {} + for device in raw: + try: + devices[device["Key"]] = { + "ip": device["IPAddress"], + "mac": device["PhysAddress"], + "host": device["Name"], + "active": device["Active"], + } + except KeyError: + continue + return devices diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 540484c6713ac7..9ec935e30b2140 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -1,184 +1,143 @@ -"""Support for Swisscom routers (Internet-Box).""" +"""Device tracker for the Swisscom Internet-Box.""" -from contextlib import suppress -import json import logging -import requests import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, PLATFORM_SCHEMA as DEVICE_TRACKER_PLATFORM_SCHEMA, - DeviceScanner, + AsyncSeeCallback, + ScannerEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_validation as cv, issue_registry as ir +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator -DEFAULT_IP = "192.168.1.1" -DEFAULT_USERNAME = "admin" +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( { - vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, + vol.Required(CONF_PASSWORD): cv.string, } ) -def get_scanner( - hass: HomeAssistant, config: ConfigType -) -> SwisscomDeviceScanner | None: - """Return the Swisscom device scanner.""" - scanner = SwisscomDeviceScanner(config[DEVICE_TRACKER_DOMAIN]) - - return scanner if scanner.success_init else None - - -class SwisscomDeviceScanner(DeviceScanner): - """Class which queries a router running Swisscom Internet-Box firmware.""" - - def __init__(self, config): - """Initialize the scanner.""" - self.host = config[CONF_HOST] - self.username = config[CONF_USERNAME] - self.password = config.get(CONF_PASSWORD) - self.last_results = {} - self._context_id: str | None = None - - # Test the router is accessible. - data = self.get_swisscom_data() - self.success_init = data is not None - - def scan_devices(self): - """Scan for new devices and return a list with found device IDs.""" - self._update_info() - return [client["mac"] for client in self.last_results] - - def get_device_name(self, device): - """Return the name of the given device or None if we don't know.""" - if not self.last_results: - return None - for client in self.last_results: - if client["mac"] == device: - return client["host"] - return None - - def _update_info(self): - """Ensure the information from the Swisscom router is up to date. - - Return boolean if scanning successful. - """ - if not self.success_init: - return False - - _LOGGER.debug("Loading data from Swisscom Internet Box") - if not (data := self.get_swisscom_data()): - return False - - active_clients = [client for client in data.values() if client["status"]] - self.last_results = active_clients - return True - - def _login(self) -> str | None: - """Authenticate against the router and return a context ID.""" - try: - response = requests.post( - f"http://{self.host}/ws", - headers={ - "Content-Type": "application/x-sah-ws-4-call+json", - "Authorization": "X-Sah-Login", - }, - data=json.dumps( - { - "service": "sah.Device.Information", - "method": "createContext", - "parameters": { - "applicationName": "webui", - "username": self.username, - "password": self.password, - }, - } - ), - timeout=10, +async def async_setup_scanner( + hass: HomeAssistant, + config: ConfigType, + async_see: AsyncSeeCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> bool: + """Import YAML configuration into a config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_HOST: config[CONF_HOST], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + }, + ) + if ( + result.get("type") is FlowResultType.ABORT + and result.get("reason") != "already_configured" + ): + _LOGGER.warning( + "Could not import Swisscom Internet-Box YAML configuration: %s", + result.get("reason"), + ) + return False + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2027.5.0", + is_fixable=False, + is_persistent=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Swisscom Internet-Box", + }, + ) + return True + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwisscomConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up device tracker entities for the Swisscom Internet-Box.""" + coordinator = entry.runtime_data + tracked: set[str] = set() + + @callback + def _add_new_entities() -> None: + new_keys = [key for key in coordinator.data if key not in tracked] + if new_keys: + tracked.update(new_keys) + async_add_entities( + SwisscomScannerEntity(coordinator, key) for key in new_keys ) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, - requests.exceptions.ConnectTimeout, - ): - _LOGGER.debug("Login request to Swisscom Internet Box failed") - return None - - if response.status_code != 200: - _LOGGER.warning( - "Authentication to Swisscom Internet Box failed (status %s)", - response.status_code, - ) - return None - - with suppress(ValueError, KeyError): - return response.json()["data"]["contextID"] - return None - - def get_swisscom_data(self): - """Retrieve data from Swisscom and return parsed result.""" - if self.password and self._context_id is None: - self._context_id = self._login() - - url = f"http://{self.host}/ws" - headers = {"Content-Type": "application/x-sah-ws-4-call+json"} - if self._context_id is not None: - headers["Authorization"] = f"X-Sah {self._context_id}" - headers["X-Context"] = self._context_id - data = """ - {"service":"Devices", "method":"get", - "parameters":{"expression":"lan and not self"}}""" - - devices = {} - - try: - request = requests.post(url, headers=headers, data=data, timeout=10) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, - requests.exceptions.ConnectTimeout, - ): - _LOGGER.debug("No response from Swisscom Internet Box") - return devices - - # Context may have expired; refresh it once and retry. - if request.status_code == 401 and self.password: - self._context_id = self._login() - if self._context_id is None: - return devices - headers["Authorization"] = f"X-Sah {self._context_id}" - headers["X-Context"] = self._context_id - try: - request = requests.post(url, headers=headers, data=data, timeout=10) - except ( - requests.exceptions.ConnectionError, - requests.exceptions.Timeout, - requests.exceptions.ConnectTimeout, - ): - _LOGGER.debug("No response from Swisscom Internet Box") - return devices - - if "status" not in request.json(): - _LOGGER.debug("No status in response from Swisscom Internet Box") - return devices - - for device in request.json()["status"]: - with suppress(KeyError, requests.exceptions.RequestException): - devices[device["Key"]] = { - "ip": device["IPAddress"], - "mac": device["PhysAddress"], - "host": device["Name"], - "status": device["Active"], - } - return devices + + _add_new_entities() + entry.async_on_unload(coordinator.async_add_listener(_add_new_entities)) + + +class SwisscomScannerEntity( + CoordinatorEntity[SwisscomDataUpdateCoordinator], ScannerEntity +): + """A device tracked by the Swisscom Internet-Box.""" + + def __init__(self, coordinator: SwisscomDataUpdateCoordinator, key: str) -> None: + """Initialize the scanner entity.""" + super().__init__(coordinator) + self._key = key + self._attr_unique_id = key + + @property + def _device(self) -> dict | None: + return self.coordinator.data.get(self._key) + + @property + def is_connected(self) -> bool: + """Return whether the device is currently connected to the LAN.""" + device = self._device + return bool(device and device["active"]) + + @property + def mac_address(self) -> str: + """Return the MAC address of the device.""" + device = self._device + return device["mac"] if device else self._key + + @property + def hostname(self) -> str | None: + """Return the hostname of the device.""" + device = self._device + return device["host"] if device else None + + @property + def ip_address(self) -> str | None: + """Return the IP address of the device.""" + device = self._device + return device["ip"] if device else None + + @property + def name(self) -> str | None: + """Return the friendly name of the device.""" + return self.hostname diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index cf1ea01ea9c04a..5119b5bd308c98 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -2,7 +2,8 @@ "domain": "swisscom", "name": "Swisscom Internet-Box", "codeowners": [], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swisscom", - "iot_class": "local_polling", - "quality_scale": "legacy" + "integration_type": "hub", + "iot_class": "local_polling" } diff --git a/homeassistant/components/swisscom/strings.json b/homeassistant/components/swisscom/strings.json new file mode 100644 index 00000000000000..68c39bce713fc2 --- /dev/null +++ b/homeassistant/components/swisscom/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "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": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of your Swisscom Internet-Box.", + "password": "The administrator password printed on the bottom of the box.", + "username": "The administrator username, normally \"admin\"." + } + } + } + } +} From e63a3ffcf21cfaf6ba9c1550075f46150bd8bb47 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Fri, 22 May 2026 10:27:00 +0200 Subject: [PATCH 3/9] Added conflig flow and integrations.json --- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0bc899ef9877a1..0be8dbe4b36a16 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -722,6 +722,7 @@ "sunweg", "surepetcare", "swiss_public_transport", + "swisscom", "switchbee", "switchbot", "switchbot_cloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e5368a6c9bcb20..2a7a51fc9c1975 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6879,7 +6879,7 @@ "swisscom": { "name": "Swisscom Internet-Box", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "switchbee": { From 917b40f2b11340f3a3ef6dd23d0f15dc4c9ffe15 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Wed, 27 May 2026 14:54:02 +0200 Subject: [PATCH 4/9] flushed API wrapping to dedicated PyPI package --- homeassistant/components/swisscom/api.py | 112 ------------------ .../components/swisscom/config_flow.py | 12 +- .../components/swisscom/coordinator.py | 28 ++--- .../components/swisscom/device_tracker.py | 10 +- .../components/swisscom/manifest.json | 3 +- requirements_all.txt | 3 + 6 files changed, 29 insertions(+), 139 deletions(-) delete mode 100644 homeassistant/components/swisscom/api.py diff --git a/homeassistant/components/swisscom/api.py b/homeassistant/components/swisscom/api.py deleted file mode 100644 index cbf23eb4cde59c..00000000000000 --- a/homeassistant/components/swisscom/api.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Lightweight client for the Swisscom Internet-Box web-services endpoint.""" - -from typing import Any - -import aiohttp - -CONTENT_TYPE = "application/x-sah-ws-4-call+json" -REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=10) - - -class SwisscomError(Exception): - """Base error for the Swisscom Internet-Box client.""" - - -class SwisscomAuthError(SwisscomError): - """Authentication with the Internet-Box failed.""" - - -class SwisscomConnectionError(SwisscomError): - """Communication with the Internet-Box failed.""" - - -class SwisscomClient: - """Authenticated client for the Internet-Box web-services endpoint.""" - - def __init__( - self, - session: aiohttp.ClientSession, - host: str, - username: str, - password: str, - ) -> None: - """Initialize the client.""" - self._session = session - self._url = f"http://{host}/ws" - self._username = username - self._password = password - self._context_id: str | None = None - - async def _post( - self, payload: dict[str, Any], headers: dict[str, str] - ) -> dict[str, Any]: - """POST a request and return the parsed JSON body.""" - try: - async with self._session.post( - self._url, - json=payload, - headers={"Content-Type": CONTENT_TYPE, **headers}, - timeout=REQUEST_TIMEOUT, - ) as response: - if response.status == 401: - raise SwisscomAuthError("Unauthorized") - response.raise_for_status() - return await response.json(content_type=None) - except (TimeoutError, aiohttp.ClientError) as err: - raise SwisscomConnectionError(str(err)) from err - - async def login(self) -> None: - """Authenticate and store a context ID.""" - data = await self._post( - { - "service": "sah.Device.Information", - "method": "createContext", - "parameters": { - "applicationName": "webui", - "username": self._username, - "password": self._password, - }, - }, - {"Authorization": "X-Sah-Login"}, - ) - try: - self._context_id = data["data"]["contextID"] - except (KeyError, TypeError) as err: - raise SwisscomAuthError("Unexpected authentication response") from err - - async def get_device_info(self) -> dict[str, Any]: - """Return the box's `DeviceInfo` (unauthenticated).""" - data = await self._post( - {"service": "DeviceInfo", "method": "get", "parameters": {}}, {} - ) - return data.get("status", {}) - - async def get_devices(self) -> list[dict[str, Any]]: - """Return the list of LAN devices known to the box. - - Re-authenticates once on context expiry. - """ - if self._context_id is None: - await self.login() - - for attempt in range(2): - assert self._context_id is not None - try: - data = await self._post( - { - "service": "Devices", - "method": "get", - "parameters": {"expression": "lan and not self"}, - }, - { - "Authorization": f"X-Sah {self._context_id}", - "X-Context": self._context_id, - }, - ) - except SwisscomAuthError: - if attempt == 0: - await self.login() - continue - raise - return data.get("status", []) - return [] diff --git a/homeassistant/components/swisscom/config_flow.py b/homeassistant/components/swisscom/config_flow.py index 8699e71ff4360b..29ff2368b56abf 100644 --- a/homeassistant/components/swisscom/config_flow.py +++ b/homeassistant/components/swisscom/config_flow.py @@ -3,6 +3,11 @@ import logging from typing import Any +from swisscom_internet_box import ( + SwisscomAuthError, + SwisscomClient, + SwisscomConnectionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -10,7 +15,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac -from .api import SwisscomAuthError, SwisscomClient, SwisscomConnectionError from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -38,9 +42,9 @@ async def _validate(self, data: dict[str, Any]) -> tuple[str | None, str]: data[CONF_PASSWORD], ) await client.login() - info = await client.get_device_info() - unique_id = format_mac(info["BaseMAC"]) if info.get("BaseMAC") else None - title = info.get("ModelName") or "Internet-Box" + info = await client.get_box_info() + unique_id = format_mac(info.base_mac) if info.base_mac else None + title = info.model_name or "Internet-Box" return unique_id, title async def async_step_user( diff --git a/homeassistant/components/swisscom/coordinator.py b/homeassistant/components/swisscom/coordinator.py index b8845c9fca6e73..c59f24dc05e4f3 100644 --- a/homeassistant/components/swisscom/coordinator.py +++ b/homeassistant/components/swisscom/coordinator.py @@ -2,7 +2,13 @@ from datetime import timedelta import logging -from typing import Any + +from swisscom_internet_box import ( + Device, + SwisscomAuthError, + SwisscomClient, + SwisscomConnectionError, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -11,7 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .api import SwisscomAuthError, SwisscomClient, SwisscomConnectionError from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -21,7 +26,7 @@ type SwisscomConfigEntry = ConfigEntry[SwisscomDataUpdateCoordinator] -class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]): +class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): """Poll the Internet-Box for the list of LAN devices.""" config_entry: SwisscomConfigEntry @@ -42,24 +47,13 @@ def __init__(self, hass: HomeAssistant, entry: SwisscomConfigEntry) -> None: entry.data[CONF_PASSWORD], ) - async def _async_update_data(self) -> dict[str, dict[str, Any]]: + async def _async_update_data(self) -> dict[str, Device]: """Fetch device data from the box.""" try: - raw = await self.client.get_devices() + devices = await self.client.get_devices() except SwisscomAuthError as err: raise ConfigEntryAuthFailed(str(err)) from err except SwisscomConnectionError as err: raise UpdateFailed(str(err)) from err - devices: dict[str, dict[str, Any]] = {} - for device in raw: - try: - devices[device["Key"]] = { - "ip": device["IPAddress"], - "mac": device["PhysAddress"], - "host": device["Name"], - "active": device["Active"], - } - except KeyError: - continue - return devices + return {device.key: device for device in devices if device.key} diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 9ec935e30b2140..613f5a7df394cb 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -110,32 +110,32 @@ def __init__(self, coordinator: SwisscomDataUpdateCoordinator, key: str) -> None self._attr_unique_id = key @property - def _device(self) -> dict | None: + def _device(self): return self.coordinator.data.get(self._key) @property def is_connected(self) -> bool: """Return whether the device is currently connected to the LAN.""" device = self._device - return bool(device and device["active"]) + return bool(device and device.active) @property def mac_address(self) -> str: """Return the MAC address of the device.""" device = self._device - return device["mac"] if device else self._key + return device.phys_address if device else self._key @property def hostname(self) -> str | None: """Return the hostname of the device.""" device = self._device - return device["host"] if device else None + return device.name if device else None @property def ip_address(self) -> str | None: """Return the IP address of the device.""" device = self._device - return device["ip"] if device else None + return device.ip_address if device else None @property def name(self) -> str | None: diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json index 5119b5bd308c98..8b259e82d90daf 100644 --- a/homeassistant/components/swisscom/manifest.json +++ b/homeassistant/components/swisscom/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/swisscom", "integration_type": "hub", - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["python-swisscom-internet-box==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4e0a976a399751..b9ec77aa2a164f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2726,6 +2726,9 @@ python-snoo==0.8.3 # homeassistant.components.songpal python-songpal==0.16.2 +# homeassistant.components.swisscom +python-swisscom-internet-box==0.1.1 + # homeassistant.components.tado python-tado==0.18.16 From a56c24f30625acdd1d94b6d3eb8fb175fa1ca8f9 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Thu, 28 May 2026 14:44:14 +0200 Subject: [PATCH 5/9] added tests --- tests/components/swisscom/__init__.py | 1 + tests/components/swisscom/conftest.py | 49 +++++++ tests/components/swisscom/const.py | 16 +++ tests/components/swisscom/test_config_flow.py | 132 ++++++++++++++++++ 4 files changed, 198 insertions(+) create mode 100644 tests/components/swisscom/__init__.py create mode 100644 tests/components/swisscom/conftest.py create mode 100644 tests/components/swisscom/const.py create mode 100644 tests/components/swisscom/test_config_flow.py diff --git a/tests/components/swisscom/__init__.py b/tests/components/swisscom/__init__.py new file mode 100644 index 00000000000000..1d6f0cba9aa3f2 --- /dev/null +++ b/tests/components/swisscom/__init__.py @@ -0,0 +1 @@ +"""Tests for the Swisscom Internet-Box integration.""" diff --git a/tests/components/swisscom/conftest.py b/tests/components/swisscom/conftest.py new file mode 100644 index 00000000000000..c11d731f32eb0e --- /dev/null +++ b/tests/components/swisscom/conftest.py @@ -0,0 +1,49 @@ +"""Fixtures for the Swisscom Internet-Box integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.swisscom.const import DOMAIN + +from .const import TEST_BASE_MAC, TEST_FORMATTED_MAC, TEST_MODEL_NAME, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title=TEST_MODEL_NAME, + domain=DOMAIN, + data=USER_INPUT, + unique_id=TEST_FORMATTED_MAC, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.swisscom.async_setup_entry", return_value=True + ) as mock_fn: + yield mock_fn + + +@pytest.fixture +def mock_swisscom_client() -> Generator[MagicMock]: + """Mock the SwisscomClient used in the config flow.""" + box_info = MagicMock() + box_info.base_mac = TEST_BASE_MAC + box_info.model_name = TEST_MODEL_NAME + + with patch( + "homeassistant.components.swisscom.config_flow.SwisscomClient", + autospec=True, + ) as mock_cls: + client = mock_cls.return_value + client.login = AsyncMock() + client.get_box_info = AsyncMock(return_value=box_info) + yield client diff --git a/tests/components/swisscom/const.py b/tests/components/swisscom/const.py new file mode 100644 index 00000000000000..2f23967f7f05da --- /dev/null +++ b/tests/components/swisscom/const.py @@ -0,0 +1,16 @@ +"""Constants for the Swisscom Internet-Box integration tests.""" + +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +TEST_HOST = "192.168.1.1" +TEST_USERNAME = "admin" +TEST_PASSWORD = "test-password" +TEST_BASE_MAC = "AA:BB:CC:DD:EE:FF" +TEST_FORMATTED_MAC = "aa:bb:cc:dd:ee:ff" +TEST_MODEL_NAME = "Internet-Box plus" + +USER_INPUT = { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/swisscom/test_config_flow.py b/tests/components/swisscom/test_config_flow.py new file mode 100644 index 00000000000000..b450f232d68ea5 --- /dev/null +++ b/tests/components/swisscom/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for the Swisscom Internet-Box config flow.""" + +from unittest.mock import MagicMock + +import pytest +from swisscom_internet_box import SwisscomAuthError, SwisscomConnectionError + +from homeassistant.components.swisscom.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import TEST_FORMATTED_MAC, TEST_MODEL_NAME, USER_INPUT + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_success( + hass: HomeAssistant, mock_swisscom_client: MagicMock +) -> None: + """Test a successful user-initiated config flow.""" + 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"], user_input=USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_MODEL_NAME + assert result["data"] == USER_INPUT + assert result["result"].unique_id == TEST_FORMATTED_MAC + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (SwisscomAuthError("bad creds"), "invalid_auth"), + (SwisscomConnectionError("unreachable"), "cannot_connect"), + (RuntimeError("boom"), "unknown"), + ], + ids=["invalid_auth", "cannot_connect", "unknown"], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_error_and_recovery( + hass: HomeAssistant, + mock_swisscom_client: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test user flow shows the correct error and the user can retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_swisscom_client.login.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + mock_swisscom_client.login.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_MODEL_NAME + assert result["data"] == USER_INPUT + assert result["result"].unique_id == TEST_FORMATTED_MAC + + +async def test_user_flow_duplicate( + hass: HomeAssistant, + mock_swisscom_client: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test that duplicate boxes are rejected.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_no_base_mac_creates_entry_without_unique_id( + hass: HomeAssistant, mock_swisscom_client: MagicMock +) -> None: + """Test the entry is created without a unique ID when the box reports no MAC.""" + mock_swisscom_client.get_box_info.return_value.base_mac = "" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_MODEL_NAME + assert result["data"] == USER_INPUT + assert result["result"].unique_id is None + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_user_flow_no_model_name_uses_default_title( + hass: HomeAssistant, mock_swisscom_client: MagicMock +) -> None: + """Test the entry falls back to a default title when the box reports no model.""" + mock_swisscom_client.get_box_info.return_value.model_name = "" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Internet-Box" From 6b7157ef358c6f9cb121418e7168f52a15a2b4b2 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Thu, 28 May 2026 14:45:24 +0200 Subject: [PATCH 6/9] removed yaml config --- .../components/swisscom/config_flow.py | 45 +++++------------ .../components/swisscom/device_tracker.py | 48 ++++--------------- .../components/swisscom/strings.json | 6 +++ 3 files changed, 29 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/swisscom/config_flow.py b/homeassistant/components/swisscom/config_flow.py index 29ff2368b56abf..85a51172a6b652 100644 --- a/homeassistant/components/swisscom/config_flow.py +++ b/homeassistant/components/swisscom/config_flow.py @@ -33,28 +33,21 @@ class SwisscomConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _validate(self, data: dict[str, Any]) -> tuple[str | None, str]: - """Validate credentials and return (unique_id, title).""" - client = SwisscomClient( - async_get_clientsession(self.hass), - data[CONF_HOST], - data[CONF_USERNAME], - data[CONF_PASSWORD], - ) - await client.login() - info = await client.get_box_info() - unique_id = format_mac(info.base_mac) if info.base_mac else None - title = info.model_name or "Internet-Box" - return unique_id, title - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the user step.""" errors: dict[str, str] = {} if user_input is not None: + client = SwisscomClient( + async_get_clientsession(self.hass), + user_input[CONF_HOST], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) try: - unique_id, title = await self._validate(user_input) + await client.login() + info = await client.get_box_info() except SwisscomAuthError: errors["base"] = "invalid_auth" except SwisscomConnectionError: @@ -63,25 +56,13 @@ async def async_step_user( _LOGGER.exception("Unexpected exception during Swisscom config flow") errors["base"] = "unknown" else: - if unique_id: - await self.async_set_unique_id(unique_id) + if info.base_mac: + await self.async_set_unique_id(format_mac(info.base_mac)) self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data=user_input) + return self.async_create_entry( + title=info.model_name or "Internet-Box", data=user_input + ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Import a configuration from configuration.yaml.""" - try: - unique_id, title = await self._validate(import_data) - except SwisscomAuthError: - return self.async_abort(reason="invalid_auth") - except SwisscomConnectionError: - return self.async_abort(reason="cannot_connect") - - if unique_id: - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - return self.async_create_entry(title=title, data=import_data) diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 613f5a7df394cb..469481aaa4aedf 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -1,7 +1,5 @@ """Device tracker for the Swisscom Internet-Box.""" -import logging - import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -9,26 +7,18 @@ AsyncSeeCallback, ScannerEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN +from .const import DEFAULT_HOST, DOMAIN from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - PLATFORM_SCHEMA = DEVICE_TRACKER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } + {vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string} ) @@ -38,42 +28,24 @@ async def async_setup_scanner( async_see: AsyncSeeCallback, discovery_info: DiscoveryInfoType | None = None, ) -> bool: - """Import YAML configuration into a config entry.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_HOST: config[CONF_HOST], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - }, - ) - if ( - result.get("type") is FlowResultType.ABORT - and result.get("reason") != "already_configured" - ): - _LOGGER.warning( - "Could not import Swisscom Internet-Box YAML configuration: %s", - result.get("reason"), - ) - return False - + """Inform users that the YAML configuration is no longer supported.""" ir.async_create_issue( hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", + DOMAIN, + "deprecated_yaml_import_issue_credentials_required", breaks_in_ha_version="2027.5.0", is_fixable=False, is_persistent=False, issue_domain=DOMAIN, severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", + translation_key="deprecated_yaml_import_issue_credentials_required", translation_placeholders={ "domain": DOMAIN, "integration_title": "Swisscom Internet-Box", + "host": config[CONF_HOST], }, ) - return True + return False async def async_setup_entry( diff --git a/homeassistant/components/swisscom/strings.json b/homeassistant/components/swisscom/strings.json index 68c39bce713fc2..5ea96118dd4da8 100644 --- a/homeassistant/components/swisscom/strings.json +++ b/homeassistant/components/swisscom/strings.json @@ -22,5 +22,11 @@ } } } + }, + "issues": { + "deprecated_yaml_import_issue_credentials_required": { + "description": "Configuring the {integration_title} integration through YAML is deprecated. The integration now requires a username and password to authenticate to your Internet-Box, which cannot be safely carried over from YAML.\n\nSet up the integration through the UI to provide your credentials (your existing host `{host}` will need to be re-entered), then remove the `{domain}` entry from your `configuration.yaml` file and restart Home Assistant.", + "title": "The {integration_title} YAML configuration is being removed" + } } } From 07a535c287a99948aa5a4997ac1d5f2f5bf8a037 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Fri, 29 May 2026 12:34:42 +0200 Subject: [PATCH 7/9] changed ha version --- homeassistant/components/swisscom/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index 469481aaa4aedf..dd7c608749f5b4 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -33,7 +33,7 @@ async def async_setup_scanner( hass, DOMAIN, "deprecated_yaml_import_issue_credentials_required", - breaks_in_ha_version="2027.5.0", + breaks_in_ha_version="2026.12.0", is_fixable=False, is_persistent=False, issue_domain=DOMAIN, From 1454afb988fdd5c8e4948827569acd38e9f0e586 Mon Sep 17 00:00:00 2001 From: Anatosun Date: Thu, 4 Jun 2026 14:17:26 +0200 Subject: [PATCH 8/9] dropped defensive guard --- .../components/swisscom/config_flow.py | 5 ++--- tests/components/swisscom/test_config_flow.py | 20 ------------------- 2 files changed, 2 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/swisscom/config_flow.py b/homeassistant/components/swisscom/config_flow.py index 85a51172a6b652..45ee3848e51380 100644 --- a/homeassistant/components/swisscom/config_flow.py +++ b/homeassistant/components/swisscom/config_flow.py @@ -56,9 +56,8 @@ async def async_step_user( _LOGGER.exception("Unexpected exception during Swisscom config flow") errors["base"] = "unknown" else: - if info.base_mac: - await self.async_set_unique_id(format_mac(info.base_mac)) - self._abort_if_unique_id_configured() + await self.async_set_unique_id(format_mac(info.base_mac)) + self._abort_if_unique_id_configured() return self.async_create_entry( title=info.model_name or "Internet-Box", data=user_input ) diff --git a/tests/components/swisscom/test_config_flow.py b/tests/components/swisscom/test_config_flow.py index b450f232d68ea5..c2152784610e59 100644 --- a/tests/components/swisscom/test_config_flow.py +++ b/tests/components/swisscom/test_config_flow.py @@ -94,26 +94,6 @@ async def test_user_flow_duplicate( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("mock_setup_entry") -async def test_user_flow_no_base_mac_creates_entry_without_unique_id( - hass: HomeAssistant, mock_swisscom_client: MagicMock -) -> None: - """Test the entry is created without a unique ID when the box reports no MAC.""" - mock_swisscom_client.get_box_info.return_value.base_mac = "" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=USER_INPUT - ) - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_MODEL_NAME - assert result["data"] == USER_INPUT - assert result["result"].unique_id is None - - @pytest.mark.usefixtures("mock_setup_entry") async def test_user_flow_no_model_name_uses_default_title( hass: HomeAssistant, mock_swisscom_client: MagicMock From 10c560c4703e470c8f5d28583383ca94a39459be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 4 Jun 2026 14:25:29 +0200 Subject: [PATCH 9/9] Update homeassistant/components/swisscom/device_tracker.py --- homeassistant/components/swisscom/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py index dd7c608749f5b4..b8acdb1acd8984 100644 --- a/homeassistant/components/swisscom/device_tracker.py +++ b/homeassistant/components/swisscom/device_tracker.py @@ -33,7 +33,7 @@ async def async_setup_scanner( hass, DOMAIN, "deprecated_yaml_import_issue_credentials_required", - breaks_in_ha_version="2026.12.0", + breaks_in_ha_version="2027.1.0", is_fixable=False, is_persistent=False, issue_domain=DOMAIN,