From d83c2c903f097e37bf82873441deef38be1f1078 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Fri, 4 May 2018 03:24:35 +0200 Subject: [PATCH] - Added support for /dashboard page with customizable widgets under the web server - Introduced Redis to pass messages between the Flask process and the main application. It now syncs messages with the bus and connected websockets - Added support to programmatically modify dashboard widgets through POST request like Dashing - Added weather forecast plugin --- platypush/backend/http/__init__.py | 68 ++++++++++++++++-- .../http/static/js/assistant.google.js | 2 + platypush/backend/http/static/js/dashboard.js | 23 ++++++ platypush/backend/http/static/js/music.mpd.js | 2 - .../static/js/widgets/date-time-weather.js | 72 +++++++++++++++++++ .../backend/http/templates/dashboard.html | 43 +++++++++++ platypush/backend/http/templates/index.html | 2 +- .../templates/widgets/date-time-weather.html | 15 ++++ platypush/backend/weather/forecast.py | 35 +++++++++ platypush/message/event/weather.py | 8 +++ platypush/message/event/web/widget.py | 10 +++ platypush/plugins/weather/forecast.py | 30 ++++++++ requirements.txt | 1 + setup.py | 2 +- 14 files changed, 304 insertions(+), 9 deletions(-) create mode 100644 platypush/backend/http/static/js/dashboard.js create mode 100644 platypush/backend/http/static/js/widgets/date-time-weather.js create mode 100644 platypush/backend/http/templates/dashboard.html create mode 100644 platypush/backend/http/templates/widgets/date-time-weather.html create mode 100644 platypush/backend/weather/forecast.py create mode 100644 platypush/message/event/weather.py create mode 100644 platypush/message/event/web/widget.py create mode 100644 platypush/plugins/weather/forecast.py diff --git a/platypush/backend/http/__init__.py b/platypush/backend/http/__init__.py index e6a89e08..2f4dd1ad 100644 --- a/platypush/backend/http/__init__.py +++ b/platypush/backend/http/__init__.py @@ -8,10 +8,12 @@ import time from threading import Thread from multiprocessing import Process from flask import Flask, abort, jsonify, request as http_request, render_template, send_from_directory +from redis import Redis from platypush.config import Config from platypush.message import Message from platypush.message.event import Event +from platypush.message.event.web.widget import WidgetUpdateEvent from platypush.message.request import Request from .. import Backend @@ -30,15 +32,19 @@ class HttpBackend(Backend): } def __init__(self, port=8008, websocket_port=8009, disable_websocket=False, - token=None, **kwargs): + redis_queue='platypush_flask_mq', token=None, dashboard={}, **kwargs): super().__init__(**kwargs) + self.port = port self.websocket_port = websocket_port + self.redis_queue = redis_queue self.token = token + self.dashboard = dashboard self.server_proc = None self.disable_websocket = disable_websocket self.websocket_thread = None self.active_websockets = set() + self.redis = Redis() def send_message(self, msg): @@ -60,16 +66,19 @@ class HttpBackend(Backend): await websocket.send(str(event)) loop = asyncio.new_event_loop() - active_websockets = set() for websocket in self.active_websockets: try: loop.run_until_complete(send_event(websocket)) - active_websockets.add(websocket) except websockets.exceptions.ConnectionClosed: logging.info('Client connection lost') - self.active_websockets = active_websockets + + def redis_poll(self): + while not self.should_stop(): + msg = self.redis.blpop(self.redis_queue) + msg = Message.build(json.loads(msg[1].decode('utf-8'))) + self.bus.post(msg) def webserver(self): @@ -77,6 +86,7 @@ class HttpBackend(Backend): template_dir = os.path.join(basedir, 'templates') static_dir = os.path.join(basedir, 'static') app = Flask(__name__, template_folder=template_dir) + Thread(target=self.redis_poll).start() @app.route('/execute', methods=['POST']) def execute(): @@ -92,7 +102,7 @@ class HttpBackend(Backend): logging.info('Processing response on the HTTP backend: {}'.format(msg)) return str(response) elif isinstance(msg, Event): - self.bus.post(msg) + self.redis.rpush(self.redis_queue, msg) return jsonify({ 'status': 'ok' }) @@ -115,10 +125,23 @@ class HttpBackend(Backend): token=self.token, websocket_port=self.websocket_port) + @app.route('/widget/', methods=['POST']) + def widget_update(widget): + event = WidgetUpdateEvent( + widget=widget, **(json.loads(http_request.data.decode('utf-8')))) + + self.redis.rpush(self.redis_queue, event) + return jsonify({ 'status': 'ok' }) + @app.route('/static/') def static_path(path): return send_from_directory(static_dir, filename) + @app.route('/dashboard') + def dashboard(): + return render_template('dashboard.html', config=self.dashboard, utils=HttpUtils, + token=self.token, websocket_port=self.websocket_port) + return app @@ -178,5 +201,40 @@ class HttpBackend(Backend): self.server_proc.join() +class HttpUtils(object): + @staticmethod + def widget_columns_to_html_class(columns): + if not isinstance(columns, int): + raise RuntimeError('columns should be a number, got "{}"'.format(columns)) + + if columns == 1: + return 'one column' + elif columns == 2: + return 'two columns' + elif columns == 3: + return 'three columns' + elif columns == 4: + return 'four columns' + elif columns == 5: + return 'five columns' + elif columns == 6: + return 'six columns' + elif columns == 7: + return 'seven columns' + elif columns == 8: + return 'eight columns' + elif columns == 9: + return 'nine columns' + elif columns == 10: + return 'ten columns' + elif columns == 11: + return 'eleven columns' + elif columns == 12: + return 'twelve columns' + else: + raise RuntimeError('Constraint violation: should be 1 <= columns <= 12, ' + + 'got columns={}'.format(columns)) + + # vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/static/js/assistant.google.js b/platypush/backend/http/static/js/assistant.google.js index 7d01e25c..903cc17d 100644 --- a/platypush/backend/http/static/js/assistant.google.js +++ b/platypush/backend/http/static/js/assistant.google.js @@ -1,5 +1,7 @@ $(document).ready(function() { var onEvent = function(event) { + console.log(event); + switch (event.args.type) { case 'platypush.message.event.assistant.ConversationStartEvent': createNotification({ diff --git a/platypush/backend/http/static/js/dashboard.js b/platypush/backend/http/static/js/dashboard.js new file mode 100644 index 00000000..0c44f4db --- /dev/null +++ b/platypush/backend/http/static/js/dashboard.js @@ -0,0 +1,23 @@ +$(document).ready(function() { + var onEvent = function(event) { + if (event.args.type == 'platypush.message.event.web.widget.WidgetUpdateEvent') { + var $widget = $('#' + event.args.widget); + delete event.args.widget; + + for (var key of Object.keys(event.args)) { + $widget.find('[data-bind=' + key + ']').text(event.args[key]); + } + } + }; + + var initEvents = function() { + window.registerEventListener(onEvent); + }; + + var init = function() { + initEvents(); + }; + + init(); +}); + diff --git a/platypush/backend/http/static/js/music.mpd.js b/platypush/backend/http/static/js/music.mpd.js index f2ccb38f..1b49b75d 100644 --- a/platypush/backend/http/static/js/music.mpd.js +++ b/platypush/backend/http/static/js/music.mpd.js @@ -185,8 +185,6 @@ $(document).ready(function() { updatePlaylist(tracks=event.args.changes); break; } - - console.log(event); }; var initStatus = function() { diff --git a/platypush/backend/http/static/js/widgets/date-time-weather.js b/platypush/backend/http/static/js/widgets/date-time-weather.js new file mode 100644 index 00000000..9b0ead87 --- /dev/null +++ b/platypush/backend/http/static/js/widgets/date-time-weather.js @@ -0,0 +1,72 @@ +$(document).ready(function() { + var $widget = $('.widget.date-time-weather'), + $dateElement = $widget.find('[data-bind=date]'), + $timeElement = $widget.find('[data-bind=time]'), + $forecastElement = $widget.find('[data-bind=forecast]'), + $tempElement = $widget.find('[data-bind=temperature]'); + + var onEvent = function(event) { + if (event.args.type == 'platypush.message.event.weather.NewWeatherConditionEvent') { + updateTemperature(event.args.temperature); + } + }; + + var updateTemperature = function(temperature) { + $tempElement.text(Math.round(temperature)); + }; + + var initEvents = function() { + window.registerEventListener(onEvent); + }; + + var refreshDateTime = function() { + var now = new Date(); + $dateElement.text(now.toDateString()); + $timeElement.text(now.getHours() + ':' + + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes() + ':' + + (now.getSeconds() < 10 ? '0' : '') + now.getSeconds()); + }; + + + var initWeather = function() { + execute( + { + type: 'request', + action: 'weather.forecast.get_current_weather', + }, + + onSuccess = function(response) { + updateTemperature(status=response.response.output.temperature); + } + ); + }; + + var refreshForecast = function() { + execute( + { + type: 'request', + action: 'weather.forecast.get_hourly_forecast', + }, + + onSuccess = function(response) { + $forecastElement.text(response.response.output.summary); + } + ); + }; + + var initWidget = function() { + refreshDateTime(); + setInterval(refreshDateTime, 500); + refreshForecast(); + setInterval(refreshForecast, 1200000); + initWeather(); + }; + + var init = function() { + initEvents(); + initWidget(); + }; + + init(); +}); + diff --git a/platypush/backend/http/templates/dashboard.html b/platypush/backend/http/templates/dashboard.html new file mode 100644 index 00000000..ef25b4a2 --- /dev/null +++ b/platypush/backend/http/templates/dashboard.html @@ -0,0 +1,43 @@ + + + Platypush Dashboard + + + + + + + + + + + + + + + + + +
+ {% for widget_name, widget in config['widgets'].items() %} +
+ {% with properties=widget %} + {% include 'widgets/' + widget_name + '.html' %} + {% endwith %} +
+ {% endfor %} + +
+
+ + diff --git a/platypush/backend/http/templates/index.html b/platypush/backend/http/templates/index.html index 0eeaca6a..54d0c6ba 100644 --- a/platypush/backend/http/templates/index.html +++ b/platypush/backend/http/templates/index.html @@ -16,7 +16,7 @@ window.websocket_port = {% print(websocket_port) %} {% if token %} - window.token = {% print(token) %} + window.token = '{% print(token) %}' {% else %} window.token = undefined {% endif %} diff --git a/platypush/backend/http/templates/widgets/date-time-weather.html b/platypush/backend/http/templates/widgets/date-time-weather.html new file mode 100644 index 00000000..d4263aaa --- /dev/null +++ b/platypush/backend/http/templates/widgets/date-time-weather.html @@ -0,0 +1,15 @@ + + +

