web frontend for configuration and local weather display

master
Kenneth Barbour 2020-01-06 16:22:59 -05:00
parent a96d053502
commit e5efee686b
22 changed files with 6406 additions and 0 deletions

46
bin/build-data.sh 100755
View File

@ -0,0 +1,46 @@
#!/bin/bash
set -e
PROJ_ROOT=${PROJ_ROOT:-..}
DATA_DIR=${PROJ_ROOT}/data
WWW_DIR=${DATA_DIR}/w
# Build data directory
printf "\033[33mBuilding data directory:\033[0m ${DATA_DIR}\n"
mkdir -p ${DATA_DIR}
# Build frontend (Not sure if I want to do this)
WWWFRONT_DIR=${WWWFRONT:-${PROJ_ROOT}/frontend}
#printf "\033[33mBuilding frontend:\033[0m ${WWWFRONT_DIR}\n"
#(cd ${WWWFRONT_DIR} && npm run release)
# Copying static web files
printf "\033[33mCopying files to static web directory:\033[0m ${WWW_DIR}\n"
rm ${WWW_DIR} -Rf
mkdir -p ${WWW_DIR}
cp -Lr ${WWWFRONT_DIR}/dist/* ${WWW_DIR}
# Create gzip compressed versions of www files
GZ_MIN_SIZE=${GZ_MIN_SIZE:-"1k"}
GZ_MIN_RATIO=${GZ_MIN_RATIO:-30}
printf "\033[33mCompressing large files:\033[0m \
(size > ${GZ_MIN_SIZE}) by at least ${GZ_MIN_RATIO}%%\n"
find ${WWW_DIR} -type f -size +${GZ_MIN_SIZE} | while read file; do
real_size=$(cat "${file}" | wc -c)
gz_size=$(gzip "${file}" -c | wc -c)
ratio=$(echo "scale=2; 100 - ${gz_size} * 100 / ${real_size}" | bc)
if [ `echo "${ratio} > ${GZ_MIN_RATIO}" | bc` -ge 1 ]; then
gzip -n -k -9 "${file}"
printf " \033[37m${file}\033[0m ${real_size} bytes ==> ${gz_size} bytes (\033[32m${ratio}%%\033[0m)\n"
else
printf " \033[37m${file}\033[0m ${real_size} bytes ==> ${gz_size} bytes (\033[31m${ratio}%%\033[0m) Not compressing!\n"
fi
done
# Finish up
printf "\n\n\033[32mFinished!\033[0m\n"
DATA_SUM=$(find ${DATA_DIR} -type f -exec sha1sum {} \; | sort -k 2 | sha1sum)
tree --du -h "${DATA_DIR}"
echo "Data checksum: ${DATA_SUM}"
echo "Apparent size: $(du -sbh ${DATA_DIR})"

1
frontend/.gitignore vendored 100644
View File

@ -0,0 +1 @@
node_modules/

File diff suppressed because one or more lines are too long

BIN
frontend/dist/favicon.ico vendored 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

54
frontend/dist/firmware.html vendored 100644
View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Local Firmware Update</title>
<link rel="stylesheet" href="css/main.bundle.css">
<meta charset="utf-8">
</head>
<body>
<section class="section">
<div class="container">
<h1 class="title is-1">Update Firmware</h1>
<h2 class="subtitle">Manually upload system firmware</h2>
<form method="POST" enctype="multipart/form-data">
<div class="field">
<div id="firmware-file" class="file has-name is-fullwidth">
<label class="file-label">
<input class="file-input" type="file" name="firmware">
<span class="file-cta">
<span class="file-icon">
<i class="fas fa-upload"></i>
</span>
<span class="file-label">
Choose a file…
</span>
</span>
<span class="file-name">
No file selected
</span>
</label>
</div>
</div>
<div class="field">
<div class="control">
<input type="submit" class="button is-danger" value="Update">
</div>
</div>
</form>
</div>
<script>
const fileInput = document.querySelector('#firmware-file input[type=file]');
fileInput.onchange = () => {
if (fileInput.files.length > 0) {
const fileName = document.querySelector('#firmware-file .file-name');
fileName.textContent = fileInput.files[0].name;
}
}
</script>
</section>
</body>
</html>

11
frontend/dist/index.html vendored 100644
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Sensor overview</title>
<link rel="stylesheet" href="css/main.bundle.css">
<meta charset="utf-8">
</head>
<body>
<script src="js/main.js"></script>
</body>
</html>

1
frontend/dist/js/main.js vendored 100644

File diff suppressed because one or more lines are too long

1
frontend/dist/js/setup.js vendored 100644

File diff suppressed because one or more lines are too long

11
frontend/dist/setup.html vendored 100644
View File

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Device Setup</title>
<link rel="stylesheet" href="css/main.bundle.css">
<meta charset="utf-8">
</head>
<body>
<script src="js/setup.js"></script>
</body>
</html>

5588
frontend/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "x-frontend",
"version": "1.0.0",
"description": "A basic frontend with bulma css",
"main": "index.js",
"scripts": {
"build": "webpack --mode production",
"watch": "webpack --mode development --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"devDependencies": {
"bulma": "^0.7.5",
"chart.js": "^2.8.0",
"css-loader": "^3.2.1",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"mini-css-extract-plugin": "^0.8.0",
"mithril": "^2.0.4",
"node-sass": "^4.13.0",
"sass-loader": "^8.0.0",
"style-loader": "^1.0.1",
"webpack": "^4.40.2",
"webpack-cli": "^3.3.9"
}
}

View File

@ -0,0 +1,205 @@
#!/usr/bin/node
const fs = require('fs');
const path = require('path');
const http = require('http');
const url = require('url');
const querystring = require('querystring');
const SERVER_PORT = process.argv[2] || 8000;
const DOC_ROOT = path.join(__dirname, '/../dist');
let wifistate = { connected: false };
let wifinetworks = {
networks: [
{
ssid: "Network A",
rssi: -66,
encryption: "wpa2"
},
{
ssid: "Network B",
rssi: -75,
encryption: "open"
},
]
};
let config = {
mqtt_host: null,
mqtt_prefix: null,
mdns_hostname: null
}
let server;
server = http.createServer(function(req, res) {
req.setEncoding('utf8');
let parsed = url.parse(req.url, true);
let pathname = parsed.pathname;
let type;
console.log('[' + new Date() + ']', req.method, pathname);
// Get request body
let body = '';
if (req.method === 'POST' || req.method === 'PUT') {
}
if (pathname === '/')
pathname = '/index.html';
else if (pathname === '/setup') {
pathname = '/setup.html';
}
if (pathname === "/weather/current") {
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify({"time": new Date(), "temperature": 24, "pressure": 1013.25, "humidity": 55}));
res.end();
return;
} else if (pathname === "/wifi/scan") {
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify(wifinetworks));
res.end();
return;
}
else if (pathname === "/wifi") {
if (req.method == 'PUT') {
let body = '';
req.on('data', function(stream){
body += stream;
if (body.length > 1e6) { req.connection.destroy(); return; }
let data = querystring.parse(body);
console.log(data);
if (!wifi_connect(data.ssid, data.key)) {
res.writeHead(400, {'Content-Type': 'application/json'});
res.write(JSON.stringify({
connected: false,
error: true,
message: 'Unable to connect'
}));
res.end()
return;
}
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify(wifistate));
res.end();
});
return;
}
if (req.method == 'DELETE') {
wifistate = { connected: false };
}
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify(wifistate));
res.end();
return;
}
else if (pathname === '/config') {
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify(config));
res.end();
return;
}
else if (pathname.startsWith('/config/')) {
elements = pathname.split('/');
propname = elements[2];
console.log('property', propname, config[propname]);
propval = config[propname];
if (!propname in config) {
res.writeHead(404, {'Content-Type': 'application/json'});
res.write(JSON.stringify({
error: true,
message: 'Unknown property'
}));
res.end();
return;
}
if (req.method === "PUT") {
req.on('data', function(stream) {
let body = '';
body += stream;
if (body.length > 1e6) { req.connection.destroy(); return; }
let data = body.replace(/\"/g,''); // strip quotes
console.log("Set property", propname, data);
config[propname] = data;
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify({
property: propname,
value: config[propname]
}));
res.end();
return;
});
return;
}
res.writeHead(200, {'Content-Type': 'application/json'});
res.write(JSON.stringify({
property: propname,
value: propval
}));
res.end();
return;
}
if (/$.htm[l]?/i.test(pathname))
type = "text/html";
else if (/$.js/i.test(pathname))
type = "text/javascript";
else if (/$.css/i.test(pathname))
type = "text/css";
else if (/$.json/i.test(pathname))
type = "application/json";
else if (/$.ico/i.test(pathname))
type = "image/png"; //TODO: better type test
let stream = fs.createReadStream(path.join(DOC_ROOT + pathname));
stream.on('error', function(error) {
res.writeHead(404);
res.write('Not Found');
res.end();
});
if (type)
res.writeHead(200, {'Content-Type': type});
stream.pipe(res);
});
/**
* Pretend to connect to wifi
*/
function wifi_connect(ssid, passphrase) {
console.log(ssid, passphrase);
if (passphrase === "foo" && ssid == "Network A") {
wifistate = {
connected: true,
ssid: ssid,
ipv4: '192.168.1.101',
rssi: -65,
channel: 1
};
} else {
wifistate = {
connected: false,
}
}
return wifistate.connected;
}
// Start Server
console.log('Starting server on port ' + SERVER_PORT);
console.log('Document root: ' + DOC_ROOT);
server.listen(SERVER_PORT);

