add web install tools

This commit is contained in:
andreas 2023-03-13 15:21:39 +01:00
parent 10b04509ba
commit 14a8b44221
5 changed files with 685 additions and 0 deletions

24
webinstall/install.css Normal file
View File

@ -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;
}

17
webinstall/install.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
<script type="module" src="install.js"></script>
<link rel="stylesheet" href="install.css"/>
</head>
<body>
</body>
<div class="heading"></div>
<div class="console"></div>
<div class="content"></div>
<div id="terminal"></div>
</html>

151
webinstall/install.js Normal file
View File

@ -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)};
}
})();

155
webinstall/install.php Normal file
View File

@ -0,0 +1,155 @@
<?php
$api="https://api.github.com/repos/#user#/#repo#/releases/latest";
$download="https://github.com/#user#/#repo#/releases/download/#dlVersion#/#dlName#";
$manifest="?dlName=#mName#&dlVersion=#mVersion#&user=#user#&repo=#repo#";
$allowed=array(
'user'=> 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");
?>

338
webinstall/installUtil.js Normal file
View File

@ -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
<script src="https://cdn.jsdelivr.net/npm/xterm@4.19.0/lib/xterm.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@4.19.0/css/xterm.css">
* and create a div element
<div id="terminal"/>
* 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<arr.length;i++){
cb(arr[i]);
}
}
static checkAvailable(){
if (! navigator.serial || ! navigator.serial.requestPort) return false;
return true;
}
/**
* execute a reset on the connected device
*/
async resetTransport() {
if (!this.transport) {
throw new Error("not connected");
}
this.espLoaderTerminal.writeLine("Resetting...");
await this.transport.device.setSignals({
dataTerminalReady: false,
requestToSend: true,
});
await this.transport.device.setSignals({
dataTerminalReady: false,
requestToSend: false,
});
};
async disconnect(){
if (this.consoleDevice){
try{
if (this.consoleReader){
await this.consoleReader.cancel();
this.consoleReader=undefined;
}
await this.consoleDevice.close();
}catch(e){
console.log(`error cancel serial read ${e}`);
}
this.consoleDevice=undefined;
}
if (this.transport){
try{
await this.transport.disconnect();
await this.transport.waitForUnlock(1500);
}catch (e){}
this.transport=undefined;
}
this.esploader=undefined;
}
async connect() {
this.espLoaderTerminal.clean();
await this.disconnect();
let device = await navigator.serial.requestPort({});
if (!device) {
return;
}
try {
this.transport = new Transport(device);
this.esploader = new ESPLoader(this.transport, 115200, this.espLoaderTerminal);
let foundChip = await this.esploader.main_fn();
if (!foundChip) {
throw new Error("unable to read chip id");
}
this.espLoaderTerminal.writeLine(`chip: ${foundChip}`);
await this.esploader.flash_id();
this.chipFamily = this.esploader.chip.CHIP_NAME;
this.espLoaderTerminal.writeLine(`chipFamily: ${this.chipFamily}`);
} catch (e) {
this.disconnect();
throw e;
}
}
async startConsole(baud) {
await this.disconnect();
try {
let device = await navigator.serial.requestPort({});
if (!device) {
return;
}
this.consoleDevice=device;
let br=baud || 115200;
await device.open({
baudRate: br
});
this.consoleReader=device.readable.getReader();
this.espLoaderTerminal.clean();
this.espLoaderTerminal.writeLine(`Console at ${br}:`);
while (this.consoleReader) {
let {value:val,done:done} = await this.consoleReader.read();
if (typeof val !== 'undefined') {
this.espLoaderTerminal.write(val);
}
if (done){
console.log("Console reader stopped");
break;
}
}
} catch (e) { this.espLoaderTerminal.writeLine(`Error: ${e}`) }
this.espLoaderTerminal.writeLine("Console reader stopped");
}
async stopConsole(){
await this.disconnect();
}
isConnected(){
return this.transport !== undefined;
}
checkConnected(){
if (! this.isConnected){
throw new Error("not connected");
}
}
getChipFamily(){
this.checkConnected();
return this.chipFamily;
}
/**
* flass the device
* @param {*} fileList : an array of entries {data:blob,address:number}
*/
async writeFlash(fileList){
this.checkConnected();
this.espLoaderTerminal.writeLine(`Flashing....`);
await this.esploader.write_flash(
fileList,
"keep",
"keep",
"keep",
false
)
await this.resetTransport();
this.espLoaderTerminal.writeLine(`Done.`);
}
/**
* fetch a release asset from github
* @param {*} user
* @param {*} repo
* @param {*} version
* @param {*} name
* @returns
*/
async getReleaseAsset(user,repo,version,name){
const url=this.base+"?dlName="+encodeURIComponent(name)+
"&dlVersion="+encodeURIComponent(version)+
"&user="+encodeURIComponent(user)+
"&repo="+encodeURIComponent(repo);
this.espLoaderTerminal.writeLine(`downloading image from ${url}`);
const resp=await fetch(url);
if (! resp.ok){
throw new Error(`unable to download image from ${url}: ${resp.status}`);
}
const reader=new FileReader();
const blob= await resp.blob();
let data=await new Promise((resolve)=>{
reader.addEventListener("load",() => 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;