From 1deef5c7de167a9a6052a7753e83a79d12cefa31 Mon Sep 17 00:00:00 2001 From: andreas Date: Sun, 7 Nov 2021 15:50:14 +0100 Subject: [PATCH] intermediate: split UI, more icons --- extra_script.py | 2 +- lib/webserver/GwWebServer.cpp | 22 +- platformio.ini | 2 + web/index.css | 190 ++++++++++ web/index.html | 653 +--------------------------------- web/index.js | 480 +++++++++++++++++++++++++ 6 files changed, 690 insertions(+), 659 deletions(-) create mode 100644 web/index.css create mode 100644 web/index.js diff --git a/extra_script.py b/extra_script.py index 7778c53..e1c979e 100644 --- a/extra_script.py +++ b/extra_script.py @@ -7,7 +7,7 @@ import inspect import json GEN_DIR='generated' CFG_FILE='web/config.json' -FILES=['web/index.html',CFG_FILE] +FILES=['web/index.html',CFG_FILE,'web/index.js','web/index.css'] CFG_INCLUDE='GwConfigDefinitions.h' def basePath(): diff --git a/lib/webserver/GwWebServer.cpp b/lib/webserver/GwWebServer.cpp index 1edf157..0dafd09 100644 --- a/lib/webserver/GwWebServer.cpp +++ b/lib/webserver/GwWebServer.cpp @@ -8,19 +8,23 @@ class EmbeddedFile { public: const uint8_t *start; int len; - EmbeddedFile(String name,const uint8_t *start,int len){ + String contentType; + EmbeddedFile(String name,String contentType, const uint8_t *start,int len){ this->start=start; this->len=len; + this->contentType=contentType; embeddedFiles[name]=this; } } ; -#define EMBED_GZ_FILE(fileName, fileExt) \ +#define EMBED_GZ_FILE(fileName, fileExt, contentType) \ extern const uint8_t fileName##_##fileExt##_File[] asm("_binary_generated_" #fileName "_" #fileExt "_gz_start"); \ extern const uint8_t fileName##_##fileExt##_FileLen[] asm("_binary_generated_" #fileName "_" #fileExt "_gz_size"); \ - const EmbeddedFile fileName##_##fileExt##_Config(#fileName "." #fileExt,(const uint8_t*)fileName##_##fileExt##_File,(int)fileName##_##fileExt##_FileLen); + const EmbeddedFile fileName##_##fileExt##_Config(#fileName "." #fileExt,contentType,(const uint8_t*)fileName##_##fileExt##_File,(int)fileName##_##fileExt##_FileLen); -EMBED_GZ_FILE(index,html) -EMBED_GZ_FILE(config,json) +EMBED_GZ_FILE(index,html,"text/html") +EMBED_GZ_FILE(config,json,"application/json") +EMBED_GZ_FILE(index,js,"text/javascript") +EMBED_GZ_FILE(index,css,"text/css") void sendEmbeddedFile(String name,String contentType,AsyncWebServerRequest *request){ std::map::iterator it=embeddedFiles.find(name); @@ -48,9 +52,13 @@ void GwWebServer::begin(){ server->on("/", HTTP_GET, [](AsyncWebServerRequest *request){ sendEmbeddedFile("index.html","text/html",request); }); - server->on("/config.json", HTTP_GET, [](AsyncWebServerRequest *request){ - sendEmbeddedFile("config.json","application/json",request); + for (auto it=embeddedFiles.begin();it != embeddedFiles.end();it++){ + String uri=String("/")+it->first; + server->on(uri.c_str(), HTTP_GET, [it](AsyncWebServerRequest *request){ + sendEmbeddedFile(it->first,it->second->contentType,request); }); + } + server->begin(); LOG_DEBUG(GwLog::LOG,"HTTP server started"); MDNS.addService("_http","_tcp",80); diff --git a/platformio.ini b/platformio.ini index d47c91d..1b71302 100644 --- a/platformio.ini +++ b/platformio.ini @@ -20,6 +20,8 @@ lib_deps = fastled/FastLED @ ^3.4.0 board_build.embed_files = generated/index.html.gz + generated/index.js.gz + generated/index.css.gz generated/config.json.gz extra_scripts = pre:extra_script.py build_flags = -Igenerated diff --git a/web/index.css b/web/index.css new file mode 100644 index 0000000..29588fa --- /dev/null +++ b/web/index.css @@ -0,0 +1,190 @@ +body{ + font-family: Arial, Helvetica, sans-serif; + } + .configForm { + padding-bottom: 0.5em; + } + .configForm .buttons { + margin-top: 0.5em; + } + .configForm .content>div:nth-child(even) { + background-color: rgb(211 211 211 / 43%); + } + #statusPage .even { + background-color: rgb(211 211 211 / 43%); + } + .category .title .label { + opacity: 1; + margin-left: 1em; + } + .changed input{ + color: green + } + .changed select{ + color: green + } + span.label { + width: 10em; + display: inline-block; + opacity: 0.6; + } + .configForm .value { + width: 21em; + display: flex; + flex-direction: row; + margin-bottom: 0.2em; + } + + span#connected { + display: inline-block; + background-color: red; + width: 1em; + height: 1em; + border-radius: 50%; + } + span#connected.ok{ + background-color: #13ac13; + } + .row { + padding: 0.5em; + display: flex; + flex-direction: row; + flex-wrap: wrap; + } + input,select { + border: 1px solid #808080a1; + font-size: 0.9em; + padding: 0.2em; + } + .filter { + display: inline-block; + } + .buttons { + padding-left: 1em; + } + button.infoButton { + margin-left: 1em; + vertical-align: bottom; + } + .category .title { + padding-left: 0.5em; + background-color: lightgray; + padding-top: 0.3em; + padding-bottom: 0.5em; + border-bottom: 1px solid darkgray; + display: flex; + align-items: center; + } + .hidden{ + display: none !important; + } + .msgDetails .value { + width: 5em; + text-align: right; + } + .msgDetails .label { + width: 5em; + } + .overlayContainer { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: #80808070; + display: flex; + } + + div#overlay { + margin: auto; + background-color: white; + padding: 0.5em; + max-width: 100%; + box-sizing: border-box; + } + div#overlayContent { + padding: 0.5em; + } + div#overlayContent.text{ + white-space: pre-line; + } + .overlayButtons { + border-top: 1px solid grey; + padding-top: 0.5em; + display: flex; + flex-direction: row; + justify-content: end; + } + #tabs { + display: flex; + border-bottom: 1px solid grey; + margin-bottom: 0.5em; + } + #tabs .tab { + background-color: lightgray; + padding: 0.5em; + border-left: 1px; + border-right: 1px; + border-top: 1px; + border-bottom: 1px; + border-color: grey; + border-style: solid; + opacity: 0.6; + } + #tabs .tab.active{ + opacity: 1; + } + .buttons button{ + padding: 0.5em; + } + button#reset{ + padding: 0.5em; + } + h1{ + margin-bottom: 0; + } + .icon-eye{ + background-image: url("data:image/svg+xml;utf-8, Layer 1 "); + margin-left: 0.5em; + opacity: 0.3; + } + .icon{ + height: 1.5em; + width: 1.5em; + display: inline-block; + } + .icon-more{ + background-image: url("data:image/svg+xml;utf-8, Layer 1 "); + } + .icon-less{ + background-image: url("data:image/svg+xml;utf-8, Layer 1 "); + } + .icon-eye.active{ + opacity: 1; + } + + .dash { + width: 6em; + height: 4em; + display: flex; + flex-direction: column; + border: 1px solid grey; + overflow: hidden; + margin: 0; + box-sizing: border-box; + font-size: 1.2em; + } + div#dashboardPage { + display: flex; + flex-wrap: wrap; + justify-content: center; + } + .dashTitle { + font-size: 0.8em; + background-color: lightgray; + } + .dashValue{ + font-size: 1.6em; + text-align: center; + } + \ No newline at end of file diff --git a/web/index.html b/web/index.html index a61da02..a4a1f8a 100644 --- a/web/index.html +++ b/web/index.html @@ -4,657 +4,8 @@ NMEA 2000 Gateway - - + +
diff --git a/web/index.js b/web/index.js new file mode 100644 index 0000000..7bb0c6d --- /dev/null +++ b/web/index.js @@ -0,0 +1,480 @@ +let self = this; +let lastUpdate = (new Date()).getTime(); +let reloadConfig = false; +function addEl(type, clazz, parent, text) { + let el = document.createElement(type); + if ( ! (clazz instanceof Array)){ + clazz=clazz.split(/ */); + } + clazz.forEach(function(ce){ + el.classList.add(ce); + }); + if (text) el.textContent = text; + if (parent) parent.appendChild(el); + return el; +} +function forEl(query, callback) { + let all = document.querySelectorAll(query); + for (let i = 0; i < all.length; i++) { + callback(all[i]); + } +} +function alertRestart() { + reloadConfig = true; + alert("Board reset triggered, reconnect WLAN if necessary"); +} +function getJson(url) { + return fetch(url) + .then(function (r) { return r.json() }); +} +function reset() { + fetch('/api/reset'); + alertRestart(); +} +function update() { + let now = (new Date()).getTime(); + let ce = document.getElementById('connected'); + if (ce) { + if ((lastUpdate + 3000) > now) { + ce.classList.add('ok'); + } + else { + ce.classList.remove('ok'); + } + } + getJson('/api/status') + .then(function (jsonData) { + for (let k in jsonData) { + if (typeof (jsonData[k]) === 'object') { + for (let sk in jsonData[k]) { + let key = k + "." + sk; + if (typeof (jsonData[k][sk]) === 'object') { + //msg details + updateMsgDetails(key, jsonData[k][sk]); + } + else { + let el = document.getElementById(key); + if (el) el.textContent = jsonData[k][sk]; + } + } + } + else { + let el = document.getElementById(k); + if (el) el.textContent = jsonData[k]; + } + } + lastUpdate = (new Date()).getTime(); + if (reloadConfig) { + reloadConfig = false; + resetForm(); + } + }) +} +function resetForm(ev) { + getJson("/api/config") + .then(function (jsonData) { + for (let k in jsonData) { + let el = document.querySelector("[name='" + k + "']"); + if (el) { + let v = jsonData[k]; + el.value = v; + el.setAttribute('data-loaded', v); + let changeEvent = new Event('change'); + el.dispatchEvent(changeEvent); + } + } + }); +} +function checkMaxClients(v) { + let parsed = parseInt(v); + if (isNaN(parsed)) return "not a valid number"; + if (parsed < 0) return "must be >= 0"; + if (parsed > 10) return "max is 10"; +} +function checkSystemName(v) { + //2...32 characters for ssid + let allowed = v.replace(/[^a-zA-Z0-9]*/g, ''); + if (allowed != v) return "contains invalid characters, only a-z, A-Z, 0-9"; + if (v.length < 2 || v.length > 32) return "invalid length (2...32)"; +} +function checkApPass(v) { + //min 8 characters + if (v.length < 8) { + return "password must be at least 8 characters"; + } +} +function changeConfig() { + let url = "/api/setConfig?"; + let values = document.querySelectorAll('.configForm select , .configForm input'); + for (let i = 0; i < values.length; i++) { + let v = values[i]; + let name = v.getAttribute('name'); + if (!name) continue; + if (name.indexOf("_") >= 0) continue; + let check = v.getAttribute('data-check'); + if (check) { + if (typeof (self[check]) === 'function') { + let res = self[check](v.value); + if (res) { + let value = v.value; + if (v.type === 'password') value = "******"; + alert("invalid config for " + v.getAttribute('name') + "(" + value + "):\n" + res); + return; + } + } + } + url += name + "=" + encodeURIComponent(v.value) + "&"; + } + getJson(url) + .then(function (status) { + if (status.status == 'OK') { + alertRestart(); + } + else { + alert("unable to set config: " + status.status); + } + }) +} +function factoryReset() { + if (!confirm("Really delete all configuration?\n" + + "This will reset all your Wifi settings and disconnect you.")) { + return; + } + getJson("/api/resetConfig") + .then(function (status) { + alertRestart(); + }) +} + +function updateMsgDetails(key, details) { + forEl('.msgDetails', function (frame) { + if (frame.getAttribute('id') !== key) return; + for (let k in details) { + let el = frame.querySelector("[data-id=\"" + k + "\"] "); + if (!el) { + el = addEl('div', 'row', frame); + let cv = addEl('span', 'label', el, k); + cv = addEl('span', 'value', el, details[k]); + cv.setAttribute('data-id', k); + } + else { + el.textContent = details[k]; + } + } + }); +} +function showOverlay(text, isHtml) { + let el = document.getElementById('overlayContent'); + if (isHtml) { + el.innerHTML = text; + el.classList.remove("text"); + } + else { + el.textContent = text; + el.classList.add("text"); + } + let container = document.getElementById('overlayContainer'); + container.classList.remove('hidden'); +} +function hideOverlay() { + let container = document.getElementById('overlayContainer'); + container.classList.add('hidden'); +} +function checkChange(el, row) { + let loaded = el.getAttribute('data-loaded'); + if (loaded !== undefined) { + if (loaded != el.value) { + row.classList.add('changed'); + } + else { + row.classList.remove("changed"); + } + } +} +function createInput(configItem, frame) { + let el; + if (configItem.type === 'boolean' || configItem.type === 'list') { + el = document.createElement('select') + el.setAttribute('name', configItem.name) + let slist = []; + if (configItem.list) { + configItem.list.forEach(function (v) { + slist.push({ l: v, v: v }); + }) + } + else { + slist.push({ l: 'on', v: 'true' }) + slist.push({ l: 'off', v: 'false' }) + } + slist.forEach(function (sitem) { + let sitemEl = document.createElement('option'); + sitemEl.setAttribute('value', sitem.v); + sitemEl.textContent = sitem.l; + el.appendChild(sitemEl); + }) + frame.appendChild(el); + return el; + } + if (configItem.type === 'filter') { + el = document.createElement('div'); + el.classList.add('filter'); + let ais = createInput({ + type: 'list', + name: configItem.name + "_ais", + list: ['aison', 'aisoff'] + }, el); + let mode = createInput({ + type: 'list', + name: configItem.name + "_mode", + list: ['whitelist', 'blacklist'] + }, el); + let sentences = createInput({ + type: 'text', + name: configItem.name + "_sentences", + }, el); + let data = document.createElement('input'); + data.setAttribute('type', 'hidden'); + el.appendChild(data); + let changeFunction = function () { + let cv = data.value || ""; + let parts = cv.split(":"); + ais.value = (parts[0] == '0') ? "aisoff" : "aison"; + mode.value = (parts[1] == '0') ? "whitelist" : "blacklist"; + sentences.value = parts[2] || ""; + } + let updateFunction = function () { + let nv = (ais.value == 'aison') ? "1" : "0"; + nv += ":"; + nv += (mode.value == 'blacklist') ? "1" : "0"; + nv += ":"; + nv += sentences.value; + data.value = nv; + let chev = new Event('change'); + data.dispatchEvent(chev); + } + mode.addEventListener('change', updateFunction); + ais.addEventListener("change", updateFunction); + sentences.addEventListener("change", updateFunction); + data.addEventListener('change', function (ev) { + changeFunction(); + }); + data.setAttribute('name', configItem.name); + frame.appendChild(el); + return data; + } + el = document.createElement('input'); + el.setAttribute('name', configItem.name) + frame.appendChild(el); + if (configItem.type === 'password') { + el.setAttribute('type', 'password'); + let vis = addEl('span', 'icon-eye icon', frame); + vis.addEventListener('click', function (v) { + if (vis.classList.toggle('active')) { + el.setAttribute('type', 'text'); + } + else { + el.setAttribute('type', 'password'); + } + }); + } + else if (configItem.type === 'number') { + el.setAttribute('type', 'number'); + } + else { + el.setAttribute('type', 'text'); + } + return el; +} +let configDefinitions; +function loadConfigDefinitions() { + getJson("api/capabilities") + .then(function (capabilities) { + getJson("config.json") + .then(function (defs) { + let category; + let categoryEl; + let frame = document.querySelector('.configFormRows'); + if (!frame) throw Error("no config form"); + frame.innerHTML = ''; + configDefinitions = defs; + defs.forEach(function (item) { + if (!item.type) return; + if (item.category != category || !categoryEl) { + let categoryFrame = addEl('div', 'category', frame); + let categoryTitle = addEl('div', 'title', categoryFrame); + let categoryButton = addEl('span', 'icon icon-more', categoryTitle); + addEl('span', 'label', categoryTitle, item.category); + categoryEl = addEl('div', 'content', categoryFrame); + categoryEl.classList.add('hidden'); + let currentEl = categoryEl; + categoryTitle.addEventListener('click', function (ev) { + let rs = currentEl.classList.toggle('hidden'); + if (rs) { + categoryButton.classList.add('icon-more'); + categoryButton.classList.remove('icon-less'); + } + else { + categoryButton.classList.remove('icon-more'); + categoryButton.classList.add('icon-less'); + } + }) + category = item.category; + } + if (item.capabilities !== undefined) { + for (let capability in item.capabilities) { + let values = item.capabilities[capability]; + if (!capabilities[capability]) return; + let found = false; + values.forEach(function (v) { + if (capabilities[capability] == v) found = true; + }); + if (!found) return; + } + } + let row = addEl('div', 'row', categoryEl); + let label = item.label || item.name; + addEl('span', 'label', row, label); + let valueFrame = addEl('div', 'value', row); + let valueEl = createInput(item, valueFrame); + if (!valueEl) return; + valueEl.setAttribute('data-default', item.default); + valueEl.addEventListener('change', function (ev) { + let el = ev.target; + checkChange(el, row); + }) + if (item.check) valueEl.setAttribute('data-check', item.check); + let btContainer = addEl('div', 'buttonContainer', row); + let bt = addEl('button', 'defaultButton', btContainer, 'X'); + bt.setAttribute('data-default', item.default); + bt.addEventListener('click', function (ev) { + valueEl.value = valueEl.getAttribute('data-default'); + let changeEvent = new Event('change'); + valueEl.dispatchEvent(changeEvent); + }) + bt = addEl('button', 'infoButton', btContainer, '?'); + bt.addEventListener('click', function (ev) { + showOverlay(item.description); + }); + }) + resetForm(); + }) + }) + .catch(function (err) { alert("unable to load config: " + err) }) +} +function converterInfo() { + getJson("api/converterInfo").then(function (json) { + let text = "

Converted entities

"; + text += "

NMEA0183 to NMEA2000:
"; + text += " " + (json.nmea0183 || "").replace(/,/g, ", "); + text += "

"; + text += "

NMEA2000 to NMEA0183:
"; + text += " " + (json.nmea2000 || "").replace(/,/g, ", "); + text += "

"; + showOverlay(text, true); + }); +} +function handleTab(el) { + let activeName = el.getAttribute('data-page'); + if (!activeName) return; + let activeTab = document.getElementById(activeName); + if (!activeTab) return; + let all = document.querySelectorAll('.tabPage'); + for (let i = 0; i < all.length; i++) { + all[i].classList.add('hidden'); + } + let tabs = document.querySelectorAll('.tab'); + for (let i = 0; i < all.length; i++) { + tabs[i].classList.remove('active'); + } + el.classList.add('active'); + activeTab.classList.remove('hidden'); +} +function createDashboardItem(name, def, parent) { + let frame = addEl('div', 'dash', parent); + let title = addEl('span', 'dashTitle', frame, name); + let value = addEl('span', 'dashValue', frame); + value.setAttribute('id', 'data_' + name); + return value; +} +function createDashboard() { + let frame = document.getElementById('dashboardPage'); + if (!frame) return; + getJson("api/boatData").then(function (json) { + frame.innerHTML = ''; + for (let n in json) { + createDashboardItem(n, json[n], frame); + } + updateDashboard(json); + }); +} +let valueFormatters = { + formatCourse: function (v) { let x = parseFloat(v); return x.toFixed(0); }, + formatKnots: function (v) { let x = parseFloat(v); return x.toFixed(2); }, + formatWind: function (v) { let x = parseFloat(v); return x.toFixed(0); }, + mtr2nm: function (v) { let x = parseFloat(v); return x.toFixed(2); }, + kelvinToC: function (v) { let x = parseFloat(v); return x.toFixed(0); }, + formatFixed0: function (v) { let x = parseFloat(v); return x.toFixed(0); }, + formatDepth: function (v) { let x = parseFloat(v); return x.toFixed(1); }, +} +function updateDashboard(data) { + for (let n in data) { + let de = document.getElementById('data_' + n); + if (de) { + if (data[n].valid) { + let formatter; + if (data[n].format && data[n].format != "NULL") { + let key = data[n].format.replace(/^\&/, ''); + formatter = valueFormatters[key]; + } + if (formatter) { + de.textContent = formatter(data[n].value); + } + else { + let v = parseFloat(data[n].value); + if (!isNaN(v)) { + v = v.toFixed(3) + de.textContent = v; + } + else { + de.textContent = data[n].value; + } + } + } + else de.textContent = "---"; + } + } +} + +window.setInterval(update, 1000); +window.setInterval(function () { + let dp = document.getElementById('dashboardPage'); + if (dp.classList.contains('hidden')) return; + getJson('api/boatData').then(function (data) { + updateDashboard(data); + }); +}, 1000); +window.addEventListener('load', function () { + let buttons = document.querySelectorAll('button'); + for (let i = 0; i < buttons.length; i++) { + let be = buttons[i]; + be.onclick = window[be.id]; //assume a function with the button id + } + forEl('.showMsgDetails', function (cd) { + cd.addEventListener('change', function (ev) { + let key = ev.target.getAttribute('data-key'); + if (!key) return; + let el = document.getElementById(key); + if (!el) return; + if (ev.target.checked) el.classList.remove('hidden'); + else (el.classList).add('hidden'); + }); + }); + let tabs = document.querySelectorAll('.tab'); + for (let i = 0; i < tabs.length; i++) { + tabs[i].addEventListener('click', function (ev) { + handleTab(ev.target); + }); + } + loadConfigDefinitions(); + createDashboard(); +}); \ No newline at end of file