Skip to content

Commit

Permalink
Add Universal Powerline Bus (home-assistant#34692)
Browse files Browse the repository at this point in the history
* Initial version.

* Tests.

* Refactored tests.

* Update requirements_all

* Increase test coverage. Catch exception.

* Update .coveragerc

* Fix lint msg.

* Tweak test (more to force CI build).

* Update based on PR comments.

* Change unique_id to use stable string.

* Add Universal Powerline Bus "link" support.

* Fix missed call.

* Revert botched merge.

* Update homeassistant/components/upb/light.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Three changes.

Update service schema to require one of brightness/brightness_pct.
Fix bug in setting brightness to zero.
Replace async_update_status and replace with async_update.

Co-authored-by: J. Nick Koston <nick@koston.org>
  • Loading branch information
Glenn Waters and bdraco authored May 8, 2020
1 parent e3e3a11 commit efb5296
Show file tree
Hide file tree
Showing 15 changed files with 649 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,9 @@ omit =
homeassistant/components/ubus/device_tracker.py
homeassistant/components/ue_smart_radio/media_player.py
homeassistant/components/unifiled/*
homeassistant/components/upb/__init__.py
homeassistant/components/upb/const.py
homeassistant/components/upb/light.py
homeassistant/components/upcloud/*
homeassistant/components/upnp/*
homeassistant/components/upc_connect/*
Expand Down
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ homeassistant/components/twilio_sms/* @robbiet480
homeassistant/components/ubee/* @mzdrale
homeassistant/components/unifi/* @Kane610
homeassistant/components/unifiled/* @florisvdk
homeassistant/components/upb/* @gwww
homeassistant/components/upc_connect/* @pvizeli
homeassistant/components/upcloud/* @scop
homeassistant/components/updater/* @home-assistant/core
Expand Down
23 changes: 23 additions & 0 deletions homeassistant/components/upb/.translations/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"config": {
"abort": {
"address_already_configured": "An UPB PIM with this address is already configured."
},
"error": {
"cannot_connect": "Failed to connect to UPB PIM, please try again.",
"invalid_upb_file": "Missing or invalid UPB UPStart export file, check the name and path of the file.",
"unknown": "Unexpected error."
},
"step": {
"user": {
"data": {
"address": "Address (see description above)",
"file_path": "Path and name of the UPStart UPB export file.",
"protocol": "Protocol"
},
"description": "Connect a Universal Powerline Bus Powerline Interface Module (UPB PIM). The address string must be in the form 'address[:port]' for 'tcp'. The port is optional and defaults to 2101. Example: '192.168.1.42'. For the serial protocol, the address must be in the form 'tty[:baud]'. The baud is optional and defaults to 4800. Example: '/dev/ttyS1'.",
"title": "Connect to UPB PIM"
}
}
}
}
122 changes: 122 additions & 0 deletions homeassistant/components/upb/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Support the UPB PIM."""
import asyncio

import upb_lib

from homeassistant.const import CONF_FILE_PATH, CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType

from .const import DOMAIN

UPB_PLATFORMS = ["light"]


async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
"""Set up the UPB platform."""
return True


async def async_setup_entry(hass, config_entry):
"""Set up a new config_entry for UPB PIM."""

url = config_entry.data[CONF_HOST]
file = config_entry.data[CONF_FILE_PATH]

upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file})
upb.connect()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][config_entry.entry_id] = {"upb": upb}

for component in UPB_PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)

return True


async def async_unload_entry(hass, config_entry):
"""Unload the config_entry."""

unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in UPB_PLATFORMS
]
)
)

if unload_ok:
upb = hass.data[DOMAIN][config_entry.entry_id]["upb"]
upb.disconnect()
hass.data[DOMAIN].pop(config_entry.entry_id)

return unload_ok


class UpbEntity(Entity):
"""Base class for all UPB entities."""

def __init__(self, element, unique_id, upb):
"""Initialize the base of all UPB devices."""
self._upb = upb
self._element = element
element_type = "link" if element.addr.is_link else "device"
self._unique_id = f"{unique_id}_{element_type}_{element.addr}"

@property
def name(self):
"""Name of the element."""
return self._element.name

@property
def unique_id(self):
"""Return unique id of the element."""
return self._unique_id

@property
def should_poll(self) -> bool:
"""Don't poll this device."""
return False

@property
def device_state_attributes(self):
"""Return the default attributes of the element."""
return self._element.as_dict()

@property
def available(self):
"""Is the entity available to be updated."""
return self._upb.is_connected()

def _element_changed(self, element, changeset):
pass

@callback
def _element_callback(self, element, changeset):
"""Handle callback from an UPB element that has changed."""
self._element_changed(element, changeset)
self.async_write_ha_state()

async def async_added_to_hass(self):
"""Register callback for UPB changes and update entity state."""
self._element.add_callback(self._element_callback)
self._element_callback(self._element, {})


class UpbAttachedEntity(UpbEntity):
"""Base class for UPB attached entities."""

@property
def device_info(self):
"""Device info for the entity."""
return {
"name": self._element.name,
"identifiers": {(DOMAIN, self._element.index)},
"sw_version": self._element.version,
"manufacturer": self._element.manufacturer,
"model": self._element.product,
}
140 changes: 140 additions & 0 deletions homeassistant/components/upb/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Config flow for UPB PIM integration."""
import asyncio
import logging
from urllib.parse import urlparse

import async_timeout
import upb_lib
import voluptuous as vol

from homeassistant import config_entries, exceptions
from homeassistant.const import CONF_ADDRESS, CONF_FILE_PATH, CONF_HOST, CONF_PROTOCOL

from .const import DOMAIN # pylint:disable=unused-import

_LOGGER = logging.getLogger(__name__)
PROTOCOL_MAP = {"TCP": "tcp://", "Serial port": "serial://"}
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_PROTOCOL, default="Serial port"): vol.In(
["TCP", "Serial port"]
),
vol.Required(CONF_ADDRESS): str,
vol.Required(CONF_FILE_PATH, default=""): str,
}
)
VALIDATE_TIMEOUT = 15


async def _validate_input(data):
"""Validate the user input allows us to connect."""

def _connected_callback():
connected_event.set()

connected_event = asyncio.Event()
file_path = data.get(CONF_FILE_PATH)
url = _make_url_from_data(data)

upb = upb_lib.UpbPim({"url": url, "UPStartExportFile": file_path})
if not upb.config_ok:
_LOGGER.error("Missing or invalid UPB file: %s", file_path)
raise InvalidUpbFile

upb.connect(_connected_callback)

try:
with async_timeout.timeout(VALIDATE_TIMEOUT):
await connected_event.wait()
except asyncio.TimeoutError:
pass

upb.disconnect()

if not connected_event.is_set():
_LOGGER.error(
"Timed out after %d seconds trying to connect with UPB PIM at %s",
VALIDATE_TIMEOUT,
url,
)
raise CannotConnect

# Return info that you want to store in the config entry.
return (upb.network_id, {"title": "UPB", CONF_HOST: url, CONF_FILE_PATH: file_path})


def _make_url_from_data(data):
host = data.get(CONF_HOST)
if host:
return host

protocol = PROTOCOL_MAP[data[CONF_PROTOCOL]]
address = data[CONF_ADDRESS]
return f"{protocol}{address}"


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for UPB PIM."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH

def __init__(self):
"""Initialize the UPB config flow."""
self.importing = False

async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
try:
if self._url_already_configured(_make_url_from_data(user_input)):
return self.async_abort(reason="address_already_configured")
network_id, info = await _validate_input(user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidUpbFile:
errors["base"] = "invalid_upb_file"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"

if "base" not in errors:
await self.async_set_unique_id(network_id)
self._abort_if_unique_id_configured()

if self.importing:
return self.async_create_entry(title=info["title"], data=user_input)

return self.async_create_entry(
title=info["title"],
data={
CONF_HOST: info[CONF_HOST],
CONF_FILE_PATH: user_input[CONF_FILE_PATH],
},
)

return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)

async def async_step_import(self, user_input):
"""Handle import."""
self.importing = True
return await self.async_step_user(user_input)

def _url_already_configured(self, url):
"""See if we already have a UPB PIM matching user input configured."""
existing_hosts = {
urlparse(entry.data[CONF_HOST]).hostname
for entry in self._async_current_entries()
}
return urlparse(url).hostname in existing_hosts


class CannotConnect(exceptions.HomeAssistantError):
"""Error to indicate we cannot connect."""


class InvalidUpbFile(exceptions.HomeAssistantError):
"""Error to indicate there is invalid or missing UPB config file."""
33 changes: 33 additions & 0 deletions homeassistant/components/upb/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Support the UPB PIM."""

import voluptuous as vol

import homeassistant.helpers.config_validation as cv

CONF_NETWORK = "network"
DOMAIN = "upb"

ATTR_BLINK_RATE = "blink_rate"
ATTR_BRIGHTNESS = "brightness"
ATTR_BRIGHTNESS_PCT = "brightness_pct"
ATTR_RATE = "rate"
VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255))
VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100))
VALID_RATE = vol.All(vol.Coerce(float), vol.Clamp(min=-1, max=3600))

UPB_BRIGHTNESS_RATE_SCHEMA = vol.All(
cv.has_at_least_one_key(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT),
cv.make_entity_service_schema(
{
vol.Exclusive(ATTR_BRIGHTNESS, ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
vol.Exclusive(ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS): VALID_BRIGHTNESS_PCT,
vol.Optional(ATTR_RATE, default=-1): VALID_RATE,
}
),
)

UPB_BLINK_RATE_SCHEMA = {
vol.Required(ATTR_BLINK_RATE, default=0.5): vol.All(
vol.Coerce(float), vol.Range(min=0, max=4.25)
)
}
Loading

0 comments on commit efb5296

Please sign in to comment.