-
-
Notifications
You must be signed in to change notification settings - Fork 37.8k
Added Push Camera #15151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added Push Camera #15151
Changes from 11 commits
1841054
7a7b1a8
0cbf418
7143c95
9294ceb
704ad99
c914c08
50087e1
12c8a4d
b3383c7
2b2f406
1196a0a
7f7c326
2247d19
a8eaece
cb6710d
cfb17a0
b79a133
e203785
469219c
53063ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| """ | ||
| 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 = 'cache' | ||
| CONF_IMAGE_FIELD = 'field' | ||
|
|
||
| DEFAULT_NAME = "Push Camera" | ||
|
|
||
| BLANK_IMAGE_SIZE = (640, 480) | ||
|
|
||
| ATTR_FILENAME = 'filename' | ||
| ATTR_LAST_TRIP = 'last_trip' | ||
|
|
||
| REQUIREMENTS = ['pillow==5.0.0'] | ||
|
|
||
| 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 = None | ||
| self._state = STATE_IDLE | ||
| self._timeout = timeout | ||
| self.queue = deque([], buffer_size) | ||
|
|
||
| self.queue.append(self._blank_image()) | ||
| self._current_image = self.queue[0] | ||
|
|
||
| @classmethod | ||
| def _blank_image(cls): | ||
| from PIL import Image | ||
| import io | ||
|
|
||
| image = Image.new('RGB', BLANK_IMAGE_SIZE) | ||
|
|
||
| imgbuf = io.BytesIO() | ||
| image.save(imgbuf, "JPEG") | ||
|
|
||
| return imgbuf.getvalue() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we really need this logic? If you return None like all other images platform in homeassistant work also with frontend...
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Other platforms will get an image real quick, this platform can wait hours until it receives the first image. This means that the frontend will show the placeholder you normally get when the browser can't retrieve an image. I find this solution to be more user friendly.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just return |
||
|
|
||
| @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._last_trip = dt_util.utcnow() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to be the same as
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is not, this will refer to the moment we start recording, last_changed will be updated during recording. The duration of the recording will be
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If state is the same, ie recording, last_changed will not be updated after a state update.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. last_trip is only set on the transition STATE_IDLE -> STATE_RECORDING both attributes will be equal only during the STATE_RECORDING state.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, thanks for clarifying. |
||
| self.queue.clear() | ||
|
|
||
| self._state = STATE_RECORDING | ||
| self._filename = filename | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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).
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have a idea. Add the options
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
| self.queue.append(image) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel like this platform is getting over complicated. It should just show the last pushed image. We should also store that image on disk and not in memory.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With que, you could see a stream if you push multible images into the entity. Maybe default 1 and you can overwrite this with a option?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is abusing a feature of our camera integration. The camera get_image function should show a realtime stream of what a camera is showing. We don't support replays in our camera integration right now. So we shouldn't hack it in.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I feel strongly against writing to disk. Most people run HA on an SD card, it's really bad to write continuously to the SD. I share @pvizeli comment (that was my intention in the implementation, therefore the current default size of deque = 1). Let me remind you that the most common use case for this camera is not realtime, and most of the time the user will be faced with the last image (very poor context on what happened).
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It could be resolved by allow customized save image path, then point it to some tmpfs.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I don't like this assumption. If you think you need a well-tuned components only for your use case, probably should not use a such generic name.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @awarecan sure it can (thats how I did before developing this code) But that's not easy for the average user, and would require hassio/hassos to be aware of that need. We are not discussing full length video, we are discussing 3 or 5 images (it doesn't take that much memory)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @awarecan I first had this component living in my custom_components repo (name is http_post). But realised people were using it for the described use case. There is nothing specific in the component related to motionEye, so I did not call it camera.motionEye and even made some changes to make it even more general.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A Raspberry Pi has 1GB of RAM. If 10 images are kept around, 1MB each, we'll be using 1% of memory for this feature.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @balloob what is now the plan, remove or hold? |
||
|
|
||
| @callback | ||
| def reset_state(now): | ||
| """Set state to idle after no new images for a period of time.""" | ||
| self._state = STATE_IDLE | ||
| self.async_schedule_update_ha_state() | ||
| self._expired = None | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. first set all variable and after that call the function |
||
| _LOGGER.debug("Reset state") | ||
|
|
||
| if self._expired: | ||
| self._expired() | ||
|
|
||
| self._expired = async_track_point_in_utc_time( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. rename this like |
||
| self.hass, reset_state, dt_util.utcnow() + self._timeout) | ||
|
|
||
| self._current_image = self.queue[-1] | ||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| """The tests for generic camera component.""" | ||
| import io | ||
|
|
||
| from datetime import timedelta | ||
| from unittest.mock import patch | ||
|
|
||
| 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.""" | ||
| with patch('homeassistant.components.camera.push.PushCamera._blank_image', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could extract this patch into a |
||
| return_value=io.BytesIO(b'fakeinit')): | ||
| await async_setup_component(hass, 'camera', { | ||
| 'camera': { | ||
| 'platform': 'push', | ||
| 'name': 'config_test', | ||
| }}) | ||
|
|
||
| client = await async_setup_auth(hass, aiohttp_client) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does this do?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to be honest I copied the aiohttp_client from this is supposed to set the environment to make an authenticated post request. |
||
|
|
||
| # 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.""" | ||
| with patch('homeassistant.components.camera.push.PushCamera._blank_image', | ||
| return_value=io.BytesIO(b'fakeinit')): | ||
| 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' | ||
There was a problem hiding this comment.
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 ifbuffer_sizeto oddThere was a problem hiding this comment.
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.