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 zcmb7G3tLiI7v5mth^CWn%MzVRkrC7xr}9#%q3EcgWQqpfKseuFzWD=l&U2o9o^{sR>s{}D&)S=H$jfad z#tZ`hSV?ksJpup%eno&U(D2}&P;wd`&_Nz=w@96pWQ-k~n|K7E_GqIb7_iu2_4azK)zAfq; z#7PlFu^jv7c}`+Vk&$DeuKQR_9qag9?UiIp;$HMR_LK+aO5&TgCY+KgIG$~!i!(fA4MCaP7hjvh&t_VZ4 zpO+uc>gG*zAvx^%hFl8T>lIX=kC7;I5OP6fc|Z^%>?q50eoCQ$&+nHDGZ$loQxJC- zob@ce7|F@*hH869!3L&?PPCfuU5*?T7Nf!twj~O>sQ(wNbKOllpbWntd}*1*E5^ow z&K?onxEEq+LT0513b^2-BxYsh%vDX=TP34Y<=gP(NRFkU-}_|>0tLxAv2JR^0o78HRPsm{Y`Z!yp1d z7B64?KmRC>!Xa&a99W&C^0pdjCcCD?^>UiKiR^zU5$3;NmaPp(9R(W)G^ND8!blh~ zqe8Ex!q+nRH$#ShEOcV5K+%Hj24yRefdkD`}mWz9X z45?YVraF}aSI6k)&CInRIy*$XvA;9b0Wv+PEx!iC@h;lI^JzvLl1vdpnG4G$8S!xyaV^I`Dgx#?D&( zYy+$t_aa68QXSHefsBQixr#|LR7VItpctt^>W|EHH!4=dd^L4G`b_6{*rt4-I2l}}+d z5@P8jUc#}j2);4;akSX_t9djV^|Zig_S;j{X@j4{mHA?o;5N^{i^WecV)Uuq6lo)} zlo!``%^oc5-J?=H2wHA4ie6Z_Z#Xa6MPbzLpqm=a4x~;Qbo9` zMia%(sdJmAEy7W^pCVO;;1S(necRN5%I3V{GqYo*ZWC`CspPdLuQb3pXRyWAR(f1` zEq}+omFH)E_2DRd0`iw6)xR{S$XXiK<}HSOnDnA98^aYwE!@2xG3yaz4t6`Jvpmzb zKUlf=_0O-U{nMAZ$bJhj{y6nZl#RNqp)1X^&uGOYn;^#|SxF&*w|9IFYU$WBx7a=n@}$Z@WL{Nrk{USKHE&_^THq63A&%D&yb;HKLm@vu_OO5 z-to@>`I;Mw$PQYz*)=^6gDuone`Ds(qk#62mt1{xyO)8PNfou#yfr=bBCx+nGm#1B zD)MkIQWMLt?G0GmJiLXGq4GRjlyKirTe`|0fm>)ymMK&Rs`GGv)?+6=AZl<6$CT6_ z7bDY@nSQ#x?@70}qvXWSmY2kSqwp7xrH&BggIHN8PbEzUnk ze@LCYyT{iRU-P2yV{lMhXCWMwc_wfq_CK#i4i2#BZQ&J$$o0@%vbp9%(f;QHuL5Fe zWyr7x4~NhsgWLTW#R$k%h)&@<7x`Ak%({-gVnG1a4vaSWJ#Q*&W$iOEEv@!Z-Nbon zfUmW>Rs4gr4(>l}`(W=am4PD#)b45>V;Ehqr7^SgI3Pm;Eps0_@|F2L@Y#d zH9w#8F#NuNL-oeR%q~FH**JhvO zBJZ1no3xZ%TvR1C-}Ef7Rm*F=f@We*>qM|0mkR>cU>Wg4pD343YuP7NZxnw-Y>={T zSo~y{vMsIY8GZxHtrxc?6JvImnb0&-lg=(F;ruO5yWvCEJy=Z>s#}^nl<|5!V%l;c z;V)OprAl*P@#*lDBJF%9#D}BLdY-Sj{8Dy@hT56s^w@;jcA`Sl{(2jlZ9gh>FzR3Y z`qyii;Z8{}#$PfqcDbMNEOg)qzNSm3`HUu%f+vJ|HgF5!Bi=&u)_-v@gkASmLI~?$ W@)9Nwd>^NcB<=Tdt@