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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion homeassistant/components/swisscom/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions homeassistant/components/swisscom/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Config flow for the Swisscom Internet-Box integration."""

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
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 .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 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:
await client.login()
info = await client.get_box_info()
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:
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
)

return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
6 changes: 6 additions & 0 deletions homeassistant/components/swisscom/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the Swisscom Internet-Box integration."""

DOMAIN = "swisscom"

DEFAULT_HOST = "192.168.1.1"
DEFAULT_USERNAME = "admin"
59 changes: 59 additions & 0 deletions homeassistant/components/swisscom/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""DataUpdateCoordinator for the Swisscom Internet-Box."""

from datetime import timedelta
import logging

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
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 .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

SCAN_INTERVAL = timedelta(seconds=30)

type SwisscomConfigEntry = ConfigEntry[SwisscomDataUpdateCoordinator]


class SwisscomDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]):
"""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, Device]:
"""Fetch device data from the box."""
try:
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

return {device.key: device for device in devices if device.key}
200 changes: 102 additions & 98 deletions homeassistant/components/swisscom/device_tracker.py
Original file line number Diff line number Diff line change
@@ -1,111 +1,115 @@
"""Support for Swisscom routers (Internet-Box)."""
"""Device tracker for the Swisscom Internet-Box."""

from contextlib import suppress
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.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType

_LOGGER = logging.getLogger(__name__)
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

DEFAULT_IP = "192.168.1.1"
from .const import DEFAULT_HOST, DOMAIN
from .coordinator import SwisscomConfigEntry, SwisscomDataUpdateCoordinator

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}
)


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.last_results = {}

# 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 get_swisscom_data(self):
"""Retrieve data from Swisscom and return parsed result."""
url = f"http://{self.host}/ws"
headers = {"Content-Type": "application/x-sah-ws-4-call+json"}
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

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
async def async_setup_scanner(
hass: HomeAssistant,
config: ConfigType,
async_see: AsyncSeeCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> bool:
"""Inform users that the YAML configuration is no longer supported."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_credentials_required",
breaks_in_ha_version="2027.1.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_credentials_required",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Swisscom Internet-Box",
"host": config[CONF_HOST],
},
)
Comment on lines +32 to +47
return False


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
)

_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):
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.phys_address if device else self._key

@property
def hostname(self) -> str | None:
"""Return the hostname of the device."""
device = self._device
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_address if device else None

@property
def name(self) -> str | None:
"""Return the friendly name of the device."""
return self.hostname
4 changes: 3 additions & 1 deletion homeassistant/components/swisscom/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"domain": "swisscom",
"name": "Swisscom Internet-Box",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/swisscom",
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "legacy"
"requirements": ["python-swisscom-internet-box==0.1.1"]
}
Loading
Loading