forked from platypush/platypush
- 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:
parent
a67b301cd6
commit
d83c2c903f
14 changed files with 304 additions and 9 deletions
|
@ -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:
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
23
platypush/backend/http/static/js/dashboard.js
Normal file
23
platypush/backend/http/static/js/dashboard.js
Normal 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();
|
||||
});
|
||||
|
|
@ -185,8 +185,6 @@ $(document).ready(function() {
|
|||
updatePlaylist(tracks=event.args.changes);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(event);
|
||||
};
|
||||
|
||||
var initStatus = function() {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
43
platypush/backend/http/templates/dashboard.html
Normal file
43
platypush/backend/http/templates/dashboard.html
Normal 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>
|
||||
|
|
@ -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 %}
|
||||
|
|
|
@ -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>°
|
||||
</h1>
|
||||
|
||||
<p class="forecast" data-bind="forecast"></p>
|
||||
|
||||
<!-- <p class="sensor-temperature"> -->
|
||||
<!-- <span data-bind="sensor-temperature">N/A</span>° -->
|
||||
<!-- </p> -->
|
||||
|
35
platypush/backend/weather/forecast.py
Normal file
35
platypush/backend/weather/forecast.py
Normal 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:
|
||||
|
8
platypush/message/event/weather.py
Normal file
8
platypush/message/event/weather.py
Normal 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:
|
||||
|
10
platypush/message/event/web/widget.py
Normal file
10
platypush/message/event/web/widget.py
Normal 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:
|
||||
|
30
platypush/plugins/weather/forecast.py
Normal file
30
platypush/plugins/weather/forecast.py
Normal 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:
|
||||
|
|
@ -11,6 +11,7 @@ websocket-client
|
|||
# HTTP backend support
|
||||
flask
|
||||
websockets
|
||||
redis
|
||||
|
||||
# HTTP poll backend support
|
||||
frozendict
|
||||
|
|
2
setup.py
2
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'],
|
||||
|
|
Loading…
Reference in a new issue