Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe queue_size ? I'm not sure if buffer_size to odd

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a buffer... that happens to be implemented with a queue.

I don't think it makes sense to rename it.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really need this? That make a state change any new picture is pushed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is actually a feature :)

That state change will help automations that require a trigger (e.g. do some image_processing, send a notification).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that will be trigger if the state of camera going to recording, why you need after that a state change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the use case of motioneye, an event will consist of 3 images (default)

I expect to be notified of the 3 for the aforementioned automations.

Copy link
Copy Markdown
Member

@pvizeli pvizeli Jul 2, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a idea. Add the options force_update (default false) and use this on entity as attribute force_update.
Other platform make the same. That allow user to force state change on if a update is exists. I think it is a bit wired to store a filename into attribute to have the same effect. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is possible yes, I can work that out tonight.

But I also believe the filename can be a useful attribute by itself

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what? Can you give a example why to you need this attribute?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filename is a unique key, and can be used to trace events.

Personal example:
MotionEye sends HA an image, HA sends a notification with the image. If I want to go back to motionEye image storage, I need a key to look for. Sending the filename with the notification lets me go back to motionEye storage and look for the image and review the security event (way past the time HA has cleared the image from the queue)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. Remove the force_update if you want staying with that attribute and we can merge it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

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'