var debug = false; var websock; var password = false; var maxNetworks; var messages = []; var free_size = 0; var urls = {}; var numChanged = 0; var numReboot = 0; var numReconnect = 0; var numReload = 0; var configurationSaved = false; var useWhite = false; var useCCT = false; var now = 0; var ago = 0; var packets; var filters = []; var magnitudes = []; // ----------------------------------------------------------------------------- // Messages // ----------------------------------------------------------------------------- function initMessages() { messages[1] = "Remote update started"; messages[2] = "OTA update started"; messages[3] = "Error parsing data!"; messages[4] = "The file does not look like a valid configuration backup or is corrupted"; messages[5] = "Changes saved. You should reboot your board now"; messages[7] = "Passwords do not match!"; messages[8] = "Changes saved"; messages[9] = "No changes detected"; messages[10] = "Session expired, please reload page..."; } function sensorName(id) { var names = [ "DHT", "Dallas", "Emon Analog", "Emon ADC121", "Emon ADS1X15", "HLW8012", "V9261F", "ECH1560", "Analog", "Digital", "Events", "PMSX003", "BMX280", "MHZ19", "SI7021", "SHT3X I2C", "BH1750", "PZEM004T", "AM2320 I2C", "GUVAS12SD", "T6613", "TMP3X", "Sonar", "SenseAir", "GeigerTicks", "GeigerCPM", "NTC", "SDS011", "MICS2710", "MICS5525", "VL53L1X", "VEML6075", "EZOPH" ]; if (1 <= id && id <= names.length) { return names[id - 1]; } return null; } function magnitudeType(type) { var types = [ "Temperature", "Humidity", "Pressure", "Current", "Voltage", "Active Power", "Apparent Power", "Reactive Power", "Power Factor", "Energy", "Energy (delta)", "Analog", "Digital", "Event", "PM1.0", "PM2.5", "PM10", "CO2", "Lux", "UVA", "UVB", "UV Index", "Distance" , "HCHO", "Local Dose Rate", "Local Dose Rate", "Count", "NO2", "CO", "Resistance", "pH" ]; if (1 <= type && type <= types.length) { return types[type - 1]; } return null; } function magnitudeError(error) { var errors = [ "OK", "Out of Range", "Warming Up", "Timeout", "Wrong ID", "Data Error", "I2C Error", "GPIO Error", "Calibration error" ]; if (0 <= error && error < errors.length) { return errors[error]; } return "Error " + error; } // ----------------------------------------------------------------------------- // Utils // ----------------------------------------------------------------------------- $.fn.enterKey = function (fnc) { return this.each(function () { $(this).keypress(function (ev) { var keycode = parseInt(ev.keyCode ? ev.keyCode : ev.which, 10); if (13 === keycode) { return fnc.call(this, ev); } }); }); }; function keepTime() { $("span[name='ago']").html(ago); ago++; if (0 === now) { return; } var date = new Date(now * 1000); var text = date.toISOString().substring(0, 19).replace("T", " "); $("input[name='now']").val(text); $("span[name='now']").html(text); now++; } function zeroPad(number, positions) { return number.toString().padStart(positions, "0"); } function loadTimeZones() { var time_zones = [ -720, -660, -600, -570, -540, -480, -420, -360, -300, -240, -210, -180, -120, -60, 0, 60, 120, 180, 210, 240, 270, 300, 330, 345, 360, 390, 420, 480, 510, 525, 540, 570, 600, 630, 660, 720, 765, 780, 840 ]; for (var i in time_zones) { var tz = time_zones[i]; var offset = tz >= 0 ? tz : -tz; var text = "GMT" + (tz >= 0 ? "+" : "-") + zeroPad(parseInt(offset / 60, 10), 2) + ":" + zeroPad(offset % 60, 2); $("select[name='ntpOffset']").append( $("") .attr("value", tz) .text(text) ); } } function validatePassword(password) { // http://www.the-art-of-web.com/javascript/validate-password/ // at least one lowercase and one uppercase letter or number // at least eight characters (letters, numbers or special characters) // MUST be 8..63 printable ASCII characters. See: // https://en.wikipedia.org/wiki/Wi-Fi_Protected_Access#Target_users_(authentication_key_distribution) // https://github.com/xoseperez/espurna/issues/1151 var re_password = /^(?=.*[A-Z\d])(?=.*[a-z])[\w~!@#$%^&*\(\)<>,.\?;:{}\[\]\\|]{8,63}$/; return ( (password !== undefined) && (typeof password === "string") && (password.length > 0) && re_password.test(password) ); } function validateFormPasswords(form) { var passwords = $("input[name='adminPass1'],input[name='adminPass2']", form); var adminPass1 = passwords.first().val(), adminPass2 = passwords.last().val(); var formValidity = passwords.first()[0].checkValidity(); if (formValidity && (adminPass1.length === 0) && (adminPass2.length === 0)) { return true; } var validPass1 = validatePassword(adminPass1), validPass2 = validatePassword(adminPass2); if (formValidity && validPass1 && validPass2) { return true; } if (!formValidity || (adminPass1.length > 0 && !validPass1)) { alert("The password you have entered is not valid, it must be 8..63 characters and have at least 1 lowercase and 1 uppercase / number!"); } if (adminPass1 !== adminPass2) { alert("Passwords are different!"); } return false; } function validateFormHostname(form) { // RFCs mandate that a hostname's labels may contain only // the ASCII letters 'a' through 'z' (case-insensitive), // the digits '0' through '9', and the hyphen. // Hostname labels cannot begin or end with a hyphen. // No other symbols, punctuation characters, or blank spaces are permitted. // Negative lookbehind does not work in Javascript // var re_hostname = new RegExp('^(?!-)[A-Za-z0-9-]{1,31}(?= 0; } function getValue(element) { if ($(element).attr("type") === "checkbox") { return $(element).prop("checked") ? 1 : 0; } else if ($(element).attr("type") === "radio") { if (!$(element).prop("checked")) { return null; } } return $(element).val(); } function addValue(data, name, value) { if (name in data) { if (!Array.isArray(data[name])) { data[name] = [data[name]]; } data[name].push(value); } else if (isGroupValue(name)) { data[name] = [value]; } else { data[name] = value; } } function getData(form, changed, cleanup) { // Populate two sets of data, ones that had been changed and ones that stayed the same var data = {}; var changed_data = []; if (cleanup === undefined) { cleanup = true; } if (changed === undefined) { changed = true; } $("input,select", form).each(function() { if ($(this).attr("data-settings-ignore") === "true") { return; } var name = $(this).attr("name"); var real_name = $(this).attr("data-settings-real-name"); if (real_name !== undefined) { name = real_name; } var value = getValue(this); if (null !== value) { var haschanged = ("true" === $(this).attr("hasChanged")); var indexed = changed_data.indexOf(name) >= 0; if ((haschanged || !changed) && !indexed) { changed_data.push(name); } addValue(data, name, value); } }); // Finally, filter out only fields that had changed. // Note: We need to preserve dynamic lists like schedules, wifi etc. // so we don't accidentally break when user deletes entry in the middle var resulting_data = {}; for (var value in data) { if (changed_data.indexOf(value) >= 0) { resulting_data[value] = data[value]; } } // Hack: clean-up leftover arrays. // When empty, the receiving side will prune all keys greater than the current one. if (cleanup) { $(".group-settings").each(function() { var haschanged = ("true" === $(this).attr("hasChanged")); if (haschanged && !this.children.length) { var targets = this.dataset.settingsTarget; if (targets === undefined) return; targets.split(" ").forEach(function(target) { resulting_data[target] = []; }); } }); } return resulting_data; } function randomString(length, args) { if (typeof args === "undefined") { args = { lowercase: true, uppercase: true, numbers: true, special: true } } var mask = ""; if (args.lowercase) { mask += "abcdefghijklmnopqrstuvwxyz"; } if (args.uppercase) { mask += "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; } if (args.numbers || args.hex) { mask += "0123456789"; } if (args.hex) { mask += "ABCDEF"; } if (args.special) { mask += "~`!@#$%^&*()_+-={}[]:\";'<>?,./|\\"; } var source = new Uint32Array(length); var result = new Array(length); window.crypto.getRandomValues(source).forEach(function(value, i) { result[i] = mask[value % mask.length]; }); return result.join(""); } function generateAPIKey() { var apikey = randomString(16, {hex: true}); $("input[name='apiKey']") .val(apikey) .attr("original", "-".repeat(16)) .attr("haschanged", "true"); return false; } function generatePassword() { var password = ""; do { password = randomString(10); } while (!validatePassword(password)); return password; } function toggleVisiblePassword() { var elem = this.previousElementSibling; if (elem.type === "password") { elem.type = "text"; } else { elem.type = "password"; } return false; } function doGeneratePassword() { var elems = $("input", $("#formPassword")); elems .val(generatePassword()) .attr("haschanged", "true") .each(function() { this.type = "text"; }); return false; } function getJson(str) { try { return JSON.parse(str); } catch (e) { return false; } } function checkTempRangeMin() { var min = parseInt($("#tempRangeMinInput").val(), 10); var max = parseInt($("#tempRangeMaxInput").val(), 10); if (min > max - 1) { $("#tempRangeMinInput").val(max - 1); } } function checkTempRangeMax() { var min = parseInt($("#tempRangeMinInput").val(), 10); var max = parseInt($("#tempRangeMaxInput").val(), 10); if (max < min + 1) { $("#tempRangeMaxInput").val(min + 1); } } function doResetThermostatCounters(ask) { var question = (typeof ask === "undefined" || false === ask) ? null : "Are you sure you want to reset burning counters?"; return doAction(question, "thermostat_reset_counters"); } function initSelectGPIO(select) { // TODO: properly lock used GPIOs via locking and apply the mask here var mapping = [ [153, "NONE"], [0, "0 (FLASH)"], [1, "1 (U0TXD)"], [2, "2 (U1TXD)"], [3, "3 (U0RXD)"], [4, "4 (SDA)"], [5, "5 (SCL)"], [9, "9 (SDD2)"], [10, "10 (SDD3)"], [12, "12 (MTDI)"], [13, "13 (MTCK)"], [14, "14 (MTMS)"], [15, "15 (MTDO)"], [16, "16 (WAKE)"], ]; for (n in mapping) { var elem = $('") .attr("value", i) .text("Switch #" + i) ); } } function initLeds(data) { var current = $("#ledConfig > div").length; if (current > 0) { return; } var size = data.length; var template = $("#ledConfigTemplate").children(); for (var i=0; i function initMagnitudes(data) { // check if already initialized (each magnitude is inside div.pure-g) var done = $("#magnitudes > div").length; if (done > 0) { return; } var size = data.size; // add templates var template = $("#magnitudeTemplate").children(); for (var i=0; i // ----------------------------------------------------------------------------- // Lights // ----------------------------------------------------------------------------- // wheelColorPicker accepts: // hsv(0...360,0...1,0...1) // hsv(0...100%,0...100%,0...100%) // While we use: // hsv(0...360,0...100%,0...100%) function _hsv_round(value) { return Math.round(value * 100) / 100; } function getPickerRGB(picker) { return $(picker).wheelColorPicker("getValue", "css"); } function setPickerRGB(picker, value) { $(picker).wheelColorPicker("setValue", value, true); } // TODO: use pct values instead of doing conversion? function getPickerHSV(picker) { var color = $(picker).wheelColorPicker("getColor"); return String(Math.ceil(_hsv_round(color.h) * 360)) + "," + String(Math.ceil(_hsv_round(color.s) * 100)) + "," + String(Math.ceil(_hsv_round(color.v) * 100)); } function setPickerHSV(picker, value) { if (value === getPickerHSV(picker)) return; var chunks = value.split(","); $(picker).wheelColorPicker("setColor", { h: _hsv_round(chunks[0] / 360), s: _hsv_round(chunks[1] / 100), v: _hsv_round(chunks[2] / 100) }); } function initColor(cfg) { var rgb = false; if (typeof cfg === "object") { rgb = cfg.rgb; } // check if already initialized var done = $("#colors > div").length; if (done > 0) { return; } // add template var template = $("#colorTemplate").children(); var line = $(template).clone(); line.appendTo("#colors"); // init color wheel $("input[name='color']").wheelColorPicker({ sliders: (rgb ? "wrgbp" : "whsp") }).on("sliderup", function() { if (rgb) { sendAction("color", {rgb: getPickerRGB(this)}); } else { sendAction("color", {hsv: getPickerHSV(this)}); } }); } function initCCT() { // check if already initialized var done = $("#cct > div").length; if (done > 0) { return; } $("#miredsTemplate").children().clone().appendTo("#cct"); $("#mireds").on("change", function() { var value = $(this).val(); var parent = $(this).parents(".pure-g"); $("span", parent).html(value); sendAction("mireds", {mireds: value}); }); } function initChannels(num) { // check if already initialized var done = $("#channels > div").length > 0; if (done) { return; } // does it have color channels? var colors = $("#colors > div").length > 0; // calculate channels to create var max = num; if (colors) { max = num % 3; if ((max > 0) & useWhite) { max--; if (useCCT) { max--; } } } var start = num - max; var onChannelSliderChange = function() { var id = $(this).attr("data"); var value = $(this).val(); var parent = $(this).parents(".pure-g"); $("span", parent).html(value); sendAction("channel", {id: id, value: value}); }; // add channel templates var i = 0; var template = $("#channelTemplate").children(); for (i=0; i").attr("value",i).text("Channel #" + i)); } // add brightness template var template = $("#brightnessTemplate").children(); var line = $(template).clone(); line.appendTo("#channels"); // init bright slider $("#brightness").on("change", function() { var value = $(this).val(); var parent = $(this).parents(".pure-g"); $("span", parent).html(value); sendAction("brightness", {value: value}); }); } // ----------------------------------------------------------------------------- // RFBridge // ----------------------------------------------------------------------------- function rfbLearn() { var parent = $(this).parents(".pure-g"); var input = $("input", parent); sendAction("rfblearn", {id: input.attr("data-id"), status: input.attr("data-status")}); } function rfbForget() { var parent = $(this).parents(".pure-g"); var input = $("input", parent); sendAction("rfbforget", {id: input.attr("data-id"), status: input.attr("data-status")}); } function rfbSend() { var parent = $(this).parents(".pure-g"); var input = $("input", parent); sendAction("rfbsend", {id: input.attr("data-id"), status: input.attr("data-status"), data: input.val()}); } function addRfbNode() { var numNodes = $("#rfbNodes > legend").length; var template = $("#rfbNodeTemplate").children(); var line = $(template).clone(); $("span", line).html(numNodes); $(line).find("input").each(function() { this.dataset["id"] = numNodes; }); $(line).find(".button-rfb-learn").on("click", rfbLearn); $(line).find(".button-rfb-forget").on("click", rfbForget); $(line).find(".button-rfb-send").on("click", rfbSend); line.appendTo("#rfbNodes"); return line; } // ----------------------------------------------------------------------------- // LightFox // ----------------------------------------------------------------------------- function lightfoxLearn() { sendAction("lightfoxLearn", {}); } function lightfoxClear() { sendAction("lightfoxClear", {}); } function initLightfox(data, relayCount) { var numNodes = data.length; var template = $("#lightfoxNodeTemplate").children(); var i, j; for (i=0; i span").text(data[i]["id"]); $line.find("select").each(function() { $(this).attr("name", "btnRelay" + data[i]["id"]); for (j=0; j < relayCount; j++) { $(this).append($("