forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Universal Powerline Bus (home-assistant#34692)
* 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
Showing
15 changed files
with
649 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.""" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) | ||
} |
Oops, something went wrong.