Very simple IP Camera support

pull/170/head
jamespcole 2015-06-05 22:51:29 +10:00
parent 378d3798fd
commit aaf0ca2105
8 changed files with 1985 additions and 1258 deletions

View File

@ -0,0 +1,263 @@
# pylint: disable=too-many-lines
"""
homeassistant.components.camera
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Component to interface with various cameras.
The following features are supported:
-Recording
-Snapshot
-Motion Detection Recording(for supported cameras)
-Automatic Configuration(for supported cameras)
-Creation of child entities for supported functions
-Collating motion event images passed via FTP into time based events
-Returning recorded camera images and streams
-Proxying image requests via HA for external access
-Converting a still image url into a live video stream
-A service for calling camera functions
Upcoming features
-Camera movement(panning)
-Zoom
-Light/Nightvision toggling
-Support for more devices
-A demo entity
-Expanded documentation
"""
import requests
from requests.auth import HTTPBasicAuth
import logging
import time
import re
from homeassistant.helpers.entity import Entity
from homeassistant.const import (
ATTR_ENTITY_PICTURE,
HTTP_NOT_FOUND,
ATTR_ENTITY_ID,
)
from homeassistant.helpers.entity_component import EntityComponent
DOMAIN = 'camera'
DEPENDENCIES = ['http']
GROUP_NAME_ALL_CAMERAS = 'all_cameras'
SCAN_INTERVAL = 30
ENTITY_ID_FORMAT = DOMAIN + '.{}'
SWITCH_ACTION_RECORD = 'record'
SWITCH_ACTION_SNAPSHOT = 'snapshot'
SERVICE_CAMERA = 'camera_service'
STATE_RECORDING = 'recording'
DEFAULT_RECORDING_SECONDS = 30
# Maps discovered services to their platforms
DISCOVERY_PLATFORMS = {}
FILE_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S-%f'
DIR_DATETIME_FORMAT = '%Y-%m-%d_%H-%M-%S'
REC_DIR_PREFIX = 'recording-'
REC_IMG_PREFIX = 'recording_image-'
STATE_STREAMING = 'streaming'
STATE_IDLE = 'idle'
# pylint: disable=too-many-branches
def setup(hass, config):
""" Track states and offer events for sensors. """
component = EntityComponent(
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
DISCOVERY_PLATFORMS)
component.setup(config)
# -------------------------------------------------------------------------
# CAMERA COMPONENT ENDPOINTS
# -------------------------------------------------------------------------
# The following defines the endpoints for serving images from the camera
# via the HA http server. This is means that you can access images from
# your camera outside of your LAN without the need for port forwards etc.
# Because the authentication header can't be added in image requests these
# endpoints are secured with session based security.
# pylint: disable=unused-argument
def _proxy_camera_image(handler, path_match, data):
""" Proxies the camera image via the HA server. """
entity_id = path_match.group(ATTR_ENTITY_ID)
camera = None
if entity_id in component.entities.keys():
camera = component.entities[entity_id]
if camera:
response = camera.get_camera_image()
handler.wfile.write(response)
else:
handler.send_response(HTTP_NOT_FOUND)
hass.http.register_path(
'GET',
re.compile(r'/api/camera_proxy/(?P<entity_id>[a-zA-Z\._0-9]+)'),
_proxy_camera_image,
require_auth=True)
# pylint: disable=unused-argument
def _proxy_camera_mjpeg_stream(handler, path_match, data):
""" Proxies the camera image as an mjpeg stream via the HA server.
This function takes still images from the IP camera and turns them
into an MJPEG stream. This means that HA can return a live video
stream even with only a still image URL available.
"""
entity_id = path_match.group(ATTR_ENTITY_ID)
camera = None
if entity_id in component.entities.keys():
camera = component.entities[entity_id]
if camera:
try:
camera.is_streaming = True
camera.update_ha_state()
handler.request.sendall(bytes('HTTP/1.1 200 OK\r\n', 'utf-8'))
handler.request.sendall(bytes(
'Content-type: multipart/x-mixed-replace; \
boundary=--jpgboundary\r\n\r\n', 'utf-8'))
handler.request.sendall(bytes('--jpgboundary\r\n', 'utf-8'))
while True:
if camera.username and camera.password:
response = requests.get(
camera.still_image_url,
auth=HTTPBasicAuth(
camera.username,
camera.password))
else:
response = requests.get(camera.still_image_url)
headers_str = '\r\n'.join((
'Content-length: {}'.format(len(response.content)),
'Content-type: image/jpeg',
)) + '\r\n\r\n'
handler.request.sendall(
bytes(headers_str, 'utf-8') +
response.content +
bytes('\r\n', 'utf-8'))
handler.request.sendall(
bytes('--jpgboundary\r\n', 'utf-8'))
except (requests.RequestException, IOError):
camera.is_streaming = False
camera.update_ha_state()
else:
handler.send_response(HTTP_NOT_FOUND)
camera.is_streaming = False
hass.http.register_path(
'GET',
re.compile(
r'/api/camera_proxy_stream/(?P<entity_id>[a-zA-Z\._0-9]+)'),
_proxy_camera_mjpeg_stream,
require_auth=True)
class Camera(Entity):
""" The base class for camera components """
@property
def is_recording(self):
""" Returns true if the device is recording """
return False
@property
def is_streaming(self):
""" Returns true if the device is streaming """
return False
@is_streaming.setter
def is_streaming(self, value):
""" Set this to true when streaming begins """
pass
@property
def brand(self):
""" Should return a string of the camera brand """
return None
@property
def model(self):
""" Returns string of camera model """
return None
@property
def base_url(self):
""" Return the configured base URL for the camera """
return None
@property
def image_url(self):
""" Return the still image segment of the URL """
return None
@property
def device_info(self):
""" Get the configuration object """
return None
@property
def username(self):
""" Get the configured username """
return None
@property
def password(self):
""" Get the configured password """
return None
@property
def still_image_url(self):
""" Get the URL of a camera still image """
return None
def get_camera_image(self):
""" Return bytes of camera image """
raise NotImplementedError()
@property
def state(self):
""" Returns the state of the entity. """
if self.is_recording:
return STATE_RECORDING
elif self.is_streaming:
return STATE_STREAMING
else:
return STATE_IDLE
@property
def state_attributes(self):
""" Returns optional state attributes. """
attr = super().state_attributes
attr['model_name'] = self.device_info.get('model', 'generic')
attr['brand'] = self.device_info.get('brand', 'generic')
attr['still_image_url'] = '/api/camera_proxy/' + self.entity_id
attr[ATTR_ENTITY_PICTURE] = (
'/api/camera_proxy/' +
self.entity_id + '?time=' +
str(time.time()))
attr['stream_url'] = '/api/camera_proxy_stream/' + self.entity_id
return attr

