Added multi-file support
This commit is contained in:
525
main-new.html
Normal file
525
main-new.html
Normal 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 & 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>
|
||||
Reference in New Issue
Block a user