Change device dashboard to use MQTT

This commit is contained in:
Sergey Lyubka 2022-06-03 07:13:08 +01:00
parent f4fa1e097d
commit 219428c249
8 changed files with 1303 additions and 5350 deletions

View File

@ -1,5 +1,5 @@
PROG ?= example
SOURCES ?= ../../mongoose.c main.c web.c packed_fs.c
SOURCES ?= ../../mongoose.c main.c net.c packed_fs.c
CFLAGS ?= -I../.. -DMG_ENABLE_PACKED_FS=1 $(EXTRA)
FILES_TO_EMBED ?= $(wildcard web_root/*)
ROOT ?= $(realpath $(CURDIR)/../../..)

View File

@ -3,6 +3,10 @@
#include "mongoose.h"
#define MQTT_SERVER "mqtt://broker.hivemq.com:1883"
#define MQTT_PUBLISH_TOPIC "mg/my_device"
#define MQTT_SUBSCRIBE_TOPIC "mg/#"
// Authenticated user.
// A user can be authenticated by:
// - a name:pass pair
@ -17,25 +21,18 @@ struct user {
// This is a configuration structure we're going to show on a dashboard
static struct config {
int value1;
char *value2;
} s_config = {123, NULL};
char *url, *pub, *sub; // MQTT settings
} s_config;
// Update config structure. Return true if changed, false otherwise
static bool update_config(struct mg_http_message *hm, struct config *cfg) {
bool changed = false;
static struct mg_connection *s_mqtt = NULL; // MQTT connection
// Try to update a single configuration value
static void update_config(struct mg_str *body, const char *name, char **value) {
char buf[256];
if (mg_http_get_var(&hm->body, "value1", buf, sizeof(buf)) > 0) {
cfg->value1 = atoi(buf);
changed = true;
if (mg_http_get_var(body, name, buf, sizeof(buf)) > 0) {
free(*value);
*value = strdup(buf);
}
if (mg_http_get_var(&hm->body, "value2", buf, sizeof(buf)) > 0) {
free(cfg->value2);
cfg->value2 = malloc(strlen(buf) + 1);
strcpy(cfg->value2, buf);
changed = true;
}
return changed;
}
// Parse HTTP requests, return authenticated user or NULL
@ -75,7 +72,7 @@ static void send_notification(struct mg_mgr *mgr, const char *name,
}
// Send simulated metrics data to the dashboard, for chart rendering
static void timer_func(void *param) {
static void timer_metrics_fn(void *param) {
char buf[50];
mg_snprintf(buf, sizeof(buf), "[ %lu, %d ]", (unsigned long) time(NULL),
10 + (int) ((double) rand() * 10 / RAND_MAX));
@ -83,11 +80,41 @@ static void timer_func(void *param) {
send_notification(param, "metrics", buf);
}
// MQTT event handler function
static void mqtt_fn(struct mg_connection *c, int ev, void *evd, void *fnd) {
if (ev == MG_EV_MQTT_OPEN) {
struct mg_str topic = mg_str(s_config.pub);
mg_mqtt_sub(s_mqtt, &topic, 1);
} else if (ev == MG_EV_MQTT_MSG) {
struct mg_mqtt_message *mm = evd;
char buf[256];
snprintf(buf, sizeof(buf), "{\"topic\":\"%.*s\",\"data\":\"%.*s\"}",
(int) mm->topic.len, mm->topic.ptr, (int) mm->data.len,
mm->data.ptr);
send_notification(param, "message", buf);
} else if (ev == MG_EV_CLOSE) {
s_mqtt = NULL;
}
}
// Keep MQTT connection open - reconnect if closed
static void timer_metrics_fn(void *param) {
struct mg_mgr *mgr = (struct mg_mgr *) param;
if (s_mqtt == NULL) {
struct mg_mqtt_opts opts = {};
s_mqtt = mg_mqtt_connect(mgr, s_config.rl, &opts, mqtt_fn, NULL);
}
}
// HTTP request handler function
void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
void *fn_data) {
if (ev == MG_EV_OPEN && c->is_listening) {
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_func, c->mgr);
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_metrics_fn, c->mgr);
mg_timer_add(c->mgr, 1000, MG_TIMER_REPEAT, timer_mqtt_fn, c->mgr);
s_config.url = strdup(MQTT_SERVER);
s_config.pub = strdup(MQTT_PUBLISH_TOPIC);
s_config.sub = strdup(MQTT_SUBSCRIBE_TOPIC);
} else if (ev == MG_EV_HTTP_MSG) {
struct mg_http_message *hm = (struct mg_http_message *) ev_data;
struct user *u = getuser(hm);
@ -100,14 +127,16 @@ void device_dashboard_fn(struct mg_connection *c, int ev, void *ev_data,
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n");
} else if (mg_http_match_uri(hm, "/api/config/get")) {
mg_printf(c, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n");
mg_http_printf_chunk(c, "{\"%s\":%d,\"%s\":\"%s\"}", "value1",
s_config.value1, "value2", s_config.value2);
mg_http_printf_chunk(c, "{\"url\":\"%s\",\"pub\":\"%s\",\"sub\":\"%s\"}",
s_config.url, s_config.pub, s_config.sub);
mg_http_printf_chunk(c, "");
} else if (mg_http_match_uri(hm, "/api/config/set")) {
// Admins only
if (strcmp(u->name, "admin") == 0) {
if (update_config(hm, &s_config))
send_notification(fn_data, "config", "null");
update_config(&hm->body, "url", &s_config.url);
update_config(&hm->body, "pub", &s_config.pub);
update_config(&hm->body, "sub", &s_config.sub);
send_notification(fn_data, "config", "null");
mg_printf(c, "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
} else {
mg_printf(c, "%s", "HTTP/1.1 403 Denied\r\nContent-Length: 0\r\n\r\n");

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,10 +5,8 @@
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="chartist.min.css" />
<link rel="stylesheet" href="style.css" />
</head>
<body></body>
<script src="chartist.min.js"></script>
<script type="module" src="main.js"></script>
</html>

View File

@ -1,6 +1,8 @@
'use strict';
import {Component, h, html, render, useEffect, useState, useRef} from './preact.min.js';
const MaxMetricsDataPoints = 50;
const Nav = props => html`
<div style="background: #333; padding: 0.5em; color: #fff;">
<div class="container d-flex">
@ -28,93 +30,77 @@ const Hero = props => html`
<div style="margin-top: 1em; background: #eee; padding: 1em; border-radius: 0.5em; color: #777; ">
<h1 style="margin: 0.2em 0;">Interactive Device Dashboard</h1>
<p>
This device dashboard is developed with modern and compact Preact framework,
in order to fit on a very small devices. This is
a <a href="https://mongoose.ws/tutorials/http-server/">hybrid server</a> which
provides both static and dynamic content. Static files, like CSS/JS/HTML
or images, are compiled into the server binary. Dynamic content is
served via the REST API.
or images, are compiled into the server binary.
This dashboard shows values kept in server memory. Many clients can open
this page. The JS code that watches state changes, reconnects on network
failures. That means if server restarts, dashboard on all connected clients
refresh automatically.
NOTE: administrators can change settings values, whilst users cannot.
<p>
This UI uses the REST API implemented by the server, which you can examine
This UI uses the REST API implemented by the device, which you can examine
using <code>curl</code> command-line utility:
</p>
<div><code>curl localhost:8000/api/config/get</code> - get current device configuration</div>
<div><code>curl localhost:8000/api/config/set -d 'value1=7&value2=hello'</code> - set device configuration</div>
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> - send chat message</div>
<div><code>curl localhost:8000/api/watch</code> - get notifications: chat, config, metrics</div>
<div><code>curl localhost:8000/api/message/send -d 'msg=hello'</code> - send MQTT message</div>
<div><code>curl localhost:8000/api/watch</code> - get notifications: MQTT messages, configuration, sensor data</div>
</div>`;
const ShowSettings = function(props) {
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #59d; color: #fff;padding: 0.4em;">Device configuration</h3>
<div style="margin: 0.5em 0;">
<span class="addon">value1:</span>
<input disabled type="text" class="smooth"
value=${(props.config || {}).value1 || ''} />
</div>
<div style="margin: 0.5em 0;">
<span class="addon">value2:</span>
<input disabled type="text" class="smooth"
value=${(props.config || {}).value2 || ''} />
</div>
<div class="msg">
Server's corresponding runtime configuration structure:
<pre>
struct config {
int value1;
char *value2;
} s_config;
</pre>
</div>
</div>`;
};
const ChangeSettings = function(props) {
const [value1, setValue1] = useState('');
const [value2, setValue2] = useState('');
const Configuration = function(props) {
const [url, setUrl] = useState('');
const [pub, setPub] = useState('');
const [sub, setSub] = useState('');
useEffect(() => {
setValue1(props.config.value1);
setValue2(props.config.value2);
}, [props.config.value1, props.config.value2])
setUrl(props.config.url);
setPub(props.config.pub);
setSub(props.config.sub);
}, [props.config.url, props.config.pub, props.config.sub])
const update = (name, val) => fetch('/api/config/set', {
method: 'post',
body: `${name}=${encodeURIComponent(val)}`
}).catch(err => err);
const updatevalue1 = ev => update('value1', value1);
const updatevalue2 = ev => update('value2', value2);
const updateurl = ev => update('url', url);
const updatepub = ev => update('pub', pub);
const updatesub = ev => update('sub', sub);
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #c03434; color: #fff; padding: 0.4em;">
Change configuration</h3>
<div style="margin: 0.5em 0;">
<span class="addon">value1:</span>
<input type="text" value=${value1} onchange=${updatevalue1}
oninput=${ev => setValue1(ev.target.value)} />
<button class="btn" disabled=${!value1} onclick=${updatevalue1}
Device Configuration</h3>
<div style="margin: 0.5em 0; display: flex;">
<span class="addon nowrap">MQTT server:</span>
<input type="text" style="flex: 1 100%;"
value=${url} onchange=${updateurl}
oninput=${ev => setUrl(ev.target.value)} />
<button class="btn" disabled=${!url} onclick=${updateurl}
style="margin-left: 1em; background: #8aa;">Update</button>
</div>
<div style="margin: 0.5em 0;">
<span class="addon">value2:</span>
<input type="text" value=${value2} onchange=${updatevalue2}
oninput=${ev => setValue2(ev.target.value)} />
<button class="btn" disabled=${!value2} onclick=${updatevalue2}
<div style="margin: 0.5em 0; display: flex; ">
<span class="addon nowrap">Subscribe topic:</span>
<input type="text" style="flex: 1 100%;"
value=${sub} onchange=${updatesub}
oninput=${ev => setSub(ev.target.value)} />
<button class="btn" disabled=${!sub} onclick=${updatesub}
style="margin-left: 1em; background: #8aa;">Update</button>
</div>
<div style="margin: 0.5em 0; display: flex;">
<span class="addon nowrap">Publish topic:</span>
<input type="text" style="flex: 1 100%;"
value=${pub} onchange=${updatepub}
oninput=${ev => setPub(ev.target.value)} />
<button class="btn" disabled=${!pub} onclick=${updatepub}
style="margin-left: 1em; background: #8aa;">Update</button>
</div>
<div class="msg">
As soon as administrator updates configuration,
server iterates over all connected clients and sends update
notifications to all of them - so they update automatically.
Device keeps a persistent connection to the configured MQTT server.
Changes to this configuration are propagated to all dashboards: try
changing them in this dashboard and observe changes in the other opened
dashboard.
</div><div class="msg">
Note: administrators can see this section and can change device
configuration, whilst users cannot.
</div>
</div>`;
};
@ -155,7 +141,7 @@ const Login = function(props) {
const Message = text => html`<div style="margin: 0.5em 0;">${text}</div>`;
const Chat = function(props) {
const Messages = function(props) {
const [message, setMessage] = useState('');
const sendmessage = ev => fetch('/api/message/send', {
method: 'post',
@ -166,40 +152,105 @@ const Chat = function(props) {
text => html`<div style="margin: 0.5em 0;">${text}</div>`);
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">User chat</h3>
<h3 style="background: #30c040; color: #fff; padding: 0.4em;">MQTT messages</h3>
<div style="height: 10em; overflow: auto; padding: 0.5em; " class="border">
${messages}
</div>
<div style="margin: 0.5em 0;">
<input placeholder="type message..." style="width: 100%" value=${message}
onchange=${sendmessage}
<div style="margin: 0.5em 0; display: flex">
<span class="addon nowrap">Publish message:</span>
<input placeholder="type and press enter..." style="flex: 1 100%;"
value=${message} onchange=${sendmessage}
oninput=${ev => setMessage(ev.target.value)} />
</div>
<div class="msg">
Chat demonsrates
real-time bidirectional data exchange between many clients and a server.
Message gets passed to the device via REST. Then a device sends it to
the MQTT server over MQTT. All MQTT messages on a subscribed topic
received by a device, are propagated to this dashboard via the
/api/watch.
</div>
</div>`;
};
const datefmt = unix => (new Date(unix * 1000)).toISOString().substr(14, 5);
// Expected arguments:
// data: timeseries, e.g. [ [1654361352, 19], [1654361353, 18], ... ]
// width, height, yticks, xticks, ymin, ymax, xmin, xmax
const SVG = function(props) {
// w
// +---------------------+
// | h1 |
// | +-----------+ |
// | | | | h
// | w1 | | w2 |
// | +-----------+ |
// | h2 |
// +---------------------+
//
let w = props.width, h = props.height, w1 = 30, w2 = 0, h1 = 8, h2 = 18;
let yticks = props.yticks || 4, xticks = props.xticks || 5;
let data = props.data || [];
let ymin = props.ymin || 0;
let ymax = props.ymax || Math.max.apply(null, data.map(p => p[1]));
let xmin = props.xmin || Math.min.apply(null, data.map(p => p[0]));
let xmax = props.xmax || Math.max.apply(null, data.map(p => p[0]));
// Y-axis tick lines and labels
let yta = (new Array(yticks + 1)).fill(0).map((_, i) => i); // indices
let yti = i => h - h2 - (h - h1 - h2) * i / yticks; // index's Y
let ytv = i => (ymax - ymin) * i / yticks;
let ytl = y => html`<line x1=${w1} y1=${y} x2=${w} y2=${y} class="tick"/>`;
let ytt = (y, v) => html`<text x=0 y=${y + 5} class="label">${v}</text>`;
// X-axis tick lines and labels
let datefmt = unix => (new Date(unix * 1000)).toISOString().substr(14, 5);
let xta = (new Array(xticks + 1)).fill(0).map((_, i) => i); // indices
let xti = i => w1 + (w - w1 - w2) * i / xticks; // index's X
let xtv = i => datefmt(xmin + (xmax - xmin) * i / xticks);
let xtl = x => html`<path d="M ${x},${h1} L ${x},${h - h2}" class="tick"/>`;
let xtt = (x, v) =>
html`<text x=${x - 15} y=${h - 2} class="label">${v}</text>`;
// Transform data points array into coordinate
let dx = v => w1 + (v - xmin) / ((xmax - xmin) || 1) * (w - w1 - w2);
let dy = v => h - h2 - (v - ymin) / ((ymax - ymin) || 1) * (h - h1 - h2);
let dd = data.map(p => [Math.round(dx(p[0])), Math.round(dy(p[1]))]);
let ddl = dd.length;
// And plot the data as <path> element
let begin0 = ddl ? `M ${dd[0][0]},${dd[0][1]}` : `M 0,0`;
let begin = `M ${w1},${h - h2}`; // Initial point
let end = ddl ? `L ${dd[ddl - 1][0]},${h - h2}` : `L ${w1},${h - h2}`;
let series = ddl ? dd.map(p => `L ${p[0]} ${p[1]}`) : [];
return html`
<svg viewBox="0 0 ${w} ${h}">
<style>
.axis { stroke: #aaa; fill: none; }
.label { stroke: #aaa; font-size: 13px; }
.tick { stroke: #ccc; fill: none; stroke-dasharray: 5; }
.seriesbg { stroke: none; fill: rgba(200,225,255, 0.25)}
.series { stroke: #25a; fill: none; }
</style>
${yta.map(i => ytl(yti(i)))}
${yta.map(i => ytt(yti(i), ytv(i)))}
${xta.map(i => xtl(xti(i)))}
${data.length ? xta.map(i => xtt(xti(i), xtv(i))) : ''}
<path d="${begin} ${series.join(' ')} ${end}" class="seriesbg" />
<path d="${begin0} ${series.join(' ')}" class="series" />
</svg>`;
};
const Chart = function(props) {
const chartdiv = useRef(null);
const refresh = () => {
const labels = props.metrics.map(el => datefmt(el[0]));
const series = props.metrics.map(el => el[1]);
const options = {low: 0, high: 20, showArea: true};
// console.log([labels, [series]]);
new Chartist.Line(chartdiv.current, {labels, series: [series]}, options);
};
useEffect(() => {chartdiv && refresh()}, [props.metrics]);
let xmax = 0, missing = MaxMetricsDataPoints - props.metrics.length;
if (missing > 0) xmax = Math.round(Date.now() / 1000) + missing;
return html`
<div style="margin: 0 0.3em;">
<h3 style="background: #ec3; color: #fff; padding: 0.4em;">Data Chart</h3>
<div style="height: 14em; overflow: auto; padding: 0.5em; " class="border">
<div ref=${chartdiv} style="height: 100%;" />
<div style="overflow: auto; padding: 0.5em;" class="">
<${SVG} height=240 width=600 ymin=0 ymax=20
xmax=${xmax} data=${props.metrics} />
</div>
<div class="msg">
This chart plots live sensor data, sent by a device via /api/watch.
</div>
</div>`;
};
@ -235,7 +286,7 @@ const App = function(props) {
} else if (msg.name == 'message') {
setMessages(m => m.concat([msg.data]));
} else if (msg.name == 'metrics') {
setMetrics(m => m.concat([msg.data]).splice(-10));
setMetrics(m => m.concat([msg.data]).splice(-MaxMetricsDataPoints));
}
// console.log(msg);
if (!result.done) return f(reader);
@ -258,16 +309,15 @@ const App = function(props) {
if (!user) return html`<${Login} login=${login} />`;
const admin = user == 'admin';
const cs = admin ? html`<${ChangeSettings} config=${config} />` : '';
const cs = admin ? html`<${Configuration} config=${config} />` : '';
return html`
<${Nav} user=${user} logout=${logout} />
<div class="container">
<${Hero} />
<div class="row">
<div class="col c6"><${Chart} metrics=${metrics} /></div>
<div class="col c6"><${ShowSettings} config=${config} /></div>
<div class="col c6"><${Chat} messages=${messages} /></div>
<div class="col c6">${cs}</div>
<div class="col c6"><${Messages} messages=${messages} /></div>
</div>
<${Footer} />
</div>`;

View File

@ -4,7 +4,7 @@ select, input, label::before, textarea { outline: none; box-shadow:none !importa
code, pre { color: #373; font-family: monospace; font-weight: bolder; font-size: smaller; background: #ddd; padding: 0.1em 0.3em; border-radius: 0.2em; }
textarea, input, .addon { font-size: 15px; border: 1px solid #ccc; padding: 0.5em; }
a, a:visited, a:active { color: #55f; }
.addon { background: #eee; }
.addon { background: #eee; min-width: 9em;}
.btn {
background: #ccc; border-radius: 0.3em; border: 0; color: #fff; cursor: pointer;
display: inline-block; padding: 0.6em 2em; font-weight: bolder;
@ -16,6 +16,7 @@ a, a:visited, a:active { color: #55f; }
.d-none { display: none; }
.border { border: 1px solid #ddd; }
.rounded { border-radius: 0.5em; }
.nowrap { white-space: nowrap; }
.msg { background: #def; border-left: 5px solid #59d; padding: 1em; margin: 1em 0; }
.row { margin: 1% 0; overflow: auto; }
.col { float: left; }