From 5e59f765ffb7784ab78699ded16a826388f2e1d8 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 15:22:37 -0700 Subject: [PATCH 01/43] add web-based flash page for vamOS Static HTML page using qdl.js (WebUSB) to flash boot.img and system.img onto comma devices. Deployed via GitHub Pages from docs/. - File picker for boot/system images with nightly.link CI download links - Flashes boot to both A/B slots, system to slot A - Sets active slot and reboots - Linux qcserial unbind instructions - GitHub Pages deployment workflow Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/pages.yml | 38 ++ docs/flash/index.html | 773 ++++++++++++++++++++++++++++++++++++ 2 files changed, 811 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/flash/index.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..ba4d1b27 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,38 @@ +name: deploy pages + +on: + push: + branches: [master] + paths: + - 'docs/**' + - '.github/workflows/pages.yml' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: configure pages + uses: actions/configure-pages@v5 + + - name: upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs + + - name: deploy to github pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/flash/index.html b/docs/flash/index.html new file mode 100644 index 00000000..e5aea5b7 --- /dev/null +++ b/docs/flash/index.html @@ -0,0 +1,773 @@ + + + + + + vamOS Flash + + + + + +
+ +
+
+ + + +
+

vamOS Flash

+

Flash vamOS onto your comma device via WebUSB

+ + +
+ + +
+
+

Select images

+

Choose the boot and system images to flash

+ + + +
+ boot.img + No file selected + +
+ +
+ system.img + No file selected + +
+ +
+ +
+
+ + +
+
+

Connect your device

+

Put your device into EDL mode and connect it

+ +
+
    +
  1. + 1 + Unplug the device and wait for it to fully power off +
  2. +
  3. + 2 + Connect port 1 (USB-C closest to edge) to your computer +
  4. +
  5. + 3 + Connect port 2 to power (computer or power brick) +
  6. +
+
+ +

+ The device screen will remain blank. This is normal. +

+ + + + +

+ Select QUSB_BULK_CID from the browser dialog +

+
+ + +
+
+
+ +
+

Connecting...

+

Do not unplug your device

+
+
+
+
+
+
+ + + +
+ + +
+
+
+ +
+

Flash complete

+

Your device is rebooting into vamOS

+ +
+
+ +
+ vamOS +
+ + + + + + From a1b1d3df65cafcd74dffdf8e9e0f3632f1b8b919 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 15:28:59 -0700 Subject: [PATCH 02/43] Temporarily allow flash pushes to trigger pages builds --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ba4d1b27..5026000c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: deploy pages on: push: - branches: [master] + branches: [master,flash] paths: - 'docs/**' - '.github/workflows/pages.yml' From a200f06df304e2abe9244f1aea6f78b859545a99 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 15:35:05 -0700 Subject: [PATCH 03/43] fix: load qdl.js from GitHub via esm.sh @commaai/qdl is not published to npm, so esm.sh returned 404 and the entire module script failed to load. Use esm.sh's GitHub source mode instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/flash/index.html | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/docs/flash/index.html b/docs/flash/index.html index e5aea5b7..50e5ea03 100644 --- a/docs/flash/index.html +++ b/docs/flash/index.html @@ -519,18 +519,9 @@

Flash complete

