Browse Source

www: do a bunch of things "more correctly"

Fix things like occasional race conditions, translation of dynamic
content, reverse logic checkboxes, status message for configuration,
page refresh on update reboot.
H. Peter Anvin 2 years ago
parent
commit
2f17e4dfb0

+ 1 - 2
esp32/max80/httpd.c

@@ -314,8 +314,7 @@ static esp_err_t httpd_update_done(httpd_req_t *req, const char *what, int err)
 	len = 0;
 
     esp_err_t rv = httpd_send_plain(req, err ? 400 : 200, response, len,
-				    HSP_CLOSE|HSP_CLOSE_SOCKET|HSP_CRLF,
-				    reboot_time+5);
+				    HSP_CLOSE, reboot_time+5);
     if (response)
 	free(response);
 

BIN
esp32/output/max80.ino.bin


+ 65 - 60
esp32/www/config.html

@@ -8,78 +8,83 @@
   <body>
     <script>inc("head.html")</script>
     <h1 class="config">Configuration</h1>
-    <form id="setconfig" action="sys/setconfig" enctype="text/plain" method="post">
+    <form id="setconfig" action="sys/setconfig" method="post" onsubmit="uploadform()">
       <fieldset class="network">
 	<legend>Network</legend>
-	<div>
-	  <label for="wifi.ssid">Network name (SSID):</label>
-	  <input type="text" id="wifi.ssid" name="wifi.ssid" />
-	</div>
-	<div>
-	  <label for="wifi.psk">Network password (PSK):</label>
-	  <input class="mono" type="password" id="wifi.psk" name="wifi.psk" />
-	  <button type="button" class="show" onclick="showpwd('wifi.psk',this)"><span class="show">show</span><span class="hide">hide</span></button>
-	</div>
+	<label class="wifi-ssid">
+	  <span>Network name (SSID):</span>
+	  <input type="text" name="wifi.ssid" />
+	</label>
+	<label class="wifi-psk">
+	  <span>Network password (PSK):</span>
+	  <input class="mono" type="password" name="wifi.psk" />
+	  <button type="button" class="show" onclick="showpwd()"><span class="show">show</span><span class="hide">hide</span></button>
+	</label>
       </fieldset>
       <fieldset class="datetime">
 	<legend>Date and Time</legend>
-	<div>
-	  <label for="tz">Time zone:</label>
-	  <select id="tzname" name="tzname" onchange="tzn(event,'tz')">
+	<label class="tz">
+	  <span>Time zone:</span>
+	  <select name="tzname" id="tzname" onchange="tzn()">
 	  </select>
-	  <input type="text" id="tz" name="TZ" oninput="tzt(event,'tzname')" />
-	</div>
-	<div>
-	  <label for="sntp.enabled">Synchronize time from network:</label>
-	  <input type="checkbox" id="sntp.enabled" name="sntp.enabled" />
-	</div>
-	<div>
-	  <label for="sntp.server">NTP server:</label>
-	  <input type="text" id="sntp.server" name="sntp.server" />
-	</div>
-	<div>
-	  <label for="ip4.dhcp.nosntp">Ignore DHCP-provided NTP server:</label>
-	  <input type="checkbox" id="ip4.dhcp.nosntp" name="ip4.dhcp.nosntp" />
-	</div>
+	  <input type="text" name="TZ" oninput="tzt()" />
+	</label>
+	<label class="sntp-enabled">
+	  <span>Synchronize time from network:</span>
+	  <input type="checkbox" name="sntp.enabled" />
+	</label>
+	<label class="sntp-server">
+	  <span>NTP server:</span>
+	  <input type="text" name="sntp.server" />
+	</label>
+	<label class="ip4-dhcp-sntp">
+	  <span>Use DHCP-provided NTP server:</span>
+	  <input type="checkbox" name="ip4.dhcp.nosntp" value="0" />
+	</label>
       </fieldset>
       <button class="submit" type="submit" disabled>Update configuration</button>
+      <br />
+      <output></output>
     </form>
     <script>
