- 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
This commit is contained in:
Fabio Manganiello 2018-05-04 03:24:35 +02:00
parent a67b301cd6
commit d83c2c903f
14 changed files with 304 additions and 9 deletions

View File

@ -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/<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/<path>')
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:

View File

@ -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({

View File

@ -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();
});

View File

@ -185,8 +185,6 @@ $(document).ready(function() {
updatePlaylist(tracks=event.args.changes);
break;
}
console.log(event);
};
var initStatus = function() {

View File

@ -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();
});

View File

@ -0,0 +1,43 @@
<!doctype html>
<head>
<title>Platypush Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/skeleton.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/skeleton-tabs.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/normalize.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='font-awesome/css/font-awesome.min.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/application.css') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/toggles.css') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery-3.3.1.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/skeleton-tabs.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/application.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/assistant.google.js') }}"></script>
<script type="text/javascript">
window.websocket_port = {% print(websocket_port) %}
{% if token %}
window.token = '{% print(token) %}'
{% else %}
window.token = undefined
{% endif %}
</script>
</head>
<body>
<main>
{% for widget_name, widget in config['widgets'].items() %}
<div class="widget {% print(utils.widget_columns_to_html_class(widget['columns'])) %}
{% print(widget_name) %}"
id="{% print(widget['id'] if 'id' in widget else widget_name) %}">
{% with properties=widget %}
{% include 'widgets/' + widget_name + '.html' %}
{% endwith %}
</div>
{% endfor %}
<div id="notification-container"></div>
</main>
<body>

View File

@ -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 %}

View File

@ -0,0 +1,15 @@
<script type="text/javascript" src="{{ url_for('static', filename='js/widgets/date-time-weather.js') }}"></script>
<p class="date" data-bind="date"></p>
<p class="time" data-bind="time"></p>
<h1 class="temperature">
<span data-bind="temperature">N/A</span>&deg;
</h1>
<p class="forecast" data-bind="forecast"></p>
<!-- <p class="sensor-temperature"> -->
<!-- <span data-bind="sensor-temperature">N/A</span>&deg; -->
<!-- </p> -->

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -11,6 +11,7 @@ websocket-client
# HTTP backend support
flask
websockets
redis
# HTTP poll backend support
frozendict

View File

@ -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'],