vamOS - - - - diff --git a/docs/flash/qdl.js b/docs/flash/qdl.js deleted file mode 100644 index ffe4032d..00000000 --- a/docs/flash/qdl.js +++ /dev/null @@ -1,4 +0,0 @@ -var X2=Object.create;var{getPrototypeOf:Z2,defineProperty:v0,getOwnPropertyNames:z2}=Object;var Y2=Object.prototype.hasOwnProperty;function U2(F){return this[F]}var W2,H2,G2=(F,$,Q)=>{var J=F!=null&&typeof F==="object";if(J){var K=$?W2??=new WeakMap:H2??=new WeakMap,q=K.get(F);if(q)return q}Q=F!=null?X2(Z2(F)):{};let X=$||!F||!F.__esModule?v0(Q,"default",{value:F,enumerable:!0}):Q;for(let Y of z2(F))if(!Y2.call(X,Y))v0(X,Y,{get:U2.bind(F,Y),enumerable:!0});if(J)K.set(F,X);return X};var V2=(F,$)=>()=>($||F(($={exports:{}}).exports,$),$.exports);var o0=V2((A0)=>{/*! crc32.js (C) 2014-present SheetJS -- http://sheetjs.com */var n0;(function(F){if(typeof DO_NOT_EXPORT_CRC>"u")if(typeof A0==="object")F(A0);else if(typeof define==="function"&&define.amd)define(function(){var $={};return F($),$});else F(n0={});else F(n0={})})(function(F){F.version="1.2.2";function $(){var H=0,L=Array(256);for(var G=0;G!=256;++G)H=G,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,H=H&1?-306674912^H>>>1:H>>>1,L[G]=H;return typeof Int32Array<"u"?new Int32Array(L):L}var Q=$();function J(H){var L=0,G=0,w=0,B=typeof Int32Array<"u"?new Int32Array(4096):Array(4096);for(w=0;w!=256;++w)B[w]=H[w];for(w=0;w!=256;++w){G=H[w];for(L=256+w;L<4096;L+=256)G=B[L]=G>>>8^H[G&255]}var _=[];for(w=1;w!=16;++w)_[w-1]=typeof Int32Array<"u"?B.subarray(w*256,w*256+256):B.slice(w*256,w*256+256);return _}var K=J(Q),q=K[0],X=K[1],Y=K[2],Z=K[3],z=K[4],W=K[5],U=K[6],V=K[7],I=K[8],y=K[9],E=K[10],k=K[11],f=K[12],l=K[13],Q2=K[14];function J2(H,L){var G=L^-1;for(var w=0,B=H.length;w>>8^Q[(G^H.charCodeAt(w++))&255];return~G}function K2(H,L){var G=L^-1,w=H.length-15,B=0;for(;B>8&255]^f[H[B++]^G>>16&255]^k[H[B++]^G>>>24]^E[H[B++]]^y[H[B++]]^I[H[B++]]^V[H[B++]]^U[H[B++]]^W[H[B++]]^z[H[B++]]^Z[H[B++]]^Y[H[B++]]^X[H[B++]]^q[H[B++]]^Q[H[B++]];w+=15;while(B>>8^Q[(G^H[B++])&255];return~G}function q2(H,L){var G=L^-1;for(var w=0,B=H.length,_=0,G0=0;w>>8^Q[(G^_)&255];else if(_<2048)G=G>>>8^Q[(G^(192|_>>6&31))&255],G=G>>>8^Q[(G^(128|_&63))&255];else if(_>=55296&&_<57344)_=(_&1023)+64,G0=H.charCodeAt(w++)&1023,G=G>>>8^Q[(G^(240|_>>8&7))&255],G=G>>>8^Q[(G^(128|_>>2&63))&255],G=G>>>8^Q[(G^(128|G0>>6&15|(_&3)<<4))&255],G=G>>>8^Q[(G^(128|G0&63))&255];else G=G>>>8^Q[(G^(224|_>>12&15))&255],G=G>>>8^Q[(G^(128|_>>6&63))&255],G=G>>>8^Q[(G^(128|_&63))&255];return~G}F.table=Q,F.bstr=J2,F.buf=K2,F.str=q2})});function d(F,$=!0){let Q=F.length,J=new ArrayBuffer(Q*4),K=new DataView(J);for(let q=0;qK+q.byteLength,0),Q=new Uint8Array($),J=0;for(let K of F)Q.set(K,J),J+=K.byteLength;return Q}function m(F,$){return new TextDecoder().decode($).includes(F)}function N0(F,$){let Q=new TextDecoder().decode($);return F===Q}function c(F,$){return new Promise((Q,J)=>{let K=!1,q=setTimeout(()=>{K=!0,J(Error(`Timed out while trying to connect ${$}`))},$);F.then((X)=>{if(!K)Q(X)}).catch((X)=>{if(!K)J(X)}).finally(()=>{if(!K)clearTimeout(q)})})}var C={SILENT:0,ERROR:1,WARN:2,INFO:3,DEBUG:4};class h0{constructor(F,$=C.INFO){this.name=F,this.level=$,this.prefix=F?`[${F}]`:"",this.deviceState={lastMessage:"",lastLogLevel:C.INFO,count:0,timeout:null,debounceMs:100}}#F(F,$,Q){if(this.level<$)return;this.prefix?F(this.prefix,...Q):F(...Q)}debug(...F){this.#F(console.debug,C.DEBUG,F)}info(...F){this.#F(console.info,C.INFO,F)}warn(...F){this.#F(console.warn,C.WARN,F)}error(...F){this.#F(console.error,C.ERROR,F)}deviceMessage(F){if(this.levelthis.#Q(),J.debounceMs)}#$(F,$){if(this.level<$)return;($===C.ERROR?console.error:console.info)(`[Device] ${F}`)}#Q(){let F=this.deviceState;if(F.count<=1)return;this.#$(`Last message repeated ${F.count-1} times`,F.lastLogLevel),F.count=1}flushDeviceMessages(){let F=this.deviceState;if(F.timeout)clearTimeout(F.timeout),F.timeout=null;this.#Q(),F.lastMessage=""}}function M2(){if(typeof process>"u")return C.INFO;let F=process.env?.QDL_LOG_LEVEL;if(!F)return process.env?.CI?C.DEBUG:C.INFO;let $=Number.parseInt(F,10);if(!Number.isNaN($)&&$>=C.SILENT&&$<=C.DEBUG)return $;let Q={silent:C.SILENT,error:C.ERROR,warn:C.WARN,info:C.INFO,debug:C.DEBUG}[F.toLowerCase()];if(Q)return Q;return console.warn(`Unknown log level: '${Q}', using 'info' level`),C.INFO}var w2=M2();function h(F,$=w2){return new h0(F,$)}var B2=":A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\-.\\d\\u00B7\\u0300-\\u036F\\u203F-\\u2040",O2="[:A-Za-z_\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD]["+B2+"]*",I2=new RegExp("^"+O2+"$");function $0(F,$){let Q=[],J=$.exec(F);while(J){let K=[];K.startIndex=$.lastIndex-J[0].length;let q=J.length;for(let X=0;X"u")};function b0(F){return typeof F<"u"}var R2={allowBooleanAttributes:!1,unpairedTags:[]};function x0(F,$){$=Object.assign({},R2,$);let Q=[],J=!1,K=!1;if(F[0]==="\uFEFF")F=F.substr(1);for(let q=0;q"&&F[q]!==" "&&F[q]!=="\t"&&F[q]!==` -`&&F[q]!=="\r";q++)Z+=F[q];if(Z=Z.trim(),Z[Z.length-1]==="/")Z=Z.substring(0,Z.length-1),q--;if(!S2(Z)){let U;if(Z.trim().length===0)U="Invalid space after '<'.";else U="Tag '"+Z+"' is an invalid name.";return A("InvalidTag",U,j(F,q))}let z=A2(F,q);if(z===!1)return A("InvalidAttr","Attributes for '"+Z+"' have open quote.",j(F,q));let W=z.value;if(q=z.index,W[W.length-1]==="/"){let U=q-W.length;W=W.substring(0,W.length-1);let V=u0(W,$);if(V===!0)J=!0;else return A(V.err.code,V.err.msg,j(F,U+V.err.line))}else if(Y)if(!z.tagClosed)return A("InvalidTag","Closing tag '"+Z+"' doesn't have proper closing.",j(F,q));else if(W.trim().length>0)return A("InvalidTag","Closing tag '"+Z+"' can't have attributes or invalid starting.",j(F,X));else if(Q.length===0)return A("InvalidTag","Closing tag '"+Z+"' has not been opened.",j(F,X));else{let U=Q.pop();if(Z!==U.tagName){let V=j(F,U.tagStartPos);return A("InvalidTag","Expected closing tag '"+U.tagName+"' (opened in line "+V.line+", col "+V.col+") instead of closing tag '"+Z+"'.",j(F,X))}if(Q.length==0)K=!0}else{let U=u0(W,$);if(U!==!0)return A(U.err.code,U.err.msg,j(F,q-W.length+U.err.line));if(K===!0)return A("InvalidXml","Multiple possible root nodes found.",j(F,q));else if($.unpairedTags.indexOf(Z)!==-1);else Q.push({tagName:Z,tagStartPos:X});J=!0}for(q++;q0)return A("InvalidXml","Invalid '"+JSON.stringify(Q.map((q)=>q.tagName),null,4).replace(/\r?\n/g,"")+"' found.",{line:1,col:1});return!0}function f0(F){return F===" "||F==="\t"||F===` -`||F==="\r"}function c0(F,$){let Q=$;for(;$5&&J==="xml")return A("InvalidXml","XML declaration allowed only at the start of the document.",j(F,$));else if(F[$]=="?"&&F[$+1]==">"){$++;break}else continue}return $}function D0(F,$){if(F.length>$+5&&F[$+1]==="-"&&F[$+2]==="-"){for($+=3;$"){$+=2;break}}else if(F.length>$+8&&F[$+1]==="D"&&F[$+2]==="O"&&F[$+3]==="C"&&F[$+4]==="T"&&F[$+5]==="Y"&&F[$+6]==="P"&&F[$+7]==="E"){let Q=1;for($+=8;$"){if(Q--,Q===0)break}}else if(F.length>$+9&&F[$+1]==="["&&F[$+2]==="C"&&F[$+3]==="D"&&F[$+4]==="A"&&F[$+5]==="T"&&F[$+6]==="A"&&F[$+7]==="["){for($+=8;$"){$+=2;break}}return $}var C2='"',k2="'";function A2(F,$){let Q="",J="",K=!1;for(;$"){if(J===""){K=!0;break}}Q+=F[$]}if(J!=="")return!1;return{value:Q,index:$,tagClosed:K}}var _2=new RegExp(`(\\s*)([^\\s=]+)(\\s*=)?(\\s*(['"])(([\\s\\S])*?)\\5)?`,"g");function u0(F,$){let Q=$0(F,_2),J={};for(let K=0;K!1,commentPropName:!1,unpairedTags:[],processEntities:!0,htmlEntities:!1,ignoreDeclaration:!1,ignorePiTags:!1,transformTagName:!1,transformAttributeName:!1,updateTag:function(F,$,Q){return F},captureMetaData:!1,maxNestedTags:100,strictReservedNames:!0};function p0(F){if(typeof F==="boolean")return{enabled:F,maxEntitySize:1e4,maxExpansionDepth:10,maxTotalExpansions:1000,maxExpandedLength:1e5,maxEntityCount:100,allowedTags:null,tagFilter:null};if(typeof F==="object"&&F!==null)return{enabled:F.enabled!==!1,maxEntitySize:F.maxEntitySize??1e4,maxExpansionDepth:F.maxExpansionDepth??10,maxTotalExpansions:F.maxTotalExpansions??1000,maxExpandedLength:F.maxExpandedLength??1e5,maxEntityCount:F.maxEntityCount??100,allowedTags:F.allowedTags??null,tagFilter:F.tagFilter??null};return p0(!0)}var g0=function(F){let $=Object.assign({},P2,F);return $.processEntities=p0($.processEntities),$};var Q0;if(typeof Symbol!=="function")Q0="@@xmlMetadata";else Q0=Symbol("XML Node Metadata");class S{constructor(F){this.tagname=F,this.child=[],this[":@"]=Object.create(null)}add(F,$){if(F==="__proto__")F="#__proto__";this.child.push({[F]:$})}addChild(F,$){if(F.tagname==="__proto__")F.tagname="#__proto__";if(F[":@"]&&Object.keys(F[":@"]).length>0)this.child.push({[F.tagname]:F.child,[":@"]:F[":@"]});else this.child.push({[F.tagname]:F.child});if($!==void 0)this.child[this.child.length-1][Q0]={startIndex:$}}static getMetaDataSymbol(){return Q0}}class J0{constructor(F){this.suppressValidationErr=!F,this.options=F}readDocType(F,$){let Q=Object.create(null),J=0;if(F[$+3]==="O"&&F[$+4]==="C"&&F[$+5]==="T"&&F[$+6]==="Y"&&F[$+7]==="P"&&F[$+8]==="E"){$=$+9;let K=1,q=!1,X=!1,Y="";for(;$=this.options.maxEntityCount)throw Error(`Entity count (${J+1}) exceeds maximum allowed (${this.options.maxEntityCount})`);let W=Z.replace(/[.\-+*:]/g,"\\.");Q[Z]={regx:RegExp(`&${W};`,"g"),val:z},J++}}else if(q&&u(F,"!ELEMENT",$)){$+=8;let{index:Z}=this.readElementExp(F,$+1);$=Z}else if(q&&u(F,"!ATTLIST",$))$+=8;else if(q&&u(F,"!NOTATION",$)){$+=9;let{index:Z}=this.readNotationExp(F,$+1,this.suppressValidationErr);$=Z}else if(u(F,"!--",$))X=!0;else throw Error("Invalid DOCTYPE");K++,Y=""}else if(F[$]===">"){if(X){if(F[$-1]==="-"&&F[$-2]==="-")X=!1,K--}else K--;if(K===0)break}else if(F[$]==="[")q=!0;else Y+=F[$];if(K!==0)throw Error("Unclosed DOCTYPE")}else throw Error("Invalid Tag instead of DOCTYPE");return{entities:Q,i:$}}readEntityExp(F,$){$=T(F,$);let Q="";while($this.options.maxEntitySize)throw Error(`Entity "${Q}" size (${J.length}) exceeds maximum allowed size (${this.options.maxEntitySize})`);return $--,[Q,J,$]}readNotationExp(F,$){$=T(F,$);let Q="";while(${while($1||q.length===1&&!Y))return F;else{let Z=Number(Q),z=String(Z);if(Z===0)return Z;if(z.search(/[eE]/)!==-1)if($.eNotation)return Z;else return F;else if(Q.indexOf(".")!==-1)if(z==="0")return Z;else if(z===X)return Z;else if(z===`${K}${X}`)return Z;else return F;let W=q?X:Q;if(q)return W===z||K+W===z?Z:F;else return W===z||W===K+z?Z:F}}else return F}}var N2=/^([-+])?(0*)(\d*(\.\d*)?[eE][-\+]?\d+)$/;function h2(F,$,Q){if(!Q.eNotation)return F;let J=$.match(N2);if(J){let K=J[1]||"",q=J[3].indexOf("e")===-1?"E":"e",X=J[2],Y=K?F[X.length+1]===q:F[X.length]===q;if(X.length>1&&Y)return F;else if(X.length===1&&(J[3].startsWith(`.${q}`)||J[3][0]===q))return Number($);else if(Q.leadingZeros&&!Y)return $=(J[1]||"")+J[3],Number($);else return F}else return F}function b2(F){if(F&&F.indexOf(".")!==-1){if(F=F.replace(/0+$/,""),F===".")F="0";else if(F[0]===".")F="0"+F;else if(F[F.length-1]===".")F=F.substring(0,F.length-1);return F}return F}function f2(F,$){if(parseInt)return parseInt(F,$);else if(Number.parseInt)return Number.parseInt(F,$);else if(window&&window.parseInt)return window.parseInt(F,$);else throw Error("parseInt, Number.parseInt, window.parseInt are not supported")}function c2(F,$,Q){let J=$===1/0;switch(Q.infinity.toLowerCase()){case"null":return null;case"infinity":return $;case"string":return J?"Infinity":"-Infinity";case"original":default:return F}}function w0(F){if(typeof F==="function")return F;if(Array.isArray(F))return($)=>{for(let Q of F){if(typeof Q==="string"&&$===Q)return!0;if(Q instanceof RegExp&&Q.test($))return!0}};return()=>!1}class K0{constructor(F){if(this.options=F,this.currentNode=null,this.tagsNodeStack=[],this.docTypeEntities={},this.lastEntities={apos:{regex:/&(apos|#39|#x27);/g,val:"'"},gt:{regex:/&(gt|#62|#x3E);/g,val:">"},lt:{regex:/&(lt|#60|#x3C);/g,val:"<"},quot:{regex:/&(quot|#34|#x22);/g,val:'"'}},this.ampEntity={regex:/&(amp|#38|#x26);/g,val:"&"},this.htmlEntities={space:{regex:/&(nbsp|#160);/g,val:" "},cent:{regex:/&(cent|#162);/g,val:"¢"},pound:{regex:/&(pound|#163);/g,val:"£"},yen:{regex:/&(yen|#165);/g,val:"¥"},euro:{regex:/&(euro|#8364);/g,val:"€"},copyright:{regex:/&(copy|#169);/g,val:"©"},reg:{regex:/&(reg|#174);/g,val:"®"},inr:{regex:/&(inr|#8377);/g,val:"₹"},num_dec:{regex:/&#([0-9]{1,7});/g,val:($,Q)=>d0(Q,10,"&#")},num_hex:{regex:/&#x([0-9a-fA-F]{1,6});/g,val:($,Q)=>d0(Q,16,"&#x")}},this.addExternalEntities=D2,this.parseXml=d2,this.parseTextData=u2,this.resolveNameSpace=x2,this.buildAttributesMap=g2,this.isItStopNode=o2,this.replaceEntitiesValue=s2,this.readStopNodeData=r2,this.saveTextToParentTag=n2,this.addChild=m2,this.ignoreAttributesFn=w0(this.options.ignoreAttributes),this.entityExpansionCount=0,this.currentExpandedLength=0,this.options.stopNodes&&this.options.stopNodes.length>0){this.stopNodesExact=new Set,this.stopNodesWildcard=new Set;for(let $=0;$0){if(!X)F=this.replaceEntitiesValue(F,$,Q);let Y=this.options.tagValueProcessor($,F,Q,K,q);if(Y===null||Y===void 0)return F;else if(typeof Y!==typeof F||Y!==F)return Y;else if(this.options.trimValues)return O0(F,this.options.parseTagValue,this.options.numberParseOptions);else if(F.trim()===F)return O0(F,this.options.parseTagValue,this.options.numberParseOptions);else return F}}}function x2(F){if(this.options.removeNSPrefix){let $=F.split(":"),Q=F.charAt(0)==="/"?"/":"";if($[0]==="xmlns")return"";if($.length===2)F=Q+$[1]}return F}var p2=new RegExp(`([^\\s=]+)\\s*(=\\s*(['"])([\\s\\S]*?)\\3)?`,"gm");function g2(F,$,Q){if(this.options.ignoreAttributes!==!0&&typeof F==="string"){let J=$0(F,p2),K=J.length,q={};for(let X=0;X",X,"Closing Tag is not closed."),z=F.substring(X+2,Z).trim();if(this.options.removeNSPrefix){let V=z.indexOf(":");if(V!==-1)z=z.substr(V+1)}if(this.options.transformTagName)z=this.options.transformTagName(z);if(Q)J=this.saveTextToParentTag(J,Q,K);let W=K.substring(K.lastIndexOf(".")+1);if(z&&this.options.unpairedTags.indexOf(z)!==-1)throw Error(`Unpaired tag can not be used as closing tag: `);let U=0;if(W&&this.options.unpairedTags.indexOf(W)!==-1)U=K.lastIndexOf(".",K.lastIndexOf(".")-1),this.tagsNodeStack.pop();else U=K.lastIndexOf(".");K=K.substring(0,U),Q=this.tagsNodeStack.pop(),J="",X=Z}else if(F[X+1]==="?"){let Z=B0(F,X,!1,"?>");if(!Z)throw Error("Pi Tag is not closed.");if(J=this.saveTextToParentTag(J,Q,K),this.options.ignoreDeclaration&&Z.tagName==="?xml"||this.options.ignorePiTags);else{let z=new S(Z.tagName);if(z.add(this.options.textNodeName,""),Z.tagName!==Z.tagExp&&Z.attrExpPresent)z[":@"]=this.buildAttributesMap(Z.tagExp,K,Z.tagName);this.addChild(Q,z,K,X)}X=Z.closeIndex+1}else if(F.substr(X+1,3)==="!--"){let Z=x(F,"-->",X+4,"Comment is not closed.");if(this.options.commentPropName){let z=F.substring(X+4,Z-2);J=this.saveTextToParentTag(J,Q,K),Q.add(this.options.commentPropName,[{[this.options.textNodeName]:z}])}X=Z}else if(F.substr(X+1,2)==="!D"){let Z=q.readDocType(F,X);this.docTypeEntities=Z.entities,X=Z.i}else if(F.substr(X+1,2)==="!["){let Z=x(F,"]]>",X,"CDATA is not closed.")-2,z=F.substring(X+9,Z);J=this.saveTextToParentTag(J,Q,K);let W=this.parseTextData(z,Q.tagname,K,!0,!1,!0,!0);if(W==null)W="";if(this.options.cdataPropName)Q.add(this.options.cdataPropName,[{[this.options.textNodeName]:z}]);else Q.add(this.options.textNodeName,W);X=Z+2}else{let Z=B0(F,X,this.options.removeNSPrefix),z=Z.tagName,W=Z.rawTagName,U=Z.tagExp,V=Z.attrExpPresent,I=Z.closeIndex;if(this.options.transformTagName){let k=this.options.transformTagName(z);if(U===z)U=k;z=k}if(this.options.strictReservedNames&&(z===this.options.commentPropName||z===this.options.cdataPropName))throw Error(`Invalid tag name: ${z}`);if(Q&&J){if(Q.tagname!=="!xml")J=this.saveTextToParentTag(J,Q,K,!1)}let y=Q;if(y&&this.options.unpairedTags.indexOf(y.tagname)!==-1)Q=this.tagsNodeStack.pop(),K=K.substring(0,K.lastIndexOf("."));if(z!==$.tagname)K+=K?"."+z:z;let E=X;if(this.isItStopNode(this.stopNodesExact,this.stopNodesWildcard,K,z)){let k="";if(U.length>0&&U.lastIndexOf("/")===U.length-1){if(z[z.length-1]==="/")z=z.substr(0,z.length-1),K=K.substr(0,K.length-1),U=z;else U=U.substr(0,U.length-1);X=Z.closeIndex}else if(this.options.unpairedTags.indexOf(z)!==-1)X=Z.closeIndex;else{let l=this.readStopNodeData(F,W,I+1);if(!l)throw Error(`Unexpected end of ${W}`);X=l.i,k=l.tagContent}let f=new S(z);if(z!==U&&V)f[":@"]=this.buildAttributesMap(U,K,z);if(k)k=this.parseTextData(k,z,K,!0,V,!0,!0);K=K.substr(0,K.lastIndexOf(".")),f.add(this.options.textNodeName,k),this.addChild(Q,f,K,E)}else{if(U.length>0&&U.lastIndexOf("/")===U.length-1){if(z[z.length-1]==="/")z=z.substr(0,z.length-1),K=K.substr(0,K.length-1),U=z;else U=U.substr(0,U.length-1);if(this.options.transformTagName){let f=this.options.transformTagName(z);if(U===z)U=f;z=f}let k=new S(z);if(z!==U&&V)k[":@"]=this.buildAttributesMap(U,K,z);this.addChild(Q,k,K,E),K=K.substr(0,K.lastIndexOf("."))}else if(this.options.unpairedTags.indexOf(z)!==-1){let k=new S(z);if(z!==U&&V)k[":@"]=this.buildAttributesMap(U,K);this.addChild(Q,k,K,E),K=K.substr(0,K.lastIndexOf(".")),X=Z.closeIndex;continue}else{let k=new S(z);if(this.tagsNodeStack.length>this.options.maxNestedTags)throw Error("Maximum nested tags exceeded");if(this.tagsNodeStack.push(Q),z!==U&&V)k[":@"]=this.buildAttributesMap(U,K,z);this.addChild(Q,k,K,E),Q=k}J="",X=I}}else J+=F[X];return $.child};function m2(F,$,Q,J){if(!this.options.captureMetaData)J=void 0;let K=this.options.updateTag($.tagname,Q,$[":@"]);if(K===!1);else if(typeof K==="string")$.tagname=K,F.addChild($,J);else F.addChild($,J)}var s2=function(F,$,Q){if(F.indexOf("&")===-1)return F;let J=this.options.processEntities;if(!J.enabled)return F;if(J.allowedTags){if(!J.allowedTags.includes($))return F}if(J.tagFilter){if(!J.tagFilter($,Q))return F}for(let K in this.docTypeEntities){let q=this.docTypeEntities[K],X=F.match(q.regx);if(X){if(this.entityExpansionCount+=X.length,J.maxTotalExpansions&&this.entityExpansionCount>J.maxTotalExpansions)throw Error(`Entity expansion limit exceeded: ${this.entityExpansionCount} > ${J.maxTotalExpansions}`);let Y=F.length;if(F=F.replace(q.regx,q.val),J.maxExpandedLength){if(this.currentExpandedLength+=F.length-Y,this.currentExpandedLength>J.maxExpandedLength)throw Error(`Total expanded content size exceeded: ${this.currentExpandedLength} > ${J.maxExpandedLength}`)}}}if(F.indexOf("&")===-1)return F;for(let K in this.lastEntities){let q=this.lastEntities[K];F=F.replace(q.regex,q.val)}if(F.indexOf("&")===-1)return F;if(this.options.htmlEntities)for(let K in this.htmlEntities){let q=this.htmlEntities[K];F=F.replace(q.regex,q.val)}return F=F.replace(this.ampEntity.regex,this.ampEntity.val),F};function n2(F,$,Q,J){if(F){if(J===void 0)J=$.child.length===0;if(F=this.parseTextData(F,$.tagname,Q,!1,$[":@"]?Object.keys($[":@"]).length!==0:!1,J),F!==void 0&&F!=="")$.add(this.options.textNodeName,F);F=""}return F}function o2(F,$,Q,J){if($&&$.has(J))return!0;if(F&&F.has(Q))return!0;return!1}function l2(F,$,Q=">"){let J,K="";for(let q=$;q",Q,`${$} is not closed`);if(F.substring(Q+2,q).trim()===$){if(K--,K===0)return{tagContent:F.substring(J,Q),i:q}}Q=q}else if(F[Q+1]==="?")Q=x(F,"?>",Q+1,"StopNode is not closed.");else if(F.substr(Q+1,3)==="!--")Q=x(F,"-->",Q+3,"StopNode is not closed.");else if(F.substr(Q+1,2)==="![")Q=x(F,"]]>",Q,"StopNode is not closed.")-2;else{let q=B0(F,Q,">");if(q){if((q&&q.tagName)===$&&q.tagExp[q.tagExp.length-1]!=="/")K++;Q=q.closeIndex}}}function O0(F,$,Q){if($&&typeof F==="string"){let J=F.trim();if(J==="true")return!0;else if(J==="false")return!1;else return M0(F,Q)}else if(b0(F))return F;else return""}function d0(F,$,Q){let J=Number.parseInt(F,$);if(J>=0&&J<=1114111)return String.fromCodePoint(J);else return Q+F+";"}var I0=S.getMetaDataSymbol();function R0(F,$){return m0(F,$)}function m0(F,$,Q){let J,K={};for(let q=0;q0)K[$.textNodeName]=J}else if(J!==void 0)K[$.textNodeName]=J;return K}function i2(F){let $=Object.keys(F);for(let Q=0;Q<$.length;Q++){let J=$[Q];if(J!==":@")return J}}function e2(F,$,Q,J){if($){let K=Object.keys($),q=K.length;for(let X=0;X`${J}="${K}"`).join(" ");return`<${F}${Q?` ${Q}`:""} />`}class C0{decoder=new TextDecoder;parser=new e({attributeNamePrefix:"",htmlEntities:!0,ignoreAttributes:!1,processEntities:!0,trimValues:!1});*#F(F){for(let $ of this.decoder.decode(F).split("K.includes("Calling handler for configure")))throw Error("Failed to configure: handler not called");if(!J.find((K)=>K.includes("Storage type set to value UFS")))throw Error("Failed to configure: storage type not set");return this.luns=Array.from({length:this.cfg.maxlun},(K,q)=>q),!0}async cmdReadBuffer(F,$,Q){await this.cdc.write(new TextEncoder().encode(P("read",{SECTOR_SIZE_IN_BYTES:this.cfg.SECTOR_SIZE_IN_BYTES,num_partition_sectors:Q,physical_partition_number:F,start_sector:$})));let J=await this.waitForData(1),K=this.xml.getResponse(J);if(this.#F(this.xml.getLog(J)),K.value!=="ACK")throw Error("Failed to read buffer: negative response code");if(K.rawmode!=="true")throw Error("Failed to read buffer: wrong mode");let q;try{q=await c(this.cdc.read(this.cfg.SECTOR_SIZE_IN_BYTES*Q),2000)}catch{throw Error("Failed to read buffer: timed out")}if(J=await this.waitForData(),K=this.xml.getResponse(J),this.#F(this.xml.getLog(J)),K.value!=="ACK")throw b.error("Negative response code",K),Error("Failed to read buffer: negative response code");return q}async waitForData(F=3){let $=new Uint8Array,Q=0;while(!m("new Uint8Array);if(N0("",J)){if(Q+=1,Q>F)break;continue}Q=0,$=V0([$,J])}return $}async cmdProgram(F,$,Q,J=void 0){let K=Q.size,q=Math.ceil(K/this.cfg.SECTOR_SIZE_IN_BYTES);if(b.debug(`Starting program on LUN ${F} at ${$} - ${BigInt($)+BigInt(q-1)} (${q})`),!(await this.xmlSend(P("program",{SECTOR_SIZE_IN_BYTES:this.cfg.SECTOR_SIZE_IN_BYTES,num_partition_sectors:q,physical_partition_number:F,start_sector:$}))).resp)return b.error("Failed to program"),!1;let Y=0,Z=0,z=K;while(z>0){let V=Math.min(z,this.cfg.MaxPayloadSizeToTargetInBytes),I=new Uint8Array(await Q.slice(Z,Z+V).arrayBuffer());if(V%this.cfg.SECTOR_SIZE_IN_BYTES!==0){let y=(Math.floor(V/this.cfg.SECTOR_SIZE_IN_BYTES)+1)*this.cfg.SECTOR_SIZE_IN_BYTES,E=new Uint8Array(y-V).fill(0);I=V0([I,E])}if(await this.cdc.write(I),await this.cdc.write(new Uint8Array(0)),Z+=V,z-=V,Y%10===0)J?.(Z);Y+=1}J?.(K);let W=await this.waitForData(),U=this.xml.getResponse(W);if(this.#F(this.xml.getLog(W)),!("value"in U))return b.error("Failed to program: no return value"),!1;if(U.value!=="ACK")return b.error("Failed to program: negative response"),!1;return!0}async cmdErase(F,$,Q){let J={SECTOR_SIZE_IN_BYTES:this.cfg.SECTOR_SIZE_IN_BYTES,num_partition_sectors:Q,physical_partition_number:F,start_sector:$};if(this.cfg.FastErase){let Y=await this.xmlSend(P("erase",J)),Z=this.xml.getResponse(Y.data);if(!("value"in Z))throw"Failed to erase: no return value";if(Z.value!=="ACK")throw"Failed to erase: NAK";return!0}let K=await this.xmlSend(P("program",J)),q=this.cfg.SECTOR_SIZE_IN_BYTES*Q,X=new Uint8Array(this.cfg.MaxPayloadSizeToTargetInBytes).fill(0);if(K.resp){while(q>0){let z=Math.min(q,this.cfg.MaxPayloadSizeToTargetInBytes);await this.cdc.write(X.slice(0,z)),q-=z,await this.cdc.write(new Uint8Array(0))}let Y=await this.waitForData(),Z=this.xml.getResponse(Y);if(this.#F(this.xml.getLog(Y)),"value"in Z){if(Z.value!=="ACK")throw"Failed to erase: NAK"}else throw"Failed to erase no return value"}return!0}async cmdSetBootLunId(F){if((await this.xmlSend(P("setbootablestoragedrive",{value:F}))).resp)return b.info(`Successfully set bootID to lun ${F}`),!0;throw`Failed to set boot lun ${F}`}async cmdReset(){if((await this.xmlSend(P("power",{value:"reset"}))).resp){b.info("Reset succeeded");try{let $=await this.waitForData();this.#F(this.xml.getLog($))}catch{}return!0}throw"Reset failed"}async cmdGetStorageInfo(){let F=await this.xmlSend(P("getstorageinfo",{physical_partition_number:0}));if(!F.resp||!F.log)throw Error("Failed to get storage info",{cause:F.error});return F.log}async cmdDeviceType(){let F=await this.xmlSend(P("devicetype"));try{return this.xml.getResponse(F.data).DeviceType}catch($){throw Error("Failed to get device type",{cause:$})}}async cmdFixGpt(F,$){if(!(await this.xmlSend(P("fixgpt",{lun:F,grow_last_partition:$}))).resp)throw"Firehose - Failed to fix gpt"}flushDeviceMessages(){b.flushDeviceMessages()}}var t=G2(o0(),1);function v(F,$,Q={}){let J=Q.littleEndian??!1,K=0,q={};for(let[Y,Z]of Object.entries($))q[Y]=K,K+=Z.size;let X={name:F,size:K,fields:$,from(Y,Z=0){let z=Y instanceof ArrayBuffer?new DataView(Y):new DataView(Y.buffer,Y.byteOffset,Y.byteLength),W={};for(let[U,V]of Object.entries($)){let I=q[U];W[U]=V.parse(z,Z+I,J)}return Object.defineProperties(W,{$struct:{value:X,enumerable:!1,writable:!1},$toBuffer:{value:function(){return X.to(this)},enumerable:!1,writable:!1},$clone:{value:function(){let U={...this};for(let[V,I]of Object.entries(this))if(I instanceof Uint8Array)U[V]=new Uint8Array(I);return Object.defineProperties(U,{$struct:{value:this.$struct,enumerable:!1,writable:!1},$toBuffer:{value:this.$toBuffer,enumerable:!1,writable:!1},$clone:{value:this.$clone,enumerable:!1,writable:!1}}),U},enumerable:!1,writable:!1}}),W},to(Y){let Z=new ArrayBuffer(K),z=new DataView(Z);for(let[W,U]of Object.entries($)){let V=q[W],I=Y[W];U.write(z,V,I,J)}return Z}};return X}function q0(F){return{size:F,parse:($,Q)=>{let J=new Uint8Array(F);for(let K=0;K{for(let K=0;KF.getUint32($,Q),write:(F,$,Q,J)=>F.setUint32($,Q,J)}}function N(){return{size:8,parse:(F,$,Q)=>F.getBigUint64($,Q),write:(F,$,Q,J)=>F.setBigUint64($,Q,J)}}function X0(){return{size:4,parse:(F,$,Q)=>F.getInt32($,Q),write:(F,$,Q,J)=>F.setInt32($,Q,J)}}function Z0(F){return{size:F,parse:($,Q)=>{return new TextDecoder().decode(new Uint8Array($.buffer,Q,F))},write:($,Q,J)=>{let K=new TextEncoder().encode(J),q=Math.min(K.length,F);for(let X=0;Xn(16,(F,$,Q)=>{let J=F.getUint32($,Q),K=F.getUint16($+4,Q),q=F.getUint16($+6,Q),X=F.getUint8($+8),Y=F.getUint8($+9),Z=Array.from({length:6},(z,W)=>F.getUint8($+10+W).toString(16).padStart(2,"0")).join("");return[J.toString(16).padStart(8,"0"),K.toString(16).padStart(4,"0"),q.toString(16).padStart(4,"0"),X.toString(16).padStart(2,"0")+Y.toString(16).padStart(2,"0"),Z].join("-")},(F,$,Q,J)=>{let K=Q.split("-");if(K.length!==5)throw Error("Invalid GUID format");let q=Number.parseInt(K[0],16),X=Number.parseInt(K[1],16),Y=Number.parseInt(K[2],16),Z=Number.parseInt(K[3],16),z=Z>>8&255,W=Z&255;F.setUint32($,q,J),F.setUint16($+4,X,J),F.setUint16($+6,Y,J),F.setUint8($+8,z),F.setUint8($+9,W);let U=K[4];for(let V=0;V<6;V++)F.setUint8($+10+V,Number.parseInt(U.substring(V*2,V*2+2),16))}),l0=(F)=>n(F*2,($,Q,J)=>{let K=[];for(let q=0;q{let q=Math.min(J.length,F-1);for(let X=0;Xthis.sectorSize)return D.error(`Invalid header size: ${this.#F.headerSize}`),null;if(this.#F.currentLba!==$)D.warn(`currentLba (${this.#F.currentLba}) does not match actual value (${$})`);let Q=this.#F.headerCrc32;this.#F.headerCrc32=0;let J=t.buf(new Uint8Array(this.#F.$toBuffer()));this.#F.headerCrc32=Q;let K=this.#F.headerCrc32!==J;if(K)D.warn(`Header CRC32 mismatch: expected ${this.#F.headerCrc32}, actual ${J}`);return{headerCrc32:this.#F.headerCrc32,mismatchCrc32:K}}parsePartEntries(F){let $=this.#F.partEntrySize;for(let K=0;KQ.$clone()),$}buildPartEntries(){let F=new Uint8Array(this.numPartEntries*this.partEntrySize);for(let $=0;$F.type!==z0).map((F)=>({type:F.type,uuid:F.unique,start:F.startingLba,end:F.endingLba,sectors:F.endingLba-F.startingLba+1n,attributes:`0x${F.attributes.toString(16).padStart(16,"0")}`,name:F.name}))}locatePartition(F){return this.getPartitions().find(($)=>$.name===F)}getPartitionsInfo(){let F=new Set,$=new Set;for(let Q of this.#$){if(Q.type===z0)continue;let{name:J}=Q;if(J.endsWith("_a"))$.add("a");if(J.endsWith("_b"))$.add("b");F.add(J)}return{partitions:F,slots:$}}getActiveSlot(){let F=null,$=-1;for(let Q of this.#$){if(Q.type===z0)continue;if(!Q.name.startsWith("boot_"))continue;let J=Q.name.slice(-2);if(J!=="_a"&&J!=="_b")continue;let K=X1(Q.attributes);if(K.active&&K.priority>$)$=K.priority,F=J==="_a"?"a":"b"}return F}setActiveSlot(F){if(F!=="a"&&F!=="b")throw Error("Invalid slot");for(let $ of this.#$){if($.type===z0)continue;let Q=$.name.slice(-2);if(Q!=="_a"&&Q!=="_b")continue;let J=Q===`_${F}`;if($.name.startsWith("boot"))if(J)$.attributes=$.attributes&~(j0|o|i0|e0|a0)|r0<>Y0),active:(F&o)!==0n,triesRemaining:Number((F&i0)>>L0),successful:(F&e0)!==0n,unbootable:(F&a0)!==0n}}var R={SAHARA_HELLO_REQ:1,SAHARA_HELLO_RSP:2,SAHARA_READ_DATA:3,SAHARA_END_TRANSFER:4,SAHARA_DONE_REQ:5,SAHARA_DONE_RSP:6,SAHARA_RESET_RSP:8,SAHARA_CMD_READY:11,SAHARA_SWITCH_MODE:12,SAHARA_EXECUTE_REQ:13,SAHARA_EXECUTE_RSP:14,SAHARA_EXECUTE_DATA:15,SAHARA_64BIT_MEMORY_READ_DATA:18},t0={SAHARA_EXEC_CMD_SERIAL_NUM_READ:1},U0={SAHARA_MODE_IMAGE_TX_PENDING:0,SAHARA_MODE_COMMAND:3},W0={SAHARA_STATUS_SUCCESS:0,SAHARA_NAK_INVALID_CMD:1};var T0=()=>n(8,(F,$,Q)=>{return Number(F.getBigUint64($,Q))},(F,$,Q,J)=>{F.setBigUint64($,Q,J)}),p={pkt_cmd_hdr:v("pkt_cmd_hdr",{cmd:M(),len:M()},{littleEndian:!0}),pkt_hello_req:v("pkt_hello_req",{cmd:M(),len:M(),version:M(),version_supported:M(),cmd_packet_length:M(),mode:M(),reserved1:M(),reserved2:M(),reserved3:M(),reserved4:M(),reserved5:M(),reserved6:M()},{littleEndian:!0}),pkt_image_end:v("pkt_image_end",{cmd:M(),len:M(),image_id:M(),image_tx_status:M()},{littleEndian:!0}),pkt_done:v("pkt_done",{cmd:M(),len:M(),image_tx_status:M()},{littleEndian:!0}),pkt_read_data_64:v("pkt_read_data_64",{cmd:M(),len:M(),image_id:T0(),data_offset:T0(),data_len:T0()},{littleEndian:!0}),pkt_execute_rsp_cmd:v("pkt_execute_rsp_cmd",{cmd:M(),len:M(),client_cmd:M(),data_len:M()},{littleEndian:!0})},g=h("sahara");class S0{constructor(F,$){this.cdc=F,this.programmer=$,this.id=null,this.serial="",this.mode=""}async connect(){let F=this.cdc.read(48),$=await c(F,500).catch(()=>new Uint8Array);if($.length>1){if($[0]===1){let Q=p.pkt_cmd_hdr.from($);if(Q.cmd===R.SAHARA_HELLO_REQ)return"sahara";if(Q.cmd===R.SAHARA_END_TRANSFER)return"sahara";throw"Sahara - Connect failed: unknown command"}if(m("new Uint8Array)}catch{$=new Uint8Array}if(m("=0){let Q=await this.getResponse();if(!Q||!("cmd"in Q))throw"Sahara - Timeout while uploading loader. Wrong loader?";let{cmd:J,data:K}=Q;if(J===R.SAHARA_64BIT_MEMORY_READ_DATA){let{image_id:q,data_offset:X,data_len:Y}=K;if(this.id=q,this.id<12)throw"Sahara - Unknown sahara id";if(this.mode!=="firehose")g.debug("Firehose mode detected, uploading..."),this.mode="firehose";let Z;if(X+Y>this.programmer.byteLength){if(Z=new Uint8Array(Y),X=this.blob.size)throw"Sparse - Chunk header out of bounds";let Q=await this.blob.slice(F,F+H0).arrayBuffer(),J=new DataView(Q),K=J.getUint32(8,!0);if(F+K>this.blob.size)throw"Sparse - Chunk data out of bounds";yield{type:J.getUint16(0,!0),blocks:J.getUint32(4,!0),data:this.blob.slice(F+H0,F+K)},F+=K}if(F!==this.blob.size)E0.warn("Sparse - Backing data larger expected")}async*read(){let F=0;for await(let{type:$,blocks:Q,data:J}of this.chunks()){let K=Q*this.header.blockSize;if($===P0.Raw)yield[F,J,K],F+=K;else if($===P0.Fill){let q=new Uint8Array(await J.arrayBuffer());if(q.some((X)=>X!==0)){let X=new Uint8Array(K);for(let Y=0;YNumber(U.start-V.start));let Z=[];if(Y.length>0){let U={...Y[0]};for(let V=1;VI.end?U.end:I.end,U.name+=`,${I.name}`;else Z.push(U),U={...I}}Z.push(U)}for(let U of Z)O.debug(`Preserving ${U.name} (${U.start}-${U.end})`);let z=[],W=-1n;for(let U of Z){if(U.start>W+1n)z.push({start:W+1n,end:U.start-1n});W=U.end}if(WX.sectors)return O.error("Image too large for partition",{imgSectors:z,partitionSectors:X.sectors}),!1;return await this.firehose.cmdProgram(q,X.start,$,Q)}if(J){if(O.debug(`Erasing ${F}...`),!await this.firehose.cmdErase(q,X.start,X.sectors))return O.error("Failed to erase partition before sparse flashing"),!1}O.debug(`Writing chunks to ${F}...`);for await(let[z,W]of Z.read()){if(!W)continue;if(z%Y.sectorSize!==0)throw"qdl - Offset not aligned to sector size";let U=X.start+BigInt(z/Y.sectorSize),V=(I)=>Q?.(z+I);if(!await this.firehose.cmdProgram(q,U,W,V))return O.debug("Failed to program chunk"),!1}return!0}async erase(F){let[$,Q,J]=await this.detectPartition(F);if(!$)throw Error(`Partition ${F} not found`);return O.info(`Erasing ${F}...`),await this.firehose.cmdErase(Q,J.start,J.sectors),O.debug(`Erased ${F} ${J.start}-${J.end} (${J.sectors} sectors)`),!0}async getDevicePartitionsInfo(){let F=new Set,$=new Set;for(let Q of this.firehose.luns){let J=(await this.getGpt(Q)).getPartitionsInfo();F=F.union(J.partitions),$=$.union(J.slots)}return[$.size,Array.from(F)]}async getActiveSlot(){for(let F of this.firehose.luns){let $=(await this.getGpt(F)).getActiveSlot();if($)return $}throw"Can't detect slot A or B"}async getStorageInfo(){let F=(await this.firehose.cmdGetStorageInfo()).find(($)=>$.includes("storage_info"));if(!F)throw Error("Storage info JSON not returned - not implemented?");try{return JSON.parse(F.substring(6))?.storage_info}catch($){throw Error("Failed to parse storage info JSON",{cause:$})}}async getDeviceType(){let F=await this.firehose.cmdDeviceType();if(!F)throw Error("Device type not returned - not implemented?");return F}async setActiveSlot(F){if(F!=="a"&&F!=="b")throw Error("Invalid slot");for(let Q of this.firehose.luns){let J=await this.getGpt(Q,1n);J.setActiveSlot(F);let K=J.buildPartEntries();await this.firehose.cmdProgram(Q,J.partEntriesStartLba,new Blob([K]));let q=J.buildHeader(K);await this.firehose.cmdProgram(Q,J.currentLba,new Blob([q]));let X=await this.getGpt(Q,J.alternateLba);X.setActiveSlot(F);let Y=X.buildPartEntries();await this.firehose.cmdProgram(Q,X.partEntriesStartLba,new Blob([Y]));let Z=X.buildHeader(Y);await this.firehose.cmdProgram(Q,X.currentLba,new Blob([Z]))}let $=F==="a"?1:2;return await this.firehose.cmdSetBootLunId($),O.info(`Successfully set slot ${F} active`),!0}async reset(){return await this.firehose.cmdReset(),!0}}export{U1 as qdlDevice}; diff --git a/docs/flash/usblib.js b/docs/flash/usblib.js deleted file mode 100644 index a60a85ac..00000000 --- a/docs/flash/usblib.js +++ /dev/null @@ -1 +0,0 @@ -var V=Object.create;var{getPrototypeOf:Z,defineProperty:N,getOwnPropertyNames:q}=Object;var z=Object.prototype.hasOwnProperty;function A(x){return this[x]}var G,H,W=(x,_,C)=>{var R=x!=null&&typeof x==="object";if(R){var D=_?G??=new WeakMap:H??=new WeakMap,F=D.get(x);if(F)return F}C=x!=null?V(Z(x)):{};let E=_||!x||!x.__esModule?N(C,"default",{value:x,enumerable:!0}):C;for(let L of q(x))if(!z.call(E,L))N(E,L,{get:A.bind(x,L),enumerable:!0});if(R)D.set(x,E);return E};var X=(x,_)=>()=>(_||x((_={exports:{}}).exports,_),_.exports);var B=[1478,14337],K=36872,Q=255,S=65536;function $(x,_=!0){let C=x.length,R=new ArrayBuffer(C*4),D=new DataView(R);for(let F=0;FD+F.byteLength,0),C=new Uint8Array(_),R=0;for(let D of x)C.set(D,R),R+=D.byteLength;return C}function P(x,_){return new TextDecoder().decode(_).includes(x)}function I(x,_){let C=new TextDecoder().decode(_);return x===C}function T(x,_){return new Promise((C,R)=>{let D=!1,F=setTimeout(()=>{D=!0,R(Error(`Timed out while trying to connect ${_}`))},_);x.then((E)=>{if(!D)C(E)}).catch((E)=>{if(!D)R(E)}).finally(()=>{if(!D)clearTimeout(F)})})}class M{constructor(){this.device=null,this.epIn=null,this.epOut=null,this.maxSize=512}get connected(){return this.device?.opened&&this.device.configurations[0].interfaces[0].claimed}#x(x){let _=x.configurations[0].interfaces[0].alternates[0];if(_.endpoints.length!==2)throw"USB - Attempted to connect to null device";let C=null,R=null;for(let D of _.endpoints){if(D.type!=="bulk")throw"USB - Interface endpoint is not bulk";if(D.direction==="in"){if(C)throw"USB - Interface has multiple IN endpoints";C=D}else if(D.direction==="out"){if(R)throw"USB - Interface has multiple OUT endpoints";R=D}}this.epIn=C,this.epOut=R,this.maxSize=this.epIn.packetSize}async#_(x){this.device=x,this.#x(x);try{await x.open(),await x.selectConfiguration(1),await x.claimInterface(0)}catch(_){try{await x.reset(),await x.forget(),await x.close()}catch{}throw Error("Error while connecting to device",{cause:_})}}async connect(){if(!("usb"in navigator))throw"USB - WebUSB not supported";let x=B.map((C)=>({vendorId:C,productId:K,classCode:Q})),_=await navigator.usb.requestDevice({filters:x});await this.#_(_)}async read(x=0){if(!this.device||!this.epIn)throw"USB - Not connected";if(x){let C=[],R=0;do{let D=await this.read();if(D.byteLength)C.push(D),R+=D.byteLength}while(R ({ + path, + kind, + "size (KiB)": (size / 1024).toFixed(1), +})); +console.table(outputs); diff --git a/tools/flash/package.json b/tools/flash/package.json new file mode 100644 index 00000000..94c33795 --- /dev/null +++ b/tools/flash/package.json @@ -0,0 +1,18 @@ +{ + "name": "vamos-flash", + "type": "module", + "scripts": { + "dev": "bun run serve.ts", + "build": "bun run build.ts" + }, + "dependencies": { + "@commaai/qdl": "github:commaai/qdl.js" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + }, + "trustedDependencies": [ + "@commaai/qdl" + ] +} diff --git a/tools/flash/serve.ts b/tools/flash/serve.ts new file mode 100644 index 00000000..76cd0e4b --- /dev/null +++ b/tools/flash/serve.ts @@ -0,0 +1,12 @@ +import index from "./src/index.html"; + +const server = Bun.serve({ + static: { + "/": index, + }, + development: true, + fetch() { + return new Response("404!"); + }, +}); +console.info(`Running on http://${server.hostname}:${server.port}`); diff --git a/tools/flash/src/app.ts b/tools/flash/src/app.ts new file mode 100644 index 00000000..d873f7ee --- /dev/null +++ b/tools/flash/src/app.ts @@ -0,0 +1,306 @@ +import { qdlDevice } from "@commaai/qdl"; +import { usbClass } from "@commaai/qdl/usblib"; + +const PROGRAMMER_URL = "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin"; + +// -- State -- +let programmer: ArrayBuffer | null = null; +let bootFile: File | null = null; +let systemFile: File | null = null; + +// -- Helpers -- +function $(id: string) { return document.getElementById(id)!; } + +function formatSize(bytes: number): string { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; +} + +function showStep(id: string) { + document.querySelectorAll(".step").forEach(s => s.classList.remove("active")); + $(id).classList.add("active"); +} + +const stepLabels = ["Images", "Connect", "Flash", "Done"]; + +function updateStepper(current: number) { + for (const id of ["stepper-images", "stepper-connect", "stepper-flash", "stepper-done"]) { + const el = document.getElementById(id); + if (!el) continue; + el.innerHTML = ""; + const stepper = document.createElement("div"); + stepper.className = "stepper"; + stepLabels.forEach((_, i) => { + if (i > 0) { + const line = document.createElement("div"); + line.className = "stepper-line" + (i <= current ? " done" : ""); + stepper.appendChild(line); + } + const dot = document.createElement("div"); + dot.className = "stepper-dot" + (i === current ? " active" : i < current ? " done" : ""); + dot.textContent = i < current ? "\u2713" : String(i + 1); + stepper.appendChild(dot); + }); + el.appendChild(stepper); + } +} + +// -- Render steps -- +function renderLanding() { + $("step-landing").innerHTML = ` +
+ + + +
+

~*~ vamOS Flash ~*~

+

flash vamOS onto ur comma device via WebUSB !!

+ + + `; + $("btn-start").onclick = () => { + if (!programmer) { alert("Programmer binary is still loading. Please wait."); return; } + showStep("step-images"); + updateStepper(0); + }; +} + +function renderImages() { + $("step-images").innerHTML = ` +
+

Pick ur images!

+

grab the boot and system images to flash

+ +
+ boot.img + No file selected + +
+
+ system.img + No file selected + +
+
+ +
+ `; + + function onFileSelect(type: "boot" | "system", input: HTMLInputElement) { + const file = input.files?.[0]; + if (!file) return; + if (type === "boot") bootFile = file; else systemFile = file; + const nameEl = $(`${type}-filename`); + nameEl.textContent = `${file.name} (${formatSize(file.size)})`; + nameEl.classList.add("set"); + $(`file-${type}`).classList.add("ready"); + if (bootFile && systemFile) ($("btn-images-next") as HTMLButtonElement).disabled = false; + } + + $("input-boot").onchange = function() { onFileSelect("boot", this as HTMLInputElement); }; + $("input-system").onchange = function() { onFileSelect("system", this as HTMLInputElement); }; + $("btn-images-next").onclick = () => { showStep("step-connect"); updateStepper(1); }; +} + +function renderConnect() { + const isLinux = navigator.platform.includes("Linux"); + $("step-connect").innerHTML = ` +
+

Connect ur device!

+

put your device into EDL mode and plug it in

+
+
    +
  1. 1Unplug the device and wait for it to fully power off
  2. +
  3. 2Connect port 1 (USB-C closest to edge) to your computer
  4. +
  5. 3Connect port 2 to power (computer or power brick)
  6. +
+
+

The device screen will remain blank. This is normal.

+ ${isLinux ? ` +

Linux: unbind qcserial first

+
for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done
+ ` : ""} + +

+ Select QUSB_BULK_CID from the browser dialog +

+ `; + + if (isLinux) { + $("btn-copy").onclick = () => { + const cmd = 'for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done'; + navigator.clipboard.writeText(cmd); + $("btn-copy").textContent = "Copied!"; + setTimeout(() => { $("btn-copy").textContent = "Copy"; }, 2000); + }; + } + + $("btn-connect").onclick = () => startFlashing(); +} + +function renderFlash() { + $("step-flash").innerHTML = ` +
+
+ +
+

Connecting...

+

Do not unplug your device

+
+
+
+
+ + + + `; + $("btn-retry").onclick = () => location.reload(); +} + +function renderDone() { + $("step-done").innerHTML = ` +
+
+ +
+

We did it!

+

your device is rebooting into vamOS!!

+ + `; + $("btn-again").onclick = () => location.reload(); +} + +// -- Flash logic -- +async function startFlashing() { + showStep("step-flash"); + updateStepper(2); + renderFlash(); + + function setProgress(pct: number) { + $("progress-fill").style.width = Math.min(pct, 100) + "%"; + $("progress-text").textContent = Math.min(pct, 100).toFixed(0) + "%"; + } + + function setStatus(title: string, message?: string) { + $("flash-title").textContent = title; + $("flash-status").textContent = message ?? ""; + } + + function setError(message: string) { + $("flash-icon").className = "flash-icon icon-red"; + $("flash-title").textContent = "Error"; + $("flash-status").textContent = ""; + $("flash-error").style.display = "block"; + $("flash-error").textContent = message; + $("btn-retry").style.display = "inline-block"; + } + + try { + setStatus("Waiting for device...", "Select your device in the browser dialog"); + const usb = new usbClass(); + const qdl = new qdlDevice(programmer!); + + try { + await qdl.connect(usb); + } catch (err: any) { + if (err.name === "NotFoundError") { + showStep("step-connect"); + updateStepper(1); + return; + } + throw err; + } + + console.info("[vamOS Flash] Connected"); + setStatus("Connected", "Reading device info..."); + + try { + const storageInfo = await qdl.getStorageInfo(); + const serial = Number(storageInfo.serial_num).toString(16).padStart(8, "0"); + $("flash-serial").textContent = "Device serial: " + serial; + $("flash-serial").style.display = "block"; + } catch (err) { + console.warn("[vamOS Flash] Could not read storage info:", err); + } + + const bootSize = bootFile!.size; + const systemSize = systemFile!.size; + + // Flash boot_a (10%) + setStatus("Flashing boot_a...", "Do not unplug your device"); + setProgress(0); + await qdl.flashBlob("boot_a", bootFile!, (written: number) => { + setProgress((written / bootSize) * 10); + }); + console.info("[vamOS Flash] boot_a done"); + + // Flash boot_b (10%) + setStatus("Flashing boot_b...", "Do not unplug your device"); + await qdl.flashBlob("boot_b", bootFile!, (written: number) => { + setProgress(10 + (written / bootSize) * 10); + }); + console.info("[vamOS Flash] boot_b done"); + + // Flash system_a (75%) + setStatus("Flashing system_a...", "This may take several minutes. Do not unplug your device."); + await qdl.flashBlob("system_a", systemFile!, (written: number) => { + setProgress(20 + (written / systemSize) * 75); + }, false); + console.info("[vamOS Flash] system_a done"); + + // Finalize + setStatus("Finalizing...", "Setting active slot"); + setProgress(95); + await qdl.setActiveSlot("a"); + + setStatus("Rebooting...", ""); + setProgress(100); + await qdl.reset(); + + showStep("step-done"); + updateStepper(3); + renderDone(); + + } catch (err: any) { + console.error("[vamOS Flash] Flash failed:", err); + setError(err.message || "An unknown error occurred. Try a different cable, USB port, or computer."); + } +} + +// -- Init -- +async function init() { + renderLanding(); + renderImages(); + renderConnect(); + + if (typeof navigator.usb === "undefined") { + $("no-webusb").style.display = "block"; + $("btn-start").style.display = "none"; + return; + } + + try { + const res = await fetch(PROGRAMMER_URL); + if (!res.ok) throw new Error(`Failed to fetch programmer: ${res.status}`); + programmer = await res.arrayBuffer(); + console.info("[vamOS Flash] Programmer loaded:", programmer.byteLength, "bytes"); + } catch (err) { + console.error("[vamOS Flash] Failed to load programmer:", err); + } +} + +window.addEventListener("beforeunload", (e) => { + if ($("step-flash").classList.contains("active") && $("btn-retry").style.display !== "inline-block") { + e.preventDefault(); + return (e.returnValue = "Flash in progress. Are you sure you want to leave?"); + } +}); + +init(); diff --git a/tools/flash/src/index.html b/tools/flash/src/index.html new file mode 100644 index 00000000..9ebd4750 --- /dev/null +++ b/tools/flash/src/index.html @@ -0,0 +1,259 @@ + + + + + + ~*~ vamOS Flash ~*~ + + + +
+ + + +
+
+
+
+
+
+
+ + + + + + + diff --git a/tools/flash/tsconfig.json b/tools/flash/tsconfig.json new file mode 100644 index 00000000..8c332e12 --- /dev/null +++ b/tools/flash/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true + } +} From 88d6986aa5fac66557d5a5b80a8cb5cd267e84fd Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:09:35 -0700 Subject: [PATCH 07/43] add bun lockfile for tools/flash Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/bun.lock | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 tools/flash/bun.lock diff --git a/tools/flash/bun.lock b/tools/flash/bun.lock new file mode 100644 index 00000000..4cd30fce --- /dev/null +++ b/tools/flash/bun.lock @@ -0,0 +1,56 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "vamos-flash", + "dependencies": { + "@commaai/qdl": "github:commaai/qdl.js", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0", + }, + }, + }, + "trustedDependencies": [ + "@commaai/qdl", + ], + "packages": { + "@commaai/qdl": ["@commaai/qdl@github:commaai/qdl.js#d1e0683", { "dependencies": { "@incognitojam/tiny-struct": "https://npm.jsr.io/~/11/@jsr/incognitojam__tiny-struct/0.1.2.tgz", "arg": "^5.0.2", "crc-32": "^1.2.2", "fast-xml-parser": "^5.0.8", "usb": "^2.15.0" }, "peerDependencies": { "typescript": "^5.7.3" }, "bin": { "simg2img.js": "src/bin/simg2img.js", "qdl.js": "src/bin/qdl.js" } }, "commaai-qdl.js-d1e0683", "sha512-TeDDAfIrnmDbhE+HlMe73GZphxCD/MbQZB6hGOArYvcPb2sSGW9mir1mEFrY5tlz8b4sXzmtOCgckKsFShLEDw=="], + + "@incognitojam/tiny-struct": ["@jsr/incognitojam__tiny-struct@https://npm.jsr.io/~/11/@jsr/incognitojam__tiny-struct/0.1.2.tgz", { "dependencies": { "type-fest": "^4.37.0" } }, "sha512-5cogSpBsKV1gTuvrX72tcx5CuG/Ddi0+RO0ldw76WavR6DD/suCjnmfuDzIuleSZD4UGHBR7njBssyorpqnVgw=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/w3c-web-usb": ["@types/w3c-web-usb@1.0.13", "", {}, "sha512-N2nSl3Xsx8mRHZBvMSdNGtzMyeleTvtlEw+ujujgXalPqOjIA6UtrqcB6OzyUjkTbDm3J7P1RNK1lgoO7jxtsw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "fast-xml-builder": ["fast-xml-builder@1.1.4", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg=="], + + "fast-xml-parser": ["fast-xml-parser@5.5.9", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g=="], + + "node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "path-expression-matcher": ["path-expression-matcher@1.2.0", "", {}, "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ=="], + + "strnum": ["strnum@2.2.2", "", {}, "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "usb": ["usb@2.17.0", "", { "dependencies": { "@types/w3c-web-usb": "^1.0.6", "node-addon-api": "^8.0.0", "node-gyp-build": "^4.5.0" } }, "sha512-UuFgrlglgDn5ll6d5l7kl3nDb2Yx43qLUGcDq+7UNLZLtbNug0HZBb2Xodhgx2JZB1LqvU+dOGqLEeYUeZqsHg=="], + } +} From 94baeb9f7be6bac31e7b06df933a03f993339156 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:12:08 -0700 Subject: [PATCH 08/43] Update gitignore --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e7b0d192..0fcbc3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,7 @@ **/.DS_Store **/*.tar.xz -__pycache__/ \ No newline at end of file +__pycache__/ + +tools/flash/dist +tools/flash/node_modules From 55015b85966b2313522decc10b4ab2cdf0fb6c8f Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:16:47 -0700 Subject: [PATCH 09/43] manifest-driven FlashManager ported from flash repo Port the full flash pipeline from commaai/flash: - FlashManager: connect -> repair GPT -> erase -> flash all partitions -> set active slot -> reboot - ImageManager: OPFS-backed image download and caching - Manifest fetched from latest GitHub release via API - Stream utility with retry and progress tracking Also: more Dora energy throughout the UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/src/app.ts | 262 ++++++++++----------------- tools/flash/src/index.html | 8 +- tools/flash/src/utils/image.ts | 55 ++++++ tools/flash/src/utils/manager.ts | 283 ++++++++++++++++++++++++++++++ tools/flash/src/utils/manifest.ts | 36 ++++ tools/flash/src/utils/progress.ts | 45 +++++ tools/flash/src/utils/stream.ts | 69 ++++++++ 7 files changed, 588 insertions(+), 170 deletions(-) create mode 100644 tools/flash/src/utils/image.ts create mode 100644 tools/flash/src/utils/manager.ts create mode 100644 tools/flash/src/utils/manifest.ts create mode 100644 tools/flash/src/utils/progress.ts create mode 100644 tools/flash/src/utils/stream.ts diff --git a/tools/flash/src/app.ts b/tools/flash/src/app.ts index d873f7ee..9b7745a0 100644 --- a/tools/flash/src/app.ts +++ b/tools/flash/src/app.ts @@ -1,12 +1,8 @@ -import { qdlDevice } from "@commaai/qdl"; -import { usbClass } from "@commaai/qdl/usblib"; - -const PROGRAMMER_URL = "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin"; +import { FlashManager, Step, ErrorCode, loadProgrammer } from "./utils/manager"; +import { getLatestManifestUrl } from "./utils/manifest"; // -- State -- -let programmer: ArrayBuffer | null = null; -let bootFile: File | null = null; -let systemFile: File | null = null; +let manager: FlashManager | null = null; // -- Helpers -- function $(id: string) { return document.getElementById(id)!; } @@ -23,10 +19,10 @@ function showStep(id: string) { $(id).classList.add("active"); } -const stepLabels = ["Images", "Connect", "Flash", "Done"]; +const stepLabels = ["Connect", "Flash", "Done"]; function updateStepper(current: number) { - for (const id of ["stepper-images", "stepper-connect", "stepper-flash", "stepper-done"]) { + for (const id of ["stepper-connect", "stepper-flash", "stepper-done"]) { const el = document.getElementById(id); if (!el) continue; el.innerHTML = ""; @@ -47,74 +43,38 @@ function updateStepper(current: number) { } } -// -- Render steps -- +// -- Render -- function renderLanding() { $("step-landing").innerHTML = ` -
- - - +
+ 🌟 + 🗺️ + 🌟

~*~ vamOS Flash ~*~

-

flash vamOS onto ur comma device via WebUSB !!

- +

can YOU help me flash vamOS onto my comma device??

+
+ + `; $("btn-start").onclick = () => { - if (!programmer) { alert("Programmer binary is still loading. Please wait."); return; } - showStep("step-images"); + if (!manager || manager.step !== Step.READY) return; + showStep("step-connect"); updateStepper(0); + renderConnect(); }; } -function renderImages() { - $("step-images").innerHTML = ` -
-

Pick ur images!

-

grab the boot and system images to flash

- -
- boot.img - No file selected - -
-
- system.img - No file selected - -
-
- -
- `; - - function onFileSelect(type: "boot" | "system", input: HTMLInputElement) { - const file = input.files?.[0]; - if (!file) return; - if (type === "boot") bootFile = file; else systemFile = file; - const nameEl = $(`${type}-filename`); - nameEl.textContent = `${file.name} (${formatSize(file.size)})`; - nameEl.classList.add("set"); - $(`file-${type}`).classList.add("ready"); - if (bootFile && systemFile) ($("btn-images-next") as HTMLButtonElement).disabled = false; - } - - $("input-boot").onchange = function() { onFileSelect("boot", this as HTMLInputElement); }; - $("input-system").onchange = function() { onFileSelect("system", this as HTMLInputElement); }; - $("btn-images-next").onclick = () => { showStep("step-connect"); updateStepper(1); }; -} - function renderConnect() { const isLinux = navigator.platform.includes("Linux"); $("step-connect").innerHTML = `
-

Connect ur device!

-

put your device into EDL mode and plug it in

+
🎒🗺️
+

connect ur device!

+

we need YOUR help! put ur device into EDL mode!

  1. 1Unplug the device and wait for it to fully power off
  2. @@ -122,17 +82,16 @@ function renderConnect() {
  3. 3Connect port 2 to power (computer or power brick)
-

The device screen will remain blank. This is normal.

+

the device screen will be blank. that's totally normal!

${isLinux ? ` -

Linux: unbind qcserial first

+

🐧 Linux: unbind qcserial first!

for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done
` : ""} - -

- Select QUSB_BULK_CID from the browser dialog + +

+ pick QUSB_BULK_CID from the list!

`; - if (isLinux) { $("btn-copy").onclick = () => { const cmd = 'for d in /sys/bus/usb/drivers/qcserial/*-*; do [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null; done'; @@ -141,7 +100,6 @@ function renderConnect() { setTimeout(() => { $("btn-copy").textContent = "Copy"; }, 2000); }; } - $("btn-connect").onclick = () => startFlashing(); } @@ -149,17 +107,17 @@ function renderFlash() { $("step-flash").innerHTML = `
- +
-

Connecting...

-

Do not unplug your device

+

connecting...

+

do NOT unplug ur device!!

- + `; $("btn-retry").onclick = () => location.reload(); } @@ -167,118 +125,74 @@ function renderFlash() { function renderDone() { $("step-done").innerHTML = `
-
- +
+ 🎉 + + 🎊
-

We did it!

-

your device is rebooting into vamOS!!

- +

we did it!! we did it!!

+

your device is rebooting into vamOS!! lo hicimos!!

+ `; $("btn-again").onclick = () => location.reload(); } -// -- Flash logic -- +// -- Flash -- async function startFlashing() { showStep("step-flash"); - updateStepper(2); + updateStepper(1); renderFlash(); function setProgress(pct: number) { - $("progress-fill").style.width = Math.min(pct, 100) + "%"; - $("progress-text").textContent = Math.min(pct, 100).toFixed(0) + "%"; - } - - function setStatus(title: string, message?: string) { - $("flash-title").textContent = title; - $("flash-status").textContent = message ?? ""; + if (pct < 0) { $("progress-text").textContent = ""; return; } + $("progress-fill").style.width = Math.min(pct * 100, 100) + "%"; + $("progress-text").textContent = Math.min(pct * 100, 100).toFixed(0) + "%"; } - function setError(message: string) { + manager!.callbacks.onStepChange = (step: number) => { + const titles: Record = { + [Step.CONNECTING]: "connecting...", + [Step.REPAIR_PARTITION_TABLES]: "repairing partition tables...", + [Step.ERASE_DEVICE]: "erasing device...", + [Step.FLASH_SYSTEM]: "flashing!! go go go!!", + [Step.FINALIZING]: "almost done...", + }; + if (titles[step]) $("flash-title").textContent = titles[step]; + }; + manager!.callbacks.onMessageChange = (msg: string) => { + if (msg) $("flash-status").textContent = msg; + }; + manager!.callbacks.onProgressChange = setProgress; + manager!.callbacks.onSerialChange = (serial: string) => { + $("flash-serial").textContent = "device serial: " + serial; + $("flash-serial").style.display = "block"; + }; + manager!.callbacks.onErrorChange = (error: number) => { + if (error === ErrorCode.NONE) return; $("flash-icon").className = "flash-icon icon-red"; - $("flash-title").textContent = "Error"; + $("flash-icon").innerHTML = '😢'; + $("flash-title").textContent = "oh no!! swiper no swiping!!"; $("flash-status").textContent = ""; $("flash-error").style.display = "block"; - $("flash-error").textContent = message; + $("flash-error").textContent = "something went wrong! try a different cable, USB port, or computer."; $("btn-retry").style.display = "inline-block"; - } - - try { - setStatus("Waiting for device...", "Select your device in the browser dialog"); - const usb = new usbClass(); - const qdl = new qdlDevice(programmer!); - - try { - await qdl.connect(usb); - } catch (err: any) { - if (err.name === "NotFoundError") { - showStep("step-connect"); - updateStepper(1); - return; - } - throw err; - } - - console.info("[vamOS Flash] Connected"); - setStatus("Connected", "Reading device info..."); - - try { - const storageInfo = await qdl.getStorageInfo(); - const serial = Number(storageInfo.serial_num).toString(16).padStart(8, "0"); - $("flash-serial").textContent = "Device serial: " + serial; - $("flash-serial").style.display = "block"; - } catch (err) { - console.warn("[vamOS Flash] Could not read storage info:", err); - } - - const bootSize = bootFile!.size; - const systemSize = systemFile!.size; - - // Flash boot_a (10%) - setStatus("Flashing boot_a...", "Do not unplug your device"); - setProgress(0); - await qdl.flashBlob("boot_a", bootFile!, (written: number) => { - setProgress((written / bootSize) * 10); - }); - console.info("[vamOS Flash] boot_a done"); - - // Flash boot_b (10%) - setStatus("Flashing boot_b...", "Do not unplug your device"); - await qdl.flashBlob("boot_b", bootFile!, (written: number) => { - setProgress(10 + (written / bootSize) * 10); - }); - console.info("[vamOS Flash] boot_b done"); - - // Flash system_a (75%) - setStatus("Flashing system_a...", "This may take several minutes. Do not unplug your device."); - await qdl.flashBlob("system_a", systemFile!, (written: number) => { - setProgress(20 + (written / systemSize) * 75); - }, false); - console.info("[vamOS Flash] system_a done"); - - // Finalize - setStatus("Finalizing...", "Setting active slot"); - setProgress(95); - await qdl.setActiveSlot("a"); + }; - setStatus("Rebooting...", ""); - setProgress(100); - await qdl.reset(); + await manager!.start(); + if (manager!.step === Step.DONE) { showStep("step-done"); - updateStepper(3); + updateStepper(2); renderDone(); - - } catch (err: any) { - console.error("[vamOS Flash] Flash failed:", err); - setError(err.message || "An unknown error occurred. Try a different cable, USB port, or computer."); } } +// Expose callbacks for manager to use +(FlashManager.prototype as any).callbacks = {}; + // -- Init -- async function init() { renderLanding(); - renderImages(); - renderConnect(); if (typeof navigator.usb === "undefined") { $("no-webusb").style.display = "block"; @@ -286,20 +200,36 @@ async function init() { return; } + $("init-status").textContent = "loading programmer + manifest..."; + try { - const res = await fetch(PROGRAMMER_URL); - if (!res.ok) throw new Error(`Failed to fetch programmer: ${res.status}`); - programmer = await res.arrayBuffer(); - console.info("[vamOS Flash] Programmer loaded:", programmer.byteLength, "bytes"); - } catch (err) { - console.error("[vamOS Flash] Failed to load programmer:", err); + const [programmer, { manifestUrl }] = await Promise.all([ + loadProgrammer(), + getLatestManifestUrl(), + ]); + + manager = new FlashManager(programmer, {}); + await manager.initialize(manifestUrl); + + if (manager.error !== ErrorCode.NONE) { + throw new Error("Initialization failed"); + } + + $("init-status").textContent = `ready! (${manager.step === Step.READY ? "manifest loaded" : "..."})`; + ($("btn-start") as HTMLButtonElement).disabled = false; + } catch (err: any) { + console.error("[vamOS Flash] Init failed:", err); + $("init-status").textContent = ""; + const el = $("init-error"); + el.style.display = "block"; + el.textContent = "failed to load: " + (err.message || err); } } window.addEventListener("beforeunload", (e) => { - if ($("step-flash").classList.contains("active") && $("btn-retry").style.display !== "inline-block") { + if ($("step-flash")?.classList.contains("active") && $("btn-retry")?.style.display !== "inline-block") { e.preventDefault(); - return (e.returnValue = "Flash in progress. Are you sure you want to leave?"); + return (e.returnValue = "Flash in progress!!"); } }); diff --git a/tools/flash/src/index.html b/tools/flash/src/index.html index 9ebd4750..7629767f 100644 --- a/tools/flash/src/index.html +++ b/tools/flash/src/index.html @@ -214,10 +214,10 @@ .version { position: fixed; bottom: 1rem; left: 1rem; font-size: 0.75rem; color: rgba(255,255,255,0.3); z-index: 2; } .version a { color: inherit; } - .sparkle { position: absolute; font-size: 1.5rem; animation: float 3s ease-in-out infinite; pointer-events: none; } - @keyframes float { - 0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.6; } - 50% { transform: translateY(-15px) rotate(180deg); opacity: 1; } + .bounce { display: inline-block; animation: bounce 1s ease infinite; } + @keyframes bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-12px); } } diff --git a/tools/flash/src/utils/image.ts b/tools/flash/src/utils/image.ts new file mode 100644 index 00000000..0f35c86b --- /dev/null +++ b/tools/flash/src/utils/image.ts @@ -0,0 +1,55 @@ +import { fetchStream } from "./stream"; +import type { ManifestEntry } from "./manifest"; + +type ProgressCallback = (progress: number) => void; + +const MIN_QUOTA_GB = 5.25; + +export class ImageManager { + root: FileSystemDirectoryHandle | null = null; + + async init() { + if (!this.root) { + this.root = await navigator.storage.getDirectory(); + try { + await (this.root as any).remove({ recursive: true }); + } catch (_) {} + this.root = await navigator.storage.getDirectory(); + console.info("[ImageManager] Initialized"); + } + + const estimate = await navigator.storage.estimate(); + const quotaGB = (estimate.quota || 0) / 1024 ** 3; + if (quotaGB < MIN_QUOTA_GB) { + throw new Error( + `Not enough storage: ${quotaGB.toFixed(1)}GB free, need ${MIN_QUOTA_GB.toFixed(1)}GB`, + ); + } + } + + async downloadImage(image: ManifestEntry, onProgress?: ProgressCallback) { + const fileName = `${image.name}-${image.hash_raw}.img`; + let writable: FileSystemWritableFileStream; + try { + const fileHandle = await this.root!.getFileHandle(fileName, { create: true }); + writable = await fileHandle.createWritable(); + } catch (e) { + throw new Error(`Error opening file handle: ${e}`, { cause: e as Error }); + } + + console.debug(`[ImageManager] Downloading ${image.name} from ${image.url}`); + const stream = await fetchStream(image.url, { mode: "cors" }, { onProgress }); + try { + await stream.pipeTo(writable); + onProgress?.(1); + } catch (e) { + throw new Error(`Error downloading image: ${e}`, { cause: e as Error }); + } + } + + async getImage(image: ManifestEntry): Promise { + const fileName = `${image.name}-${image.hash_raw}.img`; + const fileHandle = await this.root!.getFileHandle(fileName, { create: false }); + return fileHandle.getFile(); + } +} diff --git a/tools/flash/src/utils/manager.ts b/tools/flash/src/utils/manager.ts new file mode 100644 index 00000000..7006f436 --- /dev/null +++ b/tools/flash/src/utils/manager.ts @@ -0,0 +1,283 @@ +import { qdlDevice } from "@commaai/qdl"; +import { usbClass } from "@commaai/qdl/usblib"; + +import { getManifest, type ManifestEntry } from "./manifest"; +import { ImageManager } from "./image"; +import { createSteps, withProgress } from "./progress"; + +const PROGRAMMER_URL = + "https://raw.githubusercontent.com/commaai/flash/master/src/QDL/programmer.bin"; + +export const Step = { + INITIALIZING: 0, + READY: 1, + CONNECTING: 2, + REPAIR_PARTITION_TABLES: 3, + ERASE_DEVICE: 4, + FLASH_SYSTEM: 5, + FINALIZING: 6, + DONE: 7, +} as const; + +export const ErrorCode = { + NONE: 0, + UNKNOWN: -1, + REQUIREMENTS_NOT_MET: 1, + STORAGE_SPACE: 2, + LOST_CONNECTION: 3, + REPAIR_FAILED: 4, + ERASE_FAILED: 5, + FLASH_FAILED: 6, +} as const; + +export interface FlashCallbacks { + onStepChange?: (step: number) => void; + onMessageChange?: (message: string) => void; + onProgressChange?: (progress: number) => void; + onErrorChange?: (error: number) => void; + onConnectionChange?: (connected: boolean) => void; + onSerialChange?: (serial: string) => void; +} + +export class FlashManager { + private callbacks: FlashCallbacks; + private device: qdlDevice; + private imageManager: ImageManager; + private manifest: ManifestEntry[] | null = null; + step = Step.INITIALIZING; + error = ErrorCode.NONE; + + constructor(programmer: ArrayBuffer, callbacks: FlashCallbacks = {}) { + this.callbacks = callbacks; + this.device = new qdlDevice(programmer); + this.imageManager = new ImageManager(); + } + + private setStep(step: number) { + this.step = step; + this.callbacks.onStepChange?.(step); + } + + private setMessage(message: string) { + if (message) console.info("[Flash]", message); + this.callbacks.onMessageChange?.(message); + } + + private setProgress(progress: number) { + this.callbacks.onProgressChange?.(progress); + } + + private setError(error: number) { + this.error = error; + this.callbacks.onErrorChange?.(error); + this.setProgress(-1); + } + + async initialize(manifestUrl: string) { + this.setProgress(-1); + this.setMessage(""); + + if (typeof navigator.usb === "undefined") { + this.setError(ErrorCode.REQUIREMENTS_NOT_MET); + return; + } + + try { + await this.imageManager.init(); + } catch (err: any) { + console.error("[Flash] Failed to initialize image manager:", err); + if (err?.message?.startsWith("Not enough storage")) { + this.setError(ErrorCode.STORAGE_SPACE); + this.setMessage(err.message); + } else { + this.setError(ErrorCode.UNKNOWN); + } + return; + } + + try { + this.manifest = await getManifest(manifestUrl); + if (this.manifest.length === 0) throw new Error("Manifest is empty"); + console.info("[Flash] Loaded manifest:", this.manifest.length, "entries"); + } catch (err) { + console.error("[Flash] Failed to fetch manifest:", err); + this.setError(ErrorCode.UNKNOWN); + return; + } + + this.setStep(Step.READY); + } + + private async connect() { + this.setStep(Step.CONNECTING); + this.setProgress(-1); + + try { + await this.device.connect(new usbClass()); + } catch (err: any) { + if (err.name === "NotFoundError") { + this.setStep(Step.READY); + return; + } + console.error("[Flash] Connection error:", err); + this.setError(ErrorCode.LOST_CONNECTION); + return; + } + + console.info("[Flash] Connected"); + this.callbacks.onConnectionChange?.(true); + + try { + const storageInfo = await this.device.getStorageInfo(); + const serial = Number(storageInfo.serial_num).toString(16).padStart(8, "0"); + this.callbacks.onSerialChange?.(serial); + console.info("[Flash] Serial:", serial); + } catch (err) { + console.warn("[Flash] Could not read storage info:", err); + } + } + + private async repairPartitionTables() { + this.setStep(Step.REPAIR_PARTITION_TABLES); + this.setProgress(0); + + const gptImages = this.manifest!.filter((e) => !!e.gpt); + if (gptImages.length === 0) { + console.error("[Flash] No GPT images found"); + this.setError(ErrorCode.REPAIR_FAILED); + return; + } + + try { + for (const [image, onProgress] of withProgress(gptImages, this.setProgress.bind(this))) { + const [onDownload, onRepair] = createSteps([2, 1], onProgress); + this.setMessage(`Downloading ${image.name}`); + await this.imageManager.downloadImage(image, onDownload); + const blob = await this.imageManager.getImage(image); + this.setMessage(`Repairing GPT LUN ${image.gpt!.lun}`); + if (!(await this.device.repairGpt(image.gpt!.lun, blob))) { + throw new Error(`Repairing LUN ${image.gpt!.lun} failed`); + } + onRepair(1.0); + } + } catch (err) { + console.error("[Flash] Partition table repair failed:", err); + this.setError(ErrorCode.REPAIR_FAILED); + } + } + + private async eraseDevice() { + this.setStep(Step.ERASE_DEVICE); + this.setProgress(-1); + + const luns = Array.from({ length: 6 }, (_, i) => i); + + const [found, persistLun, partition] = await this.device.detectPartition("persist"); + if (!found || luns.indexOf(persistLun) < 0) { + console.error("[Flash] Could not find persist partition"); + this.setError(ErrorCode.ERASE_FAILED); + return; + } + + try { + const critical = ["mbr", "gpt"]; + for (const lun of luns) { + const preserve = [...critical]; + if (lun === persistLun) preserve.push("persist"); + this.setMessage(`Erasing LUN ${lun}`); + if (!(await this.device.eraseLun(lun, preserve))) { + throw new Error(`Erasing LUN ${lun} failed`); + } + } + } catch (err) { + console.error("[Flash] Erase failed:", err); + this.setError(ErrorCode.ERASE_FAILED); + } + } + + private async flashSystem() { + this.setStep(Step.FLASH_SYSTEM); + this.setProgress(0); + + // Flash everything except GPTs and persist + const systemImages = this.manifest!.filter((e) => !e.gpt && e.name !== "persist"); + + try { + for await (const [image, onImageProgress] of withProgress( + systemImages, + this.setProgress.bind(this), + (img) => img.size, + )) { + const [onDownload, onFlash] = createSteps( + [1, image.has_ab ? 2 : 1], + onImageProgress, + ); + + this.setMessage(`Downloading ${image.name}`); + await this.imageManager.downloadImage(image, onDownload); + const blob = await this.imageManager.getImage(image); + onDownload(1.0); + + const slots = image.has_ab ? ["_a", "_b"] : [""]; + for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) { + const partitionName = `${image.name}${slot}`; + this.setMessage(`Flashing ${partitionName}`); + if ( + !(await this.device.flashBlob( + partitionName, + blob, + (progress: number) => onSlotProgress(progress / image.size), + false, + )) + ) { + throw new Error(`Flashing ${partitionName} failed`); + } + onSlotProgress(1.0); + } + } + } catch (err) { + console.error("[Flash] Flash failed:", err); + this.setError(ErrorCode.FLASH_FAILED); + } + } + + private async finalize() { + this.setStep(Step.FINALIZING); + this.setProgress(-1); + this.setMessage("Setting active slot"); + + if (!(await this.device.setActiveSlot("a"))) { + this.setError(ErrorCode.UNKNOWN); + return; + } + + this.setMessage("Rebooting"); + await this.device.reset(); + this.callbacks.onConnectionChange?.(false); + this.setStep(Step.DONE); + } + + async start() { + if (this.step !== Step.READY) return; + + await this.connect(); + if (this.step === Step.READY || this.error !== ErrorCode.NONE) return; + + await this.repairPartitionTables(); + if (this.error !== ErrorCode.NONE) return; + + await this.eraseDevice(); + if (this.error !== ErrorCode.NONE) return; + + await this.flashSystem(); + if (this.error !== ErrorCode.NONE) return; + + await this.finalize(); + } +} + +export async function loadProgrammer(): Promise { + const res = await fetch(PROGRAMMER_URL); + if (!res.ok) throw new Error(`Failed to fetch programmer: ${res.status}`); + return res.arrayBuffer(); +} diff --git a/tools/flash/src/utils/manifest.ts b/tools/flash/src/utils/manifest.ts new file mode 100644 index 00000000..819ef451 --- /dev/null +++ b/tools/flash/src/utils/manifest.ts @@ -0,0 +1,36 @@ +export interface ManifestEntry { + name: string; + url: string; + hash: string; + hash_raw: string; + size: number; + sparse: boolean; + full_check: boolean; + has_ab: boolean; + ondevice_hash: string; + gpt?: { + lun: number; + start_sector: number; + num_sectors: number; + }; +} + +export async function getManifest(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to fetch manifest: ${response.status}`); + return response.json(); +} + +/** + * Get the manifest URL for the latest vamOS release. + * Uses the GitHub API (CORS-safe) to find the latest release tag, + * then returns the manifest.json asset URL. + */ +export async function getLatestManifestUrl(): Promise<{ tag: string; manifestUrl: string }> { + const res = await fetch("https://api.github.com/repos/commaai/vamOS/releases/latest"); + if (!res.ok) throw new Error(`No releases found: ${res.status}`); + const { tag_name, assets } = await res.json(); + const manifestAsset = assets.find((a: any) => a.name === "manifest.json"); + if (!manifestAsset) throw new Error("manifest.json not found in release assets"); + return { tag: tag_name, manifestUrl: manifestAsset.browser_download_url }; +} diff --git a/tools/flash/src/utils/progress.ts b/tools/flash/src/utils/progress.ts new file mode 100644 index 00000000..d9804e48 --- /dev/null +++ b/tools/flash/src/utils/progress.ts @@ -0,0 +1,45 @@ +type ProgressCallback = (progress: number) => void; + +export function createSteps( + steps: number[] | number, + onProgress: ProgressCallback, +): ProgressCallback[] { + const stepWeights = typeof steps === "number" ? Array(steps).fill(1) : steps; + const progressParts = Array(stepWeights.length).fill(0); + const totalSize = stepWeights.reduce((total, weight) => total + weight, 0); + + function updateProgress() { + const weightedAverage = stepWeights.reduce( + (acc, weight, idx) => acc + progressParts[idx] * weight, + 0, + ); + onProgress(weightedAverage / totalSize); + } + + return stepWeights.map((_weight, idx) => (progress: number) => { + if (progressParts[idx] !== progress) { + progressParts[idx] = progress; + updateProgress(); + } + }); +} + +export function withProgress( + steps: T[], + onProgress: ProgressCallback, + getStepWeight?: (step: T) => number, +): [T, ProgressCallback][] { + const callbacks = createSteps( + steps.map( + getStepWeight || + ((step: any) => + typeof step === "number" + ? step + : typeof step !== "string" + ? step.size || step.length || 1 + : 1), + ), + onProgress, + ); + return steps.map((step, idx) => [step, callbacks[idx]]); +} diff --git a/tools/flash/src/utils/stream.ts b/tools/flash/src/utils/stream.ts new file mode 100644 index 00000000..8f9a6090 --- /dev/null +++ b/tools/flash/src/utils/stream.ts @@ -0,0 +1,69 @@ +type ProgressCallback = (progress: number) => void; + +const getContentLength = (response: Response): number => { + const total = response.headers.get("Content-Length"); + if (total) return parseInt(total, 10); + throw new Error("Content-Length not found in response headers"); +}; + +interface FetchStreamOptions { + maxRetries?: number; + retryDelay?: number; + onProgress?: ProgressCallback; +} + +export async function fetchStream( + url: string | URL, + requestOptions: RequestInit = {}, + options: FetchStreamOptions = {}, +): Promise> { + const maxRetries = options.maxRetries || 3; + const retryDelay = options.retryDelay || 1000; + + const fetchRange = async (startByte: number, signal: AbortSignal) => { + const headers: Record = { + ...(requestOptions.headers as Record || {}), + }; + if (startByte > 0) headers["range"] = `bytes=${startByte}-`; + const response = await fetch(url, { ...requestOptions, headers, signal }); + if (!response.ok || (response.status !== 206 && response.status !== 200)) { + throw new Error(`Fetch error: ${response.status}`); + } + return response; + }; + + const abortController = new AbortController(); + let startByte = 0; + let contentLength: number | null = null; + + return new ReadableStream({ + async pull(stream) { + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetchRange(startByte, abortController.signal); + if (contentLength === null) contentLength = getContentLength(response); + const reader = response.body!.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { stream.close(); return; } + startByte += value.byteLength; + stream.enqueue(value); + options.onProgress?.(startByte / contentLength!); + } + } catch (err) { + console.warn(`Attempt ${attempt + 1} failed:`, err); + if (attempt === maxRetries) { + abortController.abort(); + stream.error(new Error("Max retries reached", { cause: err as Error })); + return; + } + await new Promise((res) => setTimeout(res, retryDelay)); + } + } + }, + cancel(reason) { + console.warn("Stream canceled:", reason); + abortController.abort(); + }, + }); +} From f2782cfc2804b234e6a850340b00979347fe5690 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:29:35 -0700 Subject: [PATCH 10/43] add Cloudflare Worker CORS proxy for release assets Manifest and image URLs are rewritten to go through the proxy at PROXY_BASE. One constant to swap to Azure CDN for production. Worker proxies github.com/commaai/vamOS/releases/download/* and adds Access-Control-Allow-Origin: * Deploy: npx wrangler deploy tools/flash/worker.js --name vamos-release-proxy Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/src/app.ts | 9 ++++--- tools/flash/src/utils/manager.ts | 16 +++-------- tools/flash/src/utils/manifest.ts | 36 +++++++++++++++---------- tools/flash/worker.js | 45 +++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 30 deletions(-) create mode 100644 tools/flash/worker.js diff --git a/tools/flash/src/app.ts b/tools/flash/src/app.ts index 9b7745a0..110913d3 100644 --- a/tools/flash/src/app.ts +++ b/tools/flash/src/app.ts @@ -1,5 +1,5 @@ import { FlashManager, Step, ErrorCode, loadProgrammer } from "./utils/manager"; -import { getLatestManifestUrl } from "./utils/manifest"; +import { getManifest } from "./utils/manifest"; // -- State -- let manager: FlashManager | null = null; @@ -203,13 +203,14 @@ async function init() { $("init-status").textContent = "loading programmer + manifest..."; try { - const [programmer, { manifestUrl }] = await Promise.all([ + const [programmer, { tag, manifest }] = await Promise.all([ loadProgrammer(), - getLatestManifestUrl(), + getManifest(), ]); + console.info("[vamOS Flash] Release:", tag, "- entries:", manifest.length); manager = new FlashManager(programmer, {}); - await manager.initialize(manifestUrl); + await manager.initialize(manifest); if (manager.error !== ErrorCode.NONE) { throw new Error("Initialization failed"); diff --git a/tools/flash/src/utils/manager.ts b/tools/flash/src/utils/manager.ts index 7006f436..4b9f416a 100644 --- a/tools/flash/src/utils/manager.ts +++ b/tools/flash/src/utils/manager.ts @@ -1,7 +1,7 @@ import { qdlDevice } from "@commaai/qdl"; import { usbClass } from "@commaai/qdl/usblib"; -import { getManifest, type ManifestEntry } from "./manifest"; +import type { ManifestEntry } from "./manifest"; import { ImageManager } from "./image"; import { createSteps, withProgress } from "./progress"; @@ -73,7 +73,7 @@ export class FlashManager { this.setProgress(-1); } - async initialize(manifestUrl: string) { + async initialize(manifest: ManifestEntry[]) { this.setProgress(-1); this.setMessage(""); @@ -95,16 +95,8 @@ export class FlashManager { return; } - try { - this.manifest = await getManifest(manifestUrl); - if (this.manifest.length === 0) throw new Error("Manifest is empty"); - console.info("[Flash] Loaded manifest:", this.manifest.length, "entries"); - } catch (err) { - console.error("[Flash] Failed to fetch manifest:", err); - this.setError(ErrorCode.UNKNOWN); - return; - } - + this.manifest = manifest; + console.info("[Flash] Loaded manifest:", this.manifest.length, "entries"); this.setStep(Step.READY); } diff --git a/tools/flash/src/utils/manifest.ts b/tools/flash/src/utils/manifest.ts index 819ef451..c2ecfc7d 100644 --- a/tools/flash/src/utils/manifest.ts +++ b/tools/flash/src/utils/manifest.ts @@ -1,3 +1,6 @@ +// Swap this to Azure CDN URL for production +export const PROXY_BASE = "https://vamos-release-proxy.mpurnell1.workers.dev"; + export interface ManifestEntry { name: string; url: string; @@ -15,22 +18,27 @@ export interface ManifestEntry { }; } -export async function getManifest(url: string): Promise { - const response = await fetch(url); - if (!response.ok) throw new Error(`Failed to fetch manifest: ${response.status}`); - return response.json(); -} - /** - * Get the manifest URL for the latest vamOS release. - * Uses the GitHub API (CORS-safe) to find the latest release tag, - * then returns the manifest.json asset URL. + * Fetch the latest vamOS release tag via the GitHub API (CORS-safe), + * then fetch the manifest through the CORS proxy and rewrite all + * image URLs to go through the proxy as well. */ -export async function getLatestManifestUrl(): Promise<{ tag: string; manifestUrl: string }> { +export async function getManifest(): Promise<{ tag: string; manifest: ManifestEntry[] }> { + // GitHub API has CORS - use it to get the latest release tag const res = await fetch("https://api.github.com/repos/commaai/vamOS/releases/latest"); if (!res.ok) throw new Error(`No releases found: ${res.status}`); - const { tag_name, assets } = await res.json(); - const manifestAsset = assets.find((a: any) => a.name === "manifest.json"); - if (!manifestAsset) throw new Error("manifest.json not found in release assets"); - return { tag: tag_name, manifestUrl: manifestAsset.browser_download_url }; + const { tag_name } = await res.json(); + + // Fetch manifest through proxy + const manifestRes = await fetch(`${PROXY_BASE}/${tag_name}/manifest.json`); + if (!manifestRes.ok) throw new Error(`Failed to fetch manifest: ${manifestRes.status}`); + const manifest: ManifestEntry[] = await manifestRes.json(); + + // Rewrite image URLs to go through proxy + for (const entry of manifest) { + const filename = entry.url.split("/").pop(); + entry.url = `${PROXY_BASE}/${tag_name}/${filename}`; + } + + return { tag: tag_name, manifest }; } diff --git a/tools/flash/worker.js b/tools/flash/worker.js new file mode 100644 index 00000000..109178a0 --- /dev/null +++ b/tools/flash/worker.js @@ -0,0 +1,45 @@ +// Cloudflare Worker: CORS proxy for GitHub release assets +// Deploy: npx wrangler deploy worker.js --name vamos-release-proxy + +const ALLOWED_REPO = "commaai/vamOS"; + +export default { + async fetch(request) { + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } + + const url = new URL(request.url); + const path = url.pathname.slice(1); + + if (!path) { + return new Response("Usage: /{tag}/{filename}\nExample: /v17.2/manifest.json", { + headers: { "content-type": "text/plain", ...corsHeaders() }, + }); + } + + const ghUrl = `https://github.com/${ALLOWED_REPO}/releases/download/${path}`; + const resp = await fetch(ghUrl, { redirect: "follow" }); + + if (!resp.ok) { + return new Response(`Not found: ${path}`, { status: resp.status, headers: corsHeaders() }); + } + + return new Response(resp.body, { + status: 200, + headers: { + "content-type": resp.headers.get("content-type") || "application/octet-stream", + "content-length": resp.headers.get("content-length"), + ...corsHeaders(), + }, + }); + }, +}; + +function corsHeaders() { + return { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "Content-Type", + }; +} From af53a9988d4daf8f5e3d14da1e2c2c6865adf94f Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:32:21 -0700 Subject: [PATCH 11/43] fix: correct Cloudflare Worker URL Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/src/utils/manifest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/flash/src/utils/manifest.ts b/tools/flash/src/utils/manifest.ts index c2ecfc7d..eb0cfc0f 100644 --- a/tools/flash/src/utils/manifest.ts +++ b/tools/flash/src/utils/manifest.ts @@ -1,5 +1,5 @@ // Swap this to Azure CDN URL for production -export const PROXY_BASE = "https://vamos-release-proxy.mpurnell1.workers.dev"; +export const PROXY_BASE = "https://vamos-release-proxy.vamos-release-proxy.workers.dev"; export interface ManifestEntry { name: string; From 4f14161b847c04f9361373005917a1bc8dada6bb Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 16:36:29 -0700 Subject: [PATCH 12/43] skip partitions not found on device Some partitions in the manifest (e.g. splash_cc) don't exist on all device variants. Check with detectPartition before flashing and skip if not found. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/src/utils/manager.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tools/flash/src/utils/manager.ts b/tools/flash/src/utils/manager.ts index 4b9f416a..9525a05c 100644 --- a/tools/flash/src/utils/manager.ts +++ b/tools/flash/src/utils/manager.ts @@ -213,6 +213,15 @@ export class FlashManager { const slots = image.has_ab ? ["_a", "_b"] : [""]; for (const [slot, onSlotProgress] of withProgress(slots, onFlash)) { const partitionName = `${image.name}${slot}`; + + // Skip partitions that don't exist on this device + const [found] = await this.device.detectPartition(partitionName); + if (!found) { + console.warn(`[Flash] Partition ${partitionName} not found, skipping`); + onSlotProgress(1.0); + continue; + } + this.setMessage(`Flashing ${partitionName}`); if ( !(await this.device.flashBlob( From 4d2e4a41144bdae154d7bd051421d4af742b7490 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sat, 28 Mar 2026 17:59:03 -0700 Subject: [PATCH 13/43] rename to flashpack Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flash/package.json | 2 +- tools/flash/src/app.ts | 6 +++--- tools/flash/src/index.html | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/flash/package.json b/tools/flash/package.json index 94c33795..23f2a292 100644 --- a/tools/flash/package.json +++ b/tools/flash/package.json @@ -1,5 +1,5 @@ { - "name": "vamos-flash", + "name": "flashpack", "type": "module", "scripts": { "dev": "bun run serve.ts", diff --git a/tools/flash/src/app.ts b/tools/flash/src/app.ts index 110913d3..d5ce1f4f 100644 --- a/tools/flash/src/app.ts +++ b/tools/flash/src/app.ts @@ -51,7 +51,7 @@ function renderLanding() { 🗺️ 🌟
-

~*~ vamOS Flash ~*~

+

~*~ flashpack ~*~

can YOU help me flash vamOS onto my comma device??

@@ -208,7 +208,7 @@ async function init() { getManifest(), ]); - console.info("[vamOS Flash] Release:", tag, "- entries:", manifest.length); + console.info("[flashpack] Release:", tag, "- entries:", manifest.length); manager = new FlashManager(programmer, {}); await manager.initialize(manifest); @@ -219,7 +219,7 @@ async function init() { $("init-status").textContent = `ready! (${manager.step === Step.READY ? "manifest loaded" : "..."})`; ($("btn-start") as HTMLButtonElement).disabled = false; } catch (err: any) { - console.error("[vamOS Flash] Init failed:", err); + console.error("[flashpack] Init failed:", err); $("init-status").textContent = ""; const el = $("init-error"); el.style.display = "block"; diff --git a/tools/flash/src/index.html b/tools/flash/src/index.html index 7629767f..4f691964 100644 --- a/tools/flash/src/index.html +++ b/tools/flash/src/index.html @@ -3,7 +3,7 @@ - ~*~ vamOS Flash ~*~ + ~*~ flashpack ~*~ 2 +1 + \ No newline at end of file diff --git a/tools/flashpack/src/assets/qdl-ports-three.svg b/tools/flashpack/src/assets/qdl-ports-three.svg new file mode 100644 index 00000000..5ae6e200 --- /dev/null +++ b/tools/flashpack/src/assets/qdl-ports-three.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/tools/flashpack/src/index.html b/tools/flashpack/src/index.html index 4f691964..706e0de9 100644 --- a/tools/flashpack/src/index.html +++ b/tools/flashpack/src/index.html @@ -219,6 +219,22 @@ 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-12px); } } + + .device-card { + display: flex; flex-direction: column; align-items: center; gap: 1rem; + padding: 1.5rem 2rem; border-radius: 0.75rem; + border: 2px dashed rgba(255,255,255,0.2); + background: rgba(255,255,255,0.05); + cursor: pointer; transition: all 0.2s; + font-family: inherit; font-size: 1.125rem; font-weight: 700; + color: white; + } + .device-card:hover { border-color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.08); } + .device-card.selected { + border-color: var(--lime); border-style: solid; + background: rgba(81,255,0,0.08); + box-shadow: 0 0 20px rgba(81,255,0,0.15); + } @@ -235,8 +251,10 @@
-
+
+
+
From c4a07ff9b04e1d84f6834117387547c0f4703846 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sun, 29 Mar 2026 03:14:34 -0700 Subject: [PATCH 22/43] fix: keep CLI flash scripts in tools/flash/ The rename to tools/flashpack/ accidentally moved the CLI flash scripts (kernel.sh, system.sh, firmware.sh, gpt.sh). These belong in tools/flash/ and are used by ./vamos flash. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/{flashpack => flash}/firmware.sh | 0 tools/{flashpack => flash}/gpt.sh | 0 tools/{flashpack => flash}/kernel.sh | 0 tools/{flashpack => flash}/system.sh | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename tools/{flashpack => flash}/firmware.sh (100%) rename tools/{flashpack => flash}/gpt.sh (100%) rename tools/{flashpack => flash}/kernel.sh (100%) rename tools/{flashpack => flash}/system.sh (100%) diff --git a/tools/flashpack/firmware.sh b/tools/flash/firmware.sh similarity index 100% rename from tools/flashpack/firmware.sh rename to tools/flash/firmware.sh diff --git a/tools/flashpack/gpt.sh b/tools/flash/gpt.sh similarity index 100% rename from tools/flashpack/gpt.sh rename to tools/flash/gpt.sh diff --git a/tools/flashpack/kernel.sh b/tools/flash/kernel.sh similarity index 100% rename from tools/flashpack/kernel.sh rename to tools/flash/kernel.sh diff --git a/tools/flashpack/system.sh b/tools/flash/system.sh similarity index 100% rename from tools/flashpack/system.sh rename to tools/flash/system.sh From 16c461ac5cd06723326032aaf6f5470b1e1dc06f Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sun, 29 Mar 2026 10:31:05 -0700 Subject: [PATCH 23/43] add stepper navigation to go back to previous steps Clicking a completed step dot navigates back to that step. Only pre-flash steps (Device, Connect, Unbind) are navigable. Flash and Done steps can't be navigated to from the stepper. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flashpack/src/app.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts index 8bcb3d8f..5632c2da 100644 --- a/tools/flashpack/src/app.ts +++ b/tools/flashpack/src/app.ts @@ -35,7 +35,7 @@ function updateStepper(current: number) { el.innerHTML = ""; const stepper = document.createElement("div"); stepper.className = "stepper"; - labels.forEach((_, i) => { + labels.forEach((label, i) => { if (i > 0) { const line = document.createElement("div"); line.className = "stepper-line" + (i <= current ? " done" : ""); @@ -44,12 +44,29 @@ function updateStepper(current: number) { const dot = document.createElement("div"); dot.className = "stepper-dot" + (i === current ? " active" : i < current ? " done" : ""); dot.textContent = i < current ? "\u2713" : String(i + 1); + if (i < current) { + dot.style.cursor = "pointer"; + dot.onclick = () => navigateTo(label); + } stepper.appendChild(dot); }); el.appendChild(stepper); } } +function navigateTo(label: string) { + const stepMap: Record void> = { + "Device": () => { showStep("step-device"); renderDevicePicker(); }, + "Connect": () => { showStep("step-connect"); renderConnect(); }, + "Unbind": () => { showStep("step-unbind"); renderUnbind(); }, + }; + const handler = stepMap[label]; + if (handler) { + handler(); + updateStepper(getStepLabels().indexOf(label)); + } +} + // -- Steps -- function renderLanding() { $("step-landing").innerHTML = ` From ab14a39b265c65d784361aa3c5bba591c154e8d5 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sun, 29 Mar 2026 12:10:29 -0700 Subject: [PATCH 24/43] add Windows Zadig step, fix stepper navigation between devices - Zadig driver install step shown on Windows between Device and Connect - Going back to Device resets selectedDevice so stepper recalculates - All stepper indices use getStepLabels().indexOf() instead of hardcoded numbers Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flashpack/src/app.ts | 44 ++++++++++++++++++++++++++++++---- tools/flashpack/src/index.html | 1 + 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts index 5632c2da..9a73a2bb 100644 --- a/tools/flashpack/src/app.ts +++ b/tools/flashpack/src/app.ts @@ -23,7 +23,9 @@ function showStep(id: string) { } function getStepLabels(): string[] { - const steps = ["Device", "Connect"]; + const steps = ["Device"]; + if (isWindows) steps.push("Driver"); + steps.push("Connect"); if (isLinux && selectedDevice === "comma3") steps.push("Unbind"); steps.push("Flash"); return steps; @@ -56,7 +58,8 @@ function updateStepper(current: number) { function navigateTo(label: string) { const stepMap: Record void> = { - "Device": () => { showStep("step-device"); renderDevicePicker(); }, + "Device": () => { selectedDevice = null; showStep("step-device"); renderDevicePicker(); }, + "Driver": () => { showStep("step-zadig"); renderZadig(); }, "Connect": () => { showStep("step-connect"); renderConnect(); }, "Unbind": () => { showStep("step-unbind"); renderUnbind(); }, }; @@ -121,9 +124,40 @@ function renderDevicePicker() { $("pick-comma3").onclick = () => select("comma3"); $("pick-comma4").onclick = () => select("comma4"); $("btn-device-next").onclick = () => { + if (isWindows) { + showStep("step-zadig"); + renderZadig(); + updateStepper(getStepLabels().indexOf("Driver")); + } else { + showStep("step-connect"); + renderConnect(); + updateStepper(getStepLabels().indexOf("Connect")); + } + }; +} + +function renderZadig() { + const vendorId = selectedDevice === "comma4" ? "3801" : "05C6"; + $("step-zadig").innerHTML = ` +
+