View File

@ -0,0 +1,6 @@
require('./style.scss');
var m = require('mithril');
var Weather = require("./models/weather.js");
var CurrentView = require('./views/CurrentView');
m.mount(document.body, CurrentView);

View File

@ -0,0 +1,50 @@
const m = require('mithril');
const querystring = require('querystring');
const Config = {
properties: {},
current: {},
updates: {},
load: function() {
return m.request({
method: 'GET',
url: '/config'
}).then(function(res){
Config.properties = res;
});
},
update: function(property, value) {
if (Config.properties[property] === value)
return;
Config.properties[property] = value;
Config.updates[property] = true;
},
propChanged: function(property) {
return Config.updates[property] === true;
},
save: function(property) {
if (!Config.propChanged(property))
return;
return m.request({
method: 'PUT',
url: '/config/:prop',
params: { prop: property },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
seralize: function(value) { return value; },
body: Config.properties[property],
}).then(function(res){
Config.properties[property] = res.value;
Config.updates[property] = false;
});
},
};
module.exports = Config;

View File

@ -0,0 +1,52 @@
const m = require('mithril');
const querystring = require('querystring');
const WiFi = {
current: { connected: false },
networks: [],
scan: function() {
return m.request({
method: "GET",
url: "/wifi/scan"
}).then(function(res){
WiFi.networks = res.networks;
})
},
load: function() {
return m.request({
method: "GET",
url: "/wifi"
}).then(function(res){
WiFi.current = res;
});
},
connect: function(ssid, passphrase) {
return m.request({
method: "PUT",
url: "/wifi",
serialize: querystring.stringify,
body: {
ssid: ssid,
key: passphrase
}
}).then(function(res) {
WiFi.current = res;
});
},
disconnect: function() {
return m.request({
method: "DELETE",
url: '/wifi'
}).then(function(res) {
WiFi.current = { connected: false }
});
}
};
module.exports = WiFi;