-      function tzn(e,p) {
-	  var tz = e.target.selectedOptions[0].dataset.tz;
-	  if (tz) { getelem(p).value = tz; }
+      function tzn() {
+	  const tz = event.target.selectedOptions[0].dataset.tz;
+	  if (tz) sib(event.target,'input').value = tz;
       }
-      function tzt(e,p) { getelem(p).value = ''; }
+      function tzt() { sib(event.target,'select').value = ''; }
+
       fetchconfig('tz.txt')
-    .then(map => {
-	function cln(z) { return ('tz/'+z).replaceAll('/','_ ')
-			  .replaceAll(/[^\w ]+/g,'-'); }
-	var elem = getelem('tzname');
-	var grp = elem;
-	map.set('',''); map.set('UTC','UTC0');
-	var zones = Array.from(map.keys());
-	zones = zones.filter(v => v && v != 'UTC').sort();
-	zones.unshift('','UTC');
-	for (const z of zones) {
-	    const zz = z.match(/^(?:(\S+?)\/)?(\S*)$/,z);
-	    if (!zz) { continue; }
-	    if (zz[1] && zz[1] != grp.label) {
-		grp = document.createElement('OPTGROUP');
-		grp.label = zz[1];
-		grp.className = cln(zz[1]);
-		elem.append(grp);
-	    } else if (!zz[1]) {
-		grp = elem;
-	    }
-	    const pz = zz[2].replaceAll('_',' ').replaceAll('/',': ');
-	    var opt = new Option(pz, z);
-	    opt.className = cln(z);
-	    opt.dataset.tz = map.get(z);
-	    grp.append(opt);
-	}
-    })
-    .finally(() => {loadform('setconfig','sys/getconfig')});
+          .then(map => {
+	      function cln(z) {
+		  return ('tz/'+z).replaceAll('/','_ ')
+		      .replaceAll(/[^\w ]+/g,'-');
+	      }
+	      var elem = getelem('tzname');
+	      var grp = elem;
+	      map.set('',''); map.set('UTC','UTC0');
+	      var zones = Array.from(map.keys());
+	      zones = zones.filter(v => v && v != 'UTC').sort();
+	      zones.unshift('','UTC');
+	      for (const z of zones) {
+		  const zz = z.match(/^(?:(\S+?)\/)?(\S*)$/,z);
+		  if (!zz) { continue; }
+		  if (zz[1] && zz[1] != grp.label) {
+		      grp = document.createElement('OPTGROUP');
+		      grp.label = zz[1];
+		      grp.className = cln(zz[1]);
+		      elem.append(grp);
+		  } else if (!zz[1]) {
+		      grp = elem;
+		  }
+		  const pz = zz[2].replaceAll('_',' ').replaceAll('/',': ');
+		  var opt = new Option(pz, z);
+		  opt.className = cln(z);
+		  opt.dataset.tz = map.get(z);
+		  grp.append(opt);
+	      }
+	      translate(elem);
+	  })
+          .finally(() => {loadform('setconfig','sys/getconfig');} );
     </script>
-    <script>translate()</script>
   </body>
 </html>

+ 8 - 8
esp32/www/lang/sv

@@ -1,6 +1,4 @@
 .logo2=Peter &amp; Per
-button .show=Visa
-button .hide=Göm
 body .status=Status
 body .config=Konfiguration
 body .update=Uppdatera
@@ -11,12 +9,14 @@ title.update=MAX80: Uppdatera
 "label[for='file']"=Välj <code class="file">.fw</code>-fil:
 .firmware .submit=Uppdatera mjukvara
 .network legend=Nätverk
-"label[for='wifi.ssid']"=Nätverksnamn (SSID):
-"label[for='wifi.psk']"=Lösenord (PSK):
+.wifi-ssid span=Nätverksnamn (SSID):
+.wifi-psk span=Lösenord (PSK):
 .datetime legend=Datum och tid
-"label[for='tz']"=Tidszon:
-"label[for='sntp.enabled']"=Synkronisera tid från nätet (NTP):
-"label[for='sntp.server']"=NTP-server:
-"label[for='ip4.dhcp.nosntp']"=Använd ej NTP-server från DHCP:
+.tz span=Tidszon:
+.sntp-enabled span=Synkronisera tid från nätet (NTP):
+.sntp-server span=NTP-server:
+.ip4-dhcp-sntp span=Använd NTP-server från DHCP:
 #setconfig .submit=Konfigurera
 .notyet=(jobbar på det)
+button .show=Visa
+button .hide=Göm

+ 21 - 22
esp32/www/max80.css

@@ -2,12 +2,11 @@
     font-family: "Prisma";
     src: url(Prisma-MAX.woff2);
 }
-
 body {
     background: #e6c185;
     font-family: "arial", "sans-serif";
 }