install USB driver

+

Windows needs a driver to communicate with your device

+
+
    +
  1. 1Download and run Zadig
  2. +
  3. 2Under Device in the menu bar, select Create New Device
  4. +
  5. 3Fill in the form:
    + Name: ${selectedDevice === "comma4" ? "comma four" : "comma 3/3X"}
    + USB ID: ${vendorId} and 9008
  6. +
  7. 4Click Install Driver
  8. +
+
+ + `; + $("btn-zadig-done").onclick = () => { showStep("step-connect"); renderConnect(); - updateStepper(1); + updateStepper(getStepLabels().indexOf("Connect")); }; } @@ -156,11 +190,11 @@ function renderConnect() { if (isLinux && selectedDevice === "comma3") { showStep("step-unbind"); renderUnbind(); - updateStepper(2); + updateStepper(getStepLabels().indexOf("Unbind")); } else { showStep("step-webusb"); renderWebUSB(); - updateStepper(getStepLabels().length - 1); + updateStepper(getStepLabels().indexOf("Flash")); } }; } diff --git a/tools/flashpack/src/index.html b/tools/flashpack/src/index.html index 706e0de9..9a4aad4d 100644 --- a/tools/flashpack/src/index.html +++ b/tools/flashpack/src/index.html @@ -252,6 +252,7 @@
+
From ee8e1a84ba0b068fe0ffda22c3ec08e1292068d9 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Sun, 29 Mar 2026 12:33:24 -0700 Subject: [PATCH 25/43] named stepper labels, preserve selection when going back Stepper shows step names (Device, Connect, Unbind, Flash) instead of numbers, matching flash.comma.ai. Going back to Device preserves the previous selection so the step count stays stable. Completed steps are clickable with hover state. Co-Authored-By: Claude Opus 4.6 (1M context) --- tools/flashpack/src/app.ts | 17 ++++++++--------- tools/flashpack/src/index.html | 19 +++++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts index 9a73a2bb..0f1017a9 100644 --- a/tools/flashpack/src/app.ts +++ b/tools/flashpack/src/app.ts @@ -43,14 +43,12 @@ function updateStepper(current: number) { line.className = "stepper-line" + (i <= current ? " done" : ""); stepper.appendChild(line); } - const dot = document.createElement("div"); - dot.className = "stepper-dot" + (i === current ? " active" : i < current ? " done" : ""); - dot.textContent = i < current ? "\u2713" : String(i + 1); - if (i < current) { - dot.style.cursor = "pointer"; - dot.onclick = () => navigateTo(label); - } - stepper.appendChild(dot); + const btn = document.createElement("button"); + btn.className = "stepper-btn" + (i === current ? " active" : i < current ? " done" : ""); + btn.textContent = i < current ? `\u2713 ${label}` : label; + if (i < current) btn.onclick = () => navigateTo(label); + else btn.disabled = true; + stepper.appendChild(btn); }); el.appendChild(stepper); } @@ -58,7 +56,7 @@ function updateStepper(current: number) { function navigateTo(label: string) { const stepMap: Record void> = { - "Device": () => { selectedDevice = null; showStep("step-device"); renderDevicePicker(); }, + "Device": () => { showStep("step-device"); renderDevicePicker(); }, "Driver": () => { showStep("step-zadig"); renderZadig(); }, "Connect": () => { showStep("step-connect"); renderConnect(); }, "Unbind": () => { showStep("step-unbind"); renderUnbind(); }, @@ -123,6 +121,7 @@ function renderDevicePicker() { $("pick-comma3").onclick = () => select("comma3"); $("pick-comma4").onclick = () => select("comma4"); + if (selectedDevice) select(selectedDevice); $("btn-device-next").onclick = () => { if (isWindows) { showStep("step-zadig"); diff --git a/tools/flashpack/src/index.html b/tools/flashpack/src/index.html index 9a4aad4d..b501e590 100644 --- a/tools/flashpack/src/index.html +++ b/tools/flashpack/src/index.html @@ -88,20 +88,23 @@ .step.active { display: block; } .stepper { display: flex; align-items: center; justify-content: center; gap: 0.5rem; margin-bottom: 2.5rem; } - .stepper-dot { - width: 2.25rem; height: 2.25rem; border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: 0.875rem; font-weight: 700; + .stepper-btn { + padding: 0.375rem 0.875rem; border-radius: 9999px; + font-size: 0.8125rem; font-weight: 700; font-family: inherit; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.4); border: 2px solid rgba(255,255,255,0.2); - transition: all 0.3s; + transition: all 0.3s; cursor: default; } - .stepper-dot.active { + .stepper-btn.active { background: var(--lime); color: black; border-color: var(--yellow); box-shadow: 0 0 15px rgba(81,255,0,0.5); } - .stepper-dot.done { - background: var(--cyan); color: black; border-color: var(--cyan); + .stepper-btn.done { + background: rgba(81,255,0,0.15); color: var(--lime); border-color: var(--lime); + cursor: pointer; + } + .stepper-btn.done:hover { + background: rgba(81,255,0,0.25); } .stepper-line { width: 2rem; height: 2px; background: rgba(255,255,255,0.2); transition: background 0.3s; } .stepper-line.done { background: var(--cyan); box-shadow: 0 0 8px rgba(0,206,209,0.5); } From 529559816af865848fac383944e68366172a00dd Mon Sep 17 00:00:00 2001 From: Trey Moen Date: Sun, 29 Mar 2026 09:19:49 -0700 Subject: [PATCH 26/43] update README.md --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7752f48a..53e3d5b7 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,14 @@ comma threex: - [x] usb - [x] modem - [ ] sound -- [ ] SPI +- [x] SPI - [ ] GPS - [ ] cameras (OX03C10) - [ ] kernel wiring - [ ] ISP - [ ] openpilot -- [ ] graphics - - [ ] gpu +- [x] graphics + - [x] gpu - [ ] opencl - via rusticl / msm_drm - [ ] Venus? (video encode/decode) @@ -65,14 +65,14 @@ comma four: - [x] usb - [x] modem - [ ] sound -- [ ] SPI +- [x] SPI - [ ] GPS - [ ] cameras (OS04C10) - [ ] kernel wiring - [ ] ISP - [ ] openpilot -- [ ] graphics - - [ ] gpu +- [x] graphics + - [x] gpu - [ ] opencl - via rusticl / msm_drm - [ ] Venus (video encode/decode) From b94729e19a4bd62547412fa8aa5b73b92c451f5b Mon Sep 17 00:00:00 2001 From: Robin Reckmann Date: Mon, 30 Mar 2026 02:10:23 +0900 Subject: [PATCH 27/43] build: enable multithreading for erofs compression (#95) Co-authored-by: Trey Moen <50057480+greatgitsby@users.noreply.github.com> --- tools/build/Dockerfile.builder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build/Dockerfile.builder b/tools/build/Dockerfile.builder index c0fdc69a..a51b7177 100644 --- a/tools/build/Dockerfile.builder +++ b/tools/build/Dockerfile.builder @@ -33,7 +33,7 @@ RUN git clone https://git.kernel.org/pub/scm/linux/kernel/git/xiang/erofs-utils. && git checkout v1.8.5 \ && apk add --no-cache autoconf automake libtool \ && autoreconf -fi \ - && ./configure --enable-lz4 --enable-lzma --disable-fuse \ + && ./configure --enable-lz4 --enable-lzma --disable-fuse --enable-multithreading \ && make -j$(nproc) \ && make install \ && rm -rf /tmp/erofs-utils From 91a6748b3c73c622dd3dbdfd3cb0dbfa04ab9750 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:33:00 -0700 Subject: [PATCH 28/43] Revert CI images in GitHub (#101) * Revert "use versioned branch names for release images (#93)" This reverts commit a4842a742dde4d6cc2d2eb6bb34ab0c91faad1de. * Revert "ci: push release images to branch instead of GitHub release (#90)" This reverts commit c937d27af53626d3b3b094f6e24b91a3e455181e. --- .github/workflows/build.yml | 32 +++++++++++++++++++------------- tools/build/package_ota.py | 33 ++++++--------------------------- 2 files changed, 25 insertions(+), 40 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 70bf1b83..4c52e3b3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -130,23 +130,29 @@ jobs: path: build/ - name: generate manifest + env: + RELEASE_URL: https://github.com/${{ github.repository }}/releases/download/v${{ env.VERSION }} run: | VERSION=$(cat userspace/root/VERSION) - BRANCH="v${VERSION}" echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "BRANCH=$BRANCH" >> $GITHUB_ENV - BASE_URL="https://raw.githubusercontent.com/${{ github.repository }}/${BRANCH}" - BASE_URL=$BASE_URL python3 tools/build/package_ota.py + RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}" + RELEASE_URL=$RELEASE_URL python3 tools/build/package_ota.py - - name: push images to version branch + - name: create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - cd build/ota - git init - git checkout --orphan "$BRANCH" - git add . - git -c user.name="github-actions" -c user.email="actions@github.com" \ - commit -m "release images for $BRANCH" - git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" - git push --force origin "$BRANCH" + TAG="v${VERSION}" + + # Delete existing release if it exists + gh release delete "$TAG" --yes 2>/dev/null || true + git tag -d "$TAG" 2>/dev/null || true + git push origin ":refs/tags/$TAG" 2>/dev/null || true + + # Create release with all images and manifest + gh release create "$TAG" \ + --title "vamOS $TAG" \ + --target "${{ github.sha }}" \ + --notes "Automated release from commit ${{ github.sha }}" \ + build/ota/*.img \ + build/ota/manifest.json diff --git a/tools/build/package_ota.py b/tools/build/package_ota.py index 96bbae58..847c6476 100644 --- a/tools/build/package_ota.py +++ b/tools/build/package_ota.py @@ -12,9 +12,8 @@ OTA_OUTPUT_DIR = OUTPUT_DIR / "ota" SECTOR_SIZE = 4096 -CHUNK_SIZE = 52_428_800 # 50 MB - must be under raw.githubusercontent.com's 100 MB limit -BASE_URL = os.environ.get("BASE_URL", "https://raw.githubusercontent.com/commaai/vamOS/release-images") +RELEASE_URL = os.environ.get("RELEASE_URL", "https://github.com/commaai/vamos/releases/download/untagged") GPT = namedtuple('GPT', ['lun', 'name', 'path', 'start_sector', 'num_sectors', 'has_ab', 'full_check']) GPTS = [ @@ -74,30 +73,14 @@ def process_file(entry): sha256.update(b'\x00' * ((SECTOR_SIZE - (size % SECTOR_SIZE)) % SECTOR_SIZE)) ondevice_hash = sha256.hexdigest() - base_name = f"{entry.name}-{hash_raw}.img" - - # Write file(s) to output directory, splitting into chunks if needed - chunks = None - if size > CHUNK_SIZE: - chunks = [] - chunk_idx = 0 - with open(entry.path, 'rb') as f: - while True: - data = f.read(CHUNK_SIZE) - if not data: - break - chunk_name = f"{base_name}.{chunk_idx:02d}" - (OTA_OUTPUT_DIR / chunk_name).write_bytes(data) - chunks.append({"url": f"{BASE_URL}/{chunk_name}", "size": len(data)}) - print(f" chunk {chunk_idx}: {chunk_name} ({len(data)} bytes)") - chunk_idx += 1 - else: - print(f" copying to {base_name}") - shutil.copy(entry.path, OTA_OUTPUT_DIR / base_name) + # Copy to output directory + out_fn = OTA_OUTPUT_DIR / f"{entry.name}-{hash_raw}.img" + print(f" copying to {out_fn.name}") + shutil.copy(entry.path, out_fn) ret = { "name": entry.name, - "url": f"{BASE_URL}/{base_name}", + "url": f"{RELEASE_URL}/{out_fn.name}", "hash": hash, "hash_raw": hash_raw, "size": size, @@ -107,10 +90,6 @@ def process_file(entry): "ondevice_hash": ondevice_hash, } - if chunks: - ret["url"] = "" - ret["chunks"] = chunks - if isinstance(entry, GPT): ret["gpt"] = { "lun": entry.lun, From bd69c46b5e49bf9e790352eb197ee3ceb6801ac0 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:55:32 -0700 Subject: [PATCH 29/43] ci: fix PR comment for fork PRs (#102) * ci: fix PR comment for fork PRs using pull_request_target GITHUB_TOKEN for pull_request events from forks only gets read permissions, so the rootfs profile comment fails. Move the comment posting to a separate workflow using pull_request_target (which runs in the base repo context with write permissions), matching openpilot's pattern. * ci: rename pr-comment.yml to profile.yml --- .github/workflows/build.yml | 42 ------------------ .github/workflows/profile.yml | 84 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/profile.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c52e3b3..ddb3a341 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -64,48 +64,6 @@ jobs: build/rootfs-profile.md if-no-files-found: error - - name: download master baseline - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mkdir -p baseline - RUN_ID=$(gh run list --workflow=build.yml --branch=master \ - --status=success --limit=1 --json databaseId --jq '.[0].databaseId') - if [ -n "$RUN_ID" ]; then - gh run download "$RUN_ID" --name rootfs-profile --dir baseline/ || true - fi - - - name: post PR comment - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Generate diff (gracefully handles missing baseline) - DIFF_MD=$(./vamos profile diff baseline/rootfs-profile.json build/rootfs-profile.json 2>/dev/null || echo "No baseline available") - PROFILE_MD=$(cat build/rootfs-profile.md) - - # Assemble comment with hidden marker for find-and-update - printf -v COMMENT_BODY '%s\n%s\n\n%s\n%s\n\n---\n\n%s' \ - '' \ - '## vamOS System Profile' \ - '### Changes vs master' \ - "$DIFF_MD" \ - "$PROFILE_MD" - - # Find existing comment by marker - COMMENT_ID=$(gh api \ - "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ - --jq '.[] | select(.body | contains("")) | .id' \ - | head -1) - - if [ -n "$COMMENT_ID" ]; then - gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" \ - -X PATCH -f body="$COMMENT_BODY" - else - gh pr comment "${{ github.event.pull_request.number }}" --body "$COMMENT_BODY" - fi - release: if: github.event_name == 'push' needs: [build-kernel, build-system] diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml new file mode 100644 index 00000000..586df6d7 --- /dev/null +++ b/.github/workflows/profile.yml @@ -0,0 +1,84 @@ +name: profile + +on: + pull_request_target: + types: [opened, synchronize, reopened] + +env: + SHA: ${{ github.event.pull_request.head.sha }} + +jobs: + pr-comment: + if: github.repository == 'commaai/vamOS' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + actions: read + steps: + - uses: actions/checkout@v4 + + - name: wait for build + uses: lewagon/wait-on-check-action@v1.3.4 + with: + ref: ${{ env.SHA }} + check-name: build-system + repo-token: ${{ secrets.GITHUB_TOKEN }} + allowed-conclusions: success + wait-interval: 20 + + - name: get build run ID + id: get_run_id + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + RUN_ID=$(gh api "repos/${{ github.repository }}/commits/${{ env.SHA }}/check-runs" \ + --jq '.check_runs[] | select(.name == "build-system") | .html_url | capture("(?[0-9]+)") | .n' \ + | head -1) + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: download rootfs profile + uses: dawidd6/action-download-artifact@v6 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + run_id: ${{ steps.get_run_id.outputs.run_id }} + name: rootfs-profile + path: build/ + + - name: download master baseline + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p baseline + RUN_ID=$(gh run list --workflow=build.yml --branch=master \ + --status=success --limit=1 --json databaseId --jq '.[0].databaseId') + if [ -n "$RUN_ID" ]; then + gh run download "$RUN_ID" --name rootfs-profile --dir baseline/ || true + fi + + - name: post PR comment + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + DIFF_MD=$(./vamos profile diff baseline/rootfs-profile.json build/rootfs-profile.json 2>/dev/null || echo "No baseline available") + PROFILE_MD=$(cat build/rootfs-profile.md) + + printf -v COMMENT_BODY '%s\n%s\n\n%s\n%s\n\n---\n\n%s' \ + '' \ + '## vamOS System Profile' \ + '### Changes vs master' \ + "$DIFF_MD" \ + "$PROFILE_MD" + + COMMENT_ID=$(gh api \ + "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + --jq '.[] | select(.body | contains("")) | .id' \ + | head -1) + + if [ -n "$COMMENT_ID" ]; then + gh api "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" \ + -X PATCH -f body="$COMMENT_BODY" + else + gh pr comment "${{ github.event.pull_request.number }}" --body "$COMMENT_BODY" + fi From ba7b19866de7d1a9c50463cddc88bd35ba0970b3 Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 1 Apr 2026 18:57:04 -0700 Subject: [PATCH 30/43] ci: fix profile workflow race with build-system check (#105) Upgrade wait-on-check-action to v1.6.0 and add checks-discovery-timeout so it waits up to 5 minutes for build-system to appear instead of immediately failing when the check doesn't exist yet. --- .github/workflows/profile.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/profile.yml b/.github/workflows/profile.yml index 586df6d7..787ba671 100644 --- a/.github/workflows/profile.yml +++ b/.github/workflows/profile.yml @@ -20,13 +20,14 @@ jobs: - uses: actions/checkout@v4 - name: wait for build - uses: lewagon/wait-on-check-action@v1.3.4 + uses: lewagon/wait-on-check-action@v1.6.0 with: ref: ${{ env.SHA }} check-name: build-system repo-token: ${{ secrets.GITHUB_TOKEN }} allowed-conclusions: success wait-interval: 20 + checks-discovery-timeout: 300 - name: get build run ID id: get_run_id From e00514b46e75dcd77bb173ae8f30f470d22aa57a Mon Sep 17 00:00:00 2001 From: Trey Moen <50057480+greatgitsby@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:37:48 -0700 Subject: [PATCH 31/43] ci: publish images to vamos-images repo (#104) * ci: publish images to vamos-images repo, manifest as release Images are committed to {owner}/vamos-images with a version tag, so forks can publish to their own vamos-images repo. The manifest is published as a GitHub Release on vamOS itself. * build: restore chunking for large images in package_ota Files over 50 MB are split into chunks to stay under raw.githubusercontent.com's 100 MB limit. * build: derive default IMAGES_URL from VERSION file * ci: skip publish gracefully when deploy key is missing Shows setup instructions as workflow warnings instead of failing. * ci: fix profile workflow race with build-system check Upgrade wait-on-check-action to v1.6.0 and add checks-discovery-timeout so it waits up to 5 minutes for build-system to appear instead of immediately failing when the check doesn't exist yet. * Revert "ci: fix profile workflow race with build-system check" This reverts commit b1d74fdff125a8212463fd302b7f978079cf1b93. * ci: fix ambiguous refspec when pushing images tag Use an unrelated orphan branch name and push with refs/tags/ prefix to avoid ambiguity between branch and tag with the same name. * ci: include chunked image files in vamos-images push The glob *.img missed chunk files (*.img.00, *.img.01, etc.) produced by the chunking logic for large images like system. * ci: include manifest in vamos-images, link from release Manifest needs CORS headers, which raw.githubusercontent.com provides but release-assets.githubusercontent.com does not. * ci: fix invalid YAML in release notes multiline string --- .github/workflows/build.yml | 60 ++++++++++++++++++++++++++++++++----- tools/build/package_ota.py | 34 +++++++++++++++++---- 2 files changed, 80 insertions(+), 14 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ddb3a341..dcf4ce2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,16 +87,48 @@ jobs: name: system.erofs.img path: build/ + - name: check deploy key + id: check-key + run: | + if [ -n "${{ secrets.VAMOS_IMAGES_DEPLOY_KEY }}" ]; then + echo "has_key=true" >> $GITHUB_OUTPUT + else + echo "has_key=false" >> $GITHUB_OUTPUT + echo "::warning::VAMOS_IMAGES_DEPLOY_KEY secret is not set — skipping image publish and release." + fi + - name: generate manifest - env: - RELEASE_URL: https://github.com/${{ github.repository }}/releases/download/v${{ env.VERSION }} + if: steps.check-key.outputs.has_key == 'true' run: | VERSION=$(cat userspace/root/VERSION) echo "VERSION=$VERSION" >> $GITHUB_ENV - RELEASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}" - RELEASE_URL=$RELEASE_URL python3 tools/build/package_ota.py + IMAGES_URL="https://github.com/${{ github.repository_owner }}/vamos-images/raw/v${VERSION}" + IMAGES_URL=$IMAGES_URL python3 tools/build/package_ota.py + + - name: push images to vamos-images + if: steps.check-key.outputs.has_key == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ github.repository_owner }}/vamos-images + ssh-key: ${{ secrets.VAMOS_IMAGES_DEPLOY_KEY }} + path: vamos-images + + - name: commit and tag images + if: steps.check-key.outputs.has_key == 'true' + run: | + TAG="v${VERSION}" + cd vamos-images + git checkout --orphan release + cp ../build/ota/*.img* . + cp ../build/ota/manifest.json . + git add . + git -c user.name="github-actions" -c user.email="actions@github.com" \ + commit -m "release images for $TAG" + git tag "$TAG" + git push origin "refs/tags/$TAG" --force - name: create release + if: steps.check-key.outputs.has_key == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -107,10 +139,22 @@ jobs: git tag -d "$TAG" 2>/dev/null || true git push origin ":refs/tags/$TAG" 2>/dev/null || true - # Create release with all images and manifest + MANIFEST_URL="https://github.com/${{ github.repository_owner }}/vamos-images/raw/${TAG}/manifest.json" + + # Create release linking to manifest + NOTES="Automated release from commit ${{ github.sha }} + + Manifest: ${MANIFEST_URL}" gh release create "$TAG" \ --title "vamOS $TAG" \ --target "${{ github.sha }}" \ - --notes "Automated release from commit ${{ github.sha }}" \ - build/ota/*.img \ - build/ota/manifest.json + --notes "$NOTES" + + - name: post setup instructions + if: steps.check-key.outputs.has_key == 'false' + run: | + echo "::warning::VAMOS_IMAGES_DEPLOY_KEY is not configured. To enable image publishing:" + echo "::warning::1. Create a ${{ github.repository_owner }}/vamos-images repo" + echo "::warning::2. Generate an SSH deploy key: ssh-keygen -t ed25519 -f vamos-images-deploy-key -N \"\"" + echo "::warning::3. Add the public key to ${{ github.repository_owner }}/vamos-images as a deploy key with write access" + echo "::warning::4. Add the private key as VAMOS_IMAGES_DEPLOY_KEY secret in ${{ github.repository }}" diff --git a/tools/build/package_ota.py b/tools/build/package_ota.py index 847c6476..4dd9f961 100644 --- a/tools/build/package_ota.py +++ b/tools/build/package_ota.py @@ -12,8 +12,10 @@ OTA_OUTPUT_DIR = OUTPUT_DIR / "ota" SECTOR_SIZE = 4096 +CHUNK_SIZE = 52_428_800 # 50 MB - must be under raw.githubusercontent.com's 100 MB limit -RELEASE_URL = os.environ.get("RELEASE_URL", "https://github.com/commaai/vamos/releases/download/untagged") +VERSION = open(ROOT / "userspace" / "root" / "VERSION").read().strip() +IMAGES_URL = os.environ.get("IMAGES_URL", f"https://github.com/commaai/vamos-images/raw/v{VERSION}") GPT = namedtuple('GPT', ['lun', 'name', 'path', 'start_sector', 'num_sectors', 'has_ab', 'full_check']) GPTS = [ @@ -73,14 +75,30 @@ def process_file(entry): sha256.update(b'\x00' * ((SECTOR_SIZE - (size % SECTOR_SIZE)) % SECTOR_SIZE)) ondevice_hash = sha256.hexdigest() - # Copy to output directory - out_fn = OTA_OUTPUT_DIR / f"{entry.name}-{hash_raw}.img" - print(f" copying to {out_fn.name}") - shutil.copy(entry.path, out_fn) + base_name = f"{entry.name}-{hash_raw}.img" + + # Write file(s) to output directory, splitting into chunks if needed + chunks = None + if size > CHUNK_SIZE: + chunks = [] + chunk_idx = 0 + with open(entry.path, 'rb') as f: + while True: + data = f.read(CHUNK_SIZE) + if not data: + break + chunk_name = f"{base_name}.{chunk_idx:02d}" + (OTA_OUTPUT_DIR / chunk_name).write_bytes(data) + chunks.append({"url": f"{IMAGES_URL}/{chunk_name}", "size": len(data)}) + print(f" chunk {chunk_idx}: {chunk_name} ({len(data)} bytes)") + chunk_idx += 1 + else: + print(f" copying to {base_name}") + shutil.copy(entry.path, OTA_OUTPUT_DIR / base_name) ret = { "name": entry.name, - "url": f"{RELEASE_URL}/{out_fn.name}", + "url": f"{IMAGES_URL}/{base_name}", "hash": hash, "hash_raw": hash_raw, "size": size, @@ -90,6 +108,10 @@ def process_file(entry): "ondevice_hash": ondevice_hash, } + if chunks: + ret["url"] = "" + ret["chunks"] = chunks + if isinstance(entry, GPT): ret["gpt"] = { "lun": entry.lun, From c5998a508101842f0779b8dfd478d03518ab8ca1 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 20:09:47 -0700 Subject: [PATCH 32/43] Fix redirect --- tools/flashpack/src/utils/manifest.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/flashpack/src/utils/manifest.ts b/tools/flashpack/src/utils/manifest.ts index 12aa195f..22fe9633 100644 --- a/tools/flashpack/src/utils/manifest.ts +++ b/tools/flashpack/src/utils/manifest.ts @@ -1,4 +1,5 @@ const REPO = "commaai/vamOS"; +const IMAGES_REPO = "commaai/vamos-images"; const VERSION_URL = `https://raw.githubusercontent.com/${REPO}/master/userspace/root/VERSION`; export interface ChunkInfo { @@ -29,7 +30,7 @@ export async function getManifest(): Promise { if (!versionRes.ok) throw new Error(`Failed to fetch version: ${versionRes.status}`); const version = (await versionRes.text()).trim(); - const manifestUrl = `https://raw.githubusercontent.com/${REPO}/v${version}/manifest.json`; + const manifestUrl = `https://raw.githubusercontent.com/${IMAGES_REPO}/v${version}/manifest.json`; const res = await fetch(manifestUrl); if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`); return res.json(); From f1c22644456a084f471350c9a6ac1df205f4c2c8 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 20:15:13 -0700 Subject: [PATCH 33/43] Remove test trigger --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5a663a11..b72860d7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: deploy pages on: push: - branches: [master, flash] + branches: [master] paths: - 'tools/flashpack/**' - '.github/workflows/pages.yml' From eb84daf6e90943e0ddb9f2be0c900d25736873bc Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 20:35:53 -0700 Subject: [PATCH 34/43] Add option to set custom domain in the future --- .github/workflows/pages.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index b72860d7..509c851a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -33,6 +33,11 @@ jobs: bun install bun run build + # To deploy to a custom domain (e.g. flashpack.comma.ai), + # uncomment this step and configure DNS to point to GitHub Pages + # - name: set custom domain + # run: echo "flashpack.comma.ai" > tools/flashpack/dist/CNAME + - name: configure pages uses: actions/configure-pages@v5 From 9b578ad7c3fdaf26c56b44d6b852f3506916a7a9 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 21:10:03 -0700 Subject: [PATCH 35/43] Enable test deploys again --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 509c851a..62db32ea 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: deploy pages on: push: - branches: [master] + branches: [master,flash] paths: - 'tools/flashpack/**' - '.github/workflows/pages.yml' From 613ab602ba0705e7b4954948daa8140245667c04 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 21:23:37 -0700 Subject: [PATCH 36/43] Make suggested changes from review --- .github/workflows/pages.yml | 5 - tools/flashpack/build.ts | 5 + tools/flashpack/setup-udev.sh | 17 - tools/flashpack/src/app.ts | 11 +- ...l-ports-three.svg => qdl-ports-threex.svg} | 0 tools/flashpack/src/index.html | 895 ++++++++++++------ tools/flashpack/src/utils/image.ts | 2 +- tools/flashpack/src/utils/manifest.ts | 7 +- 8 files changed, 634 insertions(+), 308 deletions(-) delete mode 100755 tools/flashpack/setup-udev.sh rename tools/flashpack/src/assets/{qdl-ports-three.svg => qdl-ports-threex.svg} (100%) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 62db32ea..ab40d530 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -33,11 +33,6 @@ jobs: bun install bun run build - # To deploy to a custom domain (e.g. flashpack.comma.ai), - # uncomment this step and configure DNS to point to GitHub Pages - # - name: set custom domain - # run: echo "flashpack.comma.ai" > tools/flashpack/dist/CNAME - - name: configure pages uses: actions/configure-pages@v5 diff --git a/tools/flashpack/build.ts b/tools/flashpack/build.ts index 5521ee9a..04711fff 100644 --- a/tools/flashpack/build.ts +++ b/tools/flashpack/build.ts @@ -1,8 +1,13 @@ +const gitSha = Bun.spawnSync(["git", "rev-parse", "HEAD"]).stdout.toString().trim(); + const build = await Bun.build({ entrypoints: ["./src/index.html"], outdir: "./dist", sourcemap: "linked", minify: true, + define: { + "process.env.GIT_SHA": JSON.stringify(gitSha), + }, }); if (!build.success) { diff --git a/tools/flashpack/setup-udev.sh b/tools/flashpack/setup-udev.sh deleted file mode 100755 index fcf565d3..00000000 --- a/tools/flashpack/setup-udev.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -e - -sudo tee /etc/udev/rules.d/99-qualcomm-edl.rules > /dev/null <<'EOF' -SUBSYSTEM=="usb", ATTR{idVendor}=="05c6", ATTR{idProduct}=="9008", MODE="0666" -SUBSYSTEM=="usb", ATTR{idVendor}=="3801", ATTR{idProduct}=="9008", MODE="0666" -EOF - -sudo udevadm control --reload-rules -sudo udevadm trigger - -for d in /sys/bus/usb/drivers/qcserial/*-* -do - [ -e "$d" ] && echo -n "$(basename $d)" | sudo tee /sys/bus/usb/drivers/qcserial/unbind > /dev/null -done - -echo "Done. Unplug and replug your device." diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts index 0f1017a9..673d1596 100644 --- a/tools/flashpack/src/app.ts +++ b/tools/flashpack/src/app.ts @@ -1,7 +1,7 @@ import { FlashManager, Step, ErrorCode, loadProgrammer } from "./utils/manager"; import { getManifest } from "./utils/manifest"; -import portsThree from "./assets/qdl-ports-three.svg"; +import portsThree from "./assets/qdl-ports-threex.svg"; import portsFour from "./assets/qdl-ports-four.svg"; import comma3X from "./assets/comma3X.webp"; import commaFour from "./assets/four_screen_on.webp"; @@ -333,11 +333,16 @@ async function init() { $("init-status").textContent = "loading programmer + manifest..."; try { - const [programmer, manifest] = await Promise.all([ + const [programmer, { version, manifest }] = await Promise.all([ loadProgrammer(), getManifest(), ]); + const sha = process.env.GIT_SHA || "master"; + const versionLink = document.getElementById("version-link") as HTMLAnchorElement; + versionLink.href = `https://github.com/commaai/vamOS/tree/${sha}`; + versionLink.textContent = sha.slice(0, 7); + console.info("[flashpack] Manifest loaded:", manifest.length, "entries"); manager = new FlashManager(programmer, {}); await manager.initialize(manifest); @@ -346,7 +351,7 @@ async function init() { throw new Error("Initialization failed"); } - $("init-status").textContent = `ready! (${manager.step === Step.READY ? "manifest loaded" : "..."})`; + $("init-status").textContent = ""; ($("btn-start") as HTMLButtonElement).disabled = false; } catch (err: any) { console.error("[flashpack] Init failed:", err); diff --git a/tools/flashpack/src/assets/qdl-ports-three.svg b/tools/flashpack/src/assets/qdl-ports-threex.svg similarity index 100% rename from tools/flashpack/src/assets/qdl-ports-three.svg rename to tools/flashpack/src/assets/qdl-ports-threex.svg diff --git a/tools/flashpack/src/index.html b/tools/flashpack/src/index.html index b501e590..60038065 100644 --- a/tools/flashpack/src/index.html +++ b/tools/flashpack/src/index.html @@ -1,281 +1,618 @@ - + - - - - ~*~ flashpack ~*~ - - - -
- - - -
-
-
-
-
-
-
-
-
-
- - - - - - + + + + ~*~ flashpack ~*~ + + + +
+ + + +
+
+
+
+
+
+
+
+
+
+ + + + + + diff --git a/tools/flashpack/src/utils/image.ts b/tools/flashpack/src/utils/image.ts index 2735d25b..321c9337 100644 --- a/tools/flashpack/src/utils/image.ts +++ b/tools/flashpack/src/utils/image.ts @@ -3,7 +3,7 @@ import type { ManifestEntry } from "./manifest"; type ProgressCallback = (progress: number) => void; -const MIN_QUOTA_GB = 5.25; +const MIN_QUOTA_GB = 3; export class ImageManager { root: FileSystemDirectoryHandle | null = null; diff --git a/tools/flashpack/src/utils/manifest.ts b/tools/flashpack/src/utils/manifest.ts index 22fe9633..d01cbde3 100644 --- a/tools/flashpack/src/utils/manifest.ts +++ b/tools/flashpack/src/utils/manifest.ts @@ -9,7 +9,7 @@ export interface ChunkInfo { export interface ManifestEntry { name: string; - url: string; + url?: string; hash: string; hash_raw: string; size: number; @@ -25,7 +25,7 @@ export interface ManifestEntry { }; } -export async function getManifest(): Promise { +export async function getManifest(): Promise<{ version: string; manifest: ManifestEntry[] }> { const versionRes = await fetch(VERSION_URL); if (!versionRes.ok) throw new Error(`Failed to fetch version: ${versionRes.status}`); const version = (await versionRes.text()).trim(); @@ -33,5 +33,6 @@ export async function getManifest(): Promise { const manifestUrl = `https://raw.githubusercontent.com/${IMAGES_REPO}/v${version}/manifest.json`; const res = await fetch(manifestUrl); if (!res.ok) throw new Error(`Failed to fetch manifest: ${res.status}`); - return res.json(); + const manifest = await res.json(); + return { version, manifest }; } From 354bf5eef7111b3da7415a8adb6e62c36400e16a Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 21:26:48 -0700 Subject: [PATCH 37/43] Don't build flash commits --- .github/workflows/pages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ab40d530..b72860d7 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,7 +2,7 @@ name: deploy pages on: push: - branches: [master,flash] + branches: [master] paths: - 'tools/flashpack/**' - '.github/workflows/pages.yml' From bb75bb52c91f6e852532f4ab96216c9777f0bb04 Mon Sep 17 00:00:00 2001 From: Matt Purnell Date: Wed, 1 Apr 2026 21:44:07 -0700 Subject: [PATCH 38/43] Remove init status --- tools/flashpack/src/app.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tools/flashpack/src/app.ts b/tools/flashpack/src/app.ts index 673d1596..600274de 100644 --- a/tools/flashpack/src/app.ts +++ b/tools/flashpack/src/app.ts @@ -78,7 +78,6 @@ function renderLanding() {

~*~ flashpack ~*~

can YOU help me flash vamOS onto my comma device??

-