- 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 threading import Thread
from multiprocessing import Process from multiprocessing import Process
from flask import Flask, abort, jsonify, request as http_request, render_template, send_from_directory 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.config import Config
from platypush.message import Message from platypush.message import Message
from platypush.message.event import Event from platypush.message.event import Event
from platypush.message.event.web.widget import WidgetUpdateEvent
from platypush.message.request import Request from platypush.message.request import Request
from .. import Backend from .. import Backend
@ -30,15 +32,19 @@ class HttpBackend(Backend):
} }
def __init__(self, port=8008, websocket_port=8009, disable_websocket=False, 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) super().__init__(**kwargs)
self.port = port self.port = port
self.websocket_port = websocket_port self.websocket_port = websocket_port
self.redis_queue = redis_queue
self.token = token self.token = token
self.dashboard = dashboard
self.server_proc = None self.server_proc = None
self.disable_websocket = disable_websocket self.disable_websocket = disable_websocket
self.websocket_thread = None self.websocket_thread = None
self.active_websockets = set() self.active_websockets = set()
self.redis = Redis()
def send_message(self, msg): def send_message(self, msg):
@ -60,16 +66,19 @@ class HttpBackend(Backend):
await websocket.send(str(event)) await websocket.send(str(event))
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
active_websockets = set()
for websocket in self.active_websockets: for websocket in self.active_websockets:
try: try:
loop.run_until_complete(send_event(websocket)) loop.run_until_complete(send_event(websocket))
active_websockets.add(websocket)
except websockets.exceptions.ConnectionClosed: except websockets.exceptions.ConnectionClosed:
logging.info('Client connection lost') 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): def webserver(self):
@ -77,6 +86,7 @@ class HttpBackend(Backend):
template_dir = os.path.join(basedir, 'templates') template_dir = os.path.join(basedir, 'templates')
static_dir = os.path.join(basedir, 'static') static_dir = os.path.join(basedir, 'static')
app = Flask(__name__, template_folder=template_dir) app = Flask(__name__, template_folder=template_dir)
Thread(target=self.redis_poll).start()
@app.route('/execute', methods=['POST']) @app.route('/execute', methods=['POST'])
def execute(): def execute():
@ -92,7 +102,7 @@ class HttpBackend(Backend):
logging.info('Processing response on the HTTP backend: {}'.format(msg)) logging.info('Processing response on the HTTP backend: {}'.format(msg))
return str(response) return str(response)
elif isinstance(msg, Event): elif isinstance(msg, Event):
self.bus.post(msg) self.redis.rpush(self.redis_queue, msg)
return jsonify({ 'status': 'ok' }) return jsonify({ 'status': 'ok' })
@ -115,10 +125,23 @@ class HttpBackend(Backend):
token=self.token, websocket_port=self.websocket_port) 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>') @app.route('/static/<path>')
def static_path(path): def static_path(path):
return send_from_directory(static_dir, filename) 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 return app
@ -178,5 +201,40 @@ class HttpBackend(Backend):
self.server_proc.join() 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: # vim:sw=4:ts=4:et:

View File

@ -1,5 +1,7 @@
$(document).ready(function() { $(document).ready(function() {
var onEvent = function(event) { var onEvent = function(event) {
console.log(event);
switch (event.args.type) { switch (event.args.type) {
case 'platypush.message.event.assistant.ConversationStartEvent': case 'platypush.message.event.assistant.ConversationStartEvent':
createNotification({ 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); updatePlaylist(tracks=event.args.changes);
break; break;
} }
console.log(event);
}; };
var initStatus = function() { 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) %} window.websocket_port = {% print(websocket_port) %}
{% if token %} {% if token %}
window.token = {% print(token) %} window.token = '{% print(token) %}'
{% else %} {% else %}
window.token = undefined window.token = undefined
{% endif %} {% 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 # HTTP backend support
flask flask
websockets websockets
redis
# HTTP poll backend support # HTTP poll backend support
frozendict frozendict

View File

@ -64,7 +64,7 @@ setup(
extras_require = { extras_require = {
'Support for Apache Kafka backend': ['kafka-python'], 'Support for Apache Kafka backend': ['kafka-python'],
'Support for Pushbullet backend': ['requests', 'websocket-client'], '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 HTTP poll backend': ['frozendict'],
'Support for database plugin': ['sqlalchemy'], 'Support for database plugin': ['sqlalchemy'],
'Support for RSS feeds': ['feedparser'], 'Support for RSS feeds': ['feedparser'],