-.mono {
+.mono, pre, output, tt, code {
     font-family: "source code pro", "monospace";
 }
 div.title {
@@ -79,23 +78,20 @@ form fieldset legend {
     padding: 0.25em;
     background: #af9365;
 }
-form div {
+form label {
     margin: 0;
     padding: 0.5ch;
     display: flex;
 }
-form label {
+form label span {
     width: 30ch;
 }
-input[type=text], input[type=password] {
+input[type='text'], input[type='password'] {
     flex: 1;
 }
-input[type=file] {
-    font-family: "arial", "sans-serif";
-    font-size: 80%;
-}
-select#tzname {
-    margin-right: 1px;
+.tz select {
+    padding: 0;
+    margin: 0 0 0 1px;
 }
 button.show, button.hide {
     width: 6ch;
@@ -111,28 +107,31 @@ button.hide .show {
 }
 button {
     width: 28ch;
-    margin: 1em 1ch 1em 1ch;
+    margin: 1em;
     padding: 0.25em;
-    vertical-align: center;
+    vertical-align: middle;
     font-family: "arial", "sans-serif";
     font-size: 100%;
 }
 progress {
-    display: inline-block;
+    display: block;
     width: 100%;
+    margin: 0.5em 0;
+}
+output {
+    display: none;
 }
-.result {
+output.result {
     display: block;
     border: 2px solid black;
-    padding: 0.5ch;
-    border-radius: 5px;
-}
-.result.hide {
-    display: none;
+    margin: 1em;
+    padding: 0.75em 1.25em;
+    border-radius: 1em;
+    white-space: pre-wrap;
 }
-.result.ok {
+.ok {
     background: #e0ffe0;
 }
-.result.err {
+.err {
     background: #ffe0e0;
 }

+ 140 - 84
esp32/www/max80.js

@@ -1,8 +1,16 @@
+const PassiveListener = { passive: true };
+
 // Get an element by id or an Element object
 function getelem(id) {
     return (id instanceof Element) ? id : document.getElementById(id);
 }
 
+// Find a child with a specific tag
+function chi(me,tag) { return me.getElementsByTagName(tag)[0]; }
+
+// Find a sibling element with a specific tag
+function sib(me,tag) { return chi(me.parentElement, tag); }
+
 // Read a key=value text file and return it as a Promise of a Map
 function fetchconfig(url) {
     return fetch(url, {redirect: "follow"})
@@ -26,45 +34,39 @@ function fetchconfig(url) {
 	});
 }
 
-// Initialize a form from a map
+// Parse a string for a valid boolean truth value
+function cfgbool(str) {
+    return str && !str.match(/^(0*|[fnd].*|of.*)$/i);
+}
+
+// Initialize a form from a map. Checkboxes take a cfgbool string;
+// if in the HTML their value is set to a false cfgbool string then
+// the checkbox is inverted logic.
 function initform(form,map) {
     var button = null;
-    var clearers = new Map;
+    var clearers = new Set;
     form = getelem(form);
 
-    for (var field of form.elements) {
-	if (((field instanceof HTMLInputElement) ||
-	     (field instanceof HTMLSelectElement)) &&
-	    !field.classList.contains("noload")) {
-	    const val = map.get(field.name);
-	    if (val == null) {
-	    } else if (field.type == 'checkbox') {
-		const checked = !val.match(/^(0*|[fnd].*|of.*)$/i);
-		field.checked = checked;
-		field.value = '1';
-	    } else if (field.type == 'radio') {
-		field.checked = (val == field.name);
-	    } else if (field.type == 'hidden' &&
-		       field.classList.contains('_clr')) {
-		field.remove();
+    for (var e of form.elements) {
+	if (e.classList.contains('noinit')) {
+	    continue;
+	} else if (e instanceof HTMLInputElement ||
+		   e instanceof HTMLSelectElement) {
+	    const val = map.get(e.name);
+	    if (val == null)
+		continue;
+	    if (e.type == 'checkbox') {
+		e.checked = cfgbool(val) == cfgbool(e.value);
+	    } else if (e.type == 'radio') {
+		e.checked = (val == e.name);
 	    } else {
-		field.value = val;
+		e.value = val;
 	    }
-	} else if (field instanceof HTMLButtonElement &&
-		   field.type == 'submit') {
-	    button = field;
+	} else if (e instanceof HTMLButtonElement &&
+		   e.type == 'submit') {
+	    button = e;
 	}
     }
-
-    for (const what of clearers.keys()) {
-	var clearer = document.createElement('INPUT');
-	clearer.type = 'hidden';
-	clearer.name = what;
-	clearer.value = '0';
-	clearer.classList.add('_clr');
-	form.prepend(clearer);
-    }
-
     if (button) {
 	button.disabled = false; // All loaded, enable the submit button
     }
@@ -77,14 +79,23 @@ function loadform(form, url) {
 	.catch(() => {});
 }
 
-// Replace the contents of selected HTML elements based on a map with selectors
-function fillin(map,html)
+// Replace the contents of selected HTML elements based on a map with
+// selectors.
+function fillin(map,html = false,top = document)
 {
     for (const [key,val] of map) {
 	try {
-	    for (var e of document.querySelectorAll(key))
-		if (html) e.innerHTML = val; else e.innerText = val;
-	} catch (error) { }
+	    const m = key.match(/(.*?)(?:\;([\w.-]*)(\??))?$/);
+	    for (var e of top.querySelectorAll(m[1])) {
+		try {
+		    if (!html) e.textContents = val;
+		    else if (!m[2]) e.innerHTML = val;
+		    else if (!m[3]) e.setAttribute(m[2],val);
+		    else if (!cfgbool(val)) e.removeAttribute(m[2]);
+		    else e.setAttribute(m[2],'');
+		} catch(e) { };
+	    }
+	} catch(e) { };
     }
 }
 
@@ -92,68 +103,114 @@ function fillin(map,html)
 function load(url,html = false)
 {
     fetchconfig(url)
-	.then(map => { fillin(map,html) })
+	.then(map => fillin(map,html))
 	.catch(() => {});
 }
 
-// POST file upload with progress and response text
-function uploadfile(event) {
-    event.preventDefault();
-    var form = event.currentTarget;
-    var elem = form.elements;
-    var files = elem["file"].files;
-    if (files.length != 1) {
-	/* Show error */
-	return;
-    }
-    var file = files[0];
+// POST upload of data from within a form, with progress and response text
+function upload(form,data) {
     var xhr = new XMLHttpRequest();
 
-    var progress = form.querySelector("progress");
-    if (progress != undefined) {
+    var progress = chi(form,'progress');
+    if (progress) {
 	progress.value = 0;
-	xhr.upload.addEventListener("progress", (event) => {
-	    if (event.lengthComputable) {
-		progress.max   = event.total * 1.05;
-		progress.value = event.loaded;
-	    }
-	});
+	xhr.upload.addEventListener('progress', (e) => {
+	    if (!e.lengthComputable)
+		return;
+	    progress.max   = e.total * 1.05;
+	    progress.value = e.loaded;
+	}, PassiveListener);
     }
 
-    var result = form.querySelector("pre.result");
-    if (result != undefined) {
-	result.className = "result hide";
-    }
-    xhr.addEventListener("loadend", (event) => {
-	const ok = xhr.status >= 200 && xhr.status <= 299;
-	if (progress != undefined) {
+    var result = chi(form,'output');
+    if (result)
+	result.classList.remove('result');
+
+    xhr.addEventListener('readystatechange', (e) => {
+	if (xhr.readyState != XMLHttpRequest.DONE)
+	    return;
+	const ok = xhr.status >= 200 && xhr.status < 400;
+	if (progress)
 	    progress.value = ok ? progress.max : 0;
+	if (result) {
+	    var msg = xhr.responseText.trimEnd();
+	    if (!msg)
+		msg = xhr.status + ' ' + xhr.statusText;
+	    result.textContent = msg;
+	    result.classList.toggle('ok', ok);
+	    result.classList.toggle('err', !ok);
+	    result.classList.add('result');
 	}
-	if (result != undefined) {
-	    result.className = "result " + (ok ? "ok" : "err");
-	    result.innerText = xhr.responseText;
-	}
-    });
+	const rf = parseInt(xhr.getResponseHeader('Refresh'));
+	if (rf && ok)
+	    setTimeout(() => window.location.reload(true), rf * 1000);
+    }, PassiveListener);
 
-    xhr.open("POST", form.action);
+    xhr.open(form.method, form.action);
     xhr.responseType = 'text';
-    xhr.send(file);
+    xhr.send(data);
+    return xhr;
 }
 
-// Enable a button (this ensures that the necessary scripts have been
-// run before one can submit)
-function enablebutton(id,on) {
-    getelem(id).disabled = !on;
+// Upload a data file blob
+function uploadfile() {
+    event.preventDefault();
+    const form = event.target.form || event.target;
+    var files = form.elements['file'];
+    return (files.files.length == 1)
+	? upload(form,files.files[0]) : files.click();
+}
+
+// key=value formatting of form data; including inverted checkboxes
+function textformdata(form) {
+    var data = '';
+    for (var e of form.elements) {
+	var val = e.value;
+	if (val == undefined || !e.name || e instanceof HTMLButtonElement) {
+	    continue;
+	} else if (e instanceof HTMLInputElement) {
+	    if (e.type == 'checkbox')
+		val = e.checked == cfgbool(val) ? '1' : '0';
+	    else if (e.type == 'radio' && !e.checked)
+		continue;
+	}
+	data += e.name + '=' + val + "\r\n";
+    }
+    return data;
+}
+
+// POST form contents upload with response text
+function uploadform() {
+    event.preventDefault();
+    const form = event.target.form || event.target;
+    return upload(form,textformdata(form));
 }
 
 // Flip the status of an INPUT element between text and password
-function showpwd(id,me) {
-    var pwd = getelem(id);
+function showpwd(me = event.target) {
     const now_visible = me.classList.toggle('hide');
-    me.classList.toggle('show',!now_visible);
     const new_type = now_visible ? 'text' : 'password';
-    pwd.setAttribute('type', new_type);
+    me.classList.toggle('show',!now_visible);
+    sib(me,'input').setAttribute('type', new_type);
+}
+
+// Insert translations as needed
+var translations = null;
+var delay_translate = false;
+function translate(top = document) {
+    delay_translate = (!translations || document.readyState != 'complete');
+    if (!delay_translate)
+	fillin(translations, true, top);
 }
+document.addEventListener('load', (e) => translate(), PassiveListener);
+
+fetchconfig('/sys/lang')
+    .then((map) => {
+	translations = map;
+	if (delay_translate)
+	    translate();
+    })
+    .catch(() => {});
 
 // Hack to include an HTML files. Sadly, does not support
 // including files with <script> tags.
@@ -161,10 +218,9 @@ function inc(url) {
     var me = document.currentScript;
     fetch(url, {redirect: "follow"})
 	.then((response) => response.text())
-	.then((text) => { me.outerHTML = text; });
-}
-
-// Insert translations
-function translate() {
-    load('/sys/lang', true);
+	.then((text) => {
+	    const p = me.parentElement;
+	    me.outerHTML = text;
+	    translate(p);
+	});
 }

+ 4 - 3
esp32/www/status.html

@@ -7,8 +7,9 @@
   </head>
   <body>
     <script>inc("head.html")</script>
-    <script>inc("showstatus.html")</script>
-    <script>load('sys/getstatus')</script>
-    <script>translate()</script>
+    <h1 class="status">Status</h1>
+    <img src="wip.png" width="72" height="64" alt="WIP" />
+    <p class="notyet">(not implemented yet)</p>
+    <script>load('sys/getstatus');</script>
   </body>
 </html>

+ 5 - 14
esp32/www/update.html

@@ -9,23 +9,14 @@
     <script>inc("head.html")</script>
     <h1 class="update">Update</h1>
     <form id="upload" action="sys/fwupdate" enctype="multipart/form-data"
-	  method="post" onsubmit="uploadfile(event)">
+	  method="post" onsubmit="uploadfile()">
       <fieldset class="firmware">
 	<legend>Firmware</legend>
-	<div>
-	  <label for="file">Select firmware file:</label>
-	  <input type="file" name="file" required
-		 onchange="enablebutton('upload.start',files.length==1)" />
-	</div>
-	<div>
-	  <button class="submit" type="submit" id="upload.start" disabled>Update firmware</button>
-	</div>
-	<div>
-	  <progress value="0"></progress>
-	</div>
-	<pre class="result hide"></pre>
+	<input type="file" name="file" hidden accept=".fw" onchange="uploadfile()" />
+	<button class="submit" type="submit" id="upload.start">Update firmware</button>
+	<progress value="0"></progress>
       </fieldset>
+      <output></output>
     </form>
-    <script>translate()</script>
   </body>
 </html>

BIN
fpga/output/v1.fw


BIN
fpga/output/v2.fw