View File

@ -0,0 +1,174 @@
"""
Support for IP Cameras.
This component provides basic support for IP camera models that do not have
a speicifc HA component.
As part of the basic support the following features will be provided:
-MJPEG video streaming
-Saving a snapshot
-Recording(JPEG frame capture)
NOTE: for the basic support to work you camera must support accessing a JPEG
snapshot via a URL and you will need to specify the "still_image_url" parameter
which should be the location of the JPEG snapshot relative to you specified
base_url. For example "snapshot.cgi" or "image.jpg".
To use this component you will need to add something like the following to your
config/configuration.yaml
camera:
platform: generic
base_url: http://YOUR_CAMERA_IP_AND_PORT/
name: Door Camera
brand: dlink
family: DCS
model: DCS-930L
username: YOUR_USERNAME
password: YOUR_PASSWORD
still_image_url: image.jpg
VARIABLES:
These are the variables for the device_data array:
base_url
*Required
The base URL for accessing you camera
Example: http://192.168.1.21:2112/
name
*Optional
This parameter allows you to override the name of your camera in homeassistant
brand
*Optional
The manufacturer of your device, used to help load the specific camera
functionality.
family
*Optional
The family of devices by the specified brand, useful when many models
support the same settings. This used when attempting load up specific
device functionality.
model
*Optional
The specific model number of your device.
still_image_url
*Optional
Useful if using an unsupported camera model. This should point to the location
of the still image on your particular camera and should be relative to your
specified base_url.
Example: cam/image.jpg
username
*Required
THe username for acessing your camera
password
*Required
the password for accessing your camera
"""
import logging
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
from homeassistant.helpers import validate_config
from homeassistant.components.camera import DOMAIN
from homeassistant.components.camera import Camera
import requests
_LOGGER = logging.getLogger(__name__)
# pylint: disable=unused-argument
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
""" Find and return Vera lights. """
if not validate_config(
{DOMAIN: config},
{DOMAIN: ['base_url', CONF_USERNAME, CONF_PASSWORD]},
_LOGGER):
return None
camera = GenericCamera(hass, config)
cameras = [camera]
add_devices_callback(cameras)
# pylint: disable=too-many-instance-attributes
class GenericCamera(Camera):
"""
Base class for cameras.
This is quite a large class but the camera component encompasses a lot of
functionality. It should take care of most of the heavy lifting and
plumbing associated with adding support for additional models of camera.
If you are adding support for a new camera your entity class should inherit
from this.
"""
def __init__(self, hass, device_info):
self.hass = hass
self._device_info = device_info
self._base_url = device_info.get('base_url')
if not self._base_url.endswith('/'):
self._base_url = self._base_url + '/'
self._username = device_info.get('username')
self._password = device_info.get('password')
self._is_streaming = False
self._still_image_url = device_info.get('still_image_url', 'image.jpg')
self._logger = logging.getLogger(__name__)
def get_camera_image(self):
""" Return a still image reponse from the camera """
response = requests.get(
self.still_image_url,
auth=(self._username, self._password))
return response.content
@property
def device_info(self):
""" Return the config data for this device """
return self._device_info
@property
def name(self):
""" Return the name of this device """
return self._device_info.get('name') or super().name
@property
def state_attributes(self):
""" Returns optional state attributes. """
attr = super().state_attributes
return attr
@property
def base_url(self):
return self._base_url
@property
def username(self):
return self._username
@property
def password(self):
return self._password
@property
def is_streaming(self):
return self._is_streaming
@is_streaming.setter
def is_streaming(self, value):
self._is_streaming = value
@property
def still_image_url(self):
""" This should be implemented by different camera models. """
return self.base_url + self._still_image_url

