Files
OBPkp61/web/index.js
2025-12-03 20:11:12 +01:00

1644 lines
62 KiB
JavaScript

(function () {
let lastUpdate = (new Date()).getTime();
let updateInterval = 3000; // milliseconds
let reloadConfig = false;
let needAdminPass = true;
let lastSalt = "";
let channelList = {};
let minUser = 200;
let listeners = [];
let buttonHandlers = {};
let checkers = {};
let userFormatters = {};
let apiPrefix = "";
function addEl(type, clazz, parent, text) {
let el = document.createElement(type);
if (clazz) {
if (!(clazz instanceof Array)) {
clazz = clazz.split(/ */);
}
clazz.forEach(function (ce) {
el.classList.add(ce);
});
}
if (text) el.textContent = text;
if (parent) {
if (typeof(parent) != 'object'){
parent=document.querySelector(parent);
}
if (parent) parent.appendChild(el);
}
return el;
}
function forEl(query, callback, base) {
if (!base) base = document;
let all = base.querySelectorAll(query);
for (let i = 0; i < all.length; i++) {
callback(all[i]);
}
}
function closestParent(element, clazz) {
while (true) {
let parent = element.parentElement;
if (!parent) return;
if (parent.classList.contains(clazz)) return parent;
element = parent;
}
}
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 getText(url) {
return fetch(url)
.then(function (r) { return r.text() });
}
buttonHandlers.reset=function() {
ensurePass()
.then(function (hash) {
fetch(apiPrefix + '/api/reset?_hash=' + encodeURIComponent(hash));
alertRestart();
})
.catch(function (e) { });
}
function update() {
let now = (new Date()).getTime();
let ce = document.getElementById('connected');
let cl = document.getElementById('conn_label');
if (ce) {
if ((lastUpdate + 3 * updateInterval) > now) {
ce.classList.add('ok');
cl.textContent = 'connected';
}
else {
ce.classList.remove('ok');
cl.textContent = 'disconnected';
}
}
getJson(apiPrefix + '/api/status')
.then(function (jsonData) {
if (jsonData.salt !== undefined) {
lastSalt=jsonData.salt;
delete jsonData.salt;
}
if (jsonData.minUser !== undefined){
minUser=jsonData.minUser;
delete jsonData.minUser;
}
callListeners(api.EVENTS.status,jsonData);
let statusPage = document.getElementById('statusPageContent');
let even = true; //first counter
if (statusPage){
for (let k in jsonData) {
if (typeof (jsonData[k]) === 'object') {
if (k.indexOf('count') == 0) {
createCounterDisplay(statusPage, k.replace("count", "").replace(/in$/, " in").replace(/out$/, " out"), k, even);
even = !even;
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];
}
}
}
if (k.indexOf("ch") == 0) {
//channel def
let name = k.substring(2);
channelList[name] = jsonData[k];
}
}
else {
let el = document.getElementById(k);
if (el) el.textContent = jsonData[k];
forEl('.status-' + k, function (el) {
el.textContent = jsonData[k];
});
}
}
}
lastUpdate = (new Date()).getTime();
if (reloadConfig) {
reloadConfig = false;
resetForm();
}
})
}
function resetForm(ev) {
getJson(apiPrefix + "/api/config")
.then(function (jsonData) {
callListeners(api.EVENTS.config,jsonData);
for (let k in jsonData) {
if (k == "useAdminPass") {
needAdminPass = jsonData[k] != 'false';
}
let el = document.querySelector("[name='" + k + "']");
if (el) {
let v = jsonData[k];
let def = getConfigDefition(k);
if (def.check == 'checkMinMax') {
//simple migration if the current value is outside the range
//we even "hide" this from the user
v = parseFloat(v);
if (!isNaN(v)) {
if (def.min !== undefined) {
if (v < parseFloat(def.min)) v = parseFloat(def.min);
}
if (def.max !== undefined) {
if (v > parseFloat(def.max)) v = parseFloat(def.max);
}
}
}
if (el.tagName === 'SELECT') {
setSelect(el, v);
}
else {
el.value = v;
}
el.setAttribute('data-loaded', v);
let changeEvent = new Event('change');
el.dispatchEvent(changeEvent);
}
}
let name = jsonData.systemName;
if (name) {
let el = document.getElementById('headline');
if (el) el.textContent = name;
document.title = name;
}
});
}
buttonHandlers.resetForm=resetForm;
checkers.checkMinMax=function(v, allValues, def) {
let parsed = parseFloat(v);
if (isNaN(parsed)) return "must be a number";
if (def.min !== undefined) {
if (parsed < parseFloat(def.min)) return "must be >= " + def.min;
}
if (def.max !== undefined) {
if (parsed > parseFloat(def.max)) return "must be <= " + def.max;
}
}
checkers.checkPort=function(v,allValues,def){
let parsed=parseInt(v);
if (isNaN(parsed)) return "must be a number";
if (parsed <1 || parsed >= 65536) return "port must be in the range 1..65536";
}
checkers.checkSystemName=function(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";
}
}
checkers.checkAdminPass=function(v) {
return checkApPass(v);
}
checkers.checkApIp=function(v, allValues) {
if (!v) return "cannot be empty";
let err1 = "must be in the form 192.168.x.x";
if (!v.match(/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/)) return err1;
let parts = v.split(".");
if (parts.length != 4) return err1;
for (let idx = 0; idx < 4; idx++) {
let iv = parseInt(parts[idx]);
if (iv < 0 || iv > 255) return err1;
}
}
checkers.checkNetMask=function(v, allValues) {
return checkers.checkApIp(v, allValues);
}
checkers.checkIpAddress=function(v, allValues, def) {
if (!v) return "cannot be empty";
if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/)
&& !v.match(/.*\.local/))
return "must be either in the form 192.168.1.1 or xxx.local";
}
checkers.checkMCAddress=function(v, allValues, def) {
if (!v) return "cannot be empty";
if (!v.match(/[0-9]*\.[0-9]*\.[0-9]*\.[0-9]*/))
return "must be in the form 224.0.0.1";
let parts=v.split(".");
let o1=parseInt(parts[0]);
if (o1 < 224 || o1 > 239) return "mulicast address must be in the range 224.0.0.0 to 239.255.255.255"
}
let loggedChecks={};
function getAllConfigs(omitPass) {
let values = document.querySelectorAll('.configForm select , .configForm input');
let allValues = {};
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;
if (v.getAttribute('disabled')) continue;
let def = getConfigDefition(name);
if (def.type === 'password' && (v.value == '' || omitPass)) {
continue;
}
let check = v.getAttribute('data-check');
if (check && conditionOk(name)) {
let cfgDef=getConfigDefition(name);
let checkFunction=checkers[check];
if (typeof (checkFunction) === 'function') {
if (! loggedChecks[check]){
loggedChecks[check]=true;
//console.log("check:"+check);
}
let res = checkFunction(v.value, allValues, cfgDef);
if (res) {
let value = v.value;
if (v.type === 'password') value = "******";
let label = v.getAttribute('data-label');
if (!label) label = v.getAttribute('name');
v.classList.add("error");
alert("invalid config for "+cfgDef.category+":" + label + "(" + value + "):\n" + res);
return;
}
}
else{
console.log("check not found:",check);
}
}
allValues[name] = v.value;
}
return allValues;
}
buttonHandlers.changeConfig=function() {
ensurePass()
.then(function (pass) {
let newAdminPass;
let url = apiPrefix + "/api/setconfig"
let body = "_hash=" + encodeURIComponent(pass) + "&";
let allValues = getAllConfigs();
if (!allValues) return;
for (let name in allValues) {
if (name == 'adminPassword') {
newAdminPass = allValues[name];
}
body += encodeURIComponent(name) + "=" + encodeURIComponent(allValues[name]) + "&";
}
fetch(url, {
method: 'POST',
headers: {
//'Content-Type': 'application/octet-stream' //we must lie here
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body
})
.then((rs) => rs.json())
.then(function (status) {
if (status.status == 'OK') {
if (newAdminPass !== undefined) {
forEl('#adminPassInput', function (el) {
el.valu = newAdminPass;
});
saveAdminPass(newAdminPass, true);
}
alertRestart();
}
else {
alert("unable to set config: " + status.status);
}
})
})
.catch(function (e) { alert(e); })
}
buttonHandlers.factoryReset=function() {
ensurePass()
.then(function (hash) {
if (!confirm("Really delete all configuration?\n" +
"This will reset all your Wifi settings and disconnect you.")) {
return;
}
getJson(apiPrefix + "/api/resetconfig?_hash=" + encodeURIComponent(hash))
.then(function (status) {
alertRestart();
})
})
.catch(function (e) { });
}
function createCounterDisplay(parent, label, key, isEven) {
if (parent.querySelector("#" + key)) {
return;
}
let clazz = "row icon-row counter-row";
if (isEven) clazz += " even";
let row = addEl('div', clazz, parent);
row.setAttribute("id", key);
let icon = addEl('span', 'icon icon-more', row);
addEl('span', 'label', row, label);
let value = addEl('span', 'value', row, '---');
value.setAttribute('id', key + ".sumOk");
let display = addEl('div', clazz + " msgDetails hidden", parent);
display.setAttribute('id', key + ".ok");
row.addEventListener('click', function (ev) {
let rs = display.classList.toggle('hidden');
if (rs) {
icon.classList.add('icon-more');
icon.classList.remove('icon-less');
}
else {
icon.classList.remove('icon-more');
icon.classList.add('icon-less');
}
});
callListeners(api.EVENTS.counterDisplayCreated,row);
}
function validKey(key) {
if (!key) return;
return key.replace(/[^a-z_:A-Z0-9-]/g, '');
}
function updateMsgDetails(key, details) {
forEl('.msgDetails', function (frame) {
if (frame.getAttribute('id') !== key) return;
for (let k in details) {
k = validKey(k);
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];
}
}
forEl('.value', function (el) {
let k = el.getAttribute('data-id');
if (k && !details[k]) {
el.parentElement.remove();
}
}, frame);
});
}
function showOverlay(text, isHtml, buttons) {
let el = document.getElementById('overlayContent');
if (text instanceof Object) {
el.textContent = '';
el.appendChild(text);
}
else {
if (isHtml) {
el.innerHTML = text;
el.classList.remove("text");
}
else {
el.textContent = text;
el.classList.add("text");
}
}
buttons = (buttons ? buttons : []).concat([{ label: "Close", click: hideOverlay }]);
let container = document.getElementById('overlayContainer');
let btframe = container.querySelector('.overlayButtons');
btframe.textContent = '';
buttons.forEach((btconfig) => {
let bt = addEl('button', '', btframe, btconfig.label);
bt.addEventListener("click", btconfig.click);
});
container.classList.remove('hidden');
}
function hideOverlay() {
let container = document.getElementById('overlayContainer');
container.classList.add('hidden');
let el = document.getElementById('overlayContent');
el.textContent = '';
}
function checkChange(el, row, name) {
el.classList.remove("error");
let loaded = el.getAttribute('data-loaded');
if (loaded !== undefined) {
if (loaded != el.value) {
row.classList.add('changed');
}
else {
row.classList.remove("changed");
}
}
let dependend = conditionRelations[name];
if (dependend) {
for (let el in dependend) {
checkCondition(dependend[el]);
}
}
}
let configDefinitions = {};
//a map between the name of a config item and a list of dependend items
let conditionRelations = {};
function getConfigDefition(name) {
if (!name) return {};
let def;
for (let k in configDefinitions) {
if (configDefinitions[k].name === name) {
def = configDefinitions[k];
break;
}
}
if (!def) return {};
return def;
}
function getConditions(name) {
let def = getConfigDefition(name);
if (!def) return;
let condition = def.condition;
if (!condition) return;
if (!(condition instanceof Array)) condition = [condition];
return condition;
}
function conditionOk(name){
let condition = getConditions(name);
if (!condition) return true;
let visible = false;
if (!condition instanceof Array) condition = [condition];
condition.forEach(function (cel) {
let lvis = true;
for (let k in cel) {
let item = document.querySelector('[name=' + k + ']');
if (item) {
let compare = cel[k];
if (compare instanceof Array) {
if (compare.indexOf(item.value) < 0) lvis = false;
}
else {
if (item.value != cel[k]) lvis = false;
}
}
}
if (lvis) visible = true;
});
return visible;
}
function checkCondition(element) {
let name = element.getAttribute('name');
let visible=conditionOk(name);
let row = closestParent(element, 'row');
if (!row) return;
if (visible) row.classList.remove('hidden');
else row.classList.add('hidden');
}
let caliv = 0;
function createCalSetInput(configItem, frame, clazz) {
let el = addEl('input', clazz, frame);
let cb = addEl('button', '', frame, 'C');
//el.disabled=true;
cb.addEventListener('click', (ev) => {
let cs = document.getElementById("calset").cloneNode(true);
cs.classList.remove("hidden");
cs.querySelector(".heading").textContent = configItem.label || configItem.name;
let vel = cs.querySelector(".val");
if (caliv != 0) window.clearInterval(caliv);
caliv = window.setInterval(() => {
if (document.body.contains(cs)) {
fetch(apiPrefix + "/api/calibrate?name=" + encodeURIComponent(configItem.name))
.then((r) => r.text())
.then((txt) => {
if (txt != vel.textContent) {
vel.textContent = txt;
}
})
.catch((e) => {
alert(e);
hideOverlay();
window.clearInterval(caliv);
})
}
else {
window.clearInterval(caliv);
}
}, 200);
showOverlay(cs, false, [{
label: 'Set', click: () => {
el.value = vel.textContent;
let cev = new Event('change');
el.dispatchEvent(cev);
}
}]);
})
el.setAttribute('name', configItem.name)
return el;
}
function createCalValInput(configItem, frame, clazz) {
let el = addEl('input', clazz, frame);
let cb = addEl('button', '', frame, 'C');
//el.disabled=true;
cb.addEventListener('click', (ev) => {
const sv = function (val, cfg) {
if (configItem.eval) {
let v = parseFloat(val);
let c = parseFloat(cfg);
return (eval(configItem.eval));
}
return v;
};
let cs = document.getElementById("calval").cloneNode(true);
cs.classList.remove("hidden");
cs.querySelector(".heading").textContent = configItem.label || configItem.name;
let vel = cs.querySelector(".val");
let vinp = cs.querySelector("input");
vinp.value = el.value;
if (caliv != 0) window.clearInterval(caliv);
caliv = window.setInterval(() => {
if (document.body.contains(cs)) {
fetch(apiPrefix + "/api/calibrate?name=" + encodeURIComponent(configItem.name))
.then((r) => r.text())
.then((txt) => {
txt = sv(txt, vinp.value);
if (txt != vel.textContent) {
vel.textContent = txt;
}
})
.catch((e) => {
alert(e);
hideOverlay();
window.clearInterval(caliv);
})
}
else {
window.clearInterval(caliv);
}
}, 200);
showOverlay(cs, false, [{
label: 'Set', click: () => {
el.value = vinp.value;
let cev = new Event('change');
el.dispatchEvent(cev);
}
}]);
})
el.setAttribute('name', configItem.name)
return el;
}
function createInput(configItem, frame, clazz) {
let el;
if (configItem.type === 'boolean' || configItem.type === 'list') {
el = addEl('select', clazz, frame);
if (configItem.readOnly) el.setAttribute('disabled', true);
el.setAttribute('name', configItem.name)
let slist = [];
if (configItem.list) {
configItem.list.forEach(function (v) {
if (v instanceof Object) {
slist.push({ l: v.l, v: v.v });
}
else {
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 = addEl('option', '', el, sitem.l);
sitemEl.setAttribute('value', sitem.v);
})
return el;
}
if (configItem.type === 'filter') {
return createFilterInput(configItem, frame, clazz);
}
el = addEl('input', clazz, frame);
if (configItem.readOnly) el.setAttribute('disabled', true);
el.setAttribute('name', configItem.name)
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;
}
function setSelect(item, value) {
if (!item) return;
item.value = value;
if (item.value !== value) {
//missing option with his value
let o = addEl('option', undefined, item, value);
o.setAttribute('value', value);
item.value = value;
}
}
function updateSelectList(item, slist, opt_keepValue) {
let idx = 0;
let value;
if (opt_keepValue) value = item.value;
item.innerHTML = '';
slist.forEach(function (sitem) {
let sitemEl = addEl('option', '', item, sitem.l);
sitemEl.setAttribute('value', sitem.v !== undefined ? sitem.v : idx);
idx++;
})
if (value !== undefined) {
setSelect(item, value);
}
}
function createFilterInput(configItem, frame) {
let el = addEl('div', 'filter', frame);
let ais = createInput({
type: 'list',
name: configItem.name + "_ais",
list: ['aison', 'aisoff'],
readOnly: configItem.readOnly
}, el);
let mode = createInput({
type: 'list',
name: configItem.name + "_mode",
list: ['whitelist', 'blacklist'],
readOnly: configItem.readOnly
}, el);
let sentences = createInput({
type: 'text',
name: configItem.name + "_sentences",
readOnly: configItem.readOnly
}, el);
let data = addEl('input', undefined, el);
data.setAttribute('type', 'hidden');
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);
if (configItem.readOnly) data.setAttribute('disabled', true);
return data;
}
let moreicons = ['icon-more', 'icon-less'];
function collapseCategories(parent, expand) {
let doExpand = expand;
forEl('.category', function (cat) {
if (typeof (expand) === 'function') doExpand = expand(cat);
forEl('.content', function (content) {
if (doExpand) {
content.classList.remove('hidden');
}
else {
content.classList.add('hidden');
}
}, cat);
forEl('.title .icon', function (icon) {
toggleClass(icon, doExpand ? 1 : 0, moreicons);
}, cat);
}, parent);
}
function formatDateForFilename(usePrefix, d) {
let rt = "";
if (usePrefix) {
let hdl= document.getElementById('headline');
if (hdl){
rt=hdl.textContent+"_";
}
let fwt = document.querySelector('.status-fwtype');
if (fwt) rt += fwt.textContent;
rt += "_";
}
if (!d) d = new Date();
[d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds()]
.forEach(function (v) {
if (v < 10) rt += "0" + v;
else rt += "" + v;
})
return rt;
}
function downloadData(data, name) {
let url = "data:application/octet-stream," + encodeURIComponent(JSON.stringify(data, undefined, 2));
let target = document.getElementById('downloadXdr');
if (!target) return;
target.setAttribute('href', url);
target.setAttribute('download', name);
target.click();
}
buttonHandlers.exportConfig=function() {
let data = getAllConfigs(true);
if (!data) return;
downloadData(data, formatDateForFilename(true) + ".json");
}
function importJson(opt_keyPattern) {
let clazz = 'importJson';
forEl('.' + clazz, function (ul) {
ul.remove();
});
let ip = addEl('input', clazz, document.body);
ip.setAttribute('type', 'file');
ip.addEventListener('change', function (ev) {
if (ip.files.length > 0) {
let f = ip.files[0];
let reader = new FileReader();
reader.onloadend = function (status) {
try {
let idata = JSON.parse(reader.result);
let hasOverwrites = false;
for (let k in idata) {
if (opt_keyPattern && !k.match(opt_keyPattern)) {
alert("file contains invalid key " + k);
return;
}
let del = document.querySelector('[name=' + k + ']');
if (del) {
hasOverwrites = true;
}
}
if (hasOverwrites) {
if (!confirm("overwrite existing data?")) return;
}
for (let k in idata) {
let del = document.querySelector('[name=' + k + ']');
if (del) {
if (del.tagName === 'SELECT') {
setSelect(del, idata[k]);
}
else {
del.value = idata[k];
}
let ev = new Event('change');
del.dispatchEvent(ev);
}
}
} catch (error) {
alert("unable to parse upload: " + error);
return;
}
};
reader.readAsBinaryString(f);
}
ip.remove();
});
ip.click();
}
buttonHandlers.importConfig=function() {
importJson();
}
function toggleClass(el, id, classList) {
let nc = classList[id];
let rt = false;
if (nc && !el.classList.contains(nc)) rt = true;
for (let i in classList) {
if (i == id) continue;
el.classList.remove(classList[i])
}
if (nc) el.classList.add(nc);
return rt;
}
function createConfigDefinitions(parent, capabilities, defs) {
let categories = {};
let frame = parent.querySelector('.configFormRows');
if (!frame) throw Error("no config form");
frame.innerHTML = '';
configDefinitions = defs;
let currentCategoryPopulated = true;
defs.forEach(function (item) {
if (!item.type || item.category === undefined) return;
let catEntry;
if (categories[item.category] === undefined) {
catEntry = {
populated: false,
frame: undefined,
element: undefined
}
categories[item.category] = catEntry
catEntry.frame = addEl('div', 'category', frame);
catEntry.frame.setAttribute('data-category', item.category)
let categoryTitle = addEl('div', 'title', catEntry.frame);
let categoryButton = addEl('span', 'icon icon-more', categoryTitle);
addEl('span', 'label', categoryTitle, item.category);
addEl('span', 'categoryAdd', categoryTitle);
catEntry.element = addEl('div', 'content', catEntry.frame);
catEntry.element.classList.add('hidden');
categoryTitle.addEventListener('click', function (ev) {
let rs = catEntry.element.classList.toggle('hidden');
if (rs) {
toggleClass(categoryButton, 0, moreicons);
}
else {
toggleClass(categoryButton, 1, moreicons);
}
})
}
else {
catEntry = categories[item.category];
}
let showItem = true;
let itemCapabilities = item.capabilities || {};
itemCapabilities['HIDE' + item.name] = null;
for (let capability in itemCapabilities) {
let values = itemCapabilities[capability];
let found = false;
if (!(values instanceof Array)) values = [values];
values.forEach(function (v) {
if (v === null) {
if (capabilities[capability] === undefined) found = true;
}
else {
if (capabilities[capability] == v) found = true;
}
});
if (!found) showItem = false;
}
let readOnly = false;
let mode = capabilities['CFGMODE' + item.name];
if (mode == 1) {
//hide
showItem = false;
}
if (mode == 2) {
readOnly = true;
}
if (showItem) {
item.readOnly = readOnly;
catEntry.populated = true;
let row = addEl('div', 'row', catEntry.element);
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);
if (!readOnly) valueEl.addEventListener('change', function (ev) {
let el = ev.target;
checkChange(el, row, item.name);
})
let condition = getConditions(item.name);
if (condition) {
condition.forEach(function (cel) {
for (let c in cel) {
if (!conditionRelations[c]) {
conditionRelations[c] = [];
}
conditionRelations[c].push(valueEl);
}
})
}
if (item.check) valueEl.setAttribute('data-check', item.check);
valueEl.setAttribute('data-label', label);
let btContainer = addEl('div', 'buttonContainer', row);
if (!readOnly) {
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) {
if (item.description) {
showOverlay(item.description);
}
});
}
});
for (let cat in categories) {
let catEntry = categories[cat];
if (!catEntry.populated) {
catEntry.frame.remove();
}
}
}
function loadConfigDefinitions() {
getJson("api/capabilities")
.then(function (capabilities) {
if (capabilities.HELP_URL) {
let el = document.getElementById('helpButton');
if (el) el.setAttribute('data-url', capabilities.HELP_URL);
}
try{
Object.freeze(capabilities);
callListeners(api.EVENTS.init,capabilities);
}catch (e){
console.log(e);
}
getJson("config.json")
.then(function (defs) {
configDefinitions = defs;
let normalConfig = document.getElementById('configPage');
if (normalConfig) createConfigDefinitions(normalConfig, capabilities, defs);
resetForm();
})
})
.catch(function (err) { alert("unable to load config: " + err) })
}
// New hash function by SubtleCrypto Browser API
async function hashString(str) {
const enc = new TextEncoder();
const buffer = enc.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = new Uint8Array(hashBuffer);
const hashHex = Array.from(hashArray).map(byte => byte.toString(16).padStart(2, '0')).join('');
return hashHex;
}
function verifyPass(pass) {
return new Promise(function (resolve, reject) {
let sha256hash = "";
if (window.isSecureContext) {
hashString(lastSalt + pass).then(hashHex => {
sha256hash = hashHex;
});
} else {
let hash = sha256.create();
hash.update(lastSalt + pass);
sha256hash = hash.hex();
}
getJson('api/checkpass?hash=' + encodeURIComponent(sha256hash))
.then(function (jsonData) {
if (jsonData.status == 'OK') resolve(sha256hash);
else reject(jsonData.status);
return;
})
.catch(function (error) { reject(error); })
});
}
buttonHandlers.adminPassOk=function() {
// Dummy so far
}
buttonHandlers.adminPassCancel=function() {
forEl('#adminPassOverlay', function (el) { el.classList.add('hidden') });
forEl('#adminPassInput', function (el) { el.value = '' });
}
function saveAdminPass(pass, forceIfSet) {
forEl('#adminPassKeep', function (el) {
try {
let old = localStorage.getItem('adminPass');
if (el.value == 'true' || (forceIfSet && old !== undefined)) {
localStorage.setItem('adminPass', pass);
}
else {
localStorage.removeItem('adminPass');
}
} catch (e) { }
});
}
buttonHandlers.forgetPass=function() {
localStorage.removeItem('adminPass');
forEl('#adminPassInput', function (el) {
el.value = '';
});
}
function ensurePass() {
return new Promise(function (resolve, reject) {
if (!needAdminPass) {
resolve('');
return;
}
let pe = document.getElementById('adminPassInput');
let hint = document.getElementById('adminPassError');
if (!pe) {
reject('no input');
return;
}
if (pe.value == '') {
let ok = document.getElementById('adminPassOk');
if (!ok) {
reject('no button');
return;
}
ok.onclick = function () {
verifyPass(pe.value)
.then(function (pass) {
forEl('#adminPassOverlay', function (el) { el.classList.add('hidden') });
saveAdminPass(pe.value);
resolve(pass);
})
.catch(function (err) {
if (hint) {
hint.textContent = "invalid password";
}
});
};
if (hint) hint.textContent = '';
forEl('#adminPassOverlay', function (el) { el.classList.remove('hidden') });
}
else {
verifyPass(pe.value)
.then(function (np) { resolve(np); })
.catch(function (err) {
pe.value = '';
ensurePass()
.then(function (p) { resolve(p); })
.catch(function (e) { reject(e); });
});
return;
}
});
}
buttonHandlers.converterInfo=function() {
getJson("api/converterInfo").then(function (json) {
let text = "<h3>Converted entities</h3>";
text += "<p><b>NMEA0183 to NMEA2000:</b><br/>";
text += " " + (json.nmea0183 || "").replace(/,/g, ", ");
text += "</p>";
text += "<p><b>NMEA2000 to NMEA0183:</b><br/>";
text += " " + (json.nmea2000 || "").replace(/,/g, ", ");
text += "</p>";
showOverlay(text, true);
});
}
function handleTab(el) {
let activeName = el.getAttribute('data-page');
if (!activeName) {
let extUrl = el.getAttribute('data-url');
if (!extUrl) return;
window.open(extUrl, el.getAttribute('data-window') || '_');
}
let activeTab = document.getElementById(activeName);
if (!activeTab) return;
forEl('.tabPage',function(pel){
pel.classList.add('hidden');
});
forEl('.tab',function(tel){
tel.classList.remove('active');
});
el.classList.add('active');
activeTab.classList.remove('hidden');
callListeners(api.EVENTS.tab,activeName);
}
/**
*
* @param {number} coordinate
* @param axis
* @returns {string}
*/
function formatLonLatsDecimal(coordinate, axis) {
coordinate = (coordinate + 540) % 360 - 180; // normalize for sphere being round
let abscoordinate = Math.abs(coordinate);
let coordinatedegrees = Math.floor(abscoordinate);
let coordinateminutes = (abscoordinate - coordinatedegrees) / (1 / 60);
let numdecimal = 2;
//correctly handle the toFixed(x) - will do math rounding
if (coordinateminutes.toFixed(numdecimal) == 60) {
coordinatedegrees += 1;
coordinateminutes = 0;
}
if (coordinatedegrees < 10) {
coordinatedegrees = "0" + coordinatedegrees;
}
if (coordinatedegrees < 100 && axis == 'lon') {
coordinatedegrees = "0" + coordinatedegrees;
}
let str = coordinatedegrees + "\u00B0";
if (coordinateminutes < 10) {
str += "0";
}
str += coordinateminutes.toFixed(numdecimal) + "'";
if (axis == "lon") {
str += coordinate < 0 ? "W" : "E";
} else {
str += coordinate < 0 ? "S" : "N";
}
return str;
};
function formatFixed(v, dig, fract) {
v = parseFloat(v);
if (dig === undefined) return v.toFixed(fract);
let s = v < 0 ? "-" : "";
v = Math.abs(v);
let rv = v.toFixed(fract);
let parts = rv.split('.');
parts[0] = "0000000000" + parts[0];
if (dig >= 10) dig = 10;
if (fract > 0) {
return s + parts[0].substr(parts[0].length - dig) + "." + parts[1];
}
return s + parts[0].substr(parts[0].length - dig);
}
let valueFormatters = {
formatCourse: {
f: function (v) {
let x = parseFloat(v);
let rt = x * 180.0 / Math.PI;
if (rt > 360) rt -= 360;
if (rt < 0) rt += 360;
return rt.toFixed(0);
},
u: '°'
},
formatKnots: {
f: function (v) {
let x = parseFloat(v);
x = x * 3600.0 / 1852.0;
return x.toFixed(2);
},
u: 'kn'
},
formatWind: {
f: function (v) {
let x = parseFloat(v);
x = x * 180.0 / Math.PI;
if (x > 180) x = -1 * (360 - x);
return x.toFixed(0);
},
u: '°'
},
mtr2nm: {
f: function (v) {
let x = parseFloat(v);
x = x / 1852.0;
return x.toFixed(2);
},
u: 'nm'
},
kelvinToC: {
f: function (v) {
let x = parseFloat(v);
x = x - 273.15;
return x.toFixed(0);
},
u: '°'
},
formatFixed0: {
f: function (v) {
let x = parseFloat(v);
return x.toFixed(0);
},
u: ''
},
formatDepth: {
f: function (v) {
let x = parseFloat(v);
return x.toFixed(1);
},
u: 'm'
},
formatLatitude: {
f: function (v) {
let x = parseFloat(v);
if (isNaN(x)) return '-----';
return formatLonLatsDecimal(x, 'lat');
},
u: ''
},
formatLongitude: {
f: function (v) {
let x = parseFloat(v);
if (isNaN(x)) return '-----';
return formatLonLatsDecimal(x, 'lon');
},
u: ''
},
formatRot: {
f: function (v) {
let x = parseFloat(v);
if (isNaN(x)) return '---';
x = x * 180.0 / Math.PI;
return x.toFixed(2);
},
u: '°/s'
},
formatXte: {
f: function (v) {
let x = parseFloat(v);
if (isNaN(x)) return '---';
return x.toFixed(0);
},
u: 'm'
},
formatDate: {
f: function (v) {
v = parseFloat(v);
if (isNaN(v)) return "----/--/--";
//strange day offset from NMEA0183 lib
let d = new Date("2010/01/01");
let days = 14610 - d.getTime() / 1000 / 86400;
let tbase = (v - days) * 1000 * 86400;
let od = new Date(tbase);
return formatFixed(od.getFullYear(), 4, 0) +
"/" + formatFixed(od.getMonth() + 1, 2, 0) +
"/" + formatFixed(od.getDate(), 2, 0);
},
u: ''
},
formatTime: {
f: function (v) {
v = parseFloat(v);
if (isNaN(v)) return "--:--:--";
let hr = Math.floor(v / 3600.0);
let min = Math.floor((v - hr * 3600.0) / 60);
let s = Math.floor((v - hr * 3600.0 - min * 60.0));
return formatFixed(hr, 2, 0) + ':' +
formatFixed(min, 2, 0) + ':' +
formatFixed(s, 2, 0);
},
u: ''
}
}
Object.freeze(valueFormatters);
for (let k in valueFormatters){
Object.freeze(valueFormatters[k]);
}
function resizeFont(el, reset, maxIt) {
if (maxIt === undefined) maxIt = 10;
if (!el) return;
if (reset) el.style.fontSize = '';
while (el.scrollWidth > el.clientWidth && maxIt) {
let next = parseFloat(window.getComputedStyle(el).fontSize) * 0.9;
el.style.fontSize = next + "px";
}
}
function getUnit(def,useUser){
let fmt = useUser?(userFormatters[def.name] || valueFormatters[def.format]):valueFormatters[def.format] ;
let u = fmt ? fmt.u : ' ';
if (!fmt && def.format && def.format.match(/formatXdr/)) {
u = def.format.replace(/formatXdr:[^:]*:/, '');
}
return u;
}
function sourceName(v) {
if (v == 0) return "N2K";
for (let n in channelList) {
if (v == channelList[n].id) return n;
if (v >= channelList[n].id && v <= channelList[n].max) {
return n;
}
}
if (v < minUser) return "---";
return "USER[" + v + "]";
}
let lastSelectList = [];
buttonHandlers.uploadBin=function(ev) {
let el = document.getElementById("uploadFile");
let progressEl = document.getElementById("uploadDone");
if (!el) return;
if (el.files.length < 1) return;
ev.target.disabled = true;
let file = el.files[0];
checkImageFile(file)
.then(function (result) {
let currentType;
let currentVersion;
let chipid;
forEl('.status-version', function (el) { currentVersion = el.textContent });
forEl('.status-fwtype', function (el) { currentType = el.textContent });
forEl('.status-chipid', function (el) { chipid = el.textContent });
let confirmText = 'Ready to update firmware?\n';
if (result.chipId != chipid) {
confirmText += "WARNING: the chipid in the image (" + result.chipId;
confirmText += ") does not match the current chip id (" + chipid + ").\n";
}
if (currentType != result.fwtype) {
confirmText += "WARNING: image has different type: " + result.fwtype + "\n";
confirmText += "** Really update anyway? - device can become unusable **";
}
else {
if (currentVersion == result.version) {
confirmText += "WARNING: image has the same version as current " + result.version;
}
else {
confirmText += "version in image: " + result.version;
}
}
if (!confirm(confirmText)) {
ev.target.disabled = false;
return;
}
ensurePass()
.then(function (hash) {
let len = file.size;
let req = new XMLHttpRequest();
req.onloadend = function () {
ev.target.disabled = false;
let result = "unknown error";
try {
let jresult = JSON.parse(req.responseText);
if (jresult.status == 'OK') {
result = '';
}
else {
if (jresult.status) {
result = jresult.status;
}
}
} catch (e) {
result = "Error " + req.status;
}
if (progressEl) {
progressEl.style.width = 0;
}
if (!result) {
alertRestart();
}
else {
alert("update error: " + result);
}
}
req.onerror = function (e) {
ev.target.disabled = false;
alert("unable to upload: " + e);
}
if (progressEl) {
progressEl.style.width = 0;
req.upload.onprogress = function (ev) {
if (ev.lengthComputable) {
let percent = 100 * ev.loaded / ev.total;
progressEl.style.width = percent + "%";
}
}
}
let formData = new FormData();
formData.append("file1", el.files[0]);
req.open("POST", apiPrefix + '/api/update?_hash=' + encodeURIComponent(hash));
req.send(formData);
})
.catch(function (e) {
ev.target.disabled = false;
});
})
.catch(function (e) {
alert("This file is an invalid image file:\n" + e);
ev.target.disabled = false;
})
}
let HDROFFSET = 288;
let VERSIONOFFSET = 16;
let NAMEOFFSET = 48;
let MINSIZE = HDROFFSET + NAMEOFFSET + 32;
let CHIPIDOFFSET = 12; //2 bytes chip id here
let imageCheckBytes = {
0: 0xe9, //image magic
288: 0x32, //app header magic
289: 0x54,
290: 0xcd,
291: 0xab
};
function decodeFromBuffer(buffer, start, length) {
while (length > 0 && buffer[start + length - 1] == 0) {
length--;
}
if (length <= 0) return "";
let decoder = new TextDecoder();
let rt = decoder.decode(buffer.slice(
start,
start + length));
return rt;
}
function getChipId(buffer) {
if (buffer.length < CHIPIDOFFSET + 2) return -1;
return buffer[CHIPIDOFFSET] + 256 * buffer[CHIPIDOFFSET + 1];
}
function checkImageFile(file) {
return new Promise(function (resolve, reject) {
if (!file) reject("no file");
if (file.size < MINSIZE) reject("file is too small");
let slice = file.slice(0, MINSIZE);
let reader = new FileReader();
reader.addEventListener('load', function (e) {
let content = new Uint8Array(e.target.result);
for (let idx in imageCheckBytes) {
if (content[idx] != imageCheckBytes[idx]) {
reject("missing magic byte at position " + idx + ", expected " +
imageCheckBytes[idx] + ", got " + content[idx]);
}
}
let version = decodeFromBuffer(content, HDROFFSET + VERSIONOFFSET, 32);
let fwtype = decodeFromBuffer(content, HDROFFSET + NAMEOFFSET, 32);
let chipId = getChipId(content);
let rt = {
fwtype: fwtype,
version: version,
chipId: chipId
};
resolve(rt);
});
reader.readAsArrayBuffer(slice);
});
}
function addTabPage(name,label,url){
if (label === undefined) label=name;
let tab=addEl('div','tab','#tabs',label);
tab.addEventListener('click',function(ev){
handleTab(ev.target);
})
if (url !== undefined){
tab.setAttribute('data-url',url);
return;
}
tab.setAttribute('data-page',name);
let page=addEl('div','tabPage hidden','#tabPages');
page.setAttribute('id',name);
return page;
}
function addUserFormatter(name,unit,formatter){
if (unit !== undefined && formatter !== undefined){
userFormatters[name]={
u:unit,
f:formatter
}
}
else{
delete userFormatters[name];
}
hideDashboardItem(name); //will recreate it on next data receive
}
const api= {
registerListener: function (callback,opt_event) {
if (opt_event === undefined){
listeners.push(callback);
}
else{
listeners.push({
event:opt_event,
callback:callback
})
}
},
/**
* helper for creating dom elements
* parameters:
* type: the element type (e.g. div)
* class: a list of classes separated by space
* parent (opt): a parent element (either a dom element vor a query selector)
* text (opt): the text to be set as textContent
* returns: the newly created element
*/
addEl: addEl,
/**
* iterator helper for a query selector
* parameters:
* query: the query selector
* callback: the callback function (will be called with the element as param)
* base (opt): a dome element to be used as the root (defaults to document)
*/
forEl: forEl,
/**
* find the closest parent that has a particular class
* parameters:
* element: the element to start with
* class: the class to be searched for
* returns: the element or undefined/null
*/
closestParent: closestParent,
/**
* add a new tab
* parameters:
* name - the name of the page
* label (opt): the label for the new page
* returns: the newly created element
*/
addTabPage: addTabPage,
/**
* add a user defined formatter for a boat data item
* parameters:
* name : the boat data item name
* unit: the unit to be displayed
* formatter: the formatter function (must return a string)
*/
addUserFormatter: addUserFormatter,
removeUserFormatter: function(name){
addUserFormatter(name);
},
/**
* a dict of formatters
* each one has 2 members:
* u: the unit
* f: the formatter function
*/
formatters: valueFormatters,
EVENTS: {
init: 0, //called when capabilities are loaded, data is capabilities
tab: 1, //tab page activated, data is the id of the tab page
config: 2, //called when the config data is loaded,data is the config object
dataItemCreated: 4, //data is an object with
// name: the item name, element: the frame item of the boat data display
status: 5, //status received, data is the status object
counterDisplayCreated: 6 //data is the row for the display
}
};
function callListeners(event,data){
listeners.forEach((listener)=>{
if (typeof(listener) === 'function'){
listener(event,data);
}
else if (typeof(listener) === 'object'){
if (listener.event === event){
if (typeof(listener.callback) === 'function'){
listener.callback(event,data);
}
}
}
})
}
window.esp32nmea2k = api;
window.setInterval(update, updateInterval);
window.addEventListener('load', function () {
let buttons = document.querySelectorAll('button');
for (let i = 0; i < buttons.length; i++) {
let be = buttons[i];
let buttonFunction=buttonHandlers[be.id];
if (typeof(buttonFunction) === 'function'){
be.onclick = buttonFunction; //assume a function with the button id
//console.log("button: "+be.id);
}
else{
console.log("no handler for button "+be.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();
try {
let storedPass = localStorage.getItem('adminPass');
if (storedPass) {
forEl('#adminPassInput', function (el) {
el.value = storedPass;
});
}
} catch (e) { }
forEl('#uploadFile', function (el) {
el.addEventListener('change', function (ev) {
if (ev.target.files.length < 1) return;
let file = ev.target.files[0];
checkImageFile(file)
.then(function (res) {
forEl('#imageProperties', function (iel) {
let txt = "[" + res.chipId + "] ";
txt += res.fwtype + ", " + res.version;
iel.textContent = txt;
iel.classList.remove("error");
})
})
.catch(function (e) {
forEl('#imageProperties', function (iel) {
iel.textContent = e;
iel.classList.add("error");
})
})
})
})
});
}());