Skip to content

Commit

Permalink
Added Push Camera (home-assistant#15151)
Browse files Browse the repository at this point in the history
* Added push camera

* add camera.push

* Address comments and add tests

* auff auff

* trip time made no sense

* travis lint

* Mock dependency

* hound

* long line

* long line

* better mocking

* remove blank image

* no more need to mock dependency

* remove import

* cleanup

* no longer needed

* unused constant

* address @pvizeli review

* add force_update

* Revert "add force_update"

This reverts commit e203785.

* rename parameter
  • Loading branch information
dgomes authored and pvizeli committed Jul 4, 2018
1 parent 4277514 commit 5f7ac09
Show file tree
Hide file tree
Showing 2 changed files with 225 additions and 0 deletions.
162 changes: 162 additions & 0 deletions homeassistant/components/camera/push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""
Camera platform that receives images through HTTP POST.
For more details about this platform, please refer to the documentation
https://home-assistant.io/components/camera.push/
"""
import logging

from collections import deque
from datetime import timedelta
import voluptuous as vol

from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\
STATE_IDLE, STATE_RECORDING
from homeassistant.core import callback
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import CONF_NAME, CONF_TIMEOUT, HTTP_BAD_REQUEST
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_point_in_utc_time
import homeassistant.util.dt as dt_util

_LOGGER = logging.getLogger(__name__)

CONF_BUFFER_SIZE = 'buffer'
CONF_IMAGE_FIELD = 'field'

DEFAULT_NAME = "Push Camera"

ATTR_FILENAME = 'filename'
ATTR_LAST_TRIP = 'last_trip'

PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int,
vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All(
cv.time_period, cv.positive_timedelta),
vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string,
})


async def async_setup_platform(hass, config, async_add_devices,
discovery_info=None):
"""Set up the Push Camera platform."""
cameras = [PushCamera(config[CONF_NAME],
config[CONF_BUFFER_SIZE],
config[CONF_TIMEOUT])]

hass.http.register_view(CameraPushReceiver(cameras,
config[CONF_IMAGE_FIELD]))

async_add_devices(cameras)


class CameraPushReceiver(HomeAssistantView):
"""Handle pushes from remote camera."""

url = "/api/camera_push/{entity_id}"
name = 'api:camera_push:camera_entity'

def __init__(self, cameras, image_field):
"""Initialize CameraPushReceiver with camera entity."""
self._cameras = cameras
self._image = image_field

async def post(self, request, entity_id):
"""Accept the POST from Camera."""
try:
(_camera,) = [camera for camera in self._cameras
if camera.entity_id == entity_id]
except ValueError:
_LOGGER.error("Unknown push camera %s", entity_id)
return self.json_message('Unknown Push Camera',
HTTP_BAD_REQUEST)

try:
data = await request.post()
_LOGGER.debug("Received Camera push: %s", data[self._image])
await _camera.update_image(data[self._image].file.read(),
data[self._image].filename)
except ValueError as value_error:
_LOGGER.error("Unknown value %s", value_error)
return self.json_message('Invalid POST', HTTP_BAD_REQUEST)
except KeyError as key_error:
_LOGGER.error('In your POST message %s', key_error)
return self.json_message('{} missing'.format(self._image),
HTTP_BAD_REQUEST)


class PushCamera(Camera):
"""The representation of a Push camera."""

def __init__(self, name, buffer_size, timeout):
"""Initialize push camera component."""
super().__init__()
self._name = name
self._last_trip = None
self._filename = None
self._expired_listener = None
self._state = STATE_IDLE
self._timeout = timeout
self.queue = deque([], buffer_size)
self._current_image = None

@property
def state(self):
"""Current state of the camera."""
return self._state

async def update_image(self, image, filename):
"""Update the camera image."""
if self._state == STATE_IDLE:
self._state = STATE_RECORDING
self._last_trip = dt_util.utcnow()
self.queue.clear()

self._filename = filename
self.queue.appendleft(image)

@callback
def reset_state(now):
"""Set state to idle after no new images for a period of time."""
self._state = STATE_IDLE
self._expired_listener = None
_LOGGER.debug("Reset state")
self.async_schedule_update_ha_state()

if self._expired_listener:
self._expired_listener()

self._expired_listener = async_track_point_in_utc_time(
self.hass, reset_state, dt_util.utcnow() + self._timeout)

self.async_schedule_update_ha_state()

async def async_camera_image(self):
"""Return a still image response."""
if self.queue:
if self._state == STATE_IDLE:
self.queue.rotate(1)
self._current_image = self.queue[0]

return self._current_image

@property
def name(self):
"""Return the name of this camera."""
return self._name

@property
def motion_detection_enabled(self):
"""Camera Motion Detection Status."""
return False

@property
def device_state_attributes(self):
"""Return the state attributes."""
return {
name: value for name, value in (
(ATTR_LAST_TRIP, self._last_trip),
(ATTR_FILENAME, self._filename),
) if value is not None
}
63 changes: 63 additions & 0 deletions tests/components/camera/test_push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""The tests for generic camera component."""
import io

from datetime import timedelta

from homeassistant import core as ha
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from tests.components.auth import async_setup_auth


async def test_bad_posting(aioclient_mock, hass, aiohttp_client):
"""Test that posting to wrong api endpoint fails."""
await async_setup_component(hass, 'camera', {
'camera': {
'platform': 'push',
'name': 'config_test',
}})

client = await async_setup_auth(hass, aiohttp_client)

# missing file
resp = await client.post('/api/camera_push/camera.config_test')
assert resp.status == 400

files = {'image': io.BytesIO(b'fake')}

# wrong entity
resp = await client.post('/api/camera_push/camera.wrong', data=files)
assert resp.status == 400


async def test_posting_url(aioclient_mock, hass, aiohttp_client):
"""Test that posting to api endpoint works."""
await async_setup_component(hass, 'camera', {
'camera': {
'platform': 'push',
'name': 'config_test',
}})

client = await async_setup_auth(hass, aiohttp_client)
files = {'image': io.BytesIO(b'fake')}

# initial state
camera_state = hass.states.get('camera.config_test')
assert camera_state.state == 'idle'

# post image
resp = await client.post('/api/camera_push/camera.config_test', data=files)
assert resp.status == 200

# state recording
camera_state = hass.states.get('camera.config_test')
assert camera_state.state == 'recording'

# await timeout
shifted_time = dt_util.utcnow() + timedelta(seconds=15)
hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time})
await hass.async_block_till_done()

# back to initial state
camera_state = hass.states.get('camera.config_test')
assert camera_state.state == 'idle'

0 comments on commit 5f7ac09

Please sign in to comment.