diff --git a/.coveragerc b/.coveragerc index eae6498cd0a09..d70f6a0788ad9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -151,6 +151,9 @@ omit = homeassistant/components/knx.py homeassistant/components/*/knx.py + homeassistant/components/konnected.py + homeassistant/components/*/konnected.py + homeassistant/components/lametric.py homeassistant/components/*/lametric.py diff --git a/homeassistant/components/binary_sensor/konnected.py b/homeassistant/components/binary_sensor/konnected.py new file mode 100644 index 0000000000000..8648c1921d320 --- /dev/null +++ b/homeassistant/components/binary_sensor/konnected.py @@ -0,0 +1,74 @@ +""" +Support for wired binary sensors attached to a Konnected device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.konnected/ +""" +import asyncio +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.konnected import (DOMAIN, PIN_TO_ZONE) +from homeassistant.const import ( + CONF_DEVICES, CONF_TYPE, CONF_NAME, CONF_SENSORS, ATTR_STATE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up binary sensors attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[DOMAIN] + device_id = discovery_info['device_id'] + sensors = [KonnectedBinarySensor(device_id, pin_num, pin_data) + for pin_num, pin_data in + data[CONF_DEVICES][device_id][CONF_SENSORS].items()] + async_add_devices(sensors) + + +class KonnectedBinarySensor(BinarySensorDevice): + """Representation of a Konnected binary sensor.""" + + def __init__(self, device_id, pin_num, data): + """Initialize the binary sensor.""" + self._data = data + self._device_id = device_id + self._pin_num = pin_num + self._state = self._data.get(ATTR_STATE) + self._device_class = self._data.get(CONF_TYPE) + self._name = self._data.get(CONF_NAME, 'Konnected {} Zone {}'.format( + device_id, PIN_TO_ZONE[pin_num])) + self._data['entity'] = self + _LOGGER.debug('Created new Konnected sensor: %s', self._name) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._state + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @asyncio.coroutine + def async_set_state(self, state): + """Update the sensor's state.""" + self._state = state + self._data[ATTR_STATE] = state + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index f0ebcba836677..d8c1fe7d938ba 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -37,6 +37,7 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' SERVICE_HUE = 'philips_hue' +SERVICE_KONNECTED = 'konnected' SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' @@ -60,6 +61,7 @@ SERVICE_TELLDUSLIVE: ('tellduslive', None), SERVICE_DAIKIN: ('daikin', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), + SERVICE_KONNECTED: ('konnected', None), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), @@ -189,6 +191,7 @@ def _discover(netdisco): for disc in netdisco.discover(): for service in netdisco.get_info(disc): results.append((disc, service)) + finally: netdisco.stop() diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py new file mode 100755 index 0000000000000..22be3b00ad7b4 --- /dev/null +++ b/homeassistant/components/konnected.py @@ -0,0 +1,398 @@ +""" +Support for Konnected devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/konnected/ +""" +import asyncio +import logging +import voluptuous as vol + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response # NOQA + +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.discovery import SERVICE_KONNECTED +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, + CONF_DEVICES, CONF_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, + CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, ATTR_STATE, ATTR_ENTITY_ID) +from homeassistant.helpers import discovery, config_validation + +''' Entity based lookup for services''' +from homeassistant.helpers.entity_component import EntityComponent +from datetime import timedelta +SCAN_INTERVAL = timedelta(seconds=30) +GROUP_NAME_ALL_SWITCHES = 'all switches' +ENTITY_ID_FORMAT = 'switch.{}' + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['konnected==0.1.2'] + +DOMAIN = 'konnected' + +PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6} +ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema({ + vol.Required('auth_token'): config_validation.string, + vol.Required(CONF_DEVICES): [{ + vol.Required(CONF_ID, default=''): config_validation.string, + vol.Optional(CONF_SENSORS): [{ + vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), + vol.Required(CONF_TYPE, default='motion'): + DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): config_validation.string, + }], + vol.Optional(CONF_SWITCHES): [{ + vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 'a_pin'): vol.Any(*ZONE_TO_PIN), + vol.Optional(CONF_NAME): config_validation.string, + vol.Required('activation', default='high'): + vol.All(vol.Lower, vol.Any('high', 'low')) + }], + }], + }), + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_BEEP_DEVICE_SCHEMA = vol.Schema( + { + vol.Required('device_id'): config_validation.string, + vol.Required(CONF_PIN): vol.Any(*PIN_TO_ZONE), + vol.Optional('momentary'): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional('times'): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional('pause'): vol.All(vol.Coerce(int), vol.Range(min=0)), + } +) + +SERVICE_BEEP_DEVICE_BY_ENTITY_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): config_validation.entity_ids, + vol.Optional('momentary'): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional('times'): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional('pause'): vol.All(vol.Coerce(int), vol.Range(min=0)), + } +) + +DEPENDENCIES = ['http'] + +ENDPOINT_ROOT = '/api/konnected' +UPDATE_ENDPOINT = ( + ENDPOINT_ROOT + + r'/device/{device_id:[a-zA-Z0-9]+}/{pin_num:[0-9]}/{state:[01]}') + + +@asyncio.coroutine +def async_setup(hass, config): + """Set up the Konnected platform.""" + cfg = config.get(DOMAIN) + if cfg is None: + cfg = {} + + auth_token = cfg.get('auth_token') + if DOMAIN not in hass.data: + hass.data[DOMAIN] = {'auth_token': auth_token} + + @asyncio.coroutine + def async_device_discovered(service, info): + """Call when a Konnected device has been discovered.""" + _LOGGER.info("Discovered a new Konnected device: %s", info) + host = info.get(CONF_HOST) + port = info.get(CONF_PORT) + + device = KonnectedDevice(hass, host, port, cfg) + device.setup() + + discovery.async_listen( + hass, + SERVICE_KONNECTED, + async_device_discovered) + + hass.http.register_view(KonnectedView(auth_token)) + + """Basic beep device service + {"device_id":"ecfabc07e14b", "pin":7, "momentary":100, "times":3, "pause":100} + """ + @asyncio.coroutine + def beep_device(call): + data = hass.data[DOMAIN] + + #find device with matching entity name + device_id = call.data.get('device_id') + device = data[CONF_DEVICES].get(device_id) + if device is None: + _LOGGER.error("Invalid device id specified: %s", device_id) + return False + client = data[CONF_DEVICES][device_id]['client'] + + device_pin = call.data.get(CONF_PIN) + #check if pin is valid switch + if device_pin not in data[CONF_DEVICES][device_id][CONF_SWITCHES]: + _LOGGER.error("Specified pin not configured: %s", device_pin) + return False + + flashcount = call.data.get('momentary', None) + times = call.data.get('times', None) + pause = call.data.get('pause', None) + + #def put_device(self, pin, state, momentary=None, times=None, pause=None): + #state 1 == ON + client.put_device(device_pin, 1, flashcount, times, pause) + + """Register Basic beep device service """ + hass.services.async_register(DOMAIN, 'beepDevice', beep_device, schema=SERVICE_BEEP_DEVICE_SCHEMA) + + """Entity based beep device service + {"entity_id":"switch.Piezo", "momentary":100, "times":3, "pause":100} + """ + @asyncio.coroutine + def beep_device_by_entity(call): + #FIXME - how to get drop down? + component = EntityComponent(_LOGGER, 'switch', hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) + target_switches = component.async_extract_from_service(call) + for switch in target_switches: + _LOGGER.info("Extracted switch name: %s", switch) + + data = hass.data[DOMAIN] + + #get entity name from call - find hosting device + for entityId in call.data.get('entity_id'): + #_LOGGER.info("***Entity id: %s", entityId) + for device in data[CONF_DEVICES]: + for switch in data[CONF_DEVICES][device][CONF_SWITCHES]: + kswitch = data[CONF_DEVICES][device][CONF_SWITCHES][switch] + #_LOGGER.info("Device: %s; Configured switch name: %s", device, kswitch['name']) + targetName = "switch." + kswitch['name'] + #_LOGGER.info("***Target name: %s", targetName) + if ( targetName.lower() == entityId.lower() ): + _LOGGER.info("Found switch %s on device: %s, at pin: %s", entityId, device, switch) + + #TODO + client = data[CONF_DEVICES][device]['client'] + flashcount = call.data.get('momentary', None) + times = call.data.get('times', None) + pause = call.data.get('pause', None) + + #def put_device(self, pin, state, momentary=None, times=None, pause=None): + #state 1 == ON + client.put_device(switch, 1, flashcount, times, pause) + + return True + + _LOGGER.error("Specified entity is not found as a Konnected device: %s", entityId) + return False + + """Register Entity based beep device service """ + hass.services.async_register(DOMAIN, 'beepDevice_ByEntityId', beep_device_by_entity, schema=SERVICE_BEEP_DEVICE_BY_ENTITY_SCHEMA) + + return True + + +class KonnectedDevice(object): + """A representation of a single Konnected device.""" + + def __init__(self, hass, host, port, config): + """Initialize the Konnected device.""" + self.hass = hass + self.host = host + self.port = port + self.user_config = config + + import konnected + self.client = konnected.Client(host, str(port)) + self.status = self.client.get_status() + _LOGGER.info('Initialized Konnected device %s', self.device_id) + + def setup(self): + """Set up a newly discovered Konnected device.""" + user_config = self.config() + if user_config: + _LOGGER.info('Configuring Konnected device %s', self.device_id) + self.save_data() + self.sync_device() + self.hass.async_add_job( + discovery.async_load_platform( + self.hass, 'binary_sensor', + DOMAIN, {'device_id': self.device_id})) + self.hass.async_add_job( + discovery.async_load_platform( + self.hass, 'switch', DOMAIN, + {'device_id': self.device_id})) + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.status['mac'].replace(':', '') + + def config(self): + """Return an object representing the user defined configuration.""" + device_id = self.device_id + valid_keys = [device_id, device_id.upper(), + device_id[6:], device_id.upper()[6:]] + configured_devices = self.user_config[CONF_DEVICES] + return next((device for device in + configured_devices if device[CONF_ID] in valid_keys), + None) + + def save_data(self): + """Save the device configuration to `hass.data`. + + TODO: This can probably be refactored and tidied up. + """ + sensors = {} + for entity in self.config().get(CONF_SENSORS) or []: + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] + else: + pin = entity[CONF_PIN] + + sensor_status = next((sensor for sensor in + self.status.get('sensors') if + sensor.get(CONF_PIN) == pin), {}) + if sensor_status.get(ATTR_STATE): + initial_state = bool(int(sensor_status.get(ATTR_STATE))) + else: + initial_state = None + + sensors[pin] = { + CONF_TYPE: entity[CONF_TYPE], + CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state + } + _LOGGER.info('Set up sensor %s (initial state: %s)', + sensors[pin].get('name'), + sensors[pin].get(ATTR_STATE)) + + actuators = {} + for entity in self.config().get(CONF_SWITCHES) or []: + if 'zone' in entity: + pin = ZONE_TO_PIN[entity['zone']] + else: + pin = entity['pin'] + + actuator_status = next((actuator for actuator in + self.status.get('actuators') if + actuator.get('pin') == pin), {}) + if actuator_status.get(ATTR_STATE): + initial_state = bool(int(actuator_status.get(ATTR_STATE))) + else: + initial_state = None + + actuators[pin] = { + CONF_NAME: entity.get( + CONF_NAME, 'Konnected {} Actuator {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state, + 'activation': entity['activation'], + } + _LOGGER.info('Set up actuator %s (initial state: %s)', + actuators[pin].get(CONF_NAME), + actuators[pin].get(ATTR_STATE)) + + device_data = { + 'client': self.client, + CONF_SENSORS: sensors, + CONF_SWITCHES: actuators, + CONF_HOST: self.host, + CONF_PORT: self.port, + } + + if 'devices' not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][CONF_DEVICES] = {} + + _LOGGER.info('Storing data in hass.data[konnected]: %s', device_data) + self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + + @property + def stored_configuration(self): + """Return the configuration stored in `hass.data` for this device.""" + return self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] + + def sensor_configuration(self): + """Return the configuration map for syncing sensors.""" + return [{'pin': p} for p in + self.stored_configuration[CONF_SENSORS].keys()] + + def actuator_configuration(self): + """Return the configuration map for syncing actuators.""" + return [{'pin': p, + 'trigger': (0 if data.get('activation') in [0, 'low'] else 1)} + for p, data in + self.stored_configuration[CONF_SWITCHES].items()] + + def sync_device(self): + """Sync the new pin configuration to the Konnected device.""" + desired_sensor_configuration = self.sensor_configuration() + current_sensor_configuration = [ + {'pin': s[CONF_PIN]} for s in self.status.get('sensors')] + _LOGGER.info('%s: desired sensor config: %s', self.device_id, + desired_sensor_configuration) + _LOGGER.info('%s: current sensor config: %s', self.device_id, + current_sensor_configuration) + + desired_actuator_config = self.actuator_configuration() + current_actuator_config = self.status.get('actuators') + _LOGGER.info('%s: desired actuator config: %s', self.device_id, + desired_actuator_config) + _LOGGER.info('%s: current actuator config: %s', self.device_id, + current_actuator_config) + + if (desired_sensor_configuration != current_sensor_configuration) or \ + (current_actuator_config != desired_actuator_config): + _LOGGER.info('pushing settings to device %s', self.device_id) + self.client.put_settings( + desired_sensor_configuration, + desired_actuator_config, + self.hass.data[DOMAIN].get('auth_token'), + self.hass.config.api.base_url + ENDPOINT_ROOT + ) + + +class KonnectedView(HomeAssistantView): + """View creates an endpoint to receive push updates from the device.""" + + url = UPDATE_ENDPOINT + name = 'api:konnected' + requires_auth = False # Uses access token from configuration + + def __init__(self, auth_token): + """Initialize the view.""" + self.auth_token = auth_token + + @asyncio.coroutine + def put(self, request: Request, device_id, pin_num, state) -> Response: + """Receive a sensor update via PUT request and async set state.""" + hass = request.app['hass'] + data = hass.data[DOMAIN] + + auth = request.headers.get(AUTHORIZATION, None) + if 'Bearer {}'.format(self.auth_token) != auth: + return self.json_message( + "unauthorized", status_code=HTTP_UNAUTHORIZED) + pin_num = int(pin_num) + state = bool(int(state)) + device = data[CONF_DEVICES].get(device_id) + if device is None: + return self.json_message('unregistered device', + status_code=HTTP_BAD_REQUEST) + pin_data = device[CONF_SENSORS].get(pin_num) or \ + device[CONF_SWITCHES].get(pin_num) + + if pin_data is None: + return self.json_message('unregistered sensor/actuator', + status_code=HTTP_BAD_REQUEST) + entity = pin_data.get('entity') + if entity is None: + return self.json_message('uninitialized sensor/actuator', + status_code=HTTP_INTERNAL_SERVER_ERROR) + + yield from entity.async_set_state(state) + return self.json_message('ok') diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml old mode 100644 new mode 100755 index 746c3c7f4838f..9383c9b6657fb --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -395,18 +395,6 @@ snips: intent_filter: description: Optional Array of Strings - A list of intents names to restrict the NLU resolution to on the first query. example: turnOnLights, turnOffLights - feedback_on: - description: Turns feedback sounds on. - fields: - site_id: - description: Site to turn sounds on, defaults to all sites (optional) - example: bedroom - feedback_off: - description: Turns feedback sounds off. - fields: - site_id: - description: Site to turn sounds on, defaults to all sites (optional) - example: bedroom input_boolean: toggle: @@ -556,3 +544,40 @@ xiaomi_aqara: device_id: description: Hardware address of the device to remove. example: 158d0000000000 + +konnected: + #{"device_id":"34ce00880088", "pin":7, "momentary":100, "times":3, "pause":100} + beepdevice: + description: Beep (turn on) specified device. + fields: + device_id: + description: MAC address of Konnected board hosting device. + example: '34ce00880088' + pin: + description: Pin number on board of device. + example: 7 + momentary: + description: Milliseconds for the device to be turned on. (Optional, default = 1) + example: 100 + times: + description: Number of times to turn the device on. (Optional, default = 1) + example: 3 + pause: + description: Milliseconds to pause between turning on/off (Optional, default = 0) + example: 100 + #{"entity_id":"switch.buzzer", "momentary":100, "times":3, "pause":100} + beepdevice_byentityid: + description: Beep (turn on) specified entity(s). The entity must be a switch. Will search for matching name across all known Konnected boards. + fields: + entity_id: + description: Entity id of device to be turned on. + example: 'switch.buzzer' + momentary: + description: Milliseconds for the device to be turned on. (Optional, default = 1) + example: 100 + times: + description: Number of times to turn the device on. (Optional, default = 1) + example: 3 + pause: + description: Milliseconds to pause between turning on/off (Optional, default = 0) + example: 100 diff --git a/homeassistant/components/switch/konnected.py b/homeassistant/components/switch/konnected.py new file mode 100644 index 0000000000000..57688a8bd8925 --- /dev/null +++ b/homeassistant/components/switch/konnected.py @@ -0,0 +1,84 @@ +""" +Support for wired switches attached to a Konnected device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.konnected/ +""" + +import asyncio +import logging + +from homeassistant.components.konnected import (DOMAIN, PIN_TO_ZONE) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import (CONF_DEVICES, CONF_SWITCHES, ATTR_STATE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set switches attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[DOMAIN] + device_id = discovery_info['device_id'] + client = data[CONF_DEVICES][device_id]['client'] + switches = [KonnectedSwitch(device_id, pin_num, pin_data, client) + for pin_num, pin_data in + data[CONF_DEVICES][device_id][CONF_SWITCHES].items()] + async_add_devices(switches) + + +class KonnectedSwitch(ToggleEntity): + """Representation of a Konnected switch.""" + + def __init__(self, device_id, pin_num, data, client): + """Initialize the switch.""" + self._data = data + self._device_id = device_id + self._pin_num = pin_num + self._state = self._data.get(ATTR_STATE) + self._activation = self._data.get('activation', 'high') + self._name = self._data.get( + 'name', 'Konnected {} Actuator {}'.format( + device_id, PIN_TO_ZONE[pin_num])) + self._data['entity'] = self + self._client = client + _LOGGER.debug('Created new switch: %s', self._name) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def turn_on(self, **kwargs): + """Send a command to turn on the switch.""" + self._client.put_device(self._pin_num, int(self._activation == 'high')) + self._set_state(True) + + def turn_off(self, **kwargs): + """Send a command to turn off the switch.""" + self._client.put_device(self._pin_num, int(self._activation == 'low')) + self._set_state(False) + + def _set_state(self, state): + self._state = state + self._data[ATTR_STATE] = state + self.schedule_update_ha_state() + _LOGGER.debug('Setting status of %s actuator pin %s to %s', + self._device_id, self.name, state) + + @asyncio.coroutine + def async_set_state(self, state): + """Update the switch's state.""" + self._state = state + self._data[ATTR_STATE] = state + self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index d6f811ba68c7b..d34e789689595 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -464,6 +464,9 @@ keyring==12.0.0 # homeassistant.scripts.keyring keyrings.alt==3.0 +# homeassistant.components.konnected +konnected==0.1.2 + # homeassistant.components.eufy lakeside==0.5