Files
pynamics-ide/main.html

467 lines
17 KiB
HTML
Raw Permalink Normal View History

2026-03-12 16:40:50 -05:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
2026-03-21 16:34:44 -05:00
<title>Pybricks Hub Loader</title>
2026-03-12 16:40:50 -05:00
<style>
body {
background: #0a0a0f;
color: #e0e0f0;
font-family: monospace;
padding: 24px;
}
button {
background: #e8ff47;
color: #0a0a0f;
border: none;
padding: 8px 18px;
font-family: monospace;
font-weight: bold;
cursor: pointer;
margin-right: 8px;
2026-03-21 16:34:44 -05:00
margin-bottom: 8px;
2026-03-12 16:40:50 -05:00
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
#log {
margin-top: 16px;
background: #111118;
padding: 16px;
min-height: 200px;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.6;
2026-03-21 16:34:44 -05:00
border: 1px solid #222;
2026-03-12 16:40:50 -05:00
}
.status {
margin-top: 8px;
font-size: 12px;
2026-03-21 16:34:44 -05:00
color: #888;
2026-03-12 16:40:50 -05:00
}
2026-04-10 17:19:14 -05:00
#stdinBar {
display: flex;
align-items: center;
margin-top: 12px;
gap: 8px;
}
#stdinLabel {
font-size: 12px;
color: #e8ff47;
white-space: nowrap;
user-select: none;
min-width: 52px;
}
#stdinInput {
flex: 1;
background: #111118;
color: #e0e0f0;
border: 1px solid #333;
padding: 6px 10px;
font-family: monospace;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
#stdinInput:focus {
border-color: #e8ff47;
}
#stdinInput:disabled {
opacity: 0.35;
}
#sendBtn {
background: #e8ff47;
color: #0a0a0f;
border: none;
padding: 6px 14px;
font-family: monospace;
font-weight: bold;
cursor: pointer;
white-space: nowrap;
}
#sendBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
2026-03-12 16:40:50 -05:00
</style>
</head>
<body>
<button id="connectBtn" onclick="connect()">Connect</button>
2026-04-10 17:19:14 -05:00
<button id="uploadBtn" onclick="upload()" disabled>Upload &amp; Run</button>
2026-03-21 16:34:44 -05:00
<button id="stopBtn" onclick="stopProgram()" disabled>Stop</button>
2026-03-12 16:40:50 -05:00
<button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button>
2026-03-21 16:34:44 -05:00
<button onclick="document.getElementById('log').textContent=''">Clear</button>
2026-03-12 16:40:50 -05:00
<div class="status" id="status">Disconnected</div>
2026-04-10 17:19:14 -05:00
<div id="stdinBar">
<span id="stdinLabel">stdin&gt;</span>
<input id="stdinInput" type="text"
placeholder="Type input and press Enter or Send… (only send when program is waiting)"
onkeydown="onStdinKey(event)" autocomplete="off" spellcheck="false" disabled />
<button id="sendBtn" onclick="sendStdin()" disabled>Send</button>
</div>
2026-03-12 16:40:50 -05:00
<div id="log"></div>
<script>
2026-04-10 17:19:14 -05:00
// ── Pybricks BLE UUIDs (fixed by firmware) ────────────────────────────────
2026-03-21 16:34:44 -05:00
const PYBRICKS_SERVICE_UUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
const PYBRICKS_CMD_EVENT_UUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef';
const PYBRICKS_CAPABILITIES_UUID = 'c5f50003-8280-46da-89f4-6d8051e4aeef';
2026-04-10 17:19:14 -05:00
// Device Information Service
2026-03-21 16:34:44 -05:00
const DI_SERVICE_UUID = '0000180a-0000-1000-8000-00805f9b34fb';
const SW_REV_UUID = '00002a28-0000-1000-8000-00805f9b34fb';
const FW_REV_UUID = '00002a26-0000-1000-8000-00805f9b34fb';
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
// Command bytes (PBIO_PYBRICKS_COMMAND_*)
const CMD_STOP_USER_PROGRAM = 0; // v1.0.0
const CMD_START_USER_PROGRAM = 1; // v1.2.0
const CMD_WRITE_USER_RAM = 4; // v1.2.0
const CMD_WRITE_STDIN = 6; // v1.3.0
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
// Event bytes (PBIO_PYBRICKS_EVENT_*)
2026-03-21 16:34:44 -05:00
const EVT_STATUS_REPORT = 0;
const EVT_WRITE_STDOUT = 1;
2026-04-10 17:19:14 -05:00
// ── State ─────────────────────────────────────────────────────────────────
2026-03-21 16:34:44 -05:00
let device, server, cmdChar;
let maxCharSize = 20;
let lastStatusFlags = 0;
2026-03-26 17:32:56 +00:00
2026-04-10 17:19:14 -05:00
// stdout reassembly — BLE can fragment across multiple notifications
let stdoutBuffer = '';
let flushTimer = null;
// ── Pynamics escape-code parser ───────────────────────────────────────────
// Matches: ESC [ ? PYN ; <eventId> ; <arg1> ; <arg2> ; ... ~
const PYNAMICS_RE = /\x1b\[\?PYN;([^;~]+);([^~]*?)~/g;
const PYNAMICS_EVENTS = {
'1': 'click', '2': 'focus', '3': 'blur', '4': 'navigate',
'5': 'state_change', '6': 'notify', '7': 'render', '99': 'custom',
2026-03-26 17:32:56 +00:00
};
function processPynamics(text) {
2026-04-10 17:19:14 -05:00
PYNAMICS_RE.lastIndex = 0;
let summary = '';
2026-03-26 17:32:56 +00:00
let match;
2026-04-10 17:19:14 -05:00
while ((match = PYNAMICS_RE.exec(text)) !== null) {
const name = PYNAMICS_EVENTS[match[1]] ?? `unknown(${match[1]})`;
const args = match[2].split(';').filter(Boolean);
console.log('[pynamics]', { event: name, args });
summary += `[PYN:${name}(${args.join(',')})] `;
2026-03-26 17:32:56 +00:00
}
2026-04-10 17:19:14 -05:00
return summary + text.replace(PYNAMICS_RE, '').trim();
2026-03-26 17:32:56 +00:00
}
2026-04-10 17:19:14 -05:00
// ── Logging helpers ───────────────────────────────────────────────────────
2026-03-12 16:40:50 -05:00
function log(msg) {
2026-03-21 16:34:44 -05:00
const el = document.getElementById('log');
el.textContent += msg + '\n';
el.scrollTop = el.scrollHeight;
2026-03-12 16:40:50 -05:00
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
2026-04-10 17:19:14 -05:00
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
2026-03-21 16:34:44 -05:00
2026-04-10 17:19:14 -05:00
// ── Binary helpers ────────────────────────────────────────────────────────
2026-03-21 16:34:44 -05:00
function u32le(n) {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n, true);
return b;
}
function concat(...arrays) {
2026-04-10 17:19:14 -05:00
const out = new Uint8Array(arrays.reduce((s, a) => s + a.length, 0));
2026-03-21 16:34:44 -05:00
let off = 0;
2026-03-26 17:32:56 +00:00
for (const a of arrays) { out.set(a, off); off += a.length; }
2026-03-21 16:34:44 -05:00
return out;
}
function hexpreview(data, n = 12) {
return Array.from(data.slice(0, n))
.map(b => b.toString(16).padStart(2, '0')).join(' ') +
2026-04-10 17:19:14 -05:00
(data.length > n ? ' …' : '');
2026-03-21 16:34:44 -05:00
}
2026-04-10 17:19:14 -05:00
// ── BLE write with one retry ──────────────────────────────────────────────
2026-03-21 16:34:44 -05:00
async function writeCmd(data, label = '') {
log(` TX${label ? ' [' + label + ']' : ''} ${data.length}b: ${hexpreview(data)}`);
try {
await cmdChar.writeValueWithResponse(data);
} catch (e) {
2026-04-10 17:19:14 -05:00
log(` TX error (${label}): ${e.name} — ${e.message}`);
2026-03-21 16:34:44 -05:00
if (e.name === 'NetworkError' || e.name === 'NotSupportedError') {
await sleep(200);
2026-04-10 17:19:14 -05:00
log(' Retrying…');
2026-03-21 16:34:44 -05:00
await cmdChar.writeValueWithResponse(data);
} else {
throw e;
}
}
}
2026-04-10 17:19:14 -05:00
// ── Status flags ──────────────────────────────────────────────────────────
2026-03-21 16:34:44 -05:00
function decodeStatus(flags) {
lastStatusFlags = flags;
2026-04-10 17:19:14 -05:00
return [
`running=${!!(flags & (1 << 6))}`,
`btn=${!!(flags & (1 << 5))}`,
`hostConn=${!!(flags & (1 << 9))}`,
`fileIO=${!!(flags & (1 << 13))}`,
`shutdown=${!!(flags & (1 << 7))}`,
].join(' ');
2026-03-21 16:34:44 -05:00
}
2026-04-10 17:19:14 -05:00
function isRunning() { return !!(lastStatusFlags & (1 << 6)); }
// ── stdout handling ───────────────────────────────────────────────────────
// MicroPython writes stdout in BLE-sized chunks with no guarantee that a
// newline lands at a packet boundary. input() prompts never end in \n.
//
// Strategy:
// • Complete lines (\n-terminated) are flushed immediately.
// • Any trailing fragment is held for 80 ms. If another packet arrives
// within that window the timer resets (handles mid-word BLE splits).
// After 80 ms of silence the fragment is printed (handles input() prompts).
// • On disconnect, whatever remains is flushed immediately.
function flushPartial() {
clearTimeout(flushTimer);
flushTimer = null;
if (!stdoutBuffer) return;
const clean = processPynamics(stdoutBuffer);
if (clean.trim()) log('[stdout] ' + clean);
stdoutBuffer = '';
2026-03-21 16:34:44 -05:00
}
2026-03-12 16:40:50 -05:00
function onNotify(event) {
const data = new Uint8Array(event.target.value.buffer);
2026-04-10 17:19:14 -05:00
if (!data.length) { log('[event] empty notification'); return; }
2026-03-21 16:34:44 -05:00
const type = data[0];
if (type === EVT_WRITE_STDOUT) {
2026-04-10 17:19:14 -05:00
// Cancel pending partial flush — more data just arrived
clearTimeout(flushTimer);
flushTimer = null;
stdoutBuffer += new TextDecoder().decode(data.slice(1));
2026-04-10 17:19:14 -05:00
// Flush every complete \n-terminated line immediately
const lines = stdoutBuffer.split('\n');
2026-04-10 17:19:14 -05:00
stdoutBuffer = lines.pop(); // trailing fragment (no \n yet)
for (const line of lines) {
2026-03-26 17:32:56 +00:00
const clean = processPynamics(line);
2026-04-10 17:19:14 -05:00
if (clean.trim()) log('[stdout] ' + clean);
}
2026-04-10 17:19:14 -05:00
// Schedule flush of any remaining fragment after 80 ms of silence
if (stdoutBuffer) {
flushTimer = setTimeout(flushPartial, 80);
}
2026-03-21 16:34:44 -05:00
} else if (type === EVT_STATUS_REPORT) {
2026-04-10 17:19:14 -05:00
const flags = new DataView(data.buffer).getUint32(1, true);
2026-03-21 16:34:44 -05:00
log(`[status] 0x${flags.toString(16).padStart(8, '0')} | ${decodeStatus(flags)}`);
} else {
log(`[event 0x${type.toString(16)}] ${hexpreview(data)}`);
2026-03-12 16:40:50 -05:00
}
}
2026-04-10 17:19:14 -05:00
// ── stdin ─────────────────────────────────────────────────────────────────
// The Pybricks protocol (v1.3.0+) defines CMD_WRITE_STDIN = 0x06.
// Payload is raw bytes; no framing. MicroPython's input() blocks until
// it receives a \n, so we always append one.
//
// IMPORTANT: stdin bytes are queued on the hub regardless of whether
// input() is currently blocking. Sending before the program reaches
// input() means the \n will be consumed immediately and the prompt will
// appear to be skipped. Always wait until you see the prompt in stdout.
function setConnected(yes) {
document.getElementById('connectBtn').disabled = yes;
document.getElementById('uploadBtn').disabled = !yes;
document.getElementById('stopBtn').disabled = !yes;
document.getElementById('disconnectBtn').disabled = !yes;
document.getElementById('stdinInput').disabled = !yes;
document.getElementById('sendBtn').disabled = !yes;
}
function onStdinKey(e) {
if (e.key === 'Enter') {
e.preventDefault(); // Prevent the browser from doing anything else
sendStdin();
}
}
async function sendStdin() {
const inputEl = document.getElementById('stdinInput');
const text = inputEl.value;
if (text === "" && !confirm("Send empty newline?")) return; // Optional safety check
inputEl.value = ""; // Clear it NOW
const encoded = new TextEncoder().encode(text + '\n');
const packet = concat(new Uint8Array([CMD_WRITE_STDIN]), encoded);
await writeCmd(packet, 'STDIN');
}
// ── Connect ───────────────────────────────────────────────────────────────
2026-03-12 16:40:50 -05:00
async function connect() {
try {
2026-04-10 17:19:14 -05:00
setStatus('Scanning…');
2026-03-12 16:40:50 -05:00
device = await navigator.bluetooth.requestDevice({
2026-03-21 16:34:44 -05:00
filters: [{ services: [PYBRICKS_SERVICE_UUID] }],
optionalServices: [
PYBRICKS_SERVICE_UUID,
PYBRICKS_CMD_EVENT_UUID,
PYBRICKS_CAPABILITIES_UUID,
DI_SERVICE_UUID,
2026-04-10 17:19:14 -05:00
],
2026-03-12 16:40:50 -05:00
});
2026-03-21 16:34:44 -05:00
device.addEventListener('gattserverdisconnected', onDisconnect);
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
setStatus('Connecting GATT…');
2026-03-12 16:40:50 -05:00
server = await device.gatt.connect();
2026-04-10 17:19:14 -05:00
// Device Information Service
2026-03-12 16:40:50 -05:00
try {
2026-03-21 16:34:44 -05:00
const di = await server.getPrimaryService(DI_SERVICE_UUID);
const sw = new TextDecoder().decode(
2026-04-10 17:19:14 -05:00
await (await di.getCharacteristic(SW_REV_UUID)).readValue());
2026-03-21 16:34:44 -05:00
const fw = new TextDecoder().decode(
2026-04-10 17:19:14 -05:00
await (await di.getCharacteristic(FW_REV_UUID)).readValue());
2026-03-21 16:34:44 -05:00
log(`Protocol version : ${sw}`);
log(`Firmware version : ${fw}`);
2026-04-10 17:19:14 -05:00
} catch (e) { log(`DI service: ${e.message}`); }
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
// Hub capabilities
2026-03-21 16:34:44 -05:00
const svc = await server.getPrimaryService(PYBRICKS_SERVICE_UUID);
try {
2026-04-10 17:19:14 -05:00
const raw = await (await svc.getCharacteristic(PYBRICKS_CAPABILITIES_UUID)).readValue();
2026-03-21 16:34:44 -05:00
const caps = new DataView(raw.buffer);
maxCharSize = caps.getUint16(0, true);
const features = caps.getUint32(2, true);
2026-04-10 17:19:14 -05:00
const maxProgBytes = caps.getUint32(6, true);
2026-03-21 16:34:44 -05:00
const numSlots = raw.byteLength >= 11 ? caps.getUint8(10) : 0;
log(`Max char write : ${maxCharSize} bytes`);
log(`Feature flags : 0x${features.toString(16)}`);
2026-04-10 17:19:14 -05:00
log(`Max program size : ${maxProgBytes} bytes`);
2026-03-21 16:34:44 -05:00
log(`Slots : ${numSlots}`);
log(`MPY v6: ${!!(features & 2)} | v6.1: ${!!(features & 4)} | v6.3: ${!!(features & 32)}`);
2026-04-10 17:19:14 -05:00
} catch (e) { log(`Capabilities: ${e.message}`); }
2026-03-21 16:34:44 -05:00
2026-04-10 17:19:14 -05:00
// Command/event characteristic
2026-03-21 16:34:44 -05:00
cmdChar = await svc.getCharacteristic(PYBRICKS_CMD_EVENT_UUID);
2026-04-10 17:19:14 -05:00
log(`Char props: write=${cmdChar.properties.write} ` +
`writeWOR=${cmdChar.properties.writeWithoutResponse} ` +
`notify=${cmdChar.properties.notify}`);
2026-03-21 16:34:44 -05:00
2026-03-12 16:40:50 -05:00
await cmdChar.startNotifications();
cmdChar.addEventListener('characteristicvaluechanged', onNotify);
2026-03-26 17:32:56 +00:00
await sleep(500);
2026-03-21 16:34:44 -05:00
2026-03-12 16:40:50 -05:00
log(`Connected to ${device.name}`);
2026-03-21 16:34:44 -05:00
setStatus(`Connected: ${device.name}`);
2026-04-10 17:19:14 -05:00
setConnected(true);
2026-03-21 16:34:44 -05:00
2026-03-12 16:40:50 -05:00
} catch (e) {
setStatus('Connection failed');
2026-04-10 17:19:14 -05:00
log(`Error: ${e.name} — ${e.message}`);
2026-03-21 16:34:44 -05:00
console.error(e);
2026-03-12 16:40:50 -05:00
}
}
2026-03-21 16:34:44 -05:00
function onDisconnect() {
2026-04-10 17:19:14 -05:00
flushPartial(); // emit anything still buffered
2026-03-21 16:34:44 -05:00
setStatus('Disconnected');
log('--- Hub disconnected ---');
2026-04-10 17:19:14 -05:00
setConnected(false);
2026-03-21 12:44:27 -05:00
}
2026-04-10 17:19:14 -05:00
// ── Upload & run ──────────────────────────────────────────────────────────
2026-03-12 16:40:50 -05:00
async function upload() {
2026-04-10 17:19:14 -05:00
if (!cmdChar) { log('Not connected.'); return; }
2026-03-26 17:32:56 +00:00
2026-04-10 17:19:14 -05:00
log('Stopping any running program…');
2026-03-21 16:34:44 -05:00
try {
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
await sleep(200);
} catch (e) {
2026-04-10 17:19:14 -05:00
log('Stop ignored (nothing running)');
2026-03-21 16:34:44 -05:00
}
document.getElementById('uploadBtn').disabled = true;
2026-03-12 16:40:50 -05:00
try {
2026-03-21 16:34:44 -05:00
const resp = await fetch('./main.bin');
2026-03-26 17:32:56 +00:00
if (!resp.ok) throw new Error(`fetch main.bin: ${resp.status} ${resp.statusText}`);
2026-03-21 16:34:44 -05:00
const blob = new Uint8Array(await resp.arrayBuffer());
2026-04-10 17:19:14 -05:00
log(`\nLoaded main.bin : ${blob.length} bytes`);
log(`Preview : ${hexpreview(blob, 20)}`);
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
if (blob.length > 261512) throw new Error('Program too large (max 261512 bytes)');
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
setStatus('Uploading…');
const PAYLOAD = maxCharSize - 5; // 1 cmd + 4 offset
const nChunks = Math.ceil(blob.length / PAYLOAD);
log(`\n── Upload ${blob.length} bytes in ${nChunks} chunk(s) ──`);
2026-03-21 16:34:44 -05:00
for (let offset = 0; offset < blob.length; offset += PAYLOAD) {
const chunk = blob.slice(offset, Math.min(offset + PAYLOAD, blob.length));
2026-03-26 17:32:56 +00:00
const packet = concat(new Uint8Array([CMD_WRITE_USER_RAM]), u32le(offset), chunk);
2026-03-21 16:34:44 -05:00
await writeCmd(packet, `RAM@${offset}`);
2026-03-12 16:40:50 -05:00
}
2026-03-21 16:34:44 -05:00
log('All chunks sent ✓');
await sleep(150);
2026-03-12 16:40:50 -05:00
2026-04-10 17:19:14 -05:00
// Inside your upload() function...
2026-03-21 16:34:44 -05:00
log('\n── Start program ──');
2026-04-10 17:19:14 -05:00
stdoutBuffer = ''; // Clear the JS side buffer
if (flushTimer) clearTimeout(flushTimer);
2026-03-21 16:34:44 -05:00
2026-04-10 17:19:14 -05:00
await writeCmd(new Uint8Array([CMD_START_USER_PROGRAM, 0]), 'START');
2026-03-21 16:34:44 -05:00
setStatus('Running');
2026-04-10 17:19:14 -05:00
log('Program started ✓\n');
2026-03-21 12:44:27 -05:00
2026-03-12 16:40:50 -05:00
} catch (e) {
setStatus('Upload failed');
2026-04-10 17:19:14 -05:00
log(`\nUpload error: ${e.name} — ${e.message}`);
2026-03-21 16:34:44 -05:00
console.error(e);
} finally {
document.getElementById('uploadBtn').disabled = false;
2026-03-12 16:40:50 -05:00
}
}
2026-03-21 16:34:44 -05:00
2026-04-10 17:19:14 -05:00
// ── Stop / disconnect ─────────────────────────────────────────────────────
2026-03-21 16:34:44 -05:00
async function stopProgram() {
if (!cmdChar) return;
try {
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
log('Stop sent ✓');
} catch (e) {
2026-04-10 17:19:14 -05:00
log(`Stop error: ${e.name} — ${e.message}`);
2026-03-21 12:44:27 -05:00
}
}
2026-03-21 16:34:44 -05:00
2026-03-12 16:40:50 -05:00
async function disconnect() {
2026-04-10 17:19:14 -05:00
if (device?.gatt.connected) device.gatt.disconnect();
2026-03-12 16:40:50 -05:00
}
</script>
</body>
</html>