+

+ +

+ N/A° +

+ +

+ + + + + diff --git a/platypush/backend/weather/forecast.py b/platypush/backend/weather/forecast.py new file mode 100644 index 00000000..e3818dd7 --- /dev/null +++ b/platypush/backend/weather/forecast.py @@ -0,0 +1,35 @@ +import logging +import time + +from platypush.backend import Backend +from platypush.context import get_plugin +from platypush.plugins.weather.forecast import WeatherForecastPlugin +from platypush.message.event.weather import NewWeatherConditionEvent + +class WeatherForecastBackend(Backend): + def __init__(self, poll_seconds, **kwargs): + super().__init__(**kwargs) + self.poll_seconds = poll_seconds + self.latest_update = {} + + def send_message(self, msg): + pass + + def run(self): + super().run() + weather = get_plugin('weather.forecast') + logging.info('Initialized weather forecast backend') + + while not self.should_stop(): + current_weather = weather.get_current_weather().output + del current_weather['time'] + + if current_weather != self.latest_update: + self.bus.post(NewWeatherConditionEvent(**current_weather)) + + self.latest_update = current_weather + time.sleep(self.poll_seconds) + + +# vim:sw=4:ts=4:et: + diff --git a/platypush/message/event/weather.py b/platypush/message/event/weather.py new file mode 100644 index 00000000..d1042008 --- /dev/null +++ b/platypush/message/event/weather.py @@ -0,0 +1,8 @@ +from platypush.message.event import Event + +class NewWeatherConditionEvent(Event): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + +# vim:sw=4:ts=4:et: + diff --git a/platypush/message/event/web/widget.py b/platypush/message/event/web/widget.py new file mode 100644 index 00000000..9dcbfd2d --- /dev/null +++ b/platypush/message/event/web/widget.py @@ -0,0 +1,10 @@ +from platypush.message.event import Event + + +class WidgetUpdateEvent(Event): + def __init__(self, widget, *args, **kwargs): + super().__init__(widget=widget, *args, **kwargs) + + +# vim:sw=4:ts=4:et: + diff --git a/platypush/plugins/weather/forecast.py b/platypush/plugins/weather/forecast.py new file mode 100644 index 00000000..c4b078ad --- /dev/null +++ b/platypush/plugins/weather/forecast.py @@ -0,0 +1,30 @@ +from platypush.message.response import Response +from platypush.plugins.http.request import HttpRequestPlugin + + +class WeatherForecastPlugin(HttpRequestPlugin): + """ Plugin for getting weather updates through Darksky API """ + + def __init__(self, darksky_token, lat, long, units='si', **kwargs): + """ Supported unit types: ca, uk2, us, si """ + + super().__init__(method='get', output='json') + self.latest_bulletin = {} + self.url = 'https://api.darksky.net/forecast/{}/{},{}?units={}'. \ + format(darksky_token, lat, long, units) + + def get_current_weather(self, **kwargs): + response = self.get(self.url) + return Response(output=response.output['currently']) + + def get_hourly_forecast(self, **kwargs): + response = self.get(self.url) + return Response(output=response.output['hourly']) + + def get_daily_forecast(self, **kwargs): + response = self.get(self.url) + return Response(output=response.output['daily']) + + +# vim:sw=4:ts=4:et: + diff --git a/requirements.txt b/requirements.txt index 93bbf2d8..80c96548 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ websocket-client # HTTP backend support flask websockets +redis # HTTP poll backend support frozendict diff --git a/setup.py b/setup.py index c881607a..b1064629 100755 --- a/setup.py +++ b/setup.py @@ -64,7 +64,7 @@ setup( extras_require = { 'Support for Apache Kafka backend': ['kafka-python'], 'Support for Pushbullet backend': ['requests', 'websocket-client'], - 'Support for HTTP backend': ['flask','websockets'], + 'Support for HTTP backend': ['flask','websockets','redis'], 'Support for HTTP poll backend': ['frozendict'], 'Support for database plugin': ['sqlalchemy'], 'Support for RSS feeds': ['feedparser'],