View File

@ -1,2 +1,2 @@
""" DO NOT MODIFY. Auto-generated by build_frontend script """
VERSION = "ed339673ca129a1a51dcc3975d0a492d"
VERSION = "24f15feebc48785ce908064dccbdb204"

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,7 @@
<paper-dialog id="dialog" with-backdrop>
<h2><state-card-content state-obj="[[stateObj]]"></state-card-content></h2>
<div>
<template is='dom-if' if="[[hasHistoryComponent]]">
<template is='dom-if' if="[[showHistoryComponent]]">
<state-history-charts state-history="[[stateHistory]]"
is-loading-data="[[isLoadingHistoryData]]"></state-history-charts>
</template>
@ -53,6 +53,9 @@
var stateHistoryStore = window.hass.stateHistoryStore;
var stateHistoryActions = window.hass.stateHistoryActions;
// if you don't want the history component to show add the domain to this array
var DOMAINS_WITH_NO_HISTORY = ['camera'];
Polymer({
is: 'more-info-dialog',
@ -81,6 +84,11 @@
value: false,
},
showHistoryComponent: {
type: Boolean,
value: false,
},
dialogOpen: {
type: Boolean,
value: false,
@ -100,7 +108,15 @@
var newState = this.entityId ? stateStore.get(this.entityId) : null;
if (newState !== this.stateObj) {
this.stateObj = newState;
this.stateObj = newState;
}
if(this.stateObj) {
if(DOMAINS_WITH_NO_HISTORY.indexOf(this.stateObj.domain) !== -1) {
this.showHistoryComponent = false;
}
else {
this.showHistoryComponent = this.hasHistoryComponent;
}
}
},
@ -120,11 +136,11 @@
}
},
onIronOverlayOpened: function() {
onIronOverlayOpened: function() {
this.dialogOpen = true;
},
onIronOverlayClosed: function() {
onIronOverlayClosed: function() {
this.dialogOpen = false;
},
@ -144,7 +160,7 @@
this.changeEntityId(entityId);
this.debounce('showDialogAfterRender', function() {
this.$.dialog.toggle();
this.$.dialog.toggle();
}.bind(this));
},
});

