Explorar el Código

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 hace 2 años
padre
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