From f09cea149903365edff561c8d61d4bdd1fafcae8 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 28 Dec 2018 12:39:06 +0100 Subject: [PATCH] LCN component and light platform (#18621) * Initial commit of LCN component and light platform * Corrected pre-review comments * Fixed dimming behaviour in combination with transitions for lcn lights * Removed unused logger * Combined __init__.py and core.py into lcn.py component. Bumped to pypck==0.5.6 * Fixed .coveragerc * Bumped to pypck==0.5.7 * Bump to pypck==0.5.8 * Fixed requirements_all.txt * Moved unique generation of connection names to config schema's validator * Minor changes due to review comments. Bump to pypck==0.5.9. * Address_connection is passed into LcnDevice * Set should_poll property on LcnDevice to return False * Moved platform config validation to component. Load platform using discovery helper * Furtehr changes due to the review * Light configuration is set required as there are no other platforms up to now --- .coveragerc | 3 + homeassistant/components/lcn.py | 199 ++++++++++++++++++++++++++ homeassistant/components/light/lcn.py | 121 ++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 326 insertions(+) create mode 100644 homeassistant/components/lcn.py create mode 100644 homeassistant/components/light/lcn.py diff --git a/.coveragerc b/.coveragerc index 9b78f0696a8..c2c1f972788 100644 --- a/.coveragerc +++ b/.coveragerc @@ -213,6 +213,9 @@ omit = homeassistant/components/lametric.py homeassistant/components/*/lametric.py + homeassistant/components/lcn.py + homeassistant/components/*/lcn.py + homeassistant/components/linode.py homeassistant/components/*/linode.py diff --git a/homeassistant/components/lcn.py b/homeassistant/components/lcn.py new file mode 100644 index 00000000000..597acb3bb02 --- /dev/null +++ b/homeassistant/components/lcn.py @@ -0,0 +1,199 @@ +""" +Connects to LCN platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lcn/ +""" + +import logging +import re + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ADDRESS, CONF_HOST, CONF_LIGHTS, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['pypck==0.5.9'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'lcn' +DATA_LCN = 'lcn' +DEFAULT_NAME = 'pchk' + +CONF_SK_NUM_TRIES = 'sk_num_tries' +CONF_DIM_MODE = 'dim_mode' +CONF_OUTPUT = 'output' +CONF_TRANSITION = 'transition' +CONF_DIMMABLE = 'dimmable' +CONF_CONNECTIONS = 'connections' + +DIM_MODES = ['steps50', 'steps200'] +OUTPUT_PORTS = ['output1', 'output2', 'output3', 'output4'] + +# Regex for address validation +PATTERN_ADDRESS = re.compile('^((?P\\w+)\\.)?s?(?P\\d+)' + '\\.(?Pm|g)?(?P\\d+)$') + + +def has_unique_connection_names(connections): + """Validate that all connection names are unique. + + Use 'pchk' as default connection_name (or add a numeric suffix if + pchk' is already in use. + """ + for suffix, connection in enumerate(connections): + connection_name = connection.get(CONF_NAME) + if connection_name is None: + if suffix == 0: + connection[CONF_NAME] = DEFAULT_NAME + else: + connection[CONF_NAME] = '{}{:d}'.format(DEFAULT_NAME, suffix) + + schema = vol.Schema(vol.Unique()) + schema([connection.get(CONF_NAME) for connection in connections]) + return connections + + +def is_address(value): + """Validate the given address string. + + Examples for S000M005 at myhome: + myhome.s000.m005 + myhome.s0.m5 + myhome.0.5 ("m" is implicit if missing) + + Examples for s000g011 + myhome.0.g11 + myhome.s0.g11 + """ + matcher = PATTERN_ADDRESS.match(value) + if matcher: + is_group = (matcher.group('type') == 'g') + addr = (int(matcher.group('seg_id')), + int(matcher.group('id')), + is_group) + conn_id = matcher.group('conn_id') + return addr, conn_id + raise vol.error.Invalid('Not a valid address string.') + + +LIGHTS_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_ADDRESS): is_address, + vol.Required(CONF_OUTPUT): vol.All(vol.In(OUTPUT_PORTS), vol.Upper), + vol.Optional(CONF_DIMMABLE, default=False): vol.Coerce(bool), + vol.Optional(CONF_TRANSITION, default=0): + vol.All(vol.Coerce(float), vol.Range(min=0., max=486.), + lambda value: value * 1000), +}) + +CONNECTION_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SK_NUM_TRIES, default=3): cv.positive_int, + vol.Optional(CONF_DIM_MODE, default='steps50'): vol.All(vol.In(DIM_MODES), + vol.Upper), + vol.Optional(CONF_NAME): cv.string +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONNECTIONS): vol.All( + cv.ensure_list, has_unique_connection_names, [CONNECTION_SCHEMA]), + vol.Required(CONF_LIGHTS): vol.All(cv.ensure_list, [LIGHTS_SCHEMA]) + }) +}, extra=vol.ALLOW_EXTRA) + + +def get_connection(connections, connection_id=None): + """Return the connection object from list.""" + if connection_id is None: + connection = connections[0] + else: + for connection in connections: + if connection.connection_id == connection_id: + break + else: + raise ValueError('Unknown connection_id.') + return connection + + +async def async_setup(hass, config): + """Set up the LCN component.""" + import pypck + from pypck.connection import PchkConnectionManager + + hass.data[DATA_LCN] = {} + + conf_connections = config[DOMAIN][CONF_CONNECTIONS] + connections = [] + for conf_connection in conf_connections: + connection_name = conf_connection.get(CONF_NAME) + + settings = {'SK_NUM_TRIES': conf_connection[CONF_SK_NUM_TRIES], + 'DIM_MODE': pypck.lcn_defs.OutputPortDimMode[ + conf_connection[CONF_DIM_MODE]]} + + connection = PchkConnectionManager(hass.loop, + conf_connection[CONF_HOST], + conf_connection[CONF_PORT], + conf_connection[CONF_USERNAME], + conf_connection[CONF_PASSWORD], + settings=settings, + connection_id=connection_name) + + try: + # establish connection to PCHK server + await hass.async_create_task(connection.async_connect(timeout=15)) + connections.append(connection) + _LOGGER.info('LCN connected to "%s"', connection_name) + except TimeoutError: + _LOGGER.error('Connection to PCHK server "%s" failed.', + connection_name) + return False + + hass.data[DATA_LCN][CONF_CONNECTIONS] = connections + + hass.async_create_task( + async_load_platform(hass, 'light', DOMAIN, + config[DOMAIN][CONF_LIGHTS], config)) + + return True + + +class LcnDevice(Entity): + """Parent class for all devices associated with the LCN component.""" + + def __init__(self, config, address_connection): + """Initialize the LCN device.""" + import pypck + self.pypck = pypck + self.config = config + self.address_connection = address_connection + self._name = config[CONF_NAME] + + @property + def should_poll(self) -> bool: + """Lcn device entity pushes its state to HA.""" + return False + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + self.address_connection.register_for_inputs( + self.input_received) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + def input_received(self, input_obj): + """Set state/value when LCN input object (command) is received.""" + raise NotImplementedError('Pure virtual function.') diff --git a/homeassistant/components/light/lcn.py b/homeassistant/components/light/lcn.py new file mode 100644 index 00000000000..3f00d305a14 --- /dev/null +++ b/homeassistant/components/light/lcn.py @@ -0,0 +1,121 @@ +""" +Support for LCN lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.lcn/ +""" + +from homeassistant.components.lcn import ( + CONF_CONNECTIONS, CONF_DIMMABLE, CONF_OUTPUT, CONF_TRANSITION, DATA_LCN, + LcnDevice, get_connection) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, + Light) +from homeassistant.const import CONF_ADDRESS + +DEPENDENCIES = ['lcn'] + + +async def async_setup_platform(hass, hass_config, async_add_entities, + discovery_info=None): + """Set up the LCN light platform.""" + import pypck + + devices = [] + for config in discovery_info: + address, connection_id = config[CONF_ADDRESS] + addr = pypck.lcn_addr.LcnAddr(*address) + connections = hass.data[DATA_LCN][CONF_CONNECTIONS] + connection = get_connection(connections, connection_id) + address_connection = connection.get_address_conn(addr) + + devices.append(LcnOutputLight(config, address_connection)) + async_add_entities(devices) + + +class LcnOutputLight(LcnDevice, Light): + """Representation of a LCN light for output ports.""" + + def __init__(self, config, address_connection): + """Initialize the LCN light.""" + super().__init__(config, address_connection) + + self.output = self.pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]] + + self._transition = self.pypck.lcn_defs.time_to_ramp_value( + config[CONF_TRANSITION]) + self.dimmable = config[CONF_DIMMABLE] + + self._brightness = 255 + self._is_on = None + self._is_dimming_to_zero = False + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self.hass.async_create_task( + self.address_connection.activate_status_request_handler( + self.output)) + + @property + def supported_features(self): + """Flag supported features.""" + features = SUPPORT_TRANSITION + if self.dimmable: + features |= SUPPORT_BRIGHTNESS + return features + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def is_on(self): + """Return True if entity is on.""" + return self._is_on + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + self._is_on = True + self._is_dimming_to_zero = False + if ATTR_BRIGHTNESS in kwargs: + percent = int(kwargs[ATTR_BRIGHTNESS] / 255. * 100) + else: + percent = 100 + if ATTR_TRANSITION in kwargs: + transition = self.pypck.lcn_defs.time_to_ramp_value( + kwargs[ATTR_TRANSITION] * 1000) + else: + transition = self._transition + + self.address_connection.dim_output(self.output.value, percent, + transition) + await self.async_update_ha_state() + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + self._is_on = False + if ATTR_TRANSITION in kwargs: + transition = self.pypck.lcn_defs.time_to_ramp_value( + kwargs[ATTR_TRANSITION] * 1000) + else: + transition = self._transition + + self._is_dimming_to_zero = bool(transition) + + self.address_connection.dim_output(self.output.value, 0, transition) + await self.async_update_ha_state() + + def input_received(self, input_obj): + """Set light state when LCN input object (command) is received.""" + if not isinstance(input_obj, self.pypck.inputs.ModStatusOutput) or \ + input_obj.get_output_id() != self.output.value: + return + + self._brightness = int(input_obj.get_percent() / 100.*255) + if self.brightness == 0: + self._is_dimming_to_zero = False + if not self._is_dimming_to_zero: + self._is_on = self.brightness > 0 + self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index faec3c72956..6ad6544a1ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1143,6 +1143,9 @@ pyotp==2.2.6 # homeassistant.components.weather.openweathermap pyowm==2.10.0 +# homeassistant.components.lcn +pypck==0.5.9 + # homeassistant.components.media_player.pjlink pypjlink2==1.2.0