diff --git a/config.py.example b/config.py.example new file mode 100644 index 0000000..0adc38a --- /dev/null +++ b/config.py.example @@ -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')) +] diff --git a/default.nix b/default.nix index 052acbb..f912207 100644 --- a/default.nix +++ b/default.nix @@ -1,6 +1,6 @@ { python3 }: with python3.pkgs; -buildPythonApplication rec { +buildPythonPackage rec { name = "mqtt-dash"; src = ./.; propagatedBuildInputs = [ flask ]; diff --git a/mqtt-dash b/mqtt-dash index a2bf26b..f04043a 100755 --- a/mqtt-dash +++ b/mqtt-dash @@ -1,9 +1,28 @@ #!/usr/bin/env python +import argparse import os +import sys 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.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) diff --git a/mqtt_dash/app.py b/mqtt_dash/app.py index faa302d..89872df 100755 --- a/mqtt_dash/app.py +++ b/mqtt_dash/app.py @@ -1,201 +1,13 @@ #!/usr/bin/env python -from flask import Flask, flash, redirect, render_template_string -from flask import render_template, request, session, abort -import uuid +from flask import Flask, render_template 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'
\n{template}\n
' - return render_template_string(widget_template, **kwargs) - - return f - - -def button_widget(topic, label, pub_value): - template = ''' - - ''' - 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 = ''' -
- - -{{unit}} -
-
{{label}}
- ''' - 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 = ''' -
- - - {{unit}} -
-
{{label}}
- ''' - return make_widget(template, - 'label', - sub_topic=topic, - topic=topic, - unit=unit, - label=label) - - -def log_widget(topic, label): - id_ = make_id() - template = ''' -
- - - - - - -
TimeMessage
-
-
{{label}}
- ''' - return make_widget(template, - "log", - sub_topic=topic, - topic=topic, - label=label, - id_=id_) - - -def row_layout(title, *elems): - def f(): - template = ''' -
- {% if title %} -
{{title}}
- {% endif %} -
- {% for w in elems %} -
- {{w()|safe}} -
- {% endfor %} -
-
- ''' - return render_template_string(template, title=title, elems=elems) - - return f - - -def column_layout(title, *elems): - def f(): - template = ''' - {% if title %} -
{{title}}
- {% endif %} -
- {% for w in elems %} -
- {{w()|safe}} -
- {% endfor %} -
- ''' - return render_template_string(template, title=title, elems=elems) - - return f - - @app.route('/') def home(): - return render_template( - 'base.html', - widgets=[ - column_layout( - "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')) - ]) + return render_template('base.html', + widgets=app.config['WIDGETS'], + mqtt_broker=app.config['MQTT_BROKER'], + page_title=app.config['PAGE_TITLE']) diff --git a/mqtt_dash/static/main.js b/mqtt_dash/static/main.js new file mode 100644 index 0000000..663e1c6 --- /dev/null +++ b/mqtt_dash/static/main.js @@ -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 += + 'Connecting to: ' + brokerUri+ '
'; + document.getElementById("messages").innerHTML += + 'Using the following client value: ' + clientId + '
'; + + // 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 + "
"; + view.scrollTop = view.scrollHeight; + } else if(view.classList.contains("update-policy-add-row")) { + var now = (new Date()).toLocaleTimeString(); + view.innerHTML = ""+ now + "" +message.payloadString + "" + 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 += 'Disconnected
'; + 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 = ""; + }; + } + + + } diff --git a/mqtt_dash/static/style.css b/mqtt_dash/static/style.css new file mode 100644 index 0000000..9d59990 --- /dev/null +++ b/mqtt_dash/static/style.css @@ -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; +} diff --git a/mqtt_dash/templates/base.html b/mqtt_dash/templates/base.html index 7a4541c..32ae27c 100644 --- a/mqtt_dash/templates/base.html +++ b/mqtt_dash/templates/base.html @@ -5,246 +5,15 @@ - + + + - 0xee home + window.onload = function() { init("{{mqtt_broker}}")}; + + {{page_title}}
diff --git a/mqtt_dash/widgets.py b/mqtt_dash/widgets.py new file mode 100644 index 0000000..686e47a --- /dev/null +++ b/mqtt_dash/widgets.py @@ -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'
\n{template}\n
' + return render_template_string(widget_template, **kwargs) + + return f + + +def button_widget(topic, label, pub_value): + template = ''' + + ''' + 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 = ''' +
+ + - + {{unit}} +
+
{{label}}
+ ''' + 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 = ''' +
+ - + {{unit}} +
+
{{label}}
+ ''' + return make_widget(template, + 'label', + sub_topic=topic, + topic=topic, + unit=unit, + label=label) + + +def log_widget(topic, label): + id_ = make_id() + template = ''' +
+ + + + + + +
TimeMessage
+
+
{{label}} + +
+ ''' + return make_widget(template, + "log", + sub_topic=topic, + topic=topic, + label=label, + id_=id_) + + +def row_layout(title, elems): + def f(): + template = ''' +
+ {% if title %} +
{{title}}
+ {% endif %} +
+ {% for w in elems %} +
+ {{w()|safe}} +
+ {% endfor %} +
+
+ ''' + return render_template_string(template, title=title, elems=elems) + + return f + + +def column_layout(title, elems): + def f(): + template = ''' + {% if title %} +
{{title}}
+ {% endif %} +
+ {% for w in elems %} +
+ {{w()|safe}} +
+ {% endfor %} +
+ ''' + return render_template_string(template, title=title, elems=elems) + + return f diff --git a/setup.py b/setup.py index 59bac34..3b42e70 100644 --- a/setup.py +++ b/setup.py @@ -10,5 +10,5 @@ setup( author_email='mqtt-dash@0xee.eu', packages=['mqtt_dash'], scripts=['mqtt-dash'], - package_data={'mqtt_dash': ['templates/*.html']}, + package_data={'mqtt_dash': ['templates/*.html', 'static/*']}, )