diff --git a/.coveragerc b/.coveragerc index 589b6c48196..2c8be35e7b4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -35,6 +35,9 @@ omit = homeassistant/components/wink.py homeassistant/components/*/wink.py + homeassistant/components/zigbee.py + homeassistant/components/*/zigbee.py + homeassistant/components/zwave.py homeassistant/components/*/zwave.py diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py new file mode 100644 index 00000000000..72b2499b190 --- /dev/null +++ b/homeassistant/components/binary_sensor/zigbee.py @@ -0,0 +1,29 @@ +""" +homeassistant.components.binary_sensor.zigbee + +Contains functionality to use a ZigBee device as a binary sensor. +""" + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.zigbee import ( + ZigBeeDigitalIn, ZigBeeDigitalInConfig) + + +DEPENDENCIES = ["zigbee"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """ + Create and add an entity based on the configuration. + """ + add_entities([ + ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config)) + ]) + + +class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice): + """ + Use multiple inheritance to turn a ZigBeeDigitalIn into a + BinarySensorDevice. + """ + pass diff --git a/homeassistant/components/light/zigbee.py b/homeassistant/components/light/zigbee.py new file mode 100644 index 00000000000..6e1831d79bd --- /dev/null +++ b/homeassistant/components/light/zigbee.py @@ -0,0 +1,29 @@ +""" +homeassistant.components.light.zigbee + +Contains functionality to use a ZigBee device as a light. +""" + +from homeassistant.components.light import Light +from homeassistant.components.zigbee import ( + ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + + +DEPENDENCIES = ["zigbee"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """ + Create and add an entity based on the configuration. + """ + add_entities([ + ZigBeeLight(hass, ZigBeeDigitalOutConfig(config)) + ]) + + +class ZigBeeLight(ZigBeeDigitalOut, Light): + """ + Use multiple inheritance to turn an instance of ZigBeeDigitalOut into a + Light. + """ + pass diff --git a/homeassistant/components/sensor/zigbee.py b/homeassistant/components/sensor/zigbee.py new file mode 100644 index 00000000000..49da890923c --- /dev/null +++ b/homeassistant/components/sensor/zigbee.py @@ -0,0 +1,77 @@ +""" +homeassistant.components.sensor.zigbee + +Contains functionality to use a ZigBee device as a sensor. +""" + +import logging +from binascii import hexlify + +from homeassistant.core import JobPriority +from homeassistant.const import TEMP_CELCIUS +from homeassistant.helpers.entity import Entity +from homeassistant.components import zigbee + + +DEPENDENCIES = ["zigbee"] +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """ + Uses the 'type' config value to work out which type of ZigBee sensor we're + dealing with and instantiates the relevant classes to handle it. + """ + typ = config.get("type", "").lower() + if not typ: + _LOGGER.exception( + "Must include 'type' when configuring a ZigBee sensor.") + return + try: + sensor_class, config_class = TYPE_CLASSES[typ] + except KeyError: + _LOGGER.exception("Unknown ZigBee sensor type: %s", typ) + return + add_entities([sensor_class(hass, config_class(config))]) + + +class ZigBeeTemperatureSensor(Entity): + """ + Allows usage of an XBee Pro as a temperature sensor. + """ + def __init__(self, hass, config): + self._config = config + self._temp = None + # Get initial state + hass.pool.add_job( + JobPriority.EVENT_STATE, (self.update_ha_state, True)) + + @property + def name(self): + return self._config.name + + @property + def state(self): + return self._temp + + @property + def unit_of_measurement(self): + return TEMP_CELCIUS + + def update(self, *args): + try: + self._temp = zigbee.DEVICE.get_temperature(self._config.address) + except zigbee.ZIGBEE_TX_FAILURE: + _LOGGER.warning( + "Transmission failure when attempting to get sample from " + "ZigBee device at address: %s", hexlify(self._config.address)) + except zigbee.ZIGBEE_EXCEPTION as exc: + _LOGGER.exception( + "Unable to get sample from ZigBee device: %s", exc) + + +# This must be below the classes to which it refers. +TYPE_CLASSES = { + "temperature": (ZigBeeTemperatureSensor, zigbee.ZigBeeConfig), + "analog": (zigbee.ZigBeeAnalogIn, zigbee.ZigBeeAnalogInConfig) +} diff --git a/homeassistant/components/switch/zigbee.py b/homeassistant/components/switch/zigbee.py new file mode 100644 index 00000000000..3570db8f2ed --- /dev/null +++ b/homeassistant/components/switch/zigbee.py @@ -0,0 +1,28 @@ +""" +homeassistant.components.switch.zigbee + +Contains functionality to use a ZigBee device as a switch. +""" + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.zigbee import ( + ZigBeeDigitalOut, ZigBeeDigitalOutConfig) + + +DEPENDENCIES = ["zigbee"] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """ + Create and add an entity based on the configuration. + """ + add_entities([ + ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config)) + ]) + + +class ZigBeeSwitch(ZigBeeDigitalOut, SwitchDevice): + """ + Use multiple inheritance to turn a ZigBeeDigitalOut into a SwitchDevice. + """ + pass diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py new file mode 100644 index 00000000000..7c876738859 --- /dev/null +++ b/homeassistant/components/zigbee.py @@ -0,0 +1,327 @@ +""" +homeassistant.components.zigbee +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Sets up and provides access to a ZigBee device and contains generic entity +classes. +""" + +import logging +from binascii import hexlify, unhexlify + +from homeassistant.core import JobPriority +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers.entity import Entity + + +DOMAIN = "zigbee" +REQUIREMENTS = ("xbee-helper==0.0.6",) + +CONF_DEVICE = "device" +CONF_BAUD = "baud" + +DEFAULT_DEVICE = "/dev/ttyUSB0" +DEFAULT_BAUD = 9600 +DEFAULT_ADC_MAX_VOLTS = 1.2 + +# Copied from xbee_helper during setup() +GPIO_DIGITAL_OUTPUT_LOW = None +GPIO_DIGITAL_OUTPUT_HIGH = None +ADC_PERCENTAGE = None +ZIGBEE_EXCEPTION = None +ZIGBEE_TX_FAILURE = None + +DEVICE = None + +_LOGGER = logging.getLogger(__name__) + + +def setup(hass, config): + """ + Set up the connection to the ZigBee device and instantiate the helper + class for it. + """ + global DEVICE + global GPIO_DIGITAL_OUTPUT_LOW + global GPIO_DIGITAL_OUTPUT_HIGH + global ADC_PERCENTAGE + global ZIGBEE_EXCEPTION + global ZIGBEE_TX_FAILURE + + import xbee_helper.const as xb_const + from xbee_helper import ZigBee + from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure + from serial import Serial, SerialException + + GPIO_DIGITAL_OUTPUT_LOW = xb_const.GPIO_DIGITAL_OUTPUT_LOW + GPIO_DIGITAL_OUTPUT_HIGH = xb_const.GPIO_DIGITAL_OUTPUT_HIGH + ADC_PERCENTAGE = xb_const.ADC_PERCENTAGE + ZIGBEE_EXCEPTION = ZigBeeException + ZIGBEE_TX_FAILURE = ZigBeeTxFailure + + usb_device = config[DOMAIN].get(CONF_DEVICE, DEFAULT_DEVICE) + baud = int(config[DOMAIN].get(CONF_BAUD, DEFAULT_BAUD)) + try: + ser = Serial(usb_device, baud) + except SerialException as exc: + _LOGGER.exception("Unable to open serial port for ZigBee: %s", exc) + return False + DEVICE = ZigBee(ser) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_serial_port) + return True + + +def close_serial_port(*args): + """ + Close the serial port we're using to communicate with the ZigBee. + """ + DEVICE.zb.serial.close() + + +class ZigBeeConfig(object): + """ + Handles the fetching of configuration from the config file for any ZigBee + entity. + """ + def __init__(self, config): + self._config = config + self._should_poll = config.get("poll", True) + + @property + def name(self): + """ + The name given to the entity. + """ + return self._config["name"] + + @property + def address(self): + """ + If an address has been provided, unhexlify it, otherwise return None + as we're talking to our local ZigBee device. + """ + address = self._config.get("address") + if address is not None: + address = unhexlify(address) + return address + + @property + def should_poll(self): + """ + A bool depicting whether HA should repeatedly poll this device for its + value. + """ + return self._should_poll + + +class ZigBeePinConfig(ZigBeeConfig): + """ + Handles the fetching of configuration from the config file for a ZigBee + GPIO pin. + """ + @property + def pin(self): + """ + The GPIO pin number. + """ + return self._config["pin"] + + +class ZigBeeDigitalPinConfig(ZigBeePinConfig): + """ + Handles the fetching of configuration from the config file for a ZigBee + GPIO pin set to digital in or out. + """ + def __init__(self, config): + super(ZigBeeDigitalPinConfig, self).__init__(config) + self._bool2state, self._state2bool = self.boolean_maps + + @property + def boolean_maps(self): + """ + Create dicts to map booleans to pin high/low and vice versa. Depends on + the config item "on_state" which should be set to "low" or "high". + """ + if self._config.get("on_state", "").lower() == "low": + bool2state = { + True: GPIO_DIGITAL_OUTPUT_LOW, + False: GPIO_DIGITAL_OUTPUT_HIGH + } + else: + bool2state = { + True: GPIO_DIGITAL_OUTPUT_HIGH, + False: GPIO_DIGITAL_OUTPUT_LOW + } + state2bool = {v: k for k, v in bool2state.items()} + return bool2state, state2bool + + @property + def bool2state(self): + """ + A dictionary mapping booleans to GPIOSetting objects to translate + on/off as being pin high or low. + """ + return self._bool2state + + @property + def state2bool(self): + """ + A dictionary mapping GPIOSetting objects to booleans to translate + pin high/low as being on or off. + """ + return self._state2bool + +# Create an alias so that ZigBeeDigitalOutConfig has a logical opposite. +ZigBeeDigitalInConfig = ZigBeeDigitalPinConfig + + +class ZigBeeDigitalOutConfig(ZigBeeDigitalPinConfig): + """ + A subclass of ZigBeeDigitalPinConfig which sets _should_poll to default as + False instead of True. The value will still be overridden by the presence + of a 'poll' config entry. + """ + def __init__(self, config): + super(ZigBeeDigitalOutConfig, self).__init__(config) + self._should_poll = config.get("poll", False) + + +class ZigBeeAnalogInConfig(ZigBeePinConfig): + """ + Handles the fetching of configuration from the config file for a ZigBee + GPIO pin set to analog in. + """ + @property + def max_voltage(self): + """ + The voltage at which the ADC will report its highest value. + """ + return float(self._config.get("max_volts", DEFAULT_ADC_MAX_VOLTS)) + + +class ZigBeeDigitalIn(Entity): + """ + Represents a GPIO pin configured as a digital input. + """ + def __init__(self, hass, config): + self._config = config + self._state = False + # Get initial state + hass.pool.add_job( + JobPriority.EVENT_STATE, (self.update_ha_state, True)) + + @property + def name(self): + return self._config.name + + @property + def should_poll(self): + return self._config.should_poll + + @property + def is_on(self): + """ + Returns True if the Entity is on, else False. + """ + return self._state + + def update(self): + """ + Ask the ZigBee device what its output is set to. + """ + try: + pin_state = DEVICE.get_gpio_pin( + self._config.pin, + self._config.address) + except ZIGBEE_TX_FAILURE: + _LOGGER.warning( + "Transmission failure when attempting to get sample from " + "ZigBee device at address: %s", hexlify(self._config.address)) + return + except ZIGBEE_EXCEPTION as exc: + _LOGGER.exception( + "Unable to get sample from ZigBee device: %s", exc) + return + self._state = self._config.state2bool[pin_state] + + +class ZigBeeDigitalOut(ZigBeeDigitalIn): + """ + Adds functionality to ZigBeeDigitalIn to control an output. + """ + def _set_state(self, state): + try: + DEVICE.set_gpio_pin( + self._config.pin, + self._config.bool2state[state], + self._config.address) + except ZIGBEE_TX_FAILURE: + _LOGGER.warning( + "Transmission failure when attempting to set output pin on " + "ZigBee device at address: %s", hexlify(self._config.address)) + return + except ZIGBEE_EXCEPTION as exc: + _LOGGER.exception( + "Unable to set digital pin on ZigBee device: %s", exc) + return + self._state = state + if not self.should_poll: + self.update_ha_state() + + def turn_on(self, **kwargs): + """ + Set the digital output to its 'on' state. + """ + self._set_state(True) + + def turn_off(self, **kwargs): + """ + Set the digital output to its 'off' state. + """ + self._set_state(False) + + +class ZigBeeAnalogIn(Entity): + """ + Represents a GPIO pin configured as an analog input. + """ + def __init__(self, hass, config): + self._config = config + self._value = None + # Get initial state + hass.pool.add_job( + JobPriority.EVENT_STATE, (self.update_ha_state, True)) + + @property + def name(self): + return self._config.name + + @property + def should_poll(self): + return self._config.should_poll + + @property + def state(self): + return self._value + + @property + def unit_of_measurement(self): + return "%" + + def update(self): + """ + Get the latest reading from the ADC. + """ + try: + self._value = DEVICE.read_analog_pin( + self._config.pin, + self._config.max_voltage, + self._config.address, + ADC_PERCENTAGE) + except ZIGBEE_TX_FAILURE: + _LOGGER.warning( + "Transmission failure when attempting to get sample from " + "ZigBee device at address: %s", hexlify(self._config.address)) + except ZIGBEE_EXCEPTION as exc: + _LOGGER.exception( + "Unable to get sample from ZigBee device: %s", exc) diff --git a/requirements_all.txt b/requirements_all.txt index a3b01a48097..8c08df03d73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -219,5 +219,8 @@ radiotherm==1.2 # homeassistant.components.verisure vsure==0.4.8 +# homeassistant.components.zigbee +xbee-helper==0.0.6 + # homeassistant.components.zwave pydispatcher==2.0.5