Make project fit for deployment
* Read config from file - see config.py.example * Split HTML/JS/CSS, move static stuff to mqtt_dash/static/ * Move widget definitions to mqtt_dash.widgets
This commit is contained in:
parent
8b38a8d944
commit
75a07bac21
30
config.py.example
Normal file
30
config.py.example
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from mqtt_dash.widgets import column_layout, row_layout, slider_widget, \
|
||||||
|
label_widget, button_widget, log_widget
|
||||||
|
|
||||||
|
URL = "https://dashboard.example.com"
|
||||||
|
MQTT_BROKER = "ws://example.com/mqtt"
|
||||||
|
|
||||||
|
PAGE_TITLE = 'MQTT Dashboard'
|
||||||
|
|
||||||
|
WIDGETS = [
|
||||||
|
column_layout("Light", [
|
||||||
|
slider_widget(room,
|
||||||
|
f"hue/set/lights/{room}",
|
||||||
|
f"hue/status/lights/{room}",
|
||||||
|
value_path='val')
|
||||||
|
for room in ('livingroom', 'bedroom', 'kitchen')
|
||||||
|
]),
|
||||||
|
row_layout("Temperature", [
|
||||||
|
label_widget(f"home/sensors/{room}/temperature", room, "°C")
|
||||||
|
for room in ('livingroom', 'bedroom', 'kitchen')
|
||||||
|
]),
|
||||||
|
row_layout("Device logs", [
|
||||||
|
log_widget(f"home/devices/{ip}/log", name)
|
||||||
|
for ip, name in [("192.168.1.214",
|
||||||
|
"Bedroom lamp"), ("192.168.1.213", "Sensor office"),
|
||||||
|
("192.168.1.212", "Sensor livingroom"
|
||||||
|
), ("192.168.1.211", "Sensor bedroom")]
|
||||||
|
]),
|
||||||
|
row_layout("Some buttons", button_widget("test", "set to 1", 1),
|
||||||
|
button_widget("test", "set to foo", 'foo'))
|
||||||
|
]
|
@ -1,6 +1,6 @@
|
|||||||
{ python3 }:
|
{ python3 }:
|
||||||
with python3.pkgs;
|
with python3.pkgs;
|
||||||
buildPythonApplication rec {
|
buildPythonPackage rec {
|
||||||
name = "mqtt-dash";
|
name = "mqtt-dash";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
propagatedBuildInputs = [ flask ];
|
propagatedBuildInputs = [ flask ];
|
||||||
|
19
mqtt-dash
19
mqtt-dash
@ -1,9 +1,28 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
from mqtt_dash.app import app
|
from mqtt_dash.app import app
|
||||||
|
|
||||||
|
args = argparse.ArgumentParser()
|
||||||
|
|
||||||
|
args.add_argument('config', default='config.py', help='Configuration file')
|
||||||
|
|
||||||
|
options = args.parse_args()
|
||||||
|
|
||||||
app.secret_key = os.urandom(12)
|
app.secret_key = os.urandom(12)
|
||||||
|
app.config.from_pyfile(os.path.abspath(options.config))
|
||||||
|
|
||||||
|
|
||||||
|
missing_config_fields = False
|
||||||
|
for opt in ('URL', 'MQTT_BROKER', 'PAGE_TITLE', 'WIDGETS'):
|
||||||
|
if opt not in app.config:
|
||||||
|
print(f'Error: field {opt} missing in configuration')
|
||||||
|
missing_config_fields = True
|
||||||
|
if missing_config_fields:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
app.run(debug=False, host='0.0.0.0', port=4000)
|
app.run(debug=False, host='0.0.0.0', port=4000)
|
||||||
|
198
mqtt_dash/app.py
198
mqtt_dash/app.py
@ -1,201 +1,13 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
from flask import Flask, flash, redirect, render_template_string
|
from flask import Flask, render_template
|
||||||
from flask import render_template, request, session, abort
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
|
||||||
def make_id():
|
|
||||||
return uuid.uuid4()
|
|
||||||
|
|
||||||
|
|
||||||
def make_widget(template,
|
|
||||||
widget_type,
|
|
||||||
sub_topic=None,
|
|
||||||
extra_classes=None,
|
|
||||||
**kwargs):
|
|
||||||
def f():
|
|
||||||
_classes = ['widget'] + list(extra_classes or [])
|
|
||||||
_attrs = {}
|
|
||||||
if sub_topic:
|
|
||||||
_classes.append('subscriber')
|
|
||||||
_attrs['data-sub-topic'] = sub_topic
|
|
||||||
_classes.append(f'{widget_type}-widget')
|
|
||||||
kwargs['sub_topic'] = sub_topic
|
|
||||||
_attrs['class'] = ' '.join([c for c in _classes if c is not None])
|
|
||||||
attr_str = " ".join([f'{k}="{v}"' for k, v in _attrs.items()])
|
|
||||||
widget_template = f'<div {attr_str}>\n{template}\n</div>'
|
|
||||||
return render_template_string(widget_template, **kwargs)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def button_widget(topic, label, pub_value):
|
|
||||||
template = '''
|
|
||||||
<input type="button" class="button is-large is-info is-outlined" value="{{label}}" onclick="publish('{{topic}}', '{{pub_value}}');"/>
|
|
||||||
'''
|
|
||||||
return make_widget(template,
|
|
||||||
'button',
|
|
||||||
label=label,
|
|
||||||
pub_value=pub_value,
|
|
||||||
topic=topic)
|
|
||||||
|
|
||||||
|
|
||||||
def slider_widget(label,
|
|
||||||
topic,
|
|
||||||
sub_topic=None,
|
|
||||||
value_path=None,
|
|
||||||
unit='',
|
|
||||||
min_val=0,
|
|
||||||
max_val=255):
|
|
||||||
id_ = make_id()
|
|
||||||
sub_topic = sub_topic or topic
|
|
||||||
template = '''
|
|
||||||
<div style="display:flex;justify-content:center;align-items:center;height:60px;">
|
|
||||||
<input id="{{id_}}" class="slider update-policy-update-value {{sub_topic}}-value"
|
|
||||||
data-pub-topic="{{topic}}"
|
|
||||||
{% if value_path %} data-value-path="{{value_path}}" {% endif %}
|
|
||||||
min="{{min_val}}" type="range" max="{{max_val}}"
|
|
||||||
value="{{min_val}}"/>
|
|
||||||
<span id="{{id_}}-textual" class="slider-value">-</span><span>{{unit}}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{label}}</div>
|
|
||||||
'''
|
|
||||||
return make_widget(template,
|
|
||||||
'slider',
|
|
||||||
sub_topic=sub_topic,
|
|
||||||
label=label,
|
|
||||||
min_val=min_val,
|
|
||||||
max_val=max_val,
|
|
||||||
value_path=value_path,
|
|
||||||
topic=topic,
|
|
||||||
unit=unit,
|
|
||||||
id_=id_)
|
|
||||||
|
|
||||||
|
|
||||||
def label_widget(topic, label, unit=''):
|
|
||||||
template = '''
|
|
||||||
<div align="center" class="is-size-2">
|
|
||||||
<span class="{{topic}}-value update-policy-replace-content">-</span>
|
|
||||||
<span>{{unit}}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{label}}</div>
|
|
||||||
'''
|
|
||||||
return make_widget(template,
|
|
||||||
'label',
|
|
||||||
sub_topic=topic,
|
|
||||||
topic=topic,
|
|
||||||
unit=unit,
|
|
||||||
label=label)
|
|
||||||
|
|
||||||
|
|
||||||
def log_widget(topic, label):
|
|
||||||
id_ = make_id()
|
|
||||||
template = '''
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="table is-striped is-hoverable is-fullwidth"
|
|
||||||
style="overflow: auto; height:100px;">
|
|
||||||
<thead><tr>
|
|
||||||
<th>Time</th><th>Message</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="{{id_}}-content" class="{{topic}}-value update-policy-add-row">
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div>{{label}}<button id="{{id_}}" class="delete log-delete"></button></div>
|
|
||||||
'''
|
|
||||||
return make_widget(template,
|
|
||||||
"log",
|
|
||||||
sub_topic=topic,
|
|
||||||
topic=topic,
|
|
||||||
label=label,
|
|
||||||
id_=id_)
|
|
||||||
|
|
||||||
|
|
||||||
def row_layout(title, *elems):
|
|
||||||
def f():
|
|
||||||
template = '''
|
|
||||||
<div class="box">
|
|
||||||
{% if title %}
|
|
||||||
<div class="title">{{title}}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="columns is-tablet">
|
|
||||||
{% for w in elems %}
|
|
||||||
<div class="column">
|
|
||||||
{{w()|safe}}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
'''
|
|
||||||
return render_template_string(template, title=title, elems=elems)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
def column_layout(title, *elems):
|
|
||||||
def f():
|
|
||||||
template = '''
|
|
||||||
{% if title %}
|
|
||||||
<div class="title">{{title}}</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="rows">
|
|
||||||
{% for w in elems %}
|
|
||||||
<div class="row">
|
|
||||||
{{w()|safe}}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
'''
|
|
||||||
return render_template_string(template, title=title, elems=elems)
|
|
||||||
|
|
||||||
return f
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def home():
|
def home():
|
||||||
return render_template(
|
return render_template('base.html',
|
||||||
'base.html',
|
widgets=app.config['WIDGETS'],
|
||||||
widgets=[
|
mqtt_broker=app.config['MQTT_BROKER'],
|
||||||
column_layout(
|
page_title=app.config['PAGE_TITLE'])
|
||||||
"Light",
|
|
||||||
slider_widget("Küche",
|
|
||||||
"hue/set/lights/Ku",
|
|
||||||
"hue/status/lights/Ku",
|
|
||||||
value_path='val'),
|
|
||||||
slider_widget("WZ1",
|
|
||||||
"hue/set/lights/WZ1",
|
|
||||||
"hue/status/lights/WZ1",
|
|
||||||
value_path='val'),
|
|
||||||
slider_widget("WZ2",
|
|
||||||
"hue/set/lights/WZ2",
|
|
||||||
"hue/status/lights/WZ2",
|
|
||||||
value_path='val'),
|
|
||||||
slider_widget("Vorzimmer",
|
|
||||||
"hue/set/lights/Vorzimmer",
|
|
||||||
"hue/status/lights/Vorzimmer",
|
|
||||||
value_path='val'),
|
|
||||||
slider_widget("Schlafzimmer",
|
|
||||||
"home/devices/192.168.1.214/override",
|
|
||||||
"home/devices/192.168.1.214/brightness")),
|
|
||||||
row_layout(
|
|
||||||
"Temperature",
|
|
||||||
label_widget("home/sensors/livingroom/temperature",
|
|
||||||
"Livingroom", "°C"),
|
|
||||||
label_widget("home/sensors/office/temperature", "Office",
|
|
||||||
"°C"),
|
|
||||||
label_widget("home/sensors/bedroom/temperature", "Bedroom",
|
|
||||||
"°C")),
|
|
||||||
row_layout(
|
|
||||||
"Device logs", log_widget("test", "test log"), *[
|
|
||||||
log_widget(f"home/devices/{ip}/log", name)
|
|
||||||
for ip, name in [("192.168.1.214", "Bedroom lamp"),
|
|
||||||
("192.168.1.213", "Sensor office"),
|
|
||||||
("192.168.1.212", "Sensor livingroom"),
|
|
||||||
("192.168.1.211", "Sensor bedroom")]
|
|
||||||
]),
|
|
||||||
row_layout("Some buttons", button_widget("test", "set to 1", 1),
|
|
||||||
button_widget("test", "set to foo", 'foo'))
|
|
||||||
])
|
|
||||||
|
148
mqtt_dash/static/main.js
Normal file
148
mqtt_dash/static/main.js
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// Called after form input is processed
|
||||||
|
function startConnect(brokerUri) {
|
||||||
|
// Generate a random client ID
|
||||||
|
clientId = "clientId-" + parseInt(Math.random() * 100);
|
||||||
|
document.getElementById("wrapper").classList.add("wrapper-error");
|
||||||
|
document.getElementById("wrapper").classList.remove("wrapper-ok");
|
||||||
|
|
||||||
|
// Print output for the user in the messages div
|
||||||
|
document.getElementById("messages").innerHTML +=
|
||||||
|
'<span>Connecting to: ' + brokerUri+ '</span><br/>';
|
||||||
|
document.getElementById("messages").innerHTML +=
|
||||||
|
'<span>Using the following client value: ' + clientId + '</span><br/>';
|
||||||
|
|
||||||
|
// Initialize new Paho client connection
|
||||||
|
client = new Paho.MQTT.Client(brokerUri, clientId=clientId);
|
||||||
|
|
||||||
|
// Set callback handlers
|
||||||
|
client.onConnectionLost = onConnectionLost;
|
||||||
|
client.onMessageArrived = onMessageArrived;
|
||||||
|
|
||||||
|
// Connect the client, if successful, call onConnect function
|
||||||
|
client.connect({
|
||||||
|
onSuccess: onConnect,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the client connects
|
||||||
|
function onConnect() {
|
||||||
|
document.getElementById("wrapper").classList.remove("wrapper-error");
|
||||||
|
document.getElementById("wrapper").classList.add("wrapper-ok");
|
||||||
|
var subWidgets = document.getElementsByClassName("subscriber");
|
||||||
|
var topics = new Set()
|
||||||
|
for (var i = 0; i < subWidgets.length; i++) {
|
||||||
|
var c = subWidgets.item(i);
|
||||||
|
var topic = c.getAttribute('data-sub-topic');
|
||||||
|
topics.add(topic);
|
||||||
|
c.classList.add('widget-unset');
|
||||||
|
}
|
||||||
|
for (let topic of topics)
|
||||||
|
{
|
||||||
|
client.subscribe(topic);
|
||||||
|
}
|
||||||
|
// Subscribe to the requested topic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the client loses its connection
|
||||||
|
function onConnectionLost(responseObject) {
|
||||||
|
document.getElementById("wrapper").classList.remove("wrapper-ok");
|
||||||
|
document.getElementById("wrapper").classList.add("wrapper-error");
|
||||||
|
if (responseObject.errorCode !== 0) {
|
||||||
|
console.log("onConnectionLost: " + responseObject.errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getByPath(obj, path) {
|
||||||
|
var current=obj;
|
||||||
|
path.split('.').forEach(function(p){ current = current[p]; });
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when a message arrives
|
||||||
|
function onMessageArrived(message) {
|
||||||
|
var views = document.getElementsByClassName(message.destinationName + "-value");
|
||||||
|
for (var i = 0 ; i < views.length; i++) {
|
||||||
|
var view = views.item(i);
|
||||||
|
if (view.classList.contains("update-policy-replace-content")) {
|
||||||
|
view.innerHTML = message.payloadString;
|
||||||
|
} else if(view.classList.contains("update-policy-append-content")) {
|
||||||
|
view.innerHTML += message.payloadString + "<br/>";
|
||||||
|
view.scrollTop = view.scrollHeight;
|
||||||
|
} else if(view.classList.contains("update-policy-add-row")) {
|
||||||
|
var now = (new Date()).toLocaleTimeString();
|
||||||
|
view.innerHTML = "<tr><td>"+ now + "</td><td>" +message.payloadString + "</td></tr>" + view.innerHTML;
|
||||||
|
} else if(view.classList.contains("update-policy-update-value")) {
|
||||||
|
jsonPath = view.getAttribute('data-value-path');
|
||||||
|
if (jsonPath) {
|
||||||
|
view.value = getByPath(JSON.parse(message.payloadString),
|
||||||
|
jsonPath);
|
||||||
|
} else {
|
||||||
|
view.value = message.payloadString;
|
||||||
|
}
|
||||||
|
view.onchange();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var widgets = document.querySelectorAll("[data-sub-topic='" + message.destinationName + "']");
|
||||||
|
for (var i = 0 ; i < widgets.length; i++) {
|
||||||
|
widgets.item(i).classList.remove("widget-unset");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when the disconnection button is pressed
|
||||||
|
function startDisconnect() {
|
||||||
|
client.disconnect();
|
||||||
|
document.getElementById("messages").innerHTML += '<span>Disconnected</span><br/>';
|
||||||
|
updateScroll(); // Scroll to bottom of window
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates #messages div to auto-scroll
|
||||||
|
function updateScroll() {
|
||||||
|
var element = document.getElementById("messages");
|
||||||
|
element.scrollTop = element.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(topic, value) {
|
||||||
|
var message = new Paho.MQTT.Message(value);
|
||||||
|
message.destinationName = topic;
|
||||||
|
client.send(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContents(id, value) {
|
||||||
|
elem = document.getElementById(id);
|
||||||
|
elem.innerHTML = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(brokerUri) {
|
||||||
|
startConnect(brokerUri);
|
||||||
|
|
||||||
|
var sliders = document.getElementsByClassName("slider");
|
||||||
|
|
||||||
|
for (var i = 0 ; i < sliders.length; i++) {
|
||||||
|
var slider = sliders.item(i);
|
||||||
|
slider.onchange = function() {
|
||||||
|
document.getElementById(this.id + "-textual").innerHTML =
|
||||||
|
this.value;
|
||||||
|
};
|
||||||
|
slider.oninput = function() {
|
||||||
|
this.onchange()
|
||||||
|
var topic = this.getAttribute('data-pub-topic');
|
||||||
|
publish(topic, this.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var logDeletes = document.getElementsByClassName("log-delete");
|
||||||
|
|
||||||
|
for (var i = 0 ; i < logDeletes.length; i++) {
|
||||||
|
var elem = logDeletes.item(i);
|
||||||
|
elem.onclick = function() {
|
||||||
|
console.log(this.id);
|
||||||
|
table = document.getElementById(this.id + "-content");
|
||||||
|
console.log(table.id);
|
||||||
|
table.innerHTML = "";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
60
mqtt_dash/static/style.css
Normal file
60
mqtt_dash/static/style.css
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
.widget-unset { color: #888; /* #708ea4; */ }
|
||||||
|
#wrapper { 100%;}
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.wrapper-error { background-color: #fee; }
|
||||||
|
#wrapper { width: 90%; height: 100%;}
|
||||||
|
.button {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-delete {
|
||||||
|
vertical-align: 5px;
|
||||||
|
margin-left:5px;
|
||||||
|
}
|
||||||
|
.table-container {
|
||||||
|
max-height: 10rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.slider {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
border: 0px;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background: #3298dc;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-progress {
|
||||||
|
background-color: #3298dc;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::-moz-range-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 100px;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
.slider-value {
|
||||||
|
margin-left:10px;
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
-webkit-transition-duration: 0.4s; /* Safari */
|
||||||
|
transition-duration: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
@ -5,246 +5,15 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=0.8"/>
|
<meta name="viewport" content="width=device-width,initial-scale=0.8"/>
|
||||||
<!-- http://paletton.com/#uid=13w0u0kllllaFw0g0qFqFg0w0aF -->
|
<!-- http://paletton.com/#uid=13w0u0kllllaFw0g0qFqFg0w0aF -->
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
|
||||||
<style type="text/css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
/* background-color: #486e88; color: #e0e0e0; */}
|
|
||||||
.widget-unset { color: #888; /* #708ea4; */ }
|
|
||||||
#wrapper { 100%;}
|
|
||||||
html, body {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
/* color: #e0e0e0; */
|
|
||||||
}
|
|
||||||
.wrapper-ok { /*background-color: #29516D;*/}
|
|
||||||
.wrapper-error { background-color: #fee; /*#aa5739;*/}
|
|
||||||
#wrapper { width: 90%; height: 100%;}
|
|
||||||
.button {
|
|
||||||
/* text-align: center; */
|
|
||||||
/* text-decoration: none; */
|
|
||||||
/* display: inline-block; */
|
|
||||||
/* font-size: 16px; */
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-delete {
|
|
||||||
vertical-align: 5px;
|
|
||||||
margin-left:5px;
|
|
||||||
}
|
|
||||||
.table-container {
|
|
||||||
max-height: 10rem;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
.slider {
|
|
||||||
width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
|
||||||
border: 0px;
|
|
||||||
height: 24px;
|
|
||||||
width: 24px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background: #3298dc;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-progress {
|
|
||||||
background-color: #3298dc;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-moz-range-track {
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 100px;
|
|
||||||
background-color: #eee;
|
|
||||||
}
|
|
||||||
.slider-value {
|
|
||||||
margin-left:10px;
|
|
||||||
width: 10%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
-webkit-transition-duration: 0.4s; /* Safari */
|
|
||||||
transition-duration: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget {
|
|
||||||
align: center;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
/* background-color: #123752; */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .with-border { */
|
|
||||||
/* border-width: .1rem; */
|
|
||||||
/* border-color: #ccc; #f8f8f8; */
|
|
||||||
/* border-style: solid; */
|
|
||||||
/* padding: 5px; */
|
|
||||||
/* margin: 10px; */
|
|
||||||
/* } */
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.2/mqttws31.min.js" type="text/javascript"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/paho-mqtt/1.0.2/mqttws31.min.js" type="text/javascript"></script>
|
||||||
|
<script src="/static/main.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Called after form input is processed
|
window.onload = function() { init("{{mqtt_broker}}")};
|
||||||
function startConnect() {
|
</script>
|
||||||
// Generate a random client ID
|
<title>{{page_title}}</title>
|
||||||
clientID = "clientID-" + parseInt(Math.random() * 100);
|
|
||||||
document.getElementById("wrapper").classList.add("wrapper-error");
|
|
||||||
document.getElementById("wrapper").classList.remove("wrapper-ok");
|
|
||||||
|
|
||||||
// Fetch the hostname/IP address and port number from the form
|
|
||||||
host = "bojack"
|
|
||||||
port = 8080;
|
|
||||||
|
|
||||||
// Print output for the user in the messages div
|
|
||||||
document.getElementById("messages").innerHTML += '<span>Connecting to: ' + host + ' on port: ' + port + '</span><br/>';
|
|
||||||
document.getElementById("messages").innerHTML += '<span>Using the following client value: ' + clientID + '</span><br/>';
|
|
||||||
|
|
||||||
// Initialize new Paho client connection
|
|
||||||
client = new Paho.MQTT.Client(host, Number(port), clientID);
|
|
||||||
|
|
||||||
// Set callback handlers
|
|
||||||
client.onConnectionLost = onConnectionLost;
|
|
||||||
client.onMessageArrived = onMessageArrived;
|
|
||||||
|
|
||||||
// Connect the client, if successful, call onConnect function
|
|
||||||
client.connect({
|
|
||||||
onSuccess: onConnect,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when the client connects
|
|
||||||
function onConnect() {
|
|
||||||
document.getElementById("wrapper").classList.remove("wrapper-error");
|
|
||||||
document.getElementById("wrapper").classList.add("wrapper-ok");
|
|
||||||
var subWidgets = document.getElementsByClassName("subscriber");
|
|
||||||
var topics = new Set()
|
|
||||||
for (var i = 0; i < subWidgets.length; i++) {
|
|
||||||
var c = subWidgets.item(i);
|
|
||||||
var topic = c.getAttribute('data-sub-topic');
|
|
||||||
topics.add(topic);
|
|
||||||
c.classList.add('widget-unset');
|
|
||||||
}
|
|
||||||
for (let topic of topics)
|
|
||||||
{
|
|
||||||
client.subscribe(topic);
|
|
||||||
}
|
|
||||||
// Subscribe to the requested topic
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when the client loses its connection
|
|
||||||
function onConnectionLost(responseObject) {
|
|
||||||
document.getElementById("wrapper").classList.remove("wrapper-ok");
|
|
||||||
document.getElementById("wrapper").classList.add("wrapper-error");
|
|
||||||
if (responseObject.errorCode !== 0) {
|
|
||||||
console.log("onConnectionLost: " + responseObject.errorMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getByPath(obj, path) {
|
|
||||||
var current=obj;
|
|
||||||
path.split('.').forEach(function(p){ current = current[p]; });
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when a message arrives
|
|
||||||
function onMessageArrived(message) {
|
|
||||||
var views = document.getElementsByClassName(message.destinationName + "-value");
|
|
||||||
for (var i = 0 ; i < views.length; i++) {
|
|
||||||
var view = views.item(i);
|
|
||||||
if (view.classList.contains("update-policy-replace-content")) {
|
|
||||||
view.innerHTML = message.payloadString;
|
|
||||||
} else if(view.classList.contains("update-policy-append-content")) {
|
|
||||||
view.innerHTML += message.payloadString + "<br/>";
|
|
||||||
view.scrollTop = view.scrollHeight;
|
|
||||||
} else if(view.classList.contains("update-policy-add-row")) {
|
|
||||||
var now = (new Date()).toLocaleTimeString();
|
|
||||||
view.innerHTML = "<tr><td>"+ now + "</td><td>" +message.payloadString + "</td></tr>" + view.innerHTML;
|
|
||||||
} else if(view.classList.contains("update-policy-update-value")) {
|
|
||||||
jsonPath = view.getAttribute('data-value-path');
|
|
||||||
if (jsonPath) {
|
|
||||||
view.value = getByPath(JSON.parse(message.payloadString),
|
|
||||||
jsonPath);
|
|
||||||
} else {
|
|
||||||
view.value = message.payloadString;
|
|
||||||
}
|
|
||||||
view.onchange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var widgets = document.querySelectorAll("[data-topic='" + message.destinationName + "']");
|
|
||||||
for (var i = 0 ; i < widgets.length; i++) {
|
|
||||||
widgets.item(i).classList.remove("widget-unset");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called when the disconnection button is pressed
|
|
||||||
function startDisconnect() {
|
|
||||||
client.disconnect();
|
|
||||||
document.getElementById("messages").innerHTML += '<span>Disconnected</span><br/>';
|
|
||||||
updateScroll(); // Scroll to bottom of window
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates #messages div to auto-scroll
|
|
||||||
function updateScroll() {
|
|
||||||
var element = document.getElementById("messages");
|
|
||||||
element.scrollTop = element.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish(topic, value) {
|
|
||||||
var message = new Paho.MQTT.Message(value);
|
|
||||||
message.destinationName = topic;
|
|
||||||
client.send(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateContents(id, value) {
|
|
||||||
elem = document.getElementById(id);
|
|
||||||
elem.innerHTML = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
startConnect();
|
|
||||||
|
|
||||||
var sliders = document.getElementsByClassName("slider");
|
|
||||||
|
|
||||||
for (var i = 0 ; i < sliders.length; i++) {
|
|
||||||
var slider = sliders.item(i);
|
|
||||||
slider.onchange = function() {
|
|
||||||
document.getElementById(this.id + "-textual").innerHTML =
|
|
||||||
this.value;
|
|
||||||
};
|
|
||||||
slider.oninput = function() {
|
|
||||||
this.onchange()
|
|
||||||
var topic = this.getAttribute('data-pub-topic');
|
|
||||||
publish(topic, this.value);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var logDeletes = document.getElementsByClassName("log-delete");
|
|
||||||
|
|
||||||
for (var i = 0 ; i < logDeletes.length; i++) {
|
|
||||||
var elem = logDeletes.item(i);
|
|
||||||
elem.onclick = function() {
|
|
||||||
console.log(this.id);
|
|
||||||
table = document.getElementById(this.id + "-content");
|
|
||||||
console.log(table.id);
|
|
||||||
table.innerHTML = "";
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
window.onload = init;
|
|
||||||
|
|
||||||
</script>
|
|
||||||
<title>0xee home</title>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
159
mqtt_dash/widgets.py
Normal file
159
mqtt_dash/widgets.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from flask import render_template_string
|
||||||
|
|
||||||
|
|
||||||
|
def make_id():
|
||||||
|
return uuid.uuid4()
|
||||||
|
|
||||||
|
|
||||||
|
def make_widget(template,
|
||||||
|
widget_type,
|
||||||
|
sub_topic=None,
|
||||||
|
extra_classes=None,
|
||||||
|
**kwargs):
|
||||||
|
def f():
|
||||||
|
_classes = ['widget'] + list(extra_classes or [])
|
||||||
|
_attrs = {}
|
||||||
|
if sub_topic:
|
||||||
|
_classes.append('subscriber')
|
||||||
|
_attrs['data-sub-topic'] = sub_topic
|
||||||
|
_classes.append(f'{widget_type}-widget')
|
||||||
|
kwargs['sub_topic'] = sub_topic
|
||||||
|
_attrs['class'] = ' '.join([c for c in _classes if c is not None])
|
||||||
|
attr_str = " ".join([f'{k}="{v}"' for k, v in _attrs.items()])
|
||||||
|
widget_template = f'<div {attr_str}>\n{template}\n</div>'
|
||||||
|
return render_template_string(widget_template, **kwargs)
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def button_widget(topic, label, pub_value):
|
||||||
|
template = '''
|
||||||
|
<input type="button"
|
||||||
|
class="button is-large is-info is-outlined"
|
||||||
|
value="{{label}}"
|
||||||
|
onclick="publish('{{topic}}', '{{pub_value}}');"/>
|
||||||
|
'''
|
||||||
|
return make_widget(template,
|
||||||
|
'button',
|
||||||
|
label=label,
|
||||||
|
pub_value=pub_value,
|
||||||
|
topic=topic)
|
||||||
|
|
||||||
|
|
||||||
|
def slider_widget(label,
|
||||||
|
topic,
|
||||||
|
sub_topic=None,
|
||||||
|
value_path=None,
|
||||||
|
unit='',
|
||||||
|
min_val=0,
|
||||||
|
max_val=255):
|
||||||
|
id_ = make_id()
|
||||||
|
sub_topic = sub_topic or topic
|
||||||
|
template = '''
|
||||||
|
<div style="display:flex;justify-content:center;align-items:center;
|
||||||
|
height:60px;">
|
||||||
|
<input id="{{id_}}"
|
||||||
|
class="slider update-policy-update-value {{sub_topic}}-value"
|
||||||
|
data-pub-topic="{{topic}}"
|
||||||
|
{% if value_path %} data-value-path="{{value_path}}" {% endif %}
|
||||||
|
min="{{min_val}}" type="range" max="{{max_val}}"
|
||||||
|
value="{{min_val}}"/>
|
||||||
|
<span id="{{id_}}-textual" class="slider-value">-</span>
|
||||||
|
<span>{{unit}}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{label}}</div>
|
||||||
|
'''
|
||||||
|
return make_widget(template,
|
||||||
|
'slider',
|
||||||
|
sub_topic=sub_topic,
|
||||||
|
label=label,
|
||||||
|
min_val=min_val,
|
||||||
|
max_val=max_val,
|
||||||
|
value_path=value_path,
|
||||||
|
topic=topic,
|
||||||
|
unit=unit,
|
||||||
|
id_=id_)
|
||||||
|
|
||||||
|
|
||||||
|
def label_widget(topic, label, unit=''):
|
||||||
|
template = '''
|
||||||
|
<div align="center" class="is-size-2">
|
||||||
|
<span class="{{topic}}-value update-policy-replace-content">-</span>
|
||||||
|
<span>{{unit}}</span>
|
||||||
|
</div>
|
||||||
|
<div>{{label}}</div>
|
||||||
|
'''
|
||||||
|
return make_widget(template,
|
||||||
|
'label',
|
||||||
|
sub_topic=topic,
|
||||||
|
topic=topic,
|
||||||
|
unit=unit,
|
||||||
|
label=label)
|
||||||
|
|
||||||
|
|
||||||
|
def log_widget(topic, label):
|
||||||
|
id_ = make_id()
|
||||||
|
template = '''
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table is-striped is-hoverable is-fullwidth"
|
||||||
|
style="overflow: auto; height:100px;">
|
||||||
|
<thead><tr>
|
||||||
|
<th>Time</th><th>Message</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="{{id_}}-content"
|
||||||
|
class="{{topic}}-value update-policy-add-row">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div>{{label}}
|
||||||
|
<button id="{{id_}}" class="delete log-delete"></button>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
return make_widget(template,
|
||||||
|
"log",
|
||||||
|
sub_topic=topic,
|
||||||
|
topic=topic,
|
||||||
|
label=label,
|
||||||
|
id_=id_)
|
||||||
|
|
||||||
|
|
||||||
|
def row_layout(title, elems):
|
||||||
|
def f():
|
||||||
|
template = '''
|
||||||
|
<div class="box">
|
||||||
|
{% if title %}
|
||||||
|
<div class="title">{{title}}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="columns is-tablet">
|
||||||
|
{% for w in elems %}
|
||||||
|
<div class="column">
|
||||||
|
{{w()|safe}}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
return render_template_string(template, title=title, elems=elems)
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def column_layout(title, elems):
|
||||||
|
def f():
|
||||||
|
template = '''
|
||||||
|
{% if title %}
|
||||||
|
<div class="title">{{title}}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rows">
|
||||||
|
{% for w in elems %}
|
||||||
|
<div class="row">
|
||||||
|
{{w()|safe}}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
'''
|
||||||
|
return render_template_string(template, title=title, elems=elems)
|
||||||
|
|
||||||
|
return f
|
2
setup.py
2
setup.py
@ -10,5 +10,5 @@ setup(
|
|||||||
author_email='mqtt-dash@0xee.eu',
|
author_email='mqtt-dash@0xee.eu',
|
||||||
packages=['mqtt_dash'],
|
packages=['mqtt_dash'],
|
||||||
scripts=['mqtt-dash'],
|
scripts=['mqtt-dash'],
|
||||||
package_data={'mqtt_dash': ['templates/*.html']},
|
package_data={'mqtt_dash': ['templates/*.html', 'static/*']},
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user