View File

@ -0,0 +1,15 @@
const m = require("mithril");
const Weather = {
current: null,
loadCurrent: function() {
return m.request({
method: "GET",
url: "/weather/current"
}).then(function(result){
Weather.current = result;
});
}
};
module.exports = Weather;

View File

@ -0,0 +1,5 @@
const m = require('mithril');
const SetupView = require('./views/SetupView');
m.mount(document.body, SetupView);

View File

@ -0,0 +1,2 @@
@charset "utf-8";
@import "~bulma/bulma";

View File

@ -0,0 +1,54 @@
const m = require('mithril');
const Weather = require('../models/weather');
var CurrentView = {
intervalID: null,
autorefresh_enabled: true,
oninit: function() {
CurrentView.intervalID = setInterval(CurrentView.autorefresh, 30000);
return Weather.loadCurrent();
},
view: function() {
return m('div.section', [m('div.container',
(Weather.current == null) ? [m('.notification', 'Loading current conditions...')] : [
m('h1.title', 'Current Conditions'),
m('h2.subtitle', 'as of ' + Weather.current.time),
m('.level', [
m('.level-item.has-text-centered', m("div",[
m('p.heading"', 'Temperature'),
m('p.title', Weather.current.temperature + ' °C')
])),
m('.level-item.has-text-centered', m("div",[
m('p.heading"', 'Pressure'),
m('p.title', Weather.current.pressure + ' mbar')
])),
m('.level-item.has-text-centered', m("div",[
m('p.heading"', 'Humidity'),
m('p.title', Weather.current.humidity + '%')
])),
]),
m('.field.is-grouped', [
m('.control', m('button.button.is-small', {
disabled: CurrentView.autorefresh_enabled,
onclick: CurrentView.refresh
}, 'Refresh')),
m('.control', m('label.checkbox', [ m('input[type=checkbox]',{
checked: CurrentView.autorefresh_enabled,
onchange: function(e) { CurrentView.autorefresh_enabled = e.target.checked; }
}), " Auto"]))
])
])]);
},
autorefresh: function() {
if (CurrentView.autorefresh_enabled) CurrentView.refresh();
},
refresh: function() {
Weather.loadCurrent();
}
}
module.exports = CurrentView;

