From fb744dbc74a3690c0f387347672480ead8e294c3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Tue, 17 Dec 2019 19:54:38 +0100 Subject: [PATCH] Added picamera streaming route and web panel tab --- .../http/app/routes/plugins/camera/pi.py | 54 +++++++++++++++++++ .../css/source/webpanel/plugins/camera.pi | 1 + .../http/static/js/plugins/camera.pi/index.js | 52 ++++++++++++++++++ .../templates/plugins/camera.pi/index.html | 23 ++++++++ platypush/plugins/camera/pi.py | 43 +++++++++++---- 5 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 platypush/backend/http/app/routes/plugins/camera/pi.py create mode 120000 platypush/backend/http/static/css/source/webpanel/plugins/camera.pi create mode 100644 platypush/backend/http/static/js/plugins/camera.pi/index.js create mode 100644 platypush/backend/http/templates/plugins/camera.pi/index.html diff --git a/platypush/backend/http/app/routes/plugins/camera/pi.py b/platypush/backend/http/app/routes/plugins/camera/pi.py new file mode 100644 index 0000000000..95facf4b25 --- /dev/null +++ b/platypush/backend/http/app/routes/plugins/camera/pi.py @@ -0,0 +1,54 @@ +import os +import tempfile + +from flask import Response, Blueprint, send_from_directory + +from platypush.backend.http.app import template_folder +from platypush.backend.http.app.utils import authenticate, send_request + +camera_pi = Blueprint('camera.pi', __name__, template_folder=template_folder) +filename = os.path.join(tempfile.gettempdir(), 'camera_pi.jpg') + +# Declare routes list +__routes__ = [ + camera_pi, +] + + +def get_frame_file(*args, **kwargs): + response = send_request(*args, action='camera.pi.take_picture', image_file=filename, **kwargs) + assert response['output'] and 'image_file' in response['output'],\ + response['errors'].get(0, 'Unable to capture an image file') + + return response['output']['image_file'] + + +def video_feed(): + try: + while True: + frame_file = get_frame_file(warmup_time=0, close=False) + with open(frame_file, 'rb') as f: + frame = f.read() + + yield (b'--frame\r\n' + b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n') + finally: + send_request(action='camera.pi.close') + + +@camera_pi.route('/camera/pi/frame', methods=['GET']) +@authenticate() +def get_frame(): + frame_file = get_frame_file() + return send_from_directory(os.path.dirname(frame_file), + os.path.basename(frame_file)) + + +@camera_pi.route('/camera/pi/stream', methods=['GET']) +@authenticate() +def get_stream_feed(): + return Response(video_feed(), + mimetype='multipart/x-mixed-replace; boundary=frame') + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/static/css/source/webpanel/plugins/camera.pi b/platypush/backend/http/static/css/source/webpanel/plugins/camera.pi new file mode 120000 index 0000000000..5f839334fe --- /dev/null +++ b/platypush/backend/http/static/css/source/webpanel/plugins/camera.pi @@ -0,0 +1 @@ +camera \ No newline at end of file diff --git a/platypush/backend/http/static/js/plugins/camera.pi/index.js b/platypush/backend/http/static/js/plugins/camera.pi/index.js new file mode 100644 index 0000000000..8b41933957 --- /dev/null +++ b/platypush/backend/http/static/js/plugins/camera.pi/index.js @@ -0,0 +1,52 @@ +Vue.component('camera-pi', { + template: '#tmpl-camera-pi', + props: ['config'], + + data: function() { + return { + bus: new Vue({}), + streaming: false, + capturing: false, + }; + }, + + methods: { + startStreaming: function() { + if (this.streaming) + return; + + this.streaming = true; + this.capturing = false; + this.$refs.frame.setAttribute('src', '/camera/pi/stream'); + }, + + stopStreaming: function() { + if (!this.streaming) + return; + + this.streaming = false; + this.capturing = false; + this.$refs.frame.removeAttribute('src'); + }, + + capture: function() { + if (this.capturing) + return; + + this.streaming = false; + this.capturing = true; + this.$refs.frame.setAttribute('src', '/camera/pi/frame?t=' + (new Date()).getTime()); + }, + + onFrameLoaded: function(event) { + if (this.capturing) { + this.capturing = false; + } + }, + }, + + mounted: function() { + this.$refs.frame.addEventListener('load', this.onFrameLoaded); + }, +}); + diff --git a/platypush/backend/http/templates/plugins/camera.pi/index.html b/platypush/backend/http/templates/plugins/camera.pi/index.html new file mode 100644 index 0000000000..913220819f --- /dev/null +++ b/platypush/backend/http/templates/plugins/camera.pi/index.html @@ -0,0 +1,23 @@ + + diff --git a/platypush/plugins/camera/pi.py b/platypush/plugins/camera/pi.py index 90ed7430c3..591690aaf2 100644 --- a/platypush/plugins/camera/pi.py +++ b/platypush/plugins/camera/pi.py @@ -7,6 +7,8 @@ import socket import threading import time +from typing import Optional + from platypush.plugins import action from platypush.plugins.camera import CameraPlugin @@ -21,14 +23,18 @@ class CameraPiPlugin(CameraPlugin): """ _default_resolution = (800, 600) + _default_listen_port = 5000 def __init__(self, resolution=(_default_resolution[0], _default_resolution[1]), framerate=24, hflip=False, vflip=False, sharpness=0, contrast=0, brightness=50, video_stabilization=False, iso=0, exposure_compensation=0, exposure_mode='auto', meter_mode='average', awb_mode='auto', - image_effect='none', color_effects=None, rotation=0, crop=(0.0, 0.0, 1.0, 1.0), **kwargs): + image_effect='none', color_effects=None, rotation=0, crop=(0.0, 0.0, 1.0, 1.0), + listen_port: int = _default_listen_port, **kwargs): """ See https://www.raspberrypi.org/documentation/usage/camera/python/README.md for a detailed reference about the Pi camera options. + + :param listen_port: Default port that will be used for streaming the feed (default: 5000) """ super().__init__(**kwargs) @@ -53,6 +59,7 @@ class CameraPiPlugin(CameraPlugin): } self._camera = None + self.listen_port = listen_port self._time_lapse_thread = None self._recording_thread = None @@ -76,6 +83,17 @@ class CameraPiPlugin(CameraPlugin): return self._camera + @action + def close(self): + """ + Close an active connection to the camera. + """ + if not self._camera or self._camera.closed: + self.logger.info('Camera connection already closed') + + self._camera.close() + self._camera = None + @action def start_preview(self, **opts): """ @@ -99,7 +117,7 @@ class CameraPiPlugin(CameraPlugin): self.logger.warning(str(e)) @action - def take_picture(self, image_file, preview=False, warmup_time=2, resize=None, **opts): + def take_picture(self, image_file, preview=False, warmup_time=2, resize=None, close=True, **opts): """ Take a picture. @@ -118,6 +136,11 @@ class CameraPiPlugin(CameraPlugin): :param opts: Extra options to pass to the camera (see https://www.raspberrypi.org/documentation/usage/camera/python/README.md) + :param close: If True (default) close the connection to the camera after capturing, + otherwise keep the connection open (e.g. if you want to take a sequence of pictures). + If you set close=False you should remember to call ``close`` when you don't need + the connection anymore. + :return: dict:: {"image_file": path_to_the_image} @@ -144,10 +167,11 @@ class CameraPiPlugin(CameraPlugin): if preview: camera.stop_preview() + return {'image_file': image_file} finally: - if camera: - camera.close() + if camera and close: + self.close() @action def capture_sequence(self, n_images, directory, name_format='image_%04d.jpg', preview=False, warmup_time=2, @@ -183,8 +207,6 @@ class CameraPiPlugin(CameraPlugin): """ - camera = None - try: camera = self._get_camera(**opts) directory = os.path.abspath(os.path.expanduser(directory)) @@ -213,7 +235,7 @@ class CameraPiPlugin(CameraPlugin): return {'image_files': images} finally: - camera.close() + self.close() @action def start_time_lapse(self, directory, n_images=None, interval=0, warmup_time=2, @@ -413,11 +435,11 @@ class CameraPiPlugin(CameraPlugin): # noinspection PyShadowingBuiltins @action - def start_streaming(self, listen_port=5000, format='h264', **opts): + def start_streaming(self, listen_port: Optional[int] = None, format='h264', **opts): """ Start recording to a network stream - :param listen_port: TCP listen port (default: 5000) + :param listen_port: TCP listen port (default: `listen_port` configured value or 5000) :type listen_port: int :param format: Video stream format (default: h264) @@ -430,6 +452,9 @@ class CameraPiPlugin(CameraPlugin): if self._streaming_thread: return None, 'A streaming thread is already running' + if not listen_port: + listen_port = self.listen_port + camera = self._get_camera(**opts) server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)