From 6ccb83584e3da104f65fa915bc48a761a6d507af Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Sat, 21 Apr 2018 08:34:42 +0200 Subject: [PATCH] Qwikswitch binary sensors (#14008) --- .../components/binary_sensor/qwikswitch.py | 70 +++++++++++++++++++ homeassistant/components/qwikswitch.py | 44 ++++++++---- homeassistant/components/sensor/qwikswitch.py | 12 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../{sensor => }/test_qwikswitch.py | 70 +++++++++++++------ 6 files changed, 160 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/binary_sensor/qwikswitch.py rename tests/components/{sensor => }/test_qwikswitch.py (55%) diff --git a/homeassistant/components/binary_sensor/qwikswitch.py b/homeassistant/components/binary_sensor/qwikswitch.py new file mode 100644 index 00000000000..067021b0c7a --- /dev/null +++ b/homeassistant/components/binary_sensor/qwikswitch.py @@ -0,0 +1,70 @@ +""" +Support for Qwikswitch Binary Sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.qwikswitch/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.qwikswitch import QSEntity, DOMAIN as QWIKSWITCH +from homeassistant.core import callback + +DEPENDENCIES = [QWIKSWITCH] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, _, add_devices, discovery_info=None): + """Add binary sensor from the main Qwikswitch component.""" + if discovery_info is None: + return + + qsusb = hass.data[QWIKSWITCH] + _LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s", + qsusb, discovery_info) + devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]] + add_devices(devs) + + +class QSBinarySensor(QSEntity, BinarySensorDevice): + """Sensor based on a Qwikswitch relay/dimmer module.""" + + _val = False + + def __init__(self, sensor): + """Initialize the sensor.""" + from pyqwikswitch import SENSORS + + super().__init__(sensor['id'], sensor['name']) + self.channel = sensor['channel'] + sensor_type = sensor['type'] + + self._decode, _ = SENSORS[sensor_type] + self._invert = not sensor.get('invert', False) + self._class = sensor.get('class', 'door') + + @callback + def update_packet(self, packet): + """Receive update packet from QSUSB.""" + val = self._decode(packet, channel=self.channel) + _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s", + self.entity_id, self.qsid, self.channel, val, packet) + if val is not None: + self._val = bool(val) + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Check if device is on (non-zero).""" + return self._val == self._invert + + @property + def unique_id(self): + """Return a unique identifier for this sensor.""" + return "qs{}:{}".format(self.qsid, self.channel) + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._class diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index 3dc16f513dc..f26318fa7a9 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -8,17 +8,18 @@ import logging import voluptuous as vol +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_URL, - CONF_SENSORS, CONF_SWITCHES) + CONF_SENSORS, CONF_SWITCHES, CONF_URL, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -from homeassistant.components.light import ATTR_BRIGHTNESS -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyqwikswitch==0.71'] +REQUIREMENTS = ['pyqwikswitch==0.8'] _LOGGER = logging.getLogger(__name__) @@ -28,6 +29,7 @@ CONF_DIMMER_ADJUST = 'dimmer_adjust' CONF_BUTTON_EVENTS = 'button_events' CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3)) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_URL, default='http://127.0.0.1:2020'): @@ -40,6 +42,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Optional('channel', default=1): int, vol.Required('name'): str, vol.Required('type'): str, + vol.Optional('class'): DEVICE_CLASSES_SCHEMA, + vol.Optional('invert'): bool })]), vol.Optional(CONF_SWITCHES, default=[]): vol.All( cv.ensure_list, [str]) @@ -115,7 +119,7 @@ class QSToggleEntity(QSEntity): async def async_setup(hass, config): """Qwiskswitch component setup.""" from pyqwikswitch.async_ import QSUsb - from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType + from pyqwikswitch import CMD_BUTTONS, QS_CMD, QS_ID, QSType, SENSORS # Add cmd's to in /&listen packets will fire events # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL] @@ -143,22 +147,39 @@ async def async_setup(hass, config): hass.data[DOMAIN] = qsusb - _new = {'switch': [], 'light': [], 'sensor': sensors} + comps = {'switch': [], 'light': [], 'sensor': [], 'binary_sensor': []} + + try: + for sens in sensors: + _, _type = SENSORS[sens['type']] + if _type is bool: + comps['binary_sensor'].append(sens) + continue + comps['sensor'].append(sens) + for _key in ('invert', 'class'): + if _key in sens: + _LOGGER.warning( + "%s should only be used for binary_sensors: %s", + _key, sens) + + except KeyError: + _LOGGER.warning("Sensor validation failed") + for qsid, dev in qsusb.devices.items(): if qsid in switches: if dev.qstype != QSType.relay: _LOGGER.warning( "You specified a switch that is not a relay %s", qsid) continue - _new['switch'].append(qsid) + comps['switch'].append(qsid) elif dev.qstype in (QSType.relay, QSType.dimmer): - _new['light'].append(qsid) + comps['light'].append(qsid) else: _LOGGER.warning("Ignored unknown QSUSB device: %s", dev) continue # Load platforms - for comp_name, comp_conf in _new.items(): + for comp_name, comp_conf in comps.items(): if comp_conf: load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config) @@ -190,9 +211,8 @@ async def async_setup(hass, config): @callback def async_stop(_): - """Stop the listener queue and clean up.""" + """Stop the listener.""" hass.data[DOMAIN].stop() - _LOGGER.info("Waiting for long poll to QSUSB to time out (max 30sec)") hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop) diff --git a/homeassistant/components/sensor/qwikswitch.py b/homeassistant/components/sensor/qwikswitch.py index ebd5f5254d4..1497b4ad5cc 100644 --- a/homeassistant/components/sensor/qwikswitch.py +++ b/homeassistant/components/sensor/qwikswitch.py @@ -36,18 +36,18 @@ class QSSensor(QSEntity): super().__init__(sensor['id'], sensor['name']) self.channel = sensor['channel'] - self.sensor_type = sensor['type'] + sensor_type = sensor['type'] - self._decode, self.unit = SENSORS[self.sensor_type] + self._decode, self.unit = SENSORS[sensor_type] if isinstance(self.unit, type): - self.unit = "{}:{}".format(self.sensor_type, self.channel) + self.unit = "{}:{}".format(sensor_type, self.channel) @callback def update_packet(self, packet): """Receive update packet from QSUSB.""" - val = self._decode(packet.get('data'), channel=self.channel) - _LOGGER.debug("Update %s (%s) decoded as %s: %s: %s", - self.entity_id, self.qsid, val, self.channel, packet) + val = self._decode(packet, channel=self.channel) + _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s", + self.entity_id, self.qsid, self.channel, val, packet) if val is not None: self._val = val self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index aeb5b84811e..bc3724d0930 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -898,7 +898,7 @@ pyowm==2.8.0 pypollencom==1.1.2 # homeassistant.components.qwikswitch -pyqwikswitch==0.71 +pyqwikswitch==0.8 # homeassistant.components.rainbird pyrainbird==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0d371996e36..cf4aa2e1b3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -149,7 +149,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.qwikswitch -pyqwikswitch==0.71 +pyqwikswitch==0.8 # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky diff --git a/tests/components/sensor/test_qwikswitch.py b/tests/components/test_qwikswitch.py similarity index 55% rename from tests/components/sensor/test_qwikswitch.py rename to tests/components/test_qwikswitch.py index d9dfe072fc0..76655f32816 100644 --- a/tests/components/sensor/test_qwikswitch.py +++ b/tests/components/test_qwikswitch.py @@ -13,17 +13,19 @@ _LOGGER = logging.getLogger(__name__) class AiohttpClientMockResponseList(list): - """List that fires an event on empty pop, for aiohttp Mocker.""" + """Return multiple values for aiohttp Mocker. + + aoihttp mocker uses decode to fetch the next value. + """ def decode(self, _): """Return next item from list.""" try: - res = list.pop(self) + res = list.pop(self, 0) _LOGGER.debug("MockResponseList popped %s: %s", res, self) return res except IndexError: - _LOGGER.debug("MockResponseList empty") - return "" + raise AssertionError("MockResponseList empty") async def wait_till_empty(self, hass): """Wait until empty.""" @@ -52,8 +54,8 @@ def aioclient_mock(): yield mock_session -async def test_sensor_device(hass, aioclient_mock): - """Test a sensor device.""" +async def test_binary_sensor_device(hass, aioclient_mock): + """Test a binary sensor device.""" config = { 'qwikswitch': { 'sensors': { @@ -67,21 +69,49 @@ async def test_sensor_device(hass, aioclient_mock): await async_setup_component(hass, QWIKSWITCH, config) await hass.async_block_till_done() - state_obj = hass.states.get('sensor.s1') - assert state_obj - assert state_obj.state == 'None' + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - LISTEN.append( # Close - """{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}""") + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}') + LISTEN.append('') # Will cause a sleep await hass.async_block_till_done() - state_obj = hass.states.get('sensor.s1') - assert state_obj.state == 'True' + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'on' - # Causes a 30second delay: can be uncommented when upstream library - # allows cancellation of asyncio.sleep(30) on failed packet ("") - # LISTEN.append( # Open - # """{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}""") - # await LISTEN.wait_till_empty(hass) - # state_obj = hass.states.get('sensor.s1') - # assert state_obj.state == 'False' + LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}') + hass.data[QWIKSWITCH]._sleep_task.cancel() + await LISTEN.wait_till_empty(hass) + state_obj = hass.states.get('binary_sensor.s1') + assert state_obj.state == 'off' + + +async def test_sensor_device(hass, aioclient_mock): + """Test a sensor device.""" + config = { + 'qwikswitch': { + 'sensors': { + 'name': 'ss1', + 'id': '@a00001', + 'channel': 1, + 'type': 'qwikcord', + } + } + } + await async_setup_component(hass, QWIKSWITCH, config) + await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None' + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + + LISTEN.append( + '{"id":"@a00001","name":"ss1","type":"rel",' + '"val":"4733800001a00000"}') + LISTEN.append('') # Will cause a sleep + await LISTEN.wait_till_empty(hass) # await hass.async_block_till_done() + + state_obj = hass.states.get('sensor.ss1') + assert state_obj.state == 'None'