Added multi-file support

This commit is contained in:
2026-04-10 17:19:14 -05:00
parent 8c40e111cd
commit 9d59fab9a6
8 changed files with 799 additions and 118 deletions

525
main-new.html Normal file
View File

@@ -0,0 +1,525 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Pybricks Hub Loader</title>
<style>
:root {
--bg: #0b0f14;
--panel: #111823;
--panel2: #0f1620;
--text: #d7e0ea;
--muted: #7f92a8;
--accent: #57c7ff;
--good: #2ecc71;
--bad: #ff5c5c;
--warn: #f1c40f;
--border: #223042;
--terminal: #05080c;
--stdin: #0d1320;
--cmd: #162235;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.wrap {
display: grid;
grid-template-rows: auto auto 1fr auto;
height: 100%;
gap: 10px;
padding: 12px;
box-sizing: border-box;
}
.topbar,
.statusbar,
.controls,
.terminal,
.snippet {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
}
.topbar,
.statusbar,
.controls {
padding: 10px 12px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.title {
font-weight: 700;
color: #fff;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
background: var(--panel2);
border: 1px solid var(--border);
color: var(--muted);
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bad);
box-shadow: 0 0 8px rgba(255, 92, 92, .4);
}
.dot.good {
background: var(--good);
box-shadow: 0 0 8px rgba(46, 204, 113, .4);
}
.dot.warn {
background: var(--warn);
box-shadow: 0 0 8px rgba(241, 196, 15, .4);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
button,
input[type="file"],
input[type="text"] {
background: #182231;
color: var(--text);
border: 1px solid #2b3a50;
border-radius: 8px;
padding: 10px 12px;
font: inherit;
box-sizing: border-box;
}
button {
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
button:disabled {
opacity: .45;
cursor: not-allowed;
}
.filebox {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.terminal {
background: var(--terminal);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#log {
flex: 1;
overflow: auto;
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.35;
min-height: 0;
}
.line {
margin: 0 0 4px 0;
}
.sys {
color: #9fb4ca;
}
.out {
color: #d8f3ff;
}
.err {
color: #ff9b9b;
}
.ok {
color: #7cf0a4;
}
.meta {
color: #8da1b8;
}
.stdinbar {
display: flex;
gap: 8px;
padding: 10px;
border-top: 1px solid var(--border);
background: #08101a;
}
#stdin {
flex: 1;
background: var(--stdin);
}
.snippet {
padding: 10px 12px;
color: var(--muted);
font-size: 12px;
white-space: pre-wrap;
}
.label {
color: var(--muted);
font-size: 12px;
}
.spacer {
flex: 1;
}
</style>
</head>
<body>
<div class="wrap">
<div class="topbar">
<div class="title">Pybricks Hub Loader</div>
<div class="status-pill"><span id="dot" class="dot"></span><span id="statusText">Disconnected</span></div>
</div>
<div class="statusbar">
<span class="label">Device:</span> <span id="deviceName">none</span>
<span style="margin-left:16px" class="label">Max char size:</span> <span id="maxCharSize">-</span>
<span style="margin-left:16px" class="label">Max program:</span> <span>261512 bytes</span>
<span style="margin-left:16px" class="label">Running:</span> <span id="runningState">no</span>
</div>
<div class="controls">
<button id="connectBtn">Connect</button>
<button id="uploadRunBtn" disabled>Upload &amp; Run</button>
<button id="stopBtn" disabled>Stop</button>
<button id="disconnectBtn" disabled>Disconnect</button>
<button id="clearBtn">Clear Console</button>
<div class="filebox">
<input id="binFile" type="file" accept=".bin,application/octet-stream" />
<span class="label" id="fileLabel">main.bin not selected</span>
</div>
<div class="spacer"></div>
<div class="label">Uses Web Bluetooth; open in Chrome/Edge over HTTPS or localhost.</div>
</div>
<div class="terminal">
<div id="log"></div>
<div class="stdinbar">
<input id="stdin" type="text" placeholder="Type stdin and press Enter" disabled />
<button id="sendBtn" disabled>Send</button>
</div>
</div>
<div class="snippet" id="snippet"></div>
</div>
<script>
const SERVICE_UUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
const CMD_EVT_UUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef';
const CAP_UUID = 'c5f50003-8280-46da-89f4-6d8051e4aeef';
const STOP = 0x00;
const START = 0x01;
const WRITE_RAM = 0x04;
const STDIN = 0x06;
const MAX_PROGRAM_SIZE = 261512;
const ANSI_RE = /\x1b\[\?PYN;([^;~]+);([^~]*?)~/g;
const el = id => document.getElementById(id);
const logEl = el('log');
const dotEl = el('dot');
const statusTextEl = el('statusText');
const deviceNameEl = el('deviceName');
const maxCharSizeEl = el('maxCharSize');
const runningStateEl = el('runningState');
const fileLabelEl = el('fileLabel');
const stdinEl = el('stdin');
const snippetEl = el('snippet');
let device = null;
let server = null;
let cmdChar = null;
let capChar = null;
let maxCharSize = 20;
let stdoutBuffer = '';
let flushTimer = null;
let isRunning = false;
let fileBuf = null;
snippetEl.textContent = `main.py example:
from pybricks.hubs import PrimeHub
from pybricks.parameters import Color
from pybricks.tools import wait
hub = PrimeHub()
hub.light.on(Color.RED)
wait(100)
print("Hello, World!")
while True:
answer = input("Type something: ")
print("Hub received:", answer)
if answer == "stop":
break`;
function logLine(text, cls = 'sys') {
const div = document.createElement('div');
div.className = `line ${cls}`;
div.textContent = text;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
function setConnected(connected) {
dotEl.className = 'dot ' + (connected ? 'good' : '');
statusTextEl.textContent = connected ? 'Connected' : 'Disconnected';
el('disconnectBtn').disabled = !connected;
el('stopBtn').disabled = !connected;
el('uploadRunBtn').disabled = !connected || !fileBuf;
stdinEl.disabled = !connected;
el('sendBtn').disabled = !connected;
}
function setRunning(running) {
isRunning = running;
runningStateEl.textContent = running ? 'yes' : 'no';
dotEl.className = 'dot ' + (running ? 'good' : (device ? 'warn' : ''));
}
function appendStdout(text) {
if (!text) return;
const div = document.createElement('div');
div.className = 'line out';
div.textContent = text;
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
function processAnsiCodes(s) {
return s.replace(ANSI_RE, (_, eventId, args) => {
console.log('PYN event', { eventId, args });
logLine(`PYN event ${eventId}: ${args}`, 'meta');
return '';
});
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
if (stdoutBuffer) {
appendStdout(processAnsiCodes(stdoutBuffer));
stdoutBuffer = '';
}
}, 50);
}
function handleStdoutBytes(u8) {
if (!u8 || !u8.length) return;
if (u8[0] !== 0x01) return;
const text = new TextDecoder().decode(u8.slice(1));
stdoutBuffer += text;
stdoutBuffer = stdoutBuffer.replace(/\r/g, '');
const lastNl = stdoutBuffer.lastIndexOf('\n');
if (lastNl >= 0) {
const complete = stdoutBuffer.slice(0, lastNl + 1);
stdoutBuffer = stdoutBuffer.slice(lastNl + 1);
appendStdout(processAnsiCodes(complete));
}
scheduleFlush();
}
function handleEvent(data) {
const u8 = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const type = u8[0];
if (type === 0x00 && u8.length >= 5) {
const flags = new DataView(u8.buffer, u8.byteOffset + 1, 4).getUint32(0, true);
setRunning((flags & (1 << 6)) !== 0);
logLine(`Status flags: 0x${flags.toString(16).padStart(8, '0')}`, 'meta');
} else if (type === 0x01) {
handleStdoutBytes(u8);
} else {
logLine(`Event ${type}: ${Array.from(u8.slice(1)).map(x => x.toString(16).padStart(2, '0')).join(' ')}`, 'meta');
}
}
async function connect() {
logLine('Requesting hub...', 'sys');
device = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE_UUID] }],
optionalServices: [SERVICE_UUID]
});
deviceNameEl.textContent = device.name || 'Pybricks hub';
device.addEventListener('gattserverdisconnected', onDisconnect);
server = await device.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
cmdChar = await service.getCharacteristic(CMD_EVT_UUID);
capChar = await service.getCharacteristic(CAP_UUID);
await cmdChar.startNotifications();
cmdChar.addEventListener('characteristicvaluechanged', e => handleEvent(e.target.value));
const cap = new Uint8Array(await capChar.readValue().then(v => v.buffer.slice(v.byteOffset, v.byteOffset + v.byteLength)));
maxCharSize = cap.length >= 2 ? (cap[0] | (cap[1] << 8)) : 20;
maxCharSizeEl.textContent = String(maxCharSize);
logLine(`Capabilities raw: ${Array.from(cap).map(x => x.toString(16).padStart(2, '0')).join(' ')}`, 'meta');
logLine(`Connected to ${device.name || 'hub'}`, 'ok');
setConnected(true);
}
async function sendCommand(cmd, payload = new Uint8Array()) {
const data = new Uint8Array(1 + payload.length);
data[0] = cmd;
data.set(payload, 1);
await cmdChar.writeValueWithResponse(data);
}
async function sendStdin(text) {
const payload = new TextEncoder().encode(text);
const data = new Uint8Array(1 + payload.length);
data[0] = STDIN;
data.set(payload, 1);
for (let off = 0; off < data.length; off += maxCharSize) {
await cmdChar.writeValueWithResponse(data.slice(off, off + maxCharSize));
}
}
function le32(n) {
return new Uint8Array([n & 255, (n >> 8) & 255, (n >> 16) & 255, (n >> 24) & 255]);
}
const WRITE_META = 0x03;
async function uploadBin(buf) {
if (!buf) throw new Error('Select a .bin file first.');
if (buf.byteLength > MAX_PROGRAM_SIZE) throw new Error(`Program too large: ${buf.byteLength} > ${MAX_PROGRAM_SIZE}`);
logLine(`Uploading ${buf.byteLength} bytes...`, 'sys');
await sendCommand(STOP);
// Step 1: invalidate previous program
await sendCommand(WRITE_META, le32(0));
// Step 2: upload all chunks
const bytes = new Uint8Array(buf);
const chunkSize = Math.max(1, maxCharSize - 5);
for (let off = 0; off < bytes.length; off += chunkSize) {
const payload = new Uint8Array(4 + Math.min(chunkSize, bytes.length - off));
payload.set(le32(off), 0);
payload.set(bytes.slice(off, off + chunkSize), 4);
await sendCommand(WRITE_RAM, payload);
}
// Step 3: commit program size
await sendCommand(WRITE_META, le32(buf.byteLength));
logLine('Upload complete, starting program...', 'ok');
// Step 4: start with no payload
await sendCommand(START);
}
async function stopProgram() {
await sendCommand(STOP);
logLine('STOP sent', 'sys');
}
async function disconnect() {
try { await server?.disconnect(); } catch { }
onDisconnect();
}
function onDisconnect() {
setConnected(false);
deviceNameEl.textContent = 'none';
logLine('Disconnected', 'err');
}
el('connectBtn').onclick = async () => {
try { await connect(); } catch (e) { logLine(String(e), 'err'); }
};
el('disconnectBtn').onclick = async () => {
try { await disconnect(); } catch (e) { logLine(String(e), 'err'); }
};
el('stopBtn').onclick = async () => {
try { await stopProgram(); } catch (e) { logLine(String(e), 'err'); }
};
el('uploadRunBtn').onclick = async () => {
try { await uploadBin(fileBuf); } catch (e) { logLine(String(e), 'err'); }
};
el('clearBtn').onclick = () => { logEl.innerHTML = ''; stdoutBuffer = ''; };
el('sendBtn').onclick = async () => {
const v = stdinEl.value;
if (!v) return;
stdinEl.value = '';
try {
// Change '\n' to '\r'
await sendStdin(v + '\r');
logLine(`> ${v}`, 'sys');
} catch (e) {
logLine(String(e), 'err');
}
};
stdinEl.addEventListener('keydown', async e => {
if (e.key === 'Enter') {
e.preventDefault();
el('sendBtn').click();
}
});
el('binFile').addEventListener('change', async e => {
const f = e.target.files && e.target.files[0];
if (!f) return;
fileBuf = await f.arrayBuffer();
fileLabelEl.textContent = `${f.name} (${fileBuf.byteLength} bytes)`;
el('uploadRunBtn').disabled = !device || !fileBuf;
logLine(`Selected ${f.name}`, 'sys');
});
setConnected(false);
logLine('Ready. Connect to a Pybricks hub and upload main.bin.', 'sys');
</script>
</body>
</html>