Created
May 6, 2018 07:57
-
-
Save OttoWinter/9522ad0419b50c1a5e57d8280b190e30 to your computer and use it in GitHub Desktop.
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
""" | |
Support for Konnected devices. | |
For more details about this component, please refer to the documentation at | |
https://home-assistant.io/components/konnected/ | |
""" | |
import logging | |
import hmac | |
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_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, | |
CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, ATTR_STATE) | |
from homeassistant.helpers import discovery | |
from homeassistant.helpers import config_validation as cv | |
_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()} | |
_BINARY_SENSOR_SCHEMA = vol.All( | |
vol.Schema({ | |
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): DEVICE_CLASSES_SCHEMA, | |
vol.Optional(CONF_NAME): cv.string, | |
}), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) | |
) | |
_SWITCH_SCHEMA = vol.All( | |
vol.Schema({ | |
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): cv.string, | |
vol.Optional('activation', default='high'): | |
vol.All(vol.Lower, vol.Any('high', 'low')) | |
}), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) | |
) | |
CONFIG_SCHEMA = vol.Schema( | |
{ | |
DOMAIN: vol.Schema({ | |
vol.Required('auth_token'): cv.string, | |
vol.Required(CONF_DEVICES): [{ | |
vol.Required(CONF_ID): cv.string, | |
vol.Optional(CONF_BINARY_SENSORS): vol.All( | |
cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), | |
vol.Optional(CONF_SWITCHES): vol.All( | |
cv.ensure_list, [_SWITCH_SCHEMA]), | |
}], | |
}), | |
}, | |
extra=vol.ALLOW_EXTRA, | |
) | |
DEPENDENCIES = ['http', 'discovery'] | |
ENDPOINT_ROOT = '/api/konnected' | |
UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') | |
async 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} | |
async def async_device_discovered(service, info): | |
"""Call when a Konnected device has been discovered.""" | |
_LOGGER.debug("Discovered a new Konnected device: %s", info) | |
host = info.get(CONF_HOST) | |
port = info.get(CONF_PORT) | |
device = KonnectedDevice(hass, host, port, cfg) | |
await device.async_setup() | |
discovery.async_listen( | |
hass, | |
SERVICE_KONNECTED, | |
async_device_discovered) | |
hass.http.register_view(KonnectedView(auth_token)) | |
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) | |
async def async_setup(self): | |
"""Set up a newly discovered Konnected device.""" | |
user_config = self.config() | |
if user_config: | |
_LOGGER.debug('Configuring Konnected device %s', self.device_id) | |
self.save_data() | |
await self.async_sync_device_config() | |
await discovery.async_load_platform( | |
self.hass, 'binary_sensor', | |
DOMAIN, {'device_id': self.device_id}) | |
await 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_BINARY_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.debug('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.debug('Set up actuator %s (initial state: %s)', | |
actuators[pin].get(CONF_NAME), | |
actuators[pin].get(ATTR_STATE)) | |
device_data = { | |
'client': self.client, | |
CONF_BINARY_SENSORS: sensors, | |
CONF_SWITCHES: actuators, | |
CONF_HOST: self.host, | |
CONF_PORT: self.port, | |
} | |
if CONF_DEVICES not in self.hass.data[DOMAIN]: | |
self.hass.data[DOMAIN][CONF_DEVICES] = {} | |
_LOGGER.debug('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_BINARY_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()] | |
async def async_sync_device_config(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.debug('%s: desired sensor config: %s', self.device_id, | |
desired_sensor_configuration) | |
_LOGGER.debug('%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.debug('%s: desired actuator config: %s', self.device_id, | |
desired_actuator_config) | |
_LOGGER.debug('%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.debug('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 | |
extra_urls = [UPDATE_ENDPOINT + '/{pin_num}/{state}'] | |
name = 'api:konnected' | |
requires_auth = False # Uses access token from configuration | |
def __init__(self, auth_token): | |
"""Initialize the view.""" | |
self.auth_token = auth_token | |
async def put(self, request: Request, device_id, | |
pin_num=None, state=None) -> Response: | |
"""Receive a sensor update via PUT request and async set state.""" | |
hass = request.app['hass'] | |
data = hass.data[DOMAIN] | |
payload = await request.json() | |
pin_num = pin_num or payload['pin'] | |
state = state or payload['state'] | |
auth = request.headers.get(AUTHORIZATION, None) | |
if not hmac.compare_digest('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_BINARY_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) | |
await entity.async_set_state(state) | |
return self.json_message('ok') |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment