forked from home-assistant/core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added Push Camera (home-assistant#15151)
* 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
Showing
2 changed files
with
225 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' |