From 416b8e0efe15566f476c9a516b5f15ff3ef09066 Mon Sep 17 00:00:00 2001 From: Kane610 Date: Fri, 12 May 2017 17:51:54 +0200 Subject: [PATCH] Axis component (#7381) * Added Axis hub, binary sensors and camera * Added Axis logo to static images * Added Axis logo to configurator Added Axis mdns discovery * Fixed flake8 and pylint comments * Missed a change from list to function call V5 of axis py * Added dependencies to requirements_all.txt * Clean up * Added files to coveragerc * Guide lines says to import function when needed, this makes Tox pass * Removed storing hass in config until at the end where I send it to axisdevice * Don't call update in the constructor * Don't keep hass private * Unnecessary lint ignore, following Baloobs suggestion of using NotImplementedError * Axis package not in pypi yet * Do not catch bare excepts. Device schema validations raise vol.Invalid. * setup_device still adds hass object to the config, so the need to remove it prior to writing config file still remains * Don't expect axis.conf contains correct values * Improved configuration validation * Trigger time better explains functionality than scan interval * Forgot to remove this earlier * Guideline says double qoutes for sentences * Return false from discovery if config file contains bad data * Keys in AXIS_DEVICES are serialnumber * Ordered imports in alphabetical order * Moved requirement to pypi * Moved update callback that handles trigger time to axis binary sensor * Renamed configurator instance to request_id since that is what it really is * Removed unnecessary configurator steps * Changed link in configurator to platform documentation * Add not-context-manager (#7523) * Add not-context-manager * Add missing comma * Threadsafe configurator (#7536) * Make Configurator thread safe, get_instance timing issues breaking configurator working on multiple devices * No blank lines allowed after function docstring * Fix comment Tox * Added Axis hub, binary sensors and camera * Added Axis logo to static images * Added Axis logo to configurator Added Axis mdns discovery * Fixed flake8 and pylint comments * Missed a change from list to function call V5 of axis py * Added dependencies to requirements_all.txt * Clean up * Added files to coveragerc * Guide lines says to import function when needed, this makes Tox pass * Removed storing hass in config until at the end where I send it to axisdevice * Don't call update in the constructor * Don't keep hass private * Unnecessary lint ignore, following Baloobs suggestion of using NotImplementedError * Axis package not in pypi yet * Do not catch bare excepts. Device schema validations raise vol.Invalid. * setup_device still adds hass object to the config, so the need to remove it prior to writing config file still remains * Don't expect axis.conf contains correct values * Improved configuration validation * Trigger time better explains functionality than scan interval * Forgot to remove this earlier * Guideline says double qoutes for sentences * Return false from discovery if config file contains bad data * Keys in AXIS_DEVICES are serialnumber * Ordered imports in alphabetical order * Moved requirement to pypi * Moved update callback that handles trigger time to axis binary sensor * Renamed configurator instance to request_id since that is what it really is * Removed unnecessary configurator steps * Changed link in configurator to platform documentation * No blank lines allowed after function docstring * No blank lines allowed after function docstring * Changed discovery to use axis instead of axis_mdns * Travis CI requested rerun of script/gen_requirements_all.py --- .coveragerc | 3 + homeassistant/components/axis.py | 314 ++++++++++++++++++ .../components/binary_sensor/axis.py | 68 ++++ homeassistant/components/camera/axis.py | 38 +++ homeassistant/components/discovery.py | 2 + .../frontend/www_static/images/logo_axis.png | Bin 0 -> 2858 bytes requirements_all.txt | 3 + 7 files changed, 428 insertions(+) create mode 100644 homeassistant/components/axis.py create mode 100644 homeassistant/components/binary_sensor/axis.py create mode 100644 homeassistant/components/camera/axis.py create mode 100644 homeassistant/components/frontend/www_static/images/logo_axis.png diff --git a/.coveragerc b/.coveragerc index 297c5337869..60df26cf153 100644 --- a/.coveragerc +++ b/.coveragerc @@ -20,6 +20,9 @@ omit = homeassistant/components/android_ip_webcam.py homeassistant/components/*/android_ip_webcam.py + homeassistant/components/axis.py + homeassistant/components/*/axis.py + homeassistant/components/bbb_gpio.py homeassistant/components/*/bbb_gpio.py diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py new file mode 100644 index 00000000000..593eee2356e --- /dev/null +++ b/homeassistant/components/axis.py @@ -0,0 +1,314 @@ +""" +Support for Axis devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/axis/ +""" + +import json +import logging +import os + +import voluptuous as vol + +from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED, + CONF_HOST, CONF_INCLUDE, CONF_NAME, + CONF_PASSWORD, CONF_TRIGGER_TIME, + CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) +from homeassistant.components.discovery import SERVICE_AXIS +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import discovery +from homeassistant.helpers.entity import Entity +from homeassistant.loader import get_component + + +REQUIREMENTS = ['axis==7'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'axis' +CONFIG_FILE = 'axis.conf' + +AXIS_DEVICES = {} + +EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound', + 'daynight', 'tampering', 'input'] + +PLATFORMS = ['camera'] + +AXIS_INCLUDE = EVENT_TYPES + PLATFORMS + +AXIS_DEFAULT_HOST = '192.168.0.90' +AXIS_DEFAULT_USERNAME = 'root' +AXIS_DEFAULT_PASSWORD = 'pass' + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_INCLUDE): + vol.All(cv.ensure_list, [vol.In(AXIS_INCLUDE)]), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int, + vol.Optional(ATTR_LOCATION, default=''): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + cv.slug: DEVICE_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +def request_configuration(hass, name, host, serialnumber): + """Request configuration steps from the user.""" + configurator = get_component('configurator') + + def configuration_callback(callback_data): + """Called when config is submitted.""" + if CONF_INCLUDE not in callback_data: + configurator.notify_errors(request_id, + "Functionality mandatory.") + return False + callback_data[CONF_INCLUDE] = callback_data[CONF_INCLUDE].split() + callback_data[CONF_HOST] = host + if CONF_NAME not in callback_data: + callback_data[CONF_NAME] = name + try: + config = DEVICE_SCHEMA(callback_data) + except vol.Invalid: + configurator.notify_errors(request_id, + "Bad input, please check spelling.") + return False + + if setup_device(hass, config): + config_file = _read_config(hass) + config_file[serialnumber] = dict(config) + del config_file[serialnumber]['hass'] + _write_config(hass, config_file) + configurator.request_done(request_id) + else: + configurator.notify_errors(request_id, + "Failed to register, please try again.") + return False + + title = '{} ({})'.format(name, host) + request_id = configurator.request_config( + hass, title, configuration_callback, + description='Functionality: ' + str(AXIS_INCLUDE), + entity_picture="/static/images/logo_axis.png", + link_name='Axis platform documentation', + link_url='https://home-assistant.io/components/axis/', + submit_caption="Confirm", + fields=[ + {'id': CONF_NAME, + 'name': "Device name", + 'type': 'text'}, + {'id': CONF_USERNAME, + 'name': "User name", + 'type': 'text'}, + {'id': CONF_PASSWORD, + 'name': 'Password', + 'type': 'password'}, + {'id': CONF_INCLUDE, + 'name': "Device functionality (space separated list)", + 'type': 'text'}, + {'id': ATTR_LOCATION, + 'name': "Physical location of device (optional)", + 'type': 'text'}, + {'id': CONF_TRIGGER_TIME, + 'name': "Sensor update interval (optional)", + 'type': 'number'}, + ] + ) + + +def setup(hass, base_config): + """Common setup for Axis devices.""" + def _shutdown(call): # pylint: disable=unused-argument + """Stop the metadatastream on shutdown.""" + for serialnumber, device in AXIS_DEVICES.items(): + _LOGGER.info("Stopping metadatastream for %s.", serialnumber) + device.stop_metadatastream() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + def axis_device_discovered(service, discovery_info): + """Called when axis devices has been found.""" + host = discovery_info['host'] + name = discovery_info['hostname'] + serialnumber = discovery_info['properties']['macaddress'] + + if serialnumber not in AXIS_DEVICES: + config_file = _read_config(hass) + if serialnumber in config_file: + try: + config = DEVICE_SCHEMA(config_file[serialnumber]) + except vol.Invalid as err: + _LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err) + return False + if not setup_device(hass, config): + _LOGGER.error("Couldn\'t set up %s", config['name']) + else: + request_configuration(hass, name, host, serialnumber) + + discovery.listen(hass, SERVICE_AXIS, axis_device_discovered) + + if DOMAIN in base_config: + for device in base_config[DOMAIN]: + config = base_config[DOMAIN][device] + if CONF_NAME not in config: + config[CONF_NAME] = device + if not setup_device(hass, config): + _LOGGER.error("Couldn\'t set up %s", config['name']) + + return True + + +def setup_device(hass, config): + """Set up device.""" + from axis import AxisDevice + + config['hass'] = hass + device = AxisDevice(config) # Initialize device + enable_metadatastream = False + + if device.serial_number is None: + # If there is no serial number a connection could not be made + _LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST]) + return False + + for component in config[CONF_INCLUDE]: + if component in EVENT_TYPES: + # Sensors are created by device calling event_initialized + # when receiving initialize messages on metadatastream + device.add_event_topic(convert(component, 'type', 'subscribe')) + if not enable_metadatastream: + enable_metadatastream = True + else: + discovery.load_platform(hass, component, DOMAIN, config) + + if enable_metadatastream: + device.initialize_new_event = event_initialized + device.initiate_metadatastream() + AXIS_DEVICES[device.serial_number] = device + return True + + +def _read_config(hass): + """Read Axis config.""" + path = hass.config.path(CONFIG_FILE) + + if not os.path.isfile(path): + return {} + + with open(path) as f_handle: + # Guard against empty file + return json.loads(f_handle.read() or '{}') + + +def _write_config(hass, config): + """Write Axis config.""" + data = json.dumps(config) + with open(hass.config.path(CONFIG_FILE), 'w', encoding='utf-8') as outfile: + outfile.write(data) + + +def event_initialized(event): + """Register event initialized on metadatastream here.""" + hass = event.device_config('hass') + discovery.load_platform(hass, + convert(event.topic, 'topic', 'platform'), + DOMAIN, {'axis_event': event}) + + +class AxisDeviceEvent(Entity): + """Representation of a Axis device event.""" + + def __init__(self, axis_event): + """Initialize the event.""" + self.axis_event = axis_event + self._event_class = convert(self.axis_event.topic, 'topic', 'class') + self._name = '{}_{}_{}'.format(self.axis_event.device_name, + convert(self.axis_event.topic, + 'topic', 'type'), + self.axis_event.id) + self.axis_event.callback = self._update_callback + + def _update_callback(self): + """Update the sensor's state, if needed.""" + self.update() + self.schedule_update_ha_state() + + @property + def name(self): + """Return the name of the event.""" + return self._name + + @property + def device_class(self): + """Return the class of the event.""" + return self._event_class + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes of the event.""" + attr = {} + + tripped = self.axis_event.is_tripped + attr[ATTR_TRIPPED] = 'True' if tripped else 'False' + + location = self.axis_event.device_config(ATTR_LOCATION) + if location: + attr[ATTR_LOCATION] = location + + return attr + + +def convert(item, from_key, to_key): + """Translate between Axis and HASS syntax.""" + for entry in REMAP: + if entry[from_key] == item: + return entry[to_key] + + +REMAP = [{'type': 'motion', + 'class': 'motion', + 'topic': 'tns1:VideoAnalytics/tnsaxis:MotionDetection', + 'subscribe': 'onvif:VideoAnalytics/axis:MotionDetection', + 'platform': 'binary_sensor'}, + {'type': 'vmd3', + 'class': 'motion', + 'topic': 'tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1', + 'subscribe': 'onvif:RuleEngine/axis:VMD3/vmd3_video_1', + 'platform': 'binary_sensor'}, + {'type': 'pir', + 'class': 'motion', + 'topic': 'tns1:Device/tnsaxis:Sensor/PIR', + 'subscribe': 'onvif:Device/axis:Sensor/axis:PIR', + 'platform': 'binary_sensor'}, + {'type': 'sound', + 'class': 'sound', + 'topic': 'tns1:AudioSource/tnsaxis:TriggerLevel', + 'subscribe': 'onvif:AudioSource/axis:TriggerLevel', + 'platform': 'binary_sensor'}, + {'type': 'daynight', + 'class': 'light', + 'topic': 'tns1:VideoSource/tnsaxis:DayNightVision', + 'subscribe': 'onvif:VideoSource/axis:DayNightVision', + 'platform': 'binary_sensor'}, + {'type': 'tampering', + 'class': 'safety', + 'topic': 'tns1:VideoSource/tnsaxis:Tampering', + 'subscribe': 'onvif:VideoSource/axis:Tampering', + 'platform': 'binary_sensor'}, + {'type': 'input', + 'class': 'input', + 'topic': 'tns1:Device/tnsaxis:IO/Port', + 'subscribe': 'onvif:Device/axis:IO/Port', + 'platform': 'sensor'}, ] diff --git a/homeassistant/components/binary_sensor/axis.py b/homeassistant/components/binary_sensor/axis.py new file mode 100644 index 00000000000..125e9b33bd7 --- /dev/null +++ b/homeassistant/components/binary_sensor/axis.py @@ -0,0 +1,68 @@ +""" +Support for Axis binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.axis/ +""" + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import (BinarySensorDevice) +from homeassistant.components.axis import (AxisDeviceEvent) +from homeassistant.const import (CONF_TRIGGER_TIME) +from homeassistant.helpers.event import track_point_in_utc_time +from homeassistant.util.dt import utcnow + +DEPENDENCIES = ['axis'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Axis device event.""" + add_devices([AxisBinarySensor(discovery_info['axis_event'], hass)], True) + + +class AxisBinarySensor(AxisDeviceEvent, BinarySensorDevice): + """Representation of a binary Axis event.""" + + def __init__(self, axis_event, hass): + """Initialize the binary sensor.""" + self.hass = hass + self._state = False + self._delay = axis_event.device_config(CONF_TRIGGER_TIME) + self._timer = None + AxisDeviceEvent.__init__(self, axis_event) + + @property + def is_on(self): + """Return true if event is active.""" + return self._state + + def update(self): + """Get the latest data and update the state.""" + self._state = self.axis_event.is_tripped + + def _update_callback(self): + """Update the sensor's state, if needed.""" + self.update() + + if self._timer is not None: + self._timer() + self._timer = None + + if self._delay > 0 and not self.is_on: + # Set timer to wait until updating the state + def _delay_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug("%s Called delayed (%s sec) update.", + self._name, self._delay) + self.schedule_update_ha_state() + self._timer = None + + self._timer = track_point_in_utc_time( + self.hass, _delay_update, + utcnow() + timedelta(seconds=self._delay)) + else: + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py new file mode 100644 index 00000000000..3de1c568745 --- /dev/null +++ b/homeassistant/components/camera/axis.py @@ -0,0 +1,38 @@ +""" +Support for Axis camera streaming. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.axis/ +""" +import logging + +from homeassistant.const import ( + CONF_NAME, CONF_USERNAME, CONF_PASSWORD, + CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) +from homeassistant.components.camera.mjpeg import ( + CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['axis'] +DOMAIN = 'axis' + + +def _get_image_url(host, mode): + if mode == 'mjpeg': + return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host) + elif mode == 'single': + return 'http://{}/axis-cgi/jpg/image.cgi'.format(host) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup Axis camera.""" + device_info = { + CONF_NAME: discovery_info['name'], + CONF_USERNAME: discovery_info['username'], + CONF_PASSWORD: discovery_info['password'], + CONF_MJPEG_URL: _get_image_url(discovery_info['host'], 'mjpeg'), + CONF_STILL_IMAGE_URL: _get_image_url(discovery_info['host'], 'single'), + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + } + add_devices([MjpegCamera(hass, device_info)]) diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 58fc56d2cba..4641241ea51 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -31,6 +31,7 @@ SERVICE_WEMO = 'belkin_wemo' SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_IKEA_TRADFRI = 'ikea_tradfri' SERVICE_HASSIO = 'hassio' +SERVICE_AXIS = 'axis' SERVICE_HANDLERS = { SERVICE_HASS_IOS_APP: ('ios', None), @@ -38,6 +39,7 @@ SERVICE_HANDLERS = { SERVICE_WEMO: ('wemo', None), SERVICE_IKEA_TRADFRI: ('tradfri', None), SERVICE_HASSIO: ('hassio', None), + SERVICE_AXIS: ('axis', None), 'philips_hue': ('light', 'hue'), 'google_cast': ('media_player', 'cast'), 'panasonic_viera': ('media_player', 'panasonic_viera'), diff --git a/homeassistant/components/frontend/www_static/images/logo_axis.png b/homeassistant/components/frontend/www_static/images/logo_axis.png new file mode 100644 index 0000000000000000000000000000000000000000..5eeb9b7b2a78f2e0dda673e40910361ce9bda343 GIT binary patch literal 2858 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 RZq5ZDzNf37%Q~loCICWgVV(d0 literal 0 HcmV?d00001 diff --git a/requirements_all.txt b/requirements_all.txt index 308b8f6545d..975ea766d3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,6 +70,9 @@ apns2==0.1.1 # homeassistant.components.light.avion # avion==0.6 +# homeassistant.components.axis +axis==7 + # homeassistant.components.sensor.modem_callerid basicmodem==0.7