View File

@ -0,0 +1,116 @@
const m = require('mithril');
const Config = require('../models/Config');
const WiFiSetupView = require('./WiFiSetupView');
const ConfigTextInput = {
view: function(vnode) {
return [
m('label.label', { for: vnode.attrs.name }, vnode.attrs.title),
m('.field.has-addons', [
m('.control.is-expanded', m('input.input[type=text]#' + vnode.attrs.name, {
value: Config.properties[vnode.attrs.name],
placeholder: vnode.attrs.placeholder,
oninput: function (e) { Config.update(vnode.attrs.name, e.target.value) },
})),
m('.control', m('button.button.is-info',{
disabled: !Config.updates[vnode.attrs.name],
onclick: function() {
console.log(Config.save(vnode.attrs.name));
}
}, 'Save'))
])
];
}
}
const ConfigSelectInput = {
view: function(vnode) {
return [
m('.field',[
m('.label.label', {for: vnode.attrs.name}, vnode.attrs.title),
m('.control', m('.select',
m('select', {
oninput: function(e) { Config.update(vnode.attrs.name, e.target.value); Config.save(vnode.attrs.name); }
},
vnode.attrs.options.map(function(option) {
return m('option', {
selected: (Config.properties[vnode.attrs.name] == option)
},option);
}))
))
])
];
}
}
const configurables = [
{
group: "Networking",
properties: [
{
name: "mdns_hostname",
title: "mDNS Hostname",
value: Config.properties.mdns_hostname,
placeholder: "mDNS disabled",
},
{
name: "enable_http",
title: "HTTP Server",
value: Config.properties.enable_http,
options: ['Enabled','Disabled']
},
{
name: "ntp_host",
title: "NTP Host",
value: Config.properties.ntp_host,
placeholder: 'Default NTP server',
}
],
},
{
group: "MQTT",
properties: [
{
name: "mqtt_host",
title: "Remote MQTT Host",
value: Config.properties.mqtt_host,
placeholder: 'MQTT Disabled'
},
{
name: "mqtt_prefix",
title: "MQTT Topic Prefix",
value: Config.properties.mqtt_prefix,
placeholder: "/"
},
],
},
];
const SetupView = {
oninit: function() {
return Config.load();
},
view: function() {
return m('.section', m('.container', [
m('h1.title.is-1', 'Device Setup'),
m(WiFiSetupView),
configurables.map(function(group) {
return [
m('h2.title', group.group),
group.properties.map(function(prop) {
if (prop.options != undefined) {
return m(ConfigSelectInput, prop);
} else
return m(ConfigTextInput, prop);
})
];
})
]));
}
};
module.exports = SetupView;

