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>

BIN
main.bin

Binary file not shown.

333
main.html
View File

@@ -45,82 +45,131 @@
font-size: 12px; font-size: 12px;
color: #888; color: #888;
} }
#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;
}
</style> </style>
</head> </head>
<body> <body>
<button id="connectBtn" onclick="connect()">Connect</button> <button id="connectBtn" onclick="connect()">Connect</button>
<button id="uploadBtn" onclick="upload()" disabled>Upload & Run</button> <button id="uploadBtn" onclick="upload()" disabled>Upload &amp; Run</button>
<button id="stopBtn" onclick="stopProgram()" disabled>Stop</button> <button id="stopBtn" onclick="stopProgram()" disabled>Stop</button>
<button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button> <button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button>
<button onclick="document.getElementById('log').textContent=''">Clear</button> <button onclick="document.getElementById('log').textContent=''">Clear</button>
<div class="status" id="status">Disconnected</div> <div class="status" id="status">Disconnected</div>
<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>
<div id="log"></div> <div id="log"></div>
<script> <script>
// UUIDs for Pybricks service / characteristics (fixed by firmware) // ── Pybricks BLE UUIDs (fixed by firmware) ────────────────────────────────
const PYBRICKS_SERVICE_UUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef'; const PYBRICKS_SERVICE_UUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
const PYBRICKS_CMD_EVENT_UUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef'; const PYBRICKS_CMD_EVENT_UUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef';
const PYBRICKS_CAPABILITIES_UUID = 'c5f50003-8280-46da-89f4-6d8051e4aeef'; const PYBRICKS_CAPABILITIES_UUID = 'c5f50003-8280-46da-89f4-6d8051e4aeef';
// Device Information Service UUIDs // Device Information Service
const DI_SERVICE_UUID = '0000180a-0000-1000-8000-00805f9b34fb'; const DI_SERVICE_UUID = '0000180a-0000-1000-8000-00805f9b34fb';
const SW_REV_UUID = '00002a28-0000-1000-8000-00805f9b34fb'; const SW_REV_UUID = '00002a28-0000-1000-8000-00805f9b34fb';
const FW_REV_UUID = '00002a26-0000-1000-8000-00805f9b34fb'; const FW_REV_UUID = '00002a26-0000-1000-8000-00805f9b34fb';
// Commands (PBIO_PYBRICKS_COMMAND_*) // Command bytes (PBIO_PYBRICKS_COMMAND_*)
const CMD_STOP_USER_PROGRAM = 0; const CMD_STOP_USER_PROGRAM = 0; // v1.0.0
const CMD_START_USER_PROGRAM = 1; const CMD_START_USER_PROGRAM = 1; // v1.2.0
const CMD_WRITE_USER_PROGRAM_META = 3; const CMD_WRITE_USER_RAM = 4; // v1.2.0
const CMD_WRITE_USER_RAM = 4; const CMD_WRITE_STDIN = 6; // v1.3.0
// Events (PBIO_PYBRICKS_EVENT_*) // Event bytes (PBIO_PYBRICKS_EVENT_*)
const EVT_STATUS_REPORT = 0; const EVT_STATUS_REPORT = 0;
const EVT_WRITE_STDOUT = 1; const EVT_WRITE_STDOUT = 1;
// ── State ─────────────────────────────────────────────────────────────────
let device, server, cmdChar; let device, server, cmdChar;
let maxCharSize = 20; let maxCharSize = 20;
let lastStatusFlags = 0; let lastStatusFlags = 0;
// stdout reassembly — BLE can fragment across multiple notifications
let stdoutBuffer = ''; let stdoutBuffer = '';
let flushTimer = null;
// ── Pynamics ────────────────────────────────────────────────────────────── // ── Pynamics escape-code parser ───────────────────────────────────────────
// Matches: ESC[?PYN;<EVENT_ID>;<arg1>;<arg2>;...~ // Matches: ESC [ ? PYN ; <eventId> ; <arg1> ; <arg2> ; ... ~
const PYNAMICS_REGEX = /\x1b\[\?PYN;([^;~]+);([^~]*?)~/g; const PYNAMICS_RE = /\x1b\[\?PYN;([^;~]+);([^~]*?)~/g;
const PYNAMICS_EVENTS = {
const PYNAMICS_EVENT_MAP = { '1': 'click', '2': 'focus', '3': 'blur', '4': 'navigate',
'1': 'click', '5': 'state_change', '6': 'notify', '7': 'render', '99': 'custom',
'2': 'focus',
'3': 'blur',
'4': 'navigate',
'5': 'state_change',
'6': 'notify',
'7': 'render',
'99': 'custom',
}; };
/**
* Strips all Pynamics escape codes from a string, fires console.log for
* each one, and returns the cleaned string (without the escape sequences).
*/
function processPynamics(text) { function processPynamics(text) {
PYNAMICS_RE.lastIndex = 0;
let summary = '';
let match; let match;
// Reset lastIndex so repeated calls work correctly while ((match = PYNAMICS_RE.exec(text)) !== null) {
PYNAMICS_REGEX.lastIndex = 0; const name = PYNAMICS_EVENTS[match[1]] ?? `unknown(${match[1]})`;
const args = match[2].split(';').filter(Boolean);
while ((match = PYNAMICS_REGEX.exec(text)) !== null) { console.log('[pynamics]', { event: name, args });
const eventId = match[1]; summary += `[PYN:${name}(${args.join(',')})] `;
const args = match[2].split(';').filter(Boolean);
const name = PYNAMICS_EVENT_MAP[eventId] ?? `unknown(${eventId})`;
console.log('[pynamics]', { event: name, eventId, args });
} }
return summary + text.replace(PYNAMICS_RE, '').trim();
// Remove all escape sequences from the visible text
return text.replace(PYNAMICS_REGEX, '');
} }
// ─────────────────────────────────────────────────────────────────────────
// ── Logging helpers ───────────────────────────────────────────────────────
function log(msg) { function log(msg) {
const el = document.getElementById('log'); const el = document.getElementById('log');
el.textContent += msg + '\n'; el.textContent += msg + '\n';
@@ -129,18 +178,16 @@
function setStatus(msg) { function setStatus(msg) {
document.getElementById('status').textContent = msg; document.getElementById('status').textContent = msg;
} }
function sleep(ms) { function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
return new Promise(r => setTimeout(r, ms));
}
// ── Binary helpers ────────────────────────────────────────────────────────
function u32le(n) { function u32le(n) {
const b = new Uint8Array(4); const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n, true); new DataView(b.buffer).setUint32(0, n, true);
return b; return b;
} }
function concat(...arrays) { function concat(...arrays) {
const total = arrays.reduce((s, a) => s + a.length, 0); const out = new Uint8Array(arrays.reduce((s, a) => s + a.length, 0));
const out = new Uint8Array(total);
let off = 0; let off = 0;
for (const a of arrays) { out.set(a, off); off += a.length; } for (const a of arrays) { out.set(a, off); off += a.length; }
return out; return out;
@@ -148,18 +195,19 @@
function hexpreview(data, n = 12) { function hexpreview(data, n = 12) {
return Array.from(data.slice(0, n)) return Array.from(data.slice(0, n))
.map(b => b.toString(16).padStart(2, '0')).join(' ') + .map(b => b.toString(16).padStart(2, '0')).join(' ') +
(data.length > n ? ' ...' : ''); (data.length > n ? ' ' : '');
} }
// ── BLE write with one retry ──────────────────────────────────────────────
async function writeCmd(data, label = '') { async function writeCmd(data, label = '') {
log(` TX${label ? ' [' + label + ']' : ''} ${data.length}b: ${hexpreview(data)}`); log(` TX${label ? ' [' + label + ']' : ''} ${data.length}b: ${hexpreview(data)}`);
try { try {
await cmdChar.writeValueWithResponse(data); await cmdChar.writeValueWithResponse(data);
} catch (e) { } catch (e) {
log(` TX error (${label}): name=${e.name} message=${e.message}`); log(` TX error (${label}): ${e.name} ${e.message}`);
if (e.name === 'NetworkError' || e.name === 'NotSupportedError') { if (e.name === 'NetworkError' || e.name === 'NotSupportedError') {
await sleep(200); await sleep(200);
log(' Retrying once...'); log(' Retrying');
await cmdChar.writeValueWithResponse(data); await cmdChar.writeValueWithResponse(data);
} else { } else {
throw e; throw e;
@@ -167,53 +215,116 @@
} }
} }
// ── Status flags ──────────────────────────────────────────────────────────
function decodeStatus(flags) { function decodeStatus(flags) {
lastStatusFlags = flags; lastStatusFlags = flags;
const bits = { return [
battLow: !!(flags & (1 << 0)), `running=${!!(flags & (1 << 6))}`,
battCrit: !!(flags & (1 << 1)), `btn=${!!(flags & (1 << 5))}`,
running: !!(flags & (1 << 6)), `hostConn=${!!(flags & (1 << 9))}`,
shutdown: !!(flags & (1 << 7)), `fileIO=${!!(flags & (1 << 13))}`,
btn: !!(flags & (1 << 5)), `shutdown=${!!(flags & (1 << 7))}`,
hostConn: !!(flags & (1 << 9)), ].join(' ');
fileIO: !!(flags & (1 << 13)),
};
return `running=${bits.running} btn=${bits.btn} hostConn=${bits.hostConn} fileIO=${bits.fileIO} shutdown=${bits.shutdown}`;
} }
function isRunning() { return !!(lastStatusFlags & (1 << 6)); }
function isBusy() { // ── stdout handling ───────────────────────────────────────────────────────
return !!(lastStatusFlags & (1 << 6)) || !!(lastStatusFlags & (1 << 13)); // 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 = '';
} }
function onNotify(event) { function onNotify(event) {
const data = new Uint8Array(event.target.value.buffer); const data = new Uint8Array(event.target.value.buffer);
if (data.length === 0) { log('[event] empty notification'); return; } if (!data.length) { log('[event] empty notification'); return; }
const type = data[0]; const type = data[0];
if (type === EVT_WRITE_STDOUT) { if (type === EVT_WRITE_STDOUT) {
// Cancel pending partial flush — more data just arrived
clearTimeout(flushTimer);
flushTimer = null;
stdoutBuffer += new TextDecoder().decode(data.slice(1)); stdoutBuffer += new TextDecoder().decode(data.slice(1));
// Flush every complete \n-terminated line immediately
const lines = stdoutBuffer.split('\n'); const lines = stdoutBuffer.split('\n');
stdoutBuffer = lines.pop() || ''; stdoutBuffer = lines.pop(); // trailing fragment (no \n yet)
for (const line of lines) { for (const line of lines) {
// Strip Pynamics codes and fire console events before displaying
const clean = processPynamics(line); const clean = processPynamics(line);
if (clean.trim()) { if (clean.trim()) log('[stdout] ' + clean);
log('[stdout] ' + clean);
}
} }
// Schedule flush of any remaining fragment after 80 ms of silence
if (stdoutBuffer) {
flushTimer = setTimeout(flushPartial, 80);
}
} else if (type === EVT_STATUS_REPORT) { } else if (type === EVT_STATUS_REPORT) {
const view = new DataView(data.buffer); const flags = new DataView(data.buffer).getUint32(1, true);
const flags = view.getUint32(1, true);
log(`[status] 0x${flags.toString(16).padStart(8, '0')} | ${decodeStatus(flags)}`); log(`[status] 0x${flags.toString(16).padStart(8, '0')} | ${decodeStatus(flags)}`);
} else { } else {
log(`[event 0x${type.toString(16)}] ${hexpreview(data)}`); log(`[event 0x${type.toString(16)}] ${hexpreview(data)}`);
} }
} }
// ── 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 ───────────────────────────────────────────────────────────────
async function connect() { async function connect() {
try { try {
setStatus('Scanning...'); setStatus('Scanning');
device = await navigator.bluetooth.requestDevice({ device = await navigator.bluetooth.requestDevice({
filters: [{ services: [PYBRICKS_SERVICE_UUID] }], filters: [{ services: [PYBRICKS_SERVICE_UUID] }],
optionalServices: [ optionalServices: [
@@ -221,87 +332,78 @@
PYBRICKS_CMD_EVENT_UUID, PYBRICKS_CMD_EVENT_UUID,
PYBRICKS_CAPABILITIES_UUID, PYBRICKS_CAPABILITIES_UUID,
DI_SERVICE_UUID, DI_SERVICE_UUID,
] ],
}); });
device.addEventListener('gattserverdisconnected', onDisconnect); device.addEventListener('gattserverdisconnected', onDisconnect);
setStatus('Connecting GATT...'); setStatus('Connecting GATT');
server = await device.gatt.connect(); server = await device.gatt.connect();
// Device Information Service
try { try {
const di = await server.getPrimaryService(DI_SERVICE_UUID); const di = await server.getPrimaryService(DI_SERVICE_UUID);
const sw = new TextDecoder().decode( const sw = new TextDecoder().decode(
await (await di.getCharacteristic(SW_REV_UUID)).readValue() await (await di.getCharacteristic(SW_REV_UUID)).readValue());
);
const fw = new TextDecoder().decode( const fw = new TextDecoder().decode(
await (await di.getCharacteristic(FW_REV_UUID)).readValue() await (await di.getCharacteristic(FW_REV_UUID)).readValue());
);
log(`Protocol version : ${sw}`); log(`Protocol version : ${sw}`);
log(`Firmware version : ${fw}`); log(`Firmware version : ${fw}`);
} catch (e) { } catch (e) { log(`DI service: ${e.message}`); }
log(`DI service: ${e.message}`);
}
// Hub capabilities
const svc = await server.getPrimaryService(PYBRICKS_SERVICE_UUID); const svc = await server.getPrimaryService(PYBRICKS_SERVICE_UUID);
try { try {
const capChar = await svc.getCharacteristic(PYBRICKS_CAPABILITIES_UUID); const raw = await (await svc.getCharacteristic(PYBRICKS_CAPABILITIES_UUID)).readValue();
const raw = await capChar.readValue();
const caps = new DataView(raw.buffer); const caps = new DataView(raw.buffer);
maxCharSize = caps.getUint16(0, true); maxCharSize = caps.getUint16(0, true);
const features = caps.getUint32(2, true); const features = caps.getUint32(2, true);
const maxProg = caps.getUint32(6, true); const maxProgBytes = caps.getUint32(6, true);
const numSlots = raw.byteLength >= 11 ? caps.getUint8(10) : 0; const numSlots = raw.byteLength >= 11 ? caps.getUint8(10) : 0;
log(`Max char write : ${maxCharSize} bytes`); log(`Max char write : ${maxCharSize} bytes`);
log(`Feature flags : 0x${features.toString(16)}`); log(`Feature flags : 0x${features.toString(16)}`);
log(`Max program size : ${maxProg} bytes`); log(`Max program size : ${maxProgBytes} bytes`);
log(`Slots : ${numSlots}`); log(`Slots : ${numSlots}`);
log(`MPY v6: ${!!(features & 2)} | v6.1: ${!!(features & 4)} | v6.3: ${!!(features & 32)}`); log(`MPY v6: ${!!(features & 2)} | v6.1: ${!!(features & 4)} | v6.3: ${!!(features & 32)}`);
} catch (e) { } catch (e) { log(`Capabilities: ${e.message}`); }
log(`Capabilities: ${e.message}`);
}
// Command/event characteristic
cmdChar = await svc.getCharacteristic(PYBRICKS_CMD_EVENT_UUID); cmdChar = await svc.getCharacteristic(PYBRICKS_CMD_EVENT_UUID);
log(`Char props: write=${cmdChar.properties.write} writeWOR=${cmdChar.properties.writeWithoutResponse} notify=${cmdChar.properties.notify}`); log(`Char props: write=${cmdChar.properties.write} ` +
`writeWOR=${cmdChar.properties.writeWithoutResponse} ` +
`notify=${cmdChar.properties.notify}`);
await cmdChar.startNotifications(); await cmdChar.startNotifications();
cmdChar.addEventListener('characteristicvaluechanged', onNotify); cmdChar.addEventListener('characteristicvaluechanged', onNotify);
await sleep(500); await sleep(500);
log(`Connected to ${device.name}`); log(`Connected to ${device.name}`);
setStatus(`Connected: ${device.name}`); setStatus(`Connected: ${device.name}`);
document.getElementById('connectBtn').disabled = true; setConnected(true);
document.getElementById('uploadBtn').disabled = false;
document.getElementById('stopBtn').disabled = false;
document.getElementById('disconnectBtn').disabled = false;
} catch (e) { } catch (e) {
setStatus('Connection failed'); setStatus('Connection failed');
log(`Error: ${e.name} ${e.message}`); log(`Error: ${e.name} ${e.message}`);
console.error(e); console.error(e);
} }
} }
function onDisconnect() { function onDisconnect() {
flushPartial(); // emit anything still buffered
setStatus('Disconnected'); setStatus('Disconnected');
log('--- Hub disconnected ---'); log('--- Hub disconnected ---');
document.getElementById('connectBtn').disabled = false; setConnected(false);
document.getElementById('uploadBtn').disabled = true;
document.getElementById('stopBtn').disabled = true;
document.getElementById('disconnectBtn').disabled = true;
} }
// ── Upload & run ──────────────────────────────────────────────────────────
async function upload() { async function upload() {
if (!cmdChar) { log('Not connected to hub.'); return; } if (!cmdChar) { log('Not connected.'); return; }
log('Stopping any running program...'); log('Stopping any running program');
try { try {
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP'); await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
await sleep(200); await sleep(200);
} catch (e) { } catch (e) {
log('Stop command ignored (no program running)'); log('Stop ignored (nothing running)');
} }
document.getElementById('uploadBtn').disabled = true; document.getElementById('uploadBtn').disabled = true;
@@ -309,17 +411,15 @@
const resp = await fetch('./main.bin'); const resp = await fetch('./main.bin');
if (!resp.ok) throw new Error(`fetch main.bin: ${resp.status} ${resp.statusText}`); if (!resp.ok) throw new Error(`fetch main.bin: ${resp.status} ${resp.statusText}`);
const blob = new Uint8Array(await resp.arrayBuffer()); const blob = new Uint8Array(await resp.arrayBuffer());
log(`\nLoaded main.bin: ${blob.length} bytes`); log(`\nLoaded main.bin : ${blob.length} bytes`);
log(`Preview : ${hexpreview(blob, 20)}`); log(`Preview : ${hexpreview(blob, 20)}`);
const maxProgSize = 261512; if (blob.length > 261512) throw new Error('Program too large (max 261512 bytes)');
if (blob.length > maxProgSize) throw new Error(`Program too large (max ${maxProgSize} bytes)`);
setStatus('Uploading...'); setStatus('Uploading');
const PAYLOAD = maxCharSize - 5; // 1 cmd + 4 offset
const PAYLOAD = maxCharSize - 5; const nChunks = Math.ceil(blob.length / PAYLOAD);
const chunks = Math.ceil(blob.length / PAYLOAD); log(`\n── Upload ${blob.length} bytes in ${nChunks} chunk(s) ──`);
log(`\n── Upload ${blob.length} bytes in ${chunks} chunks ──`);
for (let offset = 0; offset < blob.length; offset += PAYLOAD) { for (let offset = 0; offset < blob.length; offset += PAYLOAD) {
const chunk = blob.slice(offset, Math.min(offset + PAYLOAD, blob.length)); const chunk = blob.slice(offset, Math.min(offset + PAYLOAD, blob.length));
@@ -327,36 +427,39 @@
await writeCmd(packet, `RAM@${offset}`); await writeCmd(packet, `RAM@${offset}`);
} }
log('All chunks sent ✓'); log('All chunks sent ✓');
await sleep(150); await sleep(150);
// Inside your upload() function...
log('\n── Start program ──'); log('\n── Start program ──');
await writeCmd(new Uint8Array([CMD_START_USER_PROGRAM, 0]), 'START'); stdoutBuffer = ''; // Clear the JS side buffer
if (flushTimer) clearTimeout(flushTimer);
await writeCmd(new Uint8Array([CMD_START_USER_PROGRAM, 0]), 'START');
setStatus('Running'); setStatus('Running');
log('Program started ✓ — stdout will show as [stdout] lines'); log('Program started ✓\n');
} catch (e) { } catch (e) {
setStatus('Upload failed'); setStatus('Upload failed');
log(`\nUpload error: ${e.name} ${e.message}`); log(`\nUpload error: ${e.name} ${e.message}`);
console.error(e); console.error(e);
} finally { } finally {
document.getElementById('uploadBtn').disabled = false; document.getElementById('uploadBtn').disabled = false;
} }
} }
// ── Stop / disconnect ─────────────────────────────────────────────────────
async function stopProgram() { async function stopProgram() {
if (!cmdChar) return; if (!cmdChar) return;
try { try {
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP'); await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
log('Stop sent ✓'); log('Stop sent ✓');
} catch (e) { } catch (e) {
log(`Stop error: ${e.name} ${e.message}`); log(`Stop error: ${e.name} ${e.message}`);
} }
} }
async function disconnect() { async function disconnect() {
if (device && device.gatt.connected) device.gatt.disconnect(); if (device?.gatt.connected) device.gatt.disconnect();
} }
</script> </script>
</body> </body>

14
main.py
View File

@@ -1,8 +1,6 @@
from pybricks.hubs import PrimeHub from pybricks.hubs import PrimeHub
from pybricks.parameters import Color from pybricks.parameters import Color
from pybricks.tools import wait from pybricks.tools import wait
import micropython
hub = PrimeHub() hub = PrimeHub()
hub.light.on(Color.RED) hub.light.on(Color.RED)
wait(100) wait(100)
@@ -16,3 +14,15 @@ print("\x1b[?PYN;99;hubReady;spike-prime~")
# Another test — event 6 (notify), level="info", message="Boot complete" # Another test — event 6 (notify), level="info", message="Boot complete"
print("\x1b[?PYN;6;info;Boot complete~") print("\x1b[?PYN;6;info;Boot complete~")
while True:
# This will stay here until you send a \n
answer = input("Type something: ")
# This will now show up in your [stdout] log
print("Hub received:", answer)
if answer == "stop":
break
print("--- Program Ended ---")

11
project/bundle.py Normal file
View File

@@ -0,0 +1,11 @@
# build.py
import asyncio
from pybricksdev.compile import compile_multi_file
async def main():
result = await compile_multi_file("main.py", 5)
with open("main.bin", "wb") as f:
f.write(result)
print(f"Written {len(result)} bytes to main.bin")
asyncio.run(main())

3
project/imported.py Normal file
View File

@@ -0,0 +1,3 @@
def foo():
x = "importing works :D"
print(x)

BIN
project/main.bin Normal file

Binary file not shown.

29
project/main.py Normal file
View File

@@ -0,0 +1,29 @@
from pybricks.hubs import PrimeHub
from pybricks.parameters import Color
from pybricks.tools import wait
from imported import foo
hub = PrimeHub()
hub.light.on(Color.RED)
wait(100)
print("Hello, World!")
print("This is the spike prime hub.")
#print(micropython.mem_info())
# Pynamics escape code test — event 99 (custom), name="hubReady", payload="spike-prime"
print("\x1b[?PYN;99;hubReady;spike-prime~")
# Another test — event 6 (notify), level="info", message="Boot complete"
print("\x1b[?PYN;6;info;Boot complete~")
foo()
while True:
# This will stay here until you send a \n
answer = input("Type something: ")
# This will now show up in your [stdout] log
print("Hub received:", answer)
if answer == "stop":
break
print("--- Program Ended ---")