View File

@ -0,0 +1,106 @@
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/paper-button/paper-button.html">
<link rel="import" href="../bower_components/paper-spinner/paper-spinner.html">
<dom-module id='more-info-camera'>
<style>
:host .camera-image {
width:640px;
height:480px;
}
@media (max-width: 720px) {
:host .camera-image {
max-width: calc(100%);
height: initial
}
:host .camera-page {
max-width: calc(100%);
max-height: calc(100%);
}
}
@media (max-width: 620px) {
:host .camera-image {
max-width: calc(100%);
height: initial
}
}
:host .camera-page {
width:640px;
height:520px;
}
</style>
<template>
<div class$='[[computeClassNames(stateObj)]]'>
<div id="camera_container" class="camera-container camera-page">
<img src="{{camera_image_url}}" id="camera_image" class="camera-image" />
</div>
</div>
</template>
</dom-module>
<script>
(function() {
var serviceActions = window.hass.serviceActions;
var uiUtil = window.hass.uiUtil;
var ATTRIBUTE_CLASSES = ['camera'];
Polymer({
is: 'more-info-camera',
properties: {
stateObj: {
type: Object,
observer: 'stateObjChanged',
},
dialogOpen: {
type: Object,
observer: 'dialogOpenChanged',
},
camera_image_url: {
type: String,
}
},
stateObjChanged: function(newVal, oldVal) {
if (newVal) {
}
},
computeClassNames: function(stateObj) {
return uiUtil.attributeClassNames(stateObj, ATTRIBUTE_CLASSES);
},
dialogOpenChanged: function(newVal, oldVal) {
if (newVal) {
this.startImageStream();
}
else {
this.stopImageStream();
}
},
startImageStream: function() {
this.camera_image_url = this.stateObj.attributes['stream_url'];
this.isStreaming = true;
},
stopImageStream: function() {
this.camera_image_url = this.stateObj.attributes['still_image_url'] + '?t=' + Date.now();
this.isStreaming = false;
},
});
})();
</script>
</polymer-element>

View File

@ -8,6 +8,7 @@
<link rel='import' href='more-info-script.html'>
<link rel='import' href='more-info-light.html'>
<link rel='import' href='more-info-media_player.html'>
<link rel='import' href='more-info-camera.html'>
<dom-module id='more-info-content'>
<style>
@ -33,6 +34,7 @@
dialogOpen: {
type: Boolean,
value: false,
observer: 'dialogOpenChanged',
},
},

View File

@ -4,7 +4,7 @@
(function() {
var DOMAINS_WITH_CARD = ['thermostat', 'configurator', 'scene', 'media_player'];
var DOMAINS_WITH_MORE_INFO = [
'light', 'group', 'sun', 'configurator', 'thermostat', 'script', 'media_player'
'light', 'group', 'sun', 'configurator', 'thermostat', 'script', 'media_player', 'camera'
];
var DOMAINS_HIDE_MORE_INFO = [
'sensor',