Files
pyxis/docs/flasher/index.html
torlando-tech fd6fb4bcda Add firmware version picker to web flasher
Lets users choose between the latest dev build and tagged GitHub releases.
The dropdown queries the GitHub Releases API on page load and swaps
firmware fetch paths between Pages-relative and release-asset URLs.
CI now attaches all 4 firmware files to releases (bootloader, partitions,
boot_app0, firmware) so full installs work from any release version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:36:03 -05:00

521 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pyxis T-Deck Flasher</title>
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--bg: #0f172a;
--card: #1e293b;
--text: #e2e8f0;
--muted: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem 1rem;
line-height: 1.6;
}
.container { max-width: 700px; margin: 0 auto; }
header { text-align: center; margin-bottom: 2.5rem; }
h1 { color: var(--primary); font-size: 2rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); font-size: 1.1rem; }
.card {
background: var(--card);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.25rem;
}
.card h2 { font-size: 1.25rem; margin-bottom: 1rem; }
.flash-section { text-align: center; padding: 2rem; }
.status-box {
background: rgba(0,0,0,0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: left;
}
.status-box .label { color: var(--muted); font-size: 0.875rem; }
.status-box .value { font-size: 1.1rem; font-weight: 500; }
.status-detected { color: var(--success); }
.status-not-detected { color: var(--muted); }
.status-checking { color: var(--warning); }
.status-flashing { color: var(--primary); }
.button-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
button {
font-family: inherit;
font-size: 1rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.checkbox-group {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 8px;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.checkbox-group input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
cursor: pointer;
}
.checkbox-group label { cursor: pointer; color: var(--danger); }
.hidden { display: none !important; }
.progress-container {
margin: 1.5rem 0;
text-align: left;
}
.progress-bar {
height: 24px;
background: rgba(0,0,0,0.3);
border-radius: 12px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--success));
border-radius: 12px;
transition: width 0.2s;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.progress-text {
font-size: 0.875rem;
color: var(--muted);
}
#log {
background: rgba(0,0,0,0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
font-family: monospace;
font-size: 0.8rem;
max-height: 200px;
overflow-y: auto;
text-align: left;
}
#log .log-entry { margin-bottom: 0.25rem; }
#log .log-info { color: var(--muted); }
#log .log-success { color: var(--success); }
#log .log-error { color: var(--danger); }
.version { margin-top: 1rem; font-size: 0.875rem; color: var(--muted); }
.requirements ul { list-style: none; padding-left: 0; }
.requirements li {
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.requirements li:last-child { border-bottom: none; }
.warning {
background: rgba(245, 158, 11, 0.15);
border-left: 3px solid var(--warning);
padding: 1rem;
margin-top: 1rem;
border-radius: 0 8px 8px 0;
font-size: 0.9rem;
}
.warning strong { color: var(--warning); }
.version-select-group {
margin-bottom: 1.5rem;
}
.version-select-group label {
display: block;
color: var(--muted);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.version-select-group select {
width: 100%;
padding: 0.625rem 1rem;
background: rgba(0,0,0,0.3);
color: var(--text);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
font-family: inherit;
font-size: 1rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%2394a3b8' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
}
.version-select-group select:focus {
outline: none;
border-color: var(--primary);
}
.version-note {
font-size: 0.8rem;
color: var(--warning);
margin-top: 0.5rem;
}
footer {
text-align: center;
margin-top: 2rem;
color: var(--muted);
font-size: 0.875rem;
}
footer a { color: var(--primary); text-decoration: none; }
footer a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>Pyxis T-Deck Flasher</h1>
<p class="subtitle">Flash your LilyGO T-Deck Plus with Pyxis firmware</p>
</header>
<div class="card flash-section">
<h2>Install Firmware</h2>
<div class="status-box">
<div class="label">Status</div>
<div class="value" id="device-status">
<span class="status-not-detected">Ready to flash</span>
</div>
</div>
<div class="version-select-group">
<label for="version-select">Firmware Version</label>
<select id="version-select">
<option value="latest">Latest (loading...)</option>
</select>
<div id="version-note" class="version-note hidden"></div>
</div>
<div class="checkbox-group">
<input type="checkbox" id="erase-checkbox">
<label for="erase-checkbox">Full install (required for first install, erases settings & messages)</label>
</div>
<div class="button-group">
<button id="flash-btn" class="btn-primary">Flash Firmware</button>
</div>
<div id="progress-container" class="progress-container hidden">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill" style="width: 0%">0%</div>
</div>
<div class="progress-text" id="progress-text">Preparing...</div>
</div>
<div id="log"></div>
<p class="version">Firmware version: <span id="firmware-version">dev</span></p>
</div>
<div class="card requirements">
<h2>Requirements</h2>
<ul>
<li><strong>Browser:</strong> Chrome, Edge, or Opera (Web Serial API required)</li>
<li><strong>Device:</strong> LilyGO T-Deck Plus (ESP32-S3, 8MB Flash)</li>
<li><strong>Cable:</strong> USB-C data cable (not charge-only)</li>
</ul>
</div>
<div class="card">
<h2>How It Works</h2>
<ul style="list-style: none; padding: 0;">
<li style="padding: 0.5rem 0;"><strong>1.</strong> Connect T-Deck via USB-C</li>
<li style="padding: 0.5rem 0;"><strong>2.</strong> Click "Flash Firmware" and select the serial port</li>
<li style="padding: 0.5rem 0;"><strong>3.</strong> Wait for the firmware to be written</li>
</ul>
<div class="warning">
<strong>Tip:</strong> By default, only the firmware is updated - settings and messages are preserved.
Check "Full install" for a clean wipe.
</div>
</div>
<footer>
<p>
<a href="https://github.com/torlando-tech/pyxis" target="_blank">GitHub</a>
&nbsp;|&nbsp;
<a href="https://github.com/torlando-tech/pyxis/releases" target="_blank">Releases</a>
</p>
<p style="margin-top: 0.5rem;">Powered by esptool-js</p>
</footer>
</div>
<script type="module">
import { ESPLoader, Transport } from './js/esptool.js';
const flashBtn = document.getElementById('flash-btn');
const eraseCheckbox = document.getElementById('erase-checkbox');
const deviceStatus = document.getElementById('device-status');
const logDiv = document.getElementById('log');
const progressContainer = document.getElementById('progress-container');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const versionSelect = document.getElementById('version-select');
const versionNote = document.getElementById('version-note');
const REQUIRED_FULL_ASSETS = ['bootloader.bin', 'partitions.bin', 'boot_app0.bin', 'firmware.bin'];
// Current firmware path prefix — empty string for relative (latest), or a release download URL
let firmwarePathPrefix = '';
// Track whether the selected release has all assets for full install
let releaseHasFullAssets = true;
function makeFirmwareFiles(prefix) {
return {
full: [
{ offset: 0x0, name: 'bootloader.bin', path: prefix + 'bootloader.bin' },
{ offset: 0x8000, name: 'partitions.bin', path: prefix + 'partitions.bin' },
{ offset: 0xe000, name: 'boot_app0.bin', path: prefix + 'boot_app0.bin' },
{ offset: 0x10000, name: 'firmware.bin', path: prefix + 'firmware.bin' }
],
update: [
{ offset: 0x10000, name: 'firmware.bin', path: prefix + 'firmware.bin' }
]
};
}
function log(message, type = 'info') {
logDiv.classList.remove('hidden');
const entry = document.createElement('div');
entry.className = `log-entry log-${type}`;
const time = new Date().toLocaleTimeString();
entry.textContent = `[${time}] ${message}`;
logDiv.appendChild(entry);
logDiv.scrollTop = logDiv.scrollHeight;
}
function updateStatus(html) {
deviceStatus.innerHTML = html;
}
function setProgress(percent, text) {
progressContainer.classList.remove('hidden');
progressFill.style.width = `${percent}%`;
progressFill.textContent = `${Math.round(percent)}%`;
progressText.textContent = text;
}
// Version selector logic
let latestVersion = 'dev';
async function loadVersions() {
// Load deployed manifest version first
try {
const manifest = await fetch('manifest-install.json').then(r => r.json());
latestVersion = manifest.version;
document.getElementById('firmware-version').textContent = latestVersion;
} catch (e) {}
// Update the "Latest" option text
versionSelect.options[0].textContent = `Latest (${latestVersion})`;
// Fetch GitHub releases
try {
const resp = await fetch('https://api.github.com/repos/torlando-tech/pyxis/releases');
if (!resp.ok) return;
const releases = await resp.json();
for (const release of releases) {
if (release.draft || release.prerelease) continue;
const assetNames = release.assets.map(a => a.name);
if (!assetNames.includes('firmware.bin')) continue;
const option = document.createElement('option');
const hasAll = REQUIRED_FULL_ASSETS.every(f => assetNames.includes(f));
option.value = release.tag_name;
option.textContent = release.tag_name + (release.name && release.name !== release.tag_name ? `${release.name}` : '');
option.dataset.downloadUrl = `https://github.com/torlando-tech/pyxis/releases/download/${release.tag_name}/`;
option.dataset.hasFullAssets = hasAll ? 'true' : 'false';
versionSelect.appendChild(option);
}
} catch (e) {
// API failures are non-critical — user can still flash latest
}
}
versionSelect.addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
if (this.value === 'latest') {
firmwarePathPrefix = 'firmware/';
releaseHasFullAssets = true;
document.getElementById('firmware-version').textContent = latestVersion;
} else {
firmwarePathPrefix = selected.dataset.downloadUrl;
releaseHasFullAssets = selected.dataset.hasFullAssets === 'true';
document.getElementById('firmware-version').textContent = this.value;
}
// Handle full-install availability
if (!releaseHasFullAssets) {
eraseCheckbox.checked = false;
eraseCheckbox.disabled = true;
versionNote.textContent = 'Full install unavailable for this release (missing bootloader/partition assets)';
versionNote.classList.remove('hidden');
} else {
eraseCheckbox.disabled = false;
versionNote.classList.add('hidden');
}
});
async function flash() {
const useFullInstall = eraseCheckbox.checked;
const fw = makeFirmwareFiles(firmwarePathPrefix);
const files = useFullInstall ? fw.full : fw.update;
let transport = null;
flashBtn.disabled = true;
updateStatus('<span class="status-checking">Starting...</span>');
try {
log('Select your T-Deck serial port...');
const serialPort = await navigator.serial.requestPort({ filters: [] });
log('Connecting to device...');
updateStatus('<span class="status-checking">Connecting...</span>');
transport = new Transport(serialPort, false);
const esploader = new ESPLoader({
transport: transport,
baudrate: 921600,
debugLogging: false,
enableTracing: false,
terminal: {
clean() {},
writeLine(data) { log(data); },
write(data) { log(data); },
},
});
const chip = await esploader.main();
log(`Connected to ${chip}`, 'success');
// Load firmware files
const fileArray = [];
let totalSize = 0;
for (const file of files) {
log(`Loading ${file.name}...`);
const response = await fetch(file.path);
if (!response.ok) throw new Error(`Failed to load ${file.name}`);
const arrayBuffer = await response.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binaryString = '';
for (let i = 0; i < bytes.length; i++) {
binaryString += String.fromCharCode(bytes[i]);
}
fileArray.push({ data: binaryString, address: file.offset });
totalSize += arrayBuffer.byteLength;
log(`Loaded ${file.name} (${arrayBuffer.byteLength} bytes)`);
}
// Flash
log(`Flashing ${totalSize} bytes...`);
updateStatus('<span class="status-flashing">Flashing...</span>');
setProgress(0, 'Writing to flash...');
await esploader.writeFlash({
fileArray,
flashSize: '8MB',
flashMode: 'DIO',
flashFreq: '80MHz',
eraseAll: useFullInstall,
compress: true,
reportProgress: (fileIndex, written, total) => {
const fileName = files[fileIndex]?.name || 'file';
const percent = (written / total) * 100;
setProgress(percent, `Writing ${fileName}... ${Math.round(percent)}%`);
if (written === total) {
log(`${fileName} complete`, 'success');
}
}
});
// Reset device
log('Resetting device...');
await esploader.hardReset();
setProgress(100, 'Done!');
updateStatus('<span class="status-detected">Flash complete! Device is restarting...</span>');
log('Flash complete!', 'success');
} catch (error) {
log(`Error: ${error.message}`, 'error');
updateStatus('<span class="status-not-detected">Flash failed - try again</span>');
progressContainer.classList.add('hidden');
} finally {
try { if (transport) await transport.disconnect(); } catch (e) {}
flashBtn.disabled = false;
}
}
flashBtn.addEventListener('click', flash);
// Initialize: set default prefix and load versions
firmwarePathPrefix = 'firmware/';
loadVersions();
</script>
</body>
</html>