From 14a8b44221777b85e90b163ce1fb7e6b0126a185 Mon Sep 17 00:00:00 2001 From: andreas Date: Mon, 13 Mar 2023 15:21:39 +0100 Subject: [PATCH] add web install tools --- webinstall/install.css | 24 +++ webinstall/install.html | 17 ++ webinstall/install.js | 151 +++++++++++++++++ webinstall/install.php | 155 +++++++++++++++++ webinstall/installUtil.js | 338 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 685 insertions(+) create mode 100644 webinstall/install.css create mode 100644 webinstall/install.html create mode 100644 webinstall/install.js create mode 100644 webinstall/install.php create mode 100644 webinstall/installUtil.js diff --git a/webinstall/install.css b/webinstall/install.css new file mode 100644 index 0000000..972b865 --- /dev/null +++ b/webinstall/install.css @@ -0,0 +1,24 @@ +.item { + margin: 0.5em; +} +.itemTitle { + margin-top: 0.5em; + margin-bottom: 0.2em; +} +button.installButton, button.showConsole, button.hideConsole { + font-size: 1em; + margin-left: 0.5em; +} +select.consoleBaud { + display: inline-block; + max-width: 10em; + font-size: 1em; +} +.console { + margin-bottom: 1em; +} +body { + font-size: 16px; + font-family: system-ui; + line-height: 1.5em; +} \ No newline at end of file diff --git a/webinstall/install.html b/webinstall/install.html new file mode 100644 index 0000000..e578a82 --- /dev/null +++ b/webinstall/install.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + +
+
+
+
+ \ No newline at end of file diff --git a/webinstall/install.js b/webinstall/install.js new file mode 100644 index 0000000..0281ff4 --- /dev/null +++ b/webinstall/install.js @@ -0,0 +1,151 @@ +import {XtermOutputHandler} from "./installUtil.js"; +import ESPInstaller from "./installUtil.js"; +(function(){ + let espLoaderTerminal; + let espInstaller; + let releaseData={}; + const addEl=ESPInstaller.addEl; //shorter typing + let showConsole; + let hideConsole; + const enableConsole=(enable,disableBoth)=>{ + if (showConsole) showConsole.disabled=!enable || disableBoth; + if (hideConsole) hideConsole.disabled=enable || disableBoth; + } + const showError=(txt)=>{ + let hFrame=document.querySelector('.heading'); + if (hFrame){ + hFrame.textContent=txt; + hFrame.classList.add("error"); + } + else{ + alert(txt); + } + } + const buildHeading=(user,repo,element)=>{ + let hFrame=document.querySelector(element||'.heading'); + if (! hFrame) return; + hFrame.textContent=''; + let h=addEl('h2',undefined,hFrame,`ESP32 Install ${user}:${repo}`) + } + const checkChip=(chipFamily,assetName)=>{ + //for now only ESP32 + if (chipFamily != "ESP32"){ + throw new Error(`unexpected chip family ${chipFamily}, expected ESP32`); + } + return assetName; + } + const baudRates=[1200, + 2400, + 4800, + 9600, + 14400, + 19200, + 28800, + 38400, + 57600, + 115200, + 230400, + 460800]; + const buildConsoleButtons=(element)=>{ + let bFrame=document.querySelector(element||'.console'); + if (! bFrame) return; + bFrame.textContent=''; + let cLine=addEl('div','buttons',bFrame); + let bSelect=addEl('select','consoleBaud',cLine); + baudRates.forEach((baud)=>{ + let v=addEl('option',undefined,bSelect,baud+''); + v.setAttribute('value',baud); + }); + bSelect.value=115200; + showConsole=addEl('button','showConsole',cLine,'ShowConsole'); + showConsole.addEventListener('click',async()=>{ + enableConsole(false); + await espInstaller.startConsole(bSelect.value); + }) + hideConsole=addEl('button','hideConsole',cLine,'HideConsole'); + hideConsole.addEventListener('click',async()=>{ + await espInstaller.stopConsole(); + enableConsole(true); + }) + } + const buildButtons=(user,repo,element)=>{ + let bFrame=document.querySelector(element||'.content'); + if (! bFrame) return; + bFrame.textContent=''; + if (!releaseData.assets) return; + let version=releaseData.name; + if (! version){ + alert("no version found in release data"); + return; + } + addEl('div','version',bFrame,`Version: ${version}`); + let items={}; + releaseData.assets.forEach((asset)=>{ + let name=asset.name; + let base=name.replace(/-all\.bin/,'').replace(/-update\.bin/,''); + if (items[base] === undefined){ + items[base]={}; + } + let item=items[base]; + item.label=base.replace(/-[0-9][0-9]*/,''); + if (name.match(/-update\./)){ + item.update=name; + } + else{ + item.basic=name; + } + }); + for (let k in items){ + let item=items[k]; + let line=addEl('div','item',bFrame); + addEl('div','itemTitle',line,item.label); + let btLine=addEl('div','buttons',line); + let tb=addEl('button','installButton',line,'Initial'); + tb.addEventListener('click',async ()=>{ + enableConsole(false,true); + await espInstaller.installClicked( + true, + user, + repo, + version, + 4096, + (chip)=>checkChip(chip,item.basic) + ) + enableConsole(true); + }); + tb=addEl('button','installButton',line,'Update'); + tb.addEventListener('click',async ()=>{ + enableConsole(false,true); + await espInstaller.installClicked( + false, + user, + repo, + version, + 65536, + (chip)=>checkChip(chip,item.update) + ) + enableConsole(true); + }); + } + + } + window.onload = async () => { + if (! ESPInstaller.checkAvailable()){ + showError("your browser does not support the ESP flashing (no serial)"); + return; + } + let user = window.gitHubUser||ESPInstaller.getParam('user'); + let repo = window.gitHubRepo || ESPInstaller.getParam('repo'); + if (!user || !repo) { + alert("missing parameter user or repo"); + } + try { + espLoaderTerminal = new XtermOutputHandler('terminal'); + espInstaller = new ESPInstaller(espLoaderTerminal); + buildHeading(user, repo); + buildConsoleButtons(); + releaseData = await espInstaller.getReleaseInfo(user, repo); + buildButtons(user, repo); + } catch(error){alert("unable to query release info for user "+user+", repo "+repo+": "+error)}; + } +})(); \ No newline at end of file diff --git a/webinstall/install.php b/webinstall/install.php new file mode 100644 index 0000000..e429bec --- /dev/null +++ b/webinstall/install.php @@ -0,0 +1,155 @@ + array('wellenvogel'), + 'repo'=> array('esp32-nmea2000') + ); + if (!function_exists('getallheaders')) { + function getallheaders() + { + $headers = []; + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + return $headers; + } + } + function safeName($name){ + return preg_replace('[^0-9_a-zA-Z.-]','',$name); + } + function replaceVars($str,$vars){ + foreach ($vars as $n => &$v){ + $str=str_replace("#".$n."#",$v,$str); + } + return $str; + } + + function fillUserAndRepo($vars=null){ + global $allowed; + if ($vars == null) { + $vars=array(); + } + foreach (array('user','repo') as $n){ + if (! isset($_REQUEST[$n])){ + die("missing parameter $n"); + } + $v=$_REQUEST[$n]; + $av=$allowed[$n]; + if (! in_array($v,$av)){ + die("value $v for $n not allowed"); + } + $vars[$n]=$v; + } + return $vars; + } + function addVars($vars,$names){ + foreach ($names as $n){ + if (! isset($_REQUEST[$n])){ + die("missing parameter $n"); + } + $safe=safeName($_REQUEST[$n]); + $vars[$n]=$safe; + } + return $vars; + } + + function curl_exec_follow(/*resource*/ $ch, /*int*/ &$maxredirect = null) { + $mr = $maxredirect === null ? 5 : intval($maxredirect); + if (ini_get('open_basedir') == '' && ini_get('safe_mode' == 'Off') && false) { + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, $mr > 0); + curl_setopt($ch, CURLOPT_MAXREDIRS, $mr); + } else { + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); + if ($mr > 0) { + $newurl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + $rch = curl_copy_handle($ch); + curl_setopt($rch, CURLOPT_HEADER, true); + curl_setopt($rch, CURLOPT_NOBODY, true); + curl_setopt($rch, CURLOPT_FORBID_REUSE, false); + curl_setopt($rch, CURLOPT_RETURNTRANSFER, true); + do { + curl_setopt($rch, CURLOPT_URL, $newurl); + $header = curl_exec($rch); + if (curl_errno($rch)) { + $code = 0; + } else { + $code = curl_getinfo($rch, CURLINFO_HTTP_CODE); + if ($code == 301 || $code == 302) { + preg_match('/Location:(.*?)\n/', $header, $matches); + $newurl = trim(array_pop($matches)); + } else { + $code = 0; + } + } + } while ($code && --$mr); + curl_close($rch); + if (!$mr) { + if ($maxredirect === null) { + trigger_error('Too many redirects. When following redirects, libcurl hit the maximum amount.', E_USER_WARNING); + } else { + $maxredirect = 0; + } + return false; + } + curl_setopt($ch, CURLOPT_URL, $newurl); + } + } + curl_setopt( + $ch, + CURLOPT_HEADERFUNCTION, + function ($curl, $header) { + header($header); + return strlen($header); + } + ); + curl_setopt( + $ch, + CURLOPT_WRITEFUNCTION, + function ($curl, $body) { + echo $body; + return strlen($body); + } + ); + header('Access-Control-Allow-Origin:*'); + return curl_exec($ch); + } + function proxy($url) + { + $headers=getallheaders(); + $ch = curl_init($url); + curl_setopt_array( + $ch, + [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 30, + ] + ); + $FWHDR = ['User-Agent']; + $outHeaders = array(); + foreach ($FWHDR as $k) { + if (isset($headers[$k])) { + array_push($outHeaders, "$k: $headers[$k]"); + } + } + curl_setopt($ch, CURLOPT_HTTPHEADER, $outHeaders); + $response = curl_exec_follow($ch); + curl_close($ch); + } + + if (isset($_REQUEST['api'])) { + $vars=fillUserAndRepo(); + proxy(replaceVars($api,$vars)); + exit(0); + } + if (isset($_REQUEST['dlName'])){ + $vars=fillUserAndRepo(); + $vars=addVars($vars,array('dlName','dlVersion')); + proxy(replaceVars($download,$vars)); + exit(0); + } + die("invalid request"); + ?> \ No newline at end of file diff --git a/webinstall/installUtil.js b/webinstall/installUtil.js new file mode 100644 index 0000000..87f91c3 --- /dev/null +++ b/webinstall/installUtil.js @@ -0,0 +1,338 @@ +import {ESPLoader,Transport} from "https://cdn.jsdelivr.net/npm/esptool-js@0.2.1/bundle.js"; +/** + * write all messages to the console + */ +class ConsoleOutputHandler{ + clean() { + } + writeLine(data) { + console.log("ESPInstaller:",data); + } + write(data) { + console.log(data); + } +} + +/** + * write messages to an instance of xterm + * to use this, include in your html + + + * and create a div element +
+ * provide the id of this div to the constructor + */ +class XtermOutputHandler { + constructor(termId) { + let termElement = document.getElementById(termId); + if (termElement) { + this.term = new Terminal({ cols: 120, rows: 40 , convertEol: true }); + this.term.open(termElement); + } + this.clean=this.clean.bind(this); + this.writeLine=this.writeLine.bind(this); + this.write=this.write.bind(this); + } + clean() { + if (!this.term) return; + this.term.clear(); + } + writeLine(data) { + if (!this.term) { + console.log("TERM:", data); + return; + }; + this.term.writeln(data); + } + write(data) { + if (!this.term) { + console.log("TERM:", data); + return; + }; + this.term.write(data) + } +}; +class ESPInstaller{ + constructor(outputHandler){ + this.espLoaderTerminal=outputHandler|| new ConsoleOutputHandler(); + this.transport=undefined; + this.esploader=undefined; + this.chipFamily=undefined; + this.base=import.meta.url.replace(/[^/]*$/,"install.php"); + this.consoleDevice=undefined; + this.consoleReader=undefined; + } + /** + * get an URL query parameter + * @param key + * @returns + */ + static getParam(key){ + let value=RegExp(""+key+"[^&]+").exec(window.location.search); + // Return the unescaped value minus everything starting from the equals sign or an empty string + return decodeURIComponent(!!value ? value.toString().replace(/^[^=]+./,"") : ""); + }; + /** + * add an HTML element + * @param {*} type + * @param {*} clazz + * @param {*} parent + * @param {*} text + * @returns + */ + static 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) parent.appendChild(el); + return el; + } + /** + * call a function for each matching element + * @param {*} selector + * @param {*} cb + */ + static forEachEl(selector,cb){ + let arr=document.querySelectorAll(selector); + for (let i=0;i resolve(reader.result)); + reader.readAsBinaryString(blob); + }); + this.espLoaderTerminal.writeLine(`successfully loaded ${data.length} bytes`); + return data; + } + /** + * handle the click of an install button + * @param {*} isFull + * @param {*} user + * @param {*} repo + * @param {*} version + * @param {*} address + * @param {*} assetName the name of the asset file. + * can be a function - will be called with the chip family + * and must return the asset file name + * @returns + */ + async installClicked(isFull, user, repo, version, address, assetName) { + try { + await this.connect(); + let assetFileName = assetName; + if (typeof (assetName) === 'function') { + assetFileName = assetName(this.getChipFamily()); + } + let imageData = await this.getReleaseAsset(user, repo, version, assetFileName); + if (!imageData || imageData.length == 0) { + throw new Error(`no image data fetched`); + } + let fileList = [ + { data: imageData, address: address } + ]; + let txt = isFull ? "baseImage (all data will be erased)" : "update"; + if (!confirm(`ready to install ${version}\n${txt}`)) { + this.espLoaderTerminal.writeLine("aborted by user..."); + await this.disconnect(); + return; + } + await this.writeFlash(fileList); + await this.disconnect(); + } catch (e) { + this.espLoaderTerminal.writeLine(`Error: ${e}`); + alert(`Error: ${e}`); + } + } + /** + * fetch the release info from the github API + * @param {*} user + * @param {*} repo + * @returns + */ + async getReleaseInfo(user,repo){ + let url=this.base+"?api=1&user="+encodeURIComponent(user)+"&repo="+encodeURIComponent(repo) + let resp=await fetch(url); + if (! resp.ok){ + throw new Error(`unable to query release info from ${url}: ${resp.status}`); + } + return await resp.json(); + } + /** + * get the release info in a parsed form + * @param {*} user + * @param {*} repo + * @returns an object: {version:nnn, assets:[name1,name2,...]} + */ + async getParsedReleaseInfo(user,repo){ + let raw=await this.getReleaseInfo(user,repo); + let rt={ + version:raw.name, + assets:[] + }; + if (! raw.assets) return rt; + raw.assets.forEach((asset)=>{ + rt.assets.push(asset.name); + }) + return rt; + } +}; +export {ConsoleOutputHandler, XtermOutputHandler}; +export default ESPInstaller; \ No newline at end of file