View File

@ -0,0 +1,123 @@
const m = require('mithril');
const WiFi = require('../models/WiFi');
const WiFiSetupView = {
oninit: function(vnode) {
return WiFi.load()
},
error_message: null,
ssid: null,
passphrase: null,
is_scanning: false,
scan: function() {
if (WiFiSetupView.is_scanning) return;
WiFiSetupView.is_scanning = true;
WiFi.scan().then(function() {
WiFiSetupView.is_scanning = false;
});
},
connect: function() {
WiFiSetupView.error_message = null;
if (!WiFiSetupView.ssid) return;
WiFi.connect(WiFiSetupView.ssid, WiFiSetupView.passphrase).catch(function (e) {
WiFiSetupView.error_message = e.response.message;
});
},
disconnect: function() {
WiFiSetupView.error_message = null;
WiFi.disconnect().catch(function(e) {
WiFiSetupView.error_message = e.response.message;
});
},
view: function() {
return [
m('h2.title', 'WiFi'),
m('.columns', [
// Setup form
m('.column', [
// Error Messages
(
WiFiSetupView.error_message != null
? m('.notification.is-danger', WiFiSetupView.error_message)
: null
),
// Current Network
(
WiFi.current.connected
? m('.notification.is-info', [
m('p', [m('strong', 'SSID: '), WiFi.current.ssid]),
m('p', [m('strong', 'IPv4: '), WiFi.current.ipv4]),
m('p', [m('strong', 'RSSI: '), WiFi.current.rssi + ' dBm']),
m('p', [m('strong', 'Channel: '), WiFi.current.channel]),
])
: null
),
// SSID Field
m('.field', [
m('label.label[for="ssid"]', 'SSID'),
m('.control', m('input.input[type=text]#ssid', {
value: WiFiSetupView.ssid,
onchange: function (e) {
WiFiSetupView.ssid = e.target.value;
}
}))
]),
// Passphrase/Key field
m('.field', [
m('label.label[for="passphrase"]', 'Passphrase/Key'),
m('.control', m('input.input[type=text]#passphrase', {
value: WiFiSetupView.passphrase,
onchange: function(e) { WiFiSetupView.passphrase = e.target.value; },
placeholder: 'Empty'
}))
]),
// Connect/Disconnect buttons
m('.field.is-grouped', [
m('p.control', m('a.button.is-primary', {
disabled: (WiFi.current.connected && WiFi.current.ssid == WiFiSetupView.ssid),
onclick: WiFiSetupView.connect,
}, 'Connect')),
m('p.control', m('a.button.is-outlined.is-danger', {
disabled: WiFi.current.connected != true,
onclick: WiFiSetupView.disconnect,
}, 'Disconnect')),
])
]),
// Nearby networks
m('.column', m('.panel',[
m('p.panel-heading', "Nearby Networks"),
m('.panel-block', m('button', {
class: "button is-outlined is-fullwidth" + (WiFiSetupView.is_scanning ? ' is-loading' : ''),
onclick: WiFiSetupView.scan,
}, "Scan")),
WiFi.networks.map(function(i){
return m('a.panel-block', {
key: i.ssid,
onclick: function() {
WiFiSetupView.ssid = i.ssid;
WiFiSetupView.passphrase = null;
}
}, i.ssid);
})
]))
])
];
}
}
module.exports = WiFiSetupView;

View File

@ -0,0 +1,37 @@
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = {
entry: {
main: './src/index.js',
setup: './src/setup.js'
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].js'
},
module: {
rules: [{
test: /\.s[ac]ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader'
},
{
loader: 'sass-loader',
options: {
sourceMap: true,
// options...
}
}
]
}]
},
plugins: [
new MiniCssExtractPlugin({
filename: 'css/[name].bundle.css'
}),
]
};