System Bridge Integration (#48156)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Aidan Timson 2021-05-06 00:33:32 +01:00 committed by GitHub
parent 5dd59415a8
commit e4ef06d6b1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1436 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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."""

View File

@ -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,
)

View File

@ -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"
}

View File

@ -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

View File

@ -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:

View File

@ -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%]"
}
}
}

View File

@ -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"
}

View File

@ -240,6 +240,7 @@ FLOWS = [
"subaru",
"syncthru",
"synology_dsm",
"system_bridge",
"tado",
"tasmota",
"tellduslive",

View File

@ -172,6 +172,11 @@ ZEROCONF = {
"name": "smappee50*"
}
],
"_system-bridge._udp.local.": [
{
"domain": "system_bridge"
}
],
"_touch-able._tcp.local.": [
{
"domain": "apple_tv"

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the System Bridge integration."""

View File

@ -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"