From e4ef06d6b147f3804f2af0ea07ad0679a79cd526 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 6 May 2021 00:33:32 +0100 Subject: [PATCH] System Bridge Integration (#48156) Co-authored-by: Martin Hjelmare --- .coveragerc | 4 + CODEOWNERS | 1 + .../components/system_bridge/__init__.py | 269 ++++++++++++ .../components/system_bridge/binary_sensor.py | 72 +++ .../components/system_bridge/config_flow.py | 187 ++++++++ .../components/system_bridge/const.py | 19 + .../components/system_bridge/manifest.json | 12 + .../components/system_bridge/sensor.py | 340 +++++++++++++++ .../components/system_bridge/services.yaml | 46 ++ .../components/system_bridge/strings.json | 32 ++ .../system_bridge/translations/en.json | 32 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/system_bridge/__init__.py | 1 + .../system_bridge/test_config_flow.py | 409 ++++++++++++++++++ 17 files changed, 1436 insertions(+) create mode 100644 homeassistant/components/system_bridge/__init__.py create mode 100644 homeassistant/components/system_bridge/binary_sensor.py create mode 100644 homeassistant/components/system_bridge/config_flow.py create mode 100644 homeassistant/components/system_bridge/const.py create mode 100644 homeassistant/components/system_bridge/manifest.json create mode 100644 homeassistant/components/system_bridge/sensor.py create mode 100644 homeassistant/components/system_bridge/services.yaml create mode 100644 homeassistant/components/system_bridge/strings.json create mode 100644 homeassistant/components/system_bridge/translations/en.json create mode 100644 tests/components/system_bridge/__init__.py create mode 100644 tests/components/system_bridge/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 977cf11a752..8e0b7dba206 100644 --- a/.coveragerc +++ b/.coveragerc @@ -977,6 +977,10 @@ omit = homeassistant/components/synology_dsm/switch.py homeassistant/components/synology_srm/device_tracker.py homeassistant/components/syslog/notify.py + homeassistant/components/system_bridge/__init__.py + homeassistant/components/system_bridge/const.py + homeassistant/components/system_bridge/binary_sensor.py + homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/* homeassistant/components/tado/device_tracker.py diff --git a/CODEOWNERS b/CODEOWNERS index f69e9b3ecfe..ae71cde1754 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -481,6 +481,7 @@ homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_dsm/* @hacf-fr @Quentame @mib1185 homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff +homeassistant/components/system_bridge/* @timmo001 homeassistant/components/tado/* @michaelarnauts @bdraco @noltari homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py new file mode 100644 index 00000000000..279c63680f0 --- /dev/null +++ b/homeassistant/components/system_bridge/__init__.py @@ -0,0 +1,269 @@ +"""The System Bridge integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +import shlex + +import async_timeout +from systembridge import Bridge +from systembridge.client import BridgeClient +from systembridge.exceptions import BridgeAuthenticationException +from systembridge.objects.command.response import CommandResponse +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_COMMAND, + CONF_HOST, + CONF_PATH, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + device_registry as dr, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor", "sensor"] + +CONF_ARGUMENTS = "arguments" +CONF_BRIDGE = "bridge" +CONF_WAIT = "wait" + +SERVICE_SEND_COMMAND = "send_command" +SERVICE_SEND_COMMAND_SCHEMA = vol.Schema( + { + vol.Required(CONF_BRIDGE): cv.string, + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_ARGUMENTS, []): cv.string, + } +) +SERVICE_OPEN = "open" +SERVICE_OPEN_SCHEMA = vol.Schema( + {vol.Required(CONF_BRIDGE): cv.string, vol.Required(CONF_PATH): cv.string} +) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up System Bridge from a config entry.""" + + client = Bridge( + BridgeClient(aiohttp_client.async_get_clientsession(hass)), + f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", + entry.data[CONF_API_KEY], + ) + + async def async_update_data() -> Bridge: + """Fetch data from Bridge.""" + try: + async with async_timeout.timeout(60): + await asyncio.gather( + *[ + client.async_get_battery(), + client.async_get_cpu(), + client.async_get_filesystem(), + client.async_get_network(), + client.async_get_os(), + client.async_get_processes(), + client.async_get_system(), + ] + ) + return client + except BridgeAuthenticationException as exception: + raise ConfigEntryAuthFailed from exception + except BRIDGE_CONNECTION_ERRORS as exception: + raise UpdateFailed("Could not connect to System Bridge.") from exception + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=f"{DOMAIN}_coordinator", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=60), + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = coordinator + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_config_entry_first_refresh() + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): + return True + + async def handle_send_command(call): + """Handle the send_command service call.""" + device_registry = dr.async_get(hass) + device_id = call.data[CONF_BRIDGE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: + _LOGGER.warning("Missing device: %s", device_id) + return + + command = call.data[CONF_COMMAND] + arguments = shlex.split(call.data.get(CONF_ARGUMENTS, "")) + + entry_id = next( + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.data + + _LOGGER.debug( + "Command payload: %s", + {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False}, + ) + try: + response: CommandResponse = await bridge.async_send_command( + {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False} + ) + if response.success: + _LOGGER.debug( + "Sent command. Response message was: %s", response.message + ) + else: + _LOGGER.warning( + "Error sending command. Response message was: %s", response.message + ) + except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: + _LOGGER.warning("Error sending command. Error was: %s", exception) + + async def handle_open(call): + """Handle the open service call.""" + device_registry = dr.async_get(hass) + device_id = call.data[CONF_BRIDGE] + device_entry = device_registry.async_get(device_id) + if device_entry is None: + _LOGGER.warning("Missing device: %s", device_id) + return + + path = call.data[CONF_PATH] + + entry_id = next( + entry.entry_id + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.entry_id in device_entry.config_entries + ) + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry_id] + bridge: Bridge = coordinator.data + + _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) + try: + await bridge.async_open({CONF_PATH: path}) + _LOGGER.debug("Sent open request") + except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: + _LOGGER.warning("Error sending. Error was: %s", exception) + + hass.services.async_register( + DOMAIN, + SERVICE_SEND_COMMAND, + handle_send_command, + schema=SERVICE_SEND_COMMAND_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_OPEN, + handle_open, + schema=SERVICE_OPEN_SCHEMA, + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_SEND_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_OPEN) + + return unload_ok + + +class BridgeEntity(CoordinatorEntity): + """Defines a base System Bridge entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + bridge: Bridge, + key: str, + name: str, + icon: str | None, + enabled_by_default: bool, + ) -> None: + """Initialize the System Bridge entity.""" + super().__init__(coordinator) + self._key = f"{bridge.os.hostname}_{key}" + self._name = f"{bridge.os.hostname} {name}" + self._icon = icon + self._enabled_default = enabled_by_default + self._hostname = bridge.os.hostname + self._default_interface = bridge.network.interfaces[ + bridge.network.interfaceDefault + ] + self._manufacturer = bridge.system.system.manufacturer + self._model = bridge.system.system.model + self._version = bridge.system.system.version + + @property + def unique_id(self) -> str: + """Return the unique ID for this entity.""" + return self._key + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str | None: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + +class BridgeDeviceEntity(BridgeEntity): + """Defines a System Bridge device entity.""" + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this System Bridge instance.""" + return { + "connections": { + (dr.CONNECTION_NETWORK_MAC, self._default_interface["mac"]) + }, + "manufacturer": self._manufacturer, + "model": self._model, + "name": self._hostname, + "sw_version": self._version, + } diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py new file mode 100644 index 00000000000..b1010a19ae4 --- /dev/null +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -0,0 +1,72 @@ +"""Support for System Bridge sensors.""" +from __future__ import annotations + +from systembridge import Bridge + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_BATTERY_CHARGING, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import BridgeDeviceEntity +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +) -> None: + """Set up System Bridge sensor based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + bridge: Bridge = coordinator.data + + if bridge.battery.hasBattery: + async_add_entities([BridgeBatteryIsChargingBinarySensor(coordinator, bridge)]) + + +class BridgeBinarySensor(BridgeDeviceEntity, BinarySensorEntity): + """Defines a System Bridge sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + bridge: Bridge, + key: str, + name: str, + icon: str | None, + device_class: str | None, + enabled_by_default: bool, + ) -> None: + """Initialize System Bridge sensor.""" + self._device_class = device_class + + super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + + @property + def device_class(self) -> str | None: + """Return the class of this sensor.""" + return self._device_class + + +class BridgeBatteryIsChargingBinarySensor(BridgeBinarySensor): + """Defines a Battery is charging sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "battery_is_charging", + "Battery Is Charging", + None, + DEVICE_CLASS_BATTERY_CHARGING, + True, + ) + + @property + def is_on(self) -> bool: + """Return if the state is on.""" + bridge: Bridge = self.coordinator.data + return bridge.battery.isCharging diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py new file mode 100644 index 00000000000..a74c060fccf --- /dev/null +++ b/homeassistant/components/system_bridge/config_flow.py @@ -0,0 +1,187 @@ +"""Config flow for System Bridge integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import async_timeout +from systembridge import Bridge +from systembridge.client import BridgeClient +from systembridge.exceptions import BridgeAuthenticationException +from systembridge.objects.os import Os +from systembridge.objects.system import System +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_AUTHENTICATE_DATA_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): cv.string}) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=9170): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + bridge = Bridge( + BridgeClient(aiohttp_client.async_get_clientsession(hass)), + f"http://{data[CONF_HOST]}:{data[CONF_PORT]}", + data[CONF_API_KEY], + ) + + hostname = data[CONF_HOST] + try: + async with async_timeout.timeout(30): + bridge_os: Os = await bridge.async_get_os() + if bridge_os.hostname is not None: + hostname = bridge_os.hostname + bridge_system: System = await bridge.async_get_system() + except BridgeAuthenticationException as exception: + _LOGGER.info(exception) + raise InvalidAuth from exception + except BRIDGE_CONNECTION_ERRORS as exception: + _LOGGER.info(exception) + raise CannotConnect from exception + + return {"hostname": hostname, "uuid": bridge_system.uuid.os} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for System Bridge.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize flow.""" + self._name: str | None = None + self._input: dict[str, Any] = {} + self._reauth = False + + async def _async_get_info( + self, user_input: dict[str, Any] + ) -> tuple[dict[str, str], dict[str, str] | None]: + errors = {} + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, info + + return errors, None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors, info = await self._async_get_info(user_input) + if not errors and info is not None: + # Check if already configured + await self.async_set_unique_id(info["uuid"], raise_on_progress=False) + self._abort_if_unique_id_configured(updates={CONF_HOST: info["hostname"]}) + + return self.async_create_entry(title=info["hostname"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_authenticate( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle getting the api-key for authentication.""" + errors: dict[str, str] = {} + + if user_input is not None: + user_input = {**self._input, **user_input} + errors, info = await self._async_get_info(user_input) + if not errors and info is not None: + # Check if already configured + existing_entry = await self.async_set_unique_id(info["uuid"]) + + if self._reauth and existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data=user_input + ) + await self.hass.config_entries.async_reload(existing_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + self._abort_if_unique_id_configured( + updates={CONF_HOST: info["hostname"]} + ) + + return self.async_create_entry(title=info["hostname"], data=user_input) + + return self.async_show_form( + step_id="authenticate", + data_schema=STEP_AUTHENTICATE_DATA_SCHEMA, + description_placeholders={"name": self._name}, + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle zeroconf discovery.""" + host = discovery_info["properties"].get("ip") + uuid = discovery_info["properties"].get("uuid") + + if host is None or uuid is None: + return self.async_abort(reason="unknown") + + # Check if already configured + await self.async_set_unique_id(uuid) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self._name = host + self._input = { + CONF_HOST: host, + CONF_PORT: discovery_info["properties"].get("port"), + } + + return await self.async_step_authenticate() + + async def async_step_reauth(self, entry_data: ConfigType) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._name = entry_data[CONF_HOST] + self._input = { + CONF_HOST: entry_data[CONF_HOST], + CONF_PORT: entry_data[CONF_PORT], + } + self._reauth = True + return await self.async_step_authenticate() + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py new file mode 100644 index 00000000000..5560d79a769 --- /dev/null +++ b/homeassistant/components/system_bridge/const.py @@ -0,0 +1,19 @@ +"""Constants for the System Bridge integration.""" +import asyncio + +from aiohttp.client_exceptions import ( + ClientConnectionError, + ClientConnectorError, + ClientResponseError, +) +from systembridge.exceptions import BridgeException + +DOMAIN = "system_bridge" + +BRIDGE_CONNECTION_ERRORS = ( + asyncio.TimeoutError, + BridgeException, + ClientConnectionError, + ClientConnectorError, + ClientResponseError, +) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json new file mode 100644 index 00000000000..c960c1c6557 --- /dev/null +++ b/homeassistant/components/system_bridge/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "system_bridge", + "name": "System Bridge", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/system_bridge", + "requirements": ["systembridge==1.1.3"], + "codeowners": ["@timmo001"], + "zeroconf": ["_system-bridge._udp.local."], + "after_dependencies": ["zeroconf"], + "quality_scale": "silver", + "iot_class": "local_polling" +} diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py new file mode 100644 index 00000000000..7fa9efd791e --- /dev/null +++ b/homeassistant/components/system_bridge/sensor.py @@ -0,0 +1,340 @@ +"""Support for System Bridge sensors.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from systembridge import Bridge + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_TIMESTAMP, + DEVICE_CLASS_VOLTAGE, + FREQUENCY_GIGAHERTZ, + PERCENTAGE, + TEMP_CELSIUS, + VOLT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import BridgeDeviceEntity +from .const import DOMAIN + +ATTR_AVAILABLE = "available" +ATTR_FILESYSTEM = "filesystem" +ATTR_LOAD_AVERAGE = "load_average" +ATTR_LOAD_IDLE = "load_idle" +ATTR_LOAD_SYSTEM = "load_system" +ATTR_LOAD_USER = "load_user" +ATTR_MOUNT = "mount" +ATTR_SIZE = "size" +ATTR_TYPE = "type" +ATTR_USED = "used" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +) -> None: + """Set up System Bridge sensor based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + bridge: Bridge = coordinator.data + + entities = [ + BridgeCpuSpeedSensor(coordinator, bridge), + BridgeCpuTemperatureSensor(coordinator, bridge), + BridgeCpuVoltageSensor(coordinator, bridge), + *[ + BridgeFilesystemSensor(coordinator, bridge, key) + for key, _ in bridge.filesystem.fsSize.items() + ], + BridgeKernelSensor(coordinator, bridge), + BridgeOsSensor(coordinator, bridge), + BridgeProcessesLoadSensor(coordinator, bridge), + ] + + if bridge.battery.hasBattery: + entities.append(BridgeBatterySensor(coordinator, bridge)) + entities.append(BridgeBatteryTimeRemainingSensor(coordinator, bridge)) + + async_add_entities(entities) + + +class BridgeSensor(BridgeDeviceEntity, SensorEntity): + """Defines a System Bridge sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + bridge: Bridge, + key: str, + name: str, + icon: str | None, + device_class: str | None, + unit_of_measurement: str | None, + enabled_by_default: bool, + ) -> None: + """Initialize System Bridge sensor.""" + self._device_class = device_class + self._unit_of_measurement = unit_of_measurement + + super().__init__(coordinator, bridge, key, name, icon, enabled_by_default) + + @property + def device_class(self) -> str | None: + """Return the class of this sensor.""" + return self._device_class + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class BridgeBatterySensor(BridgeSensor): + """Defines a Battery sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "battery", + "Battery", + None, + DEVICE_CLASS_BATTERY, + PERCENTAGE, + True, + ) + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return bridge.battery.percent + + +class BridgeBatteryTimeRemainingSensor(BridgeSensor): + """Defines the Battery Time Remaining sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "battery_time_remaining", + "Battery Time Remaining", + None, + DEVICE_CLASS_TIMESTAMP, + None, + True, + ) + + @property + def state(self) -> str | None: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + if bridge.battery.timeRemaining is None: + return None + return str(datetime.now() + timedelta(minutes=bridge.battery.timeRemaining)) + + +class BridgeCpuSpeedSensor(BridgeSensor): + """Defines a CPU speed sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "cpu_speed", + "CPU Speed", + None, + None, + FREQUENCY_GIGAHERTZ, + True, + ) + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return bridge.cpu.currentSpeed.avg + + +class BridgeCpuTemperatureSensor(BridgeSensor): + """Defines a CPU temperature sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "cpu_temperature", + "CPU Temperature", + None, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + False, + ) + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return bridge.cpu.temperature.main + + +class BridgeCpuVoltageSensor(BridgeSensor): + """Defines a CPU voltage sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "cpu_voltage", + "CPU Voltage", + None, + DEVICE_CLASS_VOLTAGE, + VOLT, + False, + ) + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return bridge.cpu.cpu.voltage + + +class BridgeFilesystemSensor(BridgeSensor): + """Defines a filesystem sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge, key: str): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + f"filesystem_{key}", + f"{key} Space Used", + None, + None, + PERCENTAGE, + True, + ) + self._key = key + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return ( + round(bridge.filesystem.fsSize[self._key]["use"], 2) + if bridge.filesystem.fsSize[self._key]["use"] is not None + else None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + bridge: Bridge = self.coordinator.data + return { + ATTR_AVAILABLE: bridge.filesystem.fsSize[self._key]["available"], + ATTR_FILESYSTEM: bridge.filesystem.fsSize[self._key]["fs"], + ATTR_MOUNT: bridge.filesystem.fsSize[self._key]["mount"], + ATTR_SIZE: bridge.filesystem.fsSize[self._key]["size"], + ATTR_TYPE: bridge.filesystem.fsSize[self._key]["type"], + ATTR_USED: bridge.filesystem.fsSize[self._key]["used"], + } + + +class BridgeKernelSensor(BridgeSensor): + """Defines a kernel sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "kernel", + "Kernel", + "mdi:devices", + None, + None, + True, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return bridge.os.kernel + + +class BridgeOsSensor(BridgeSensor): + """Defines an OS sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "os", + "Operating System", + "mdi:devices", + None, + None, + True, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return f"{bridge.os.distro} {bridge.os.release}" + + +class BridgeProcessesLoadSensor(BridgeSensor): + """Defines a Processes Load sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, bridge: Bridge): + """Initialize System Bridge sensor.""" + super().__init__( + coordinator, + bridge, + "processes_load", + "Load", + "mdi:percent", + None, + PERCENTAGE, + True, + ) + + @property + def state(self) -> float: + """Return the state of the sensor.""" + bridge: Bridge = self.coordinator.data + return ( + round(bridge.processes.load.currentLoad, 2) + if bridge.processes.load.currentLoad is not None + else None + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + bridge: Bridge = self.coordinator.data + attrs = {} + if bridge.processes.load.avgLoad is not None: + attrs[ATTR_LOAD_AVERAGE] = round(bridge.processes.load.avgLoad, 2) + if bridge.processes.load.currentLoadUser is not None: + attrs[ATTR_LOAD_USER] = round(bridge.processes.load.currentLoadUser, 2) + if bridge.processes.load.currentLoadSystem is not None: + attrs[ATTR_LOAD_SYSTEM] = round(bridge.processes.load.currentLoadSystem, 2) + if bridge.processes.load.currentLoadIdle is not None: + attrs[ATTR_LOAD_IDLE] = round(bridge.processes.load.currentLoadIdle, 2) + return attrs diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml new file mode 100644 index 00000000000..0ee12b39846 --- /dev/null +++ b/homeassistant/components/system_bridge/services.yaml @@ -0,0 +1,46 @@ +send_command: + name: Send Command + description: Sends a command to the server to run. + fields: + bridge: + name: Bridge + description: The server to send the command to. + example: "" + required: true + selector: + device: + integration: system_bridge + command: + name: Command + description: Command to send to the server. + required: true + example: "echo" + selector: + text: + arguments: + name: Arguments + description: Arguments to send to the server. + required: false + default: "" + example: "hello" + selector: + text: +open: + name: Open Path/URL + description: Open a URL or file on the server using the default application. + fields: + bridge: + name: Bridge + description: The server to talk to. + example: "" + required: true + selector: + device: + integration: system_bridge + path: + name: Path/URL + description: Path/URL to open. + required: true + example: "https://www.home-assistant.io" + selector: + text: diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json new file mode 100644 index 00000000000..eeaad92fd1b --- /dev/null +++ b/homeassistant/components/system_bridge/strings.json @@ -0,0 +1,32 @@ +{ + "title": "System Bridge", + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "flow_title": "System Bridge: {name}", + "step": { + "authenticate": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Please enter the API Key you set in your configuration for {name}." + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "description": "Please enter your connection details." + } + }, + "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%]" + } + } +} diff --git a/homeassistant/components/system_bridge/translations/en.json b/homeassistant/components/system_bridge/translations/en.json new file mode 100644 index 00000000000..71d10c54476 --- /dev/null +++ b/homeassistant/components/system_bridge/translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful", + "unknown": "Unexpected error" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "flow_title": "System Bridge: {name}", + "step": { + "authenticate": { + "data": { + "api_key": "API Key" + }, + "description": "Please enter the API Key you set in your configuration for {name}." + }, + "user": { + "data": { + "api_key": "API Key", + "host": "Host", + "port": "Port" + }, + "description": "Please enter your connection details." + } + } + }, + "title": "System Bridge" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef577726b99..35b48cf4cb5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -240,6 +240,7 @@ FLOWS = [ "subaru", "syncthru", "synology_dsm", + "system_bridge", "tado", "tasmota", "tellduslive", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 6ea8b7d2ebb..33c08579c36 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -172,6 +172,11 @@ ZEROCONF = { "name": "smappee50*" } ], + "_system-bridge._udp.local.": [ + { + "domain": "system_bridge" + } + ], "_touch-able._tcp.local.": [ { "domain": "apple_tv" diff --git a/requirements_all.txt b/requirements_all.txt index 13b693d7480..cfe8e6f48c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2189,6 +2189,9 @@ synology-srm==0.2.0 # homeassistant.components.synology_dsm synologydsm-api==1.0.2 +# homeassistant.components.system_bridge +systembridge==1.1.3 + # homeassistant.components.tahoma tahoma-api==0.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 274f5b1d75d..1d425345a5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1173,6 +1173,9 @@ surepy==0.6.0 # homeassistant.components.synology_dsm synologydsm-api==1.0.2 +# homeassistant.components.system_bridge +systembridge==1.1.3 + # homeassistant.components.tellduslive tellduslive==0.10.11 diff --git a/tests/components/system_bridge/__init__.py b/tests/components/system_bridge/__init__.py new file mode 100644 index 00000000000..f049f887584 --- /dev/null +++ b/tests/components/system_bridge/__init__.py @@ -0,0 +1 @@ +"""Tests for the System Bridge integration.""" diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py new file mode 100644 index 00000000000..5cd7a77d911 --- /dev/null +++ b/tests/components/system_bridge/test_config_flow.py @@ -0,0 +1,409 @@ +"""Test the System Bridge config flow.""" +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientConnectionError +from systembridge.exceptions import BridgeAuthenticationException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.system_bridge.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +FIXTURE_MAC_ADDRESS = "aa:bb:cc:dd:ee:ff" +FIXTURE_UUID = "e91bf575-56f3-4c83-8f42-70ac17adcd33" + +FIXTURE_AUTH_INPUT = {CONF_API_KEY: "abc-123-def-456-ghi"} + +FIXTURE_USER_INPUT = { + CONF_API_KEY: "abc-123-def-456-ghi", + CONF_HOST: "test-bridge", + CONF_PORT: "9170", +} + +FIXTURE_ZEROCONF_INPUT = { + CONF_API_KEY: "abc-123-def-456-ghi", + CONF_HOST: "1.1.1.1", + CONF_PORT: "9170", +} + +FIXTURE_ZEROCONF = { + CONF_HOST: "1.1.1.1", + CONF_PORT: 9170, + "hostname": "test-bridge.local.", + "type": "_system-bridge._udp.local.", + "name": "System Bridge - test-bridge._system-bridge._udp.local.", + "properties": { + "address": "http://test-bridge:9170", + "fqdn": "test-bridge", + "host": "test-bridge", + "ip": "1.1.1.1", + "mac": FIXTURE_MAC_ADDRESS, + "port": "9170", + "uuid": FIXTURE_UUID, + }, +} + +FIXTURE_ZEROCONF_BAD = { + CONF_HOST: "1.1.1.1", + CONF_PORT: 9170, + "hostname": "test-bridge.local.", + "type": "_system-bridge._udp.local.", + "name": "System Bridge - test-bridge._system-bridge._udp.local.", + "properties": { + "something": "bad", + }, +} + +FIXTURE_OS = { + "platform": "linux", + "distro": "Ubuntu", + "release": "20.10", + "codename": "Groovy Gorilla", + "kernel": "5.8.0-44-generic", + "arch": "x64", + "hostname": "test-bridge", + "fqdn": "test-bridge.local", + "codepage": "UTF-8", + "logofile": "ubuntu", + "serial": "abcdefghijklmnopqrstuvwxyz", + "build": "", + "servicepack": "", + "uefi": True, + "users": [], +} + + +FIXTURE_NETWORK = { + "connections": [], + "gatewayDefault": "192.168.1.1", + "interfaceDefault": "wlp2s0", + "interfaces": { + "wlp2s0": { + "iface": "wlp2s0", + "ifaceName": "wlp2s0", + "ip4": "1.1.1.1", + "mac": FIXTURE_MAC_ADDRESS, + }, + }, + "stats": {}, +} + +FIXTURE_SYSTEM = { + "baseboard": { + "manufacturer": "System manufacturer", + "model": "Model", + "version": "Rev X.0x", + "serial": "1234567", + "assetTag": "", + "memMax": 134217728, + "memSlots": 4, + }, + "bios": { + "vendor": "System vendor", + "version": "12345", + "releaseDate": "2019-11-13", + "revision": "", + }, + "chassis": { + "manufacturer": "Default string", + "model": "", + "type": "Desktop", + "version": "Default string", + "serial": "Default string", + "assetTag": "", + "sku": "", + }, + "system": { + "manufacturer": "System manufacturer", + "model": "System Product Name", + "version": "System Version", + "serial": "System Serial Number", + "uuid": "abc123-def456", + "sku": "SKU", + "virtual": False, + }, + "uuid": { + "os": FIXTURE_UUID, + "hardware": "abc123-def456", + "macs": [FIXTURE_MAC_ADDRESS], + }, +} + + +FIXTURE_BASE_URL = ( + f"http://{FIXTURE_USER_INPUT[CONF_HOST]}:{FIXTURE_USER_INPUT[CONF_PORT]}" +) + +FIXTURE_ZEROCONF_BASE_URL = ( + f"http://{FIXTURE_ZEROCONF[CONF_HOST]}:{FIXTURE_ZEROCONF[CONF_PORT]}" +) + + +async def test_user_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test full user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + + with patch( + "homeassistant.components.system_bridge.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-bridge" + assert result2["data"] == FIXTURE_USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknow_error( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.system_bridge.config_flow.Bridge.async_get_os", + side_effect=Exception("Boom"), + ): + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} + + +async def test_reauth_authorization_error( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test we show user form on authorization error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=BridgeAuthenticationException) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=BridgeAuthenticationException) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=BridgeAuthenticationException) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "authenticate" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_connection_error( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", exc=ClientConnectionError) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", exc=ClientConnectionError) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "authenticate" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=FIXTURE_UUID, data=FIXTURE_USER_INPUT + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + aioclient_mock.get(f"{FIXTURE_BASE_URL}/os", json=FIXTURE_OS) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/network", json=FIXTURE_NETWORK) + aioclient_mock.get(f"{FIXTURE_BASE_URL}/system", json=FIXTURE_SYSTEM) + + with patch( + "homeassistant.components.system_bridge.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "reauth_successful" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_flow( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test zeroconf flow.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=FIXTURE_ZEROCONF, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert not result["errors"] + + aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", json=FIXTURE_OS) + aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/network", json=FIXTURE_NETWORK) + aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", json=FIXTURE_SYSTEM) + + with patch( + "homeassistant.components.system_bridge.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-bridge" + assert result2["data"] == FIXTURE_ZEROCONF_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_cannot_connect( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test zeroconf cannot connect flow.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=FIXTURE_ZEROCONF, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert not result["errors"] + + aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/os", exc=ClientConnectionError) + aioclient_mock.get( + f"{FIXTURE_ZEROCONF_BASE_URL}/network", exc=ClientConnectionError + ) + aioclient_mock.get(f"{FIXTURE_ZEROCONF_BASE_URL}/system", exc=ClientConnectionError) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "authenticate" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_zeroconf_bad_zeroconf_info( + hass, aiohttp_client, aioclient_mock, current_request_with_host +) -> None: + """Test zeroconf cannot connect flow.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=FIXTURE_ZEROCONF_BAD, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "unknown"