Working loader
This commit is contained in:
310
main.html
310
main.html
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pybricks Hub</title>
|
||||
<title>Pybricks Hub Loader</title>
|
||||
<style>
|
||||
body {
|
||||
background: #0a0a0f;
|
||||
@@ -21,6 +21,7 @@
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
@@ -36,50 +37,138 @@
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #555570;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<button id="connectBtn" onclick="connect()">Connect</button>
|
||||
<button id="uploadBtn" onclick="upload()" disabled>Upload main.mpy</button>
|
||||
<button id="uploadBtn" onclick="upload()" disabled>Upload & Run</button>
|
||||
<button id="stopBtn" onclick="stopProgram()" disabled>Stop</button>
|
||||
<button id="disconnectBtn" onclick="disconnect()" disabled>Disconnect</button>
|
||||
<button onclick="document.getElementById('log').textContent=''">Clear</button>
|
||||
|
||||
<div class="status" id="status">Disconnected</div>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
const SERVICE_UUID = 'c5f50001-8280-46da-89f4-6d8051e4aeef';
|
||||
const CMD_EVENT_UUID = 'c5f50002-8280-46da-89f4-6d8051e4aeef';
|
||||
const CAPABILITIES_UUID = 'c5f50003-8280-46da-89f4-6d8051e4aeef';
|
||||
// UUIDs for Pybricks service / characteristics (fixed by firmware)
|
||||
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';
|
||||
|
||||
// Command codes
|
||||
const CMD_WRITE_USER_PROGRAM_META = 0x06; // ✅ correct
|
||||
const CMD_WRITE_USER_PROGRAM = 0x07; // writes to FLASH (button runnable)
|
||||
const CMD_WRITE_USER_RAM = 0x03; // writes to RAM only
|
||||
const CMD_START_USER_PROGRAM = 0x01; // triggers run immediately
|
||||
// Device Information Service UUIDs
|
||||
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';
|
||||
|
||||
let device, server, cmdChar, maxDataSize = 20;
|
||||
// Commands (PBIO_PYBRICKS_COMMAND_*)
|
||||
const CMD_STOP_USER_PROGRAM = 0;
|
||||
const CMD_START_USER_PROGRAM = 1;
|
||||
const CMD_WRITE_USER_PROGRAM_META = 3; // not used here
|
||||
const CMD_WRITE_USER_RAM = 4;
|
||||
|
||||
// Events (PBIO_PYBRICKS_EVENT_*)
|
||||
const EVT_STATUS_REPORT = 0;
|
||||
const EVT_WRITE_STDOUT = 1;
|
||||
|
||||
let device, server, cmdChar;
|
||||
let maxCharSize = 20;
|
||||
let lastStatusFlags = 0;
|
||||
|
||||
function log(msg) {
|
||||
document.getElementById('log').textContent += msg + '\n';
|
||||
const el = document.getElementById('log');
|
||||
el.textContent += msg + '\n';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(msg) {
|
||||
document.getElementById('status').textContent = msg;
|
||||
}
|
||||
function sleep(ms) {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function u32le(n) {
|
||||
const b = new Uint8Array(4);
|
||||
new DataView(b.buffer).setUint32(0, n, true);
|
||||
return b;
|
||||
}
|
||||
function concat(...arrays) {
|
||||
const total = arrays.reduce((s, a) => s + a.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let off = 0;
|
||||
for (const a of arrays) {
|
||||
out.set(a, off);
|
||||
off += a.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function hexpreview(data, n = 12) {
|
||||
return Array.from(data.slice(0, n))
|
||||
.map(b => b.toString(16).padStart(2, '0')).join(' ') +
|
||||
(data.length > n ? ' ...' : '');
|
||||
}
|
||||
|
||||
async function writeCmd(data, label = '') {
|
||||
log(` TX${label ? ' [' + label + ']' : ''} ${data.length}b: ${hexpreview(data)}`);
|
||||
try {
|
||||
await cmdChar.writeValueWithResponse(data);
|
||||
} catch (e) {
|
||||
log(` TX error (${label}): name=${e.name} message=${e.message}`);
|
||||
if (e.name === 'NetworkError' || e.name === 'NotSupportedError') {
|
||||
await sleep(200);
|
||||
log(' Retrying once...');
|
||||
await cmdChar.writeValueWithResponse(data);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function decodeStatus(flags) {
|
||||
lastStatusFlags = flags;
|
||||
const bits = {
|
||||
battLow: !!(flags & (1 << 0)),
|
||||
battCrit: !!(flags & (1 << 1)),
|
||||
running: !!(flags & (1 << 6)),
|
||||
shutdown: !!(flags & (1 << 7)),
|
||||
btn: !!(flags & (1 << 5)),
|
||||
hostConn: !!(flags & (1 << 9)),
|
||||
fileIO: !!(flags & (1 << 13)),
|
||||
};
|
||||
return `running=${bits.running} btn=${bits.btn} hostConn=${bits.hostConn} fileIO=${bits.fileIO} shutdown=${bits.shutdown}`;
|
||||
}
|
||||
|
||||
function isBusy() {
|
||||
const running = !!(lastStatusFlags & (1 << 6));
|
||||
const fileIO = !!(lastStatusFlags & (1 << 13));
|
||||
return running || fileIO;
|
||||
}
|
||||
|
||||
function onNotify(event) {
|
||||
const data = new Uint8Array(event.target.value.buffer);
|
||||
if (data[0] === 0x01) {
|
||||
// stdout event
|
||||
if (data.length === 0) {
|
||||
log('[event] empty notification');
|
||||
return;
|
||||
}
|
||||
const type = data[0];
|
||||
|
||||
if (type === EVT_WRITE_STDOUT) {
|
||||
const text = new TextDecoder().decode(data.slice(1));
|
||||
log(text);
|
||||
log('[stdout] ' + text);
|
||||
} else if (type === EVT_STATUS_REPORT) {
|
||||
const view = new DataView(data.buffer);
|
||||
const flags = view.getUint32(1, true);
|
||||
log(`[status] 0x${flags.toString(16).padStart(8, '0')} | ${decodeStatus(flags)}`);
|
||||
} else {
|
||||
log(`[event 0x${type.toString(16)}] ${hexpreview(data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,108 +176,163 @@
|
||||
try {
|
||||
setStatus('Scanning...');
|
||||
device = await navigator.bluetooth.requestDevice({
|
||||
filters: [{ services: [SERVICE_UUID] }],
|
||||
optionalServices: [SERVICE_UUID]
|
||||
filters: [{ services: [PYBRICKS_SERVICE_UUID] }],
|
||||
optionalServices: [
|
||||
PYBRICKS_SERVICE_UUID,
|
||||
PYBRICKS_CMD_EVENT_UUID,
|
||||
PYBRICKS_CAPABILITIES_UUID,
|
||||
DI_SERVICE_UUID,
|
||||
]
|
||||
});
|
||||
|
||||
device.addEventListener('gattserverdisconnected', () => {
|
||||
setStatus('Disconnected');
|
||||
document.getElementById('connectBtn').disabled = false;
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
document.getElementById('disconnectBtn').disabled = true;
|
||||
});
|
||||
device.addEventListener('gattserverdisconnected', onDisconnect);
|
||||
|
||||
setStatus('Connecting...');
|
||||
setStatus('Connecting GATT...');
|
||||
server = await device.gatt.connect();
|
||||
const service = await server.getPrimaryService(SERVICE_UUID);
|
||||
cmdChar = await service.getCharacteristic(CMD_EVENT_UUID);
|
||||
|
||||
// Read capabilities to get max data size
|
||||
// Device Information
|
||||
try {
|
||||
const capChar = await service.getCharacteristic(CAPABILITIES_UUID);
|
||||
const caps = new DataView((await capChar.readValue()).buffer);
|
||||
maxDataSize = caps.getUint16(0, true); // bytes 0-1: max write size
|
||||
log(`Hub max data size: ${maxDataSize} bytes`);
|
||||
const di = await server.getPrimaryService(DI_SERVICE_UUID);
|
||||
const sw = new TextDecoder().decode(
|
||||
await (await di.getCharacteristic(SW_REV_UUID)).readValue()
|
||||
);
|
||||
const fw = new TextDecoder().decode(
|
||||
await (await di.getCharacteristic(FW_REV_UUID)).readValue()
|
||||
);
|
||||
log(`Protocol version : ${sw}`);
|
||||
log(`Firmware version : ${fw}`);
|
||||
} catch (e) {
|
||||
log('Could not read capabilities, using default chunk size of 20 bytes');
|
||||
log(`DI service: ${e.message}`);
|
||||
}
|
||||
|
||||
const svc = await server.getPrimaryService(PYBRICKS_SERVICE_UUID);
|
||||
|
||||
// Capabilities
|
||||
try {
|
||||
const capChar = await svc.getCharacteristic(PYBRICKS_CAPABILITIES_UUID);
|
||||
const raw = await capChar.readValue();
|
||||
const caps = new DataView(raw.buffer);
|
||||
maxCharSize = caps.getUint16(0, true);
|
||||
const features = caps.getUint32(2, true);
|
||||
const maxProg = caps.getUint32(6, true);
|
||||
const numSlots = raw.byteLength >= 11 ? caps.getUint8(10) : 0;
|
||||
log(`Max char write : ${maxCharSize} bytes`);
|
||||
log(`Feature flags : 0x${features.toString(16)}`);
|
||||
log(`Max program size : ${maxProg} bytes`);
|
||||
log(`Slots : ${numSlots}`);
|
||||
log(`MPY v6: ${!!(features & 2)} | v6.1: ${!!(features & 4)} | v6.3: ${!!(features & 32)}`);
|
||||
} catch (e) {
|
||||
log(`Capabilities: ${e.message}`);
|
||||
}
|
||||
|
||||
cmdChar = await svc.getCharacteristic(PYBRICKS_CMD_EVENT_UUID);
|
||||
log(`Char props: write=${cmdChar.properties.write} writeWOR=${cmdChar.properties.writeWithoutResponse} notify=${cmdChar.properties.notify}`);
|
||||
|
||||
await cmdChar.startNotifications();
|
||||
cmdChar.addEventListener('characteristicvaluechanged', onNotify);
|
||||
|
||||
setStatus(`Connected to ${device.name}`);
|
||||
await sleep(500); // wait for first status
|
||||
|
||||
log(`Connected to ${device.name}`);
|
||||
setStatus(`Connected: ${device.name}`);
|
||||
document.getElementById('connectBtn').disabled = true;
|
||||
document.getElementById('uploadBtn').disabled = false;
|
||||
document.getElementById('stopBtn').disabled = false;
|
||||
document.getElementById('disconnectBtn').disabled = false;
|
||||
|
||||
} catch (e) {
|
||||
setStatus('Connection failed');
|
||||
log('Error: ' + e.message);
|
||||
log(`Error: ${e.name} ${e.message}`);
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function crc32(buf) {
|
||||
let crc = 0xFFFFFFFF;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
crc ^= buf[i];
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xFFFFFFFF) >>> 0;
|
||||
function onDisconnect() {
|
||||
setStatus('Disconnected');
|
||||
log('--- Hub disconnected ---');
|
||||
document.getElementById('connectBtn').disabled = false;
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
document.getElementById('stopBtn').disabled = true;
|
||||
document.getElementById('disconnectBtn').disabled = true;
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!cmdChar) {
|
||||
log('Not connected to hub.');
|
||||
return;
|
||||
}
|
||||
// Always stop any running program first
|
||||
log('Stopping any running program...');
|
||||
try {
|
||||
const resp = await fetch('./main.mpy');
|
||||
if (!resp.ok) throw new Error('Could not fetch main.mpy');
|
||||
const mpy = new Uint8Array(await resp.arrayBuffer());
|
||||
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
|
||||
await sleep(200);
|
||||
} catch (e) {
|
||||
log('Stop command ignored (no program running)');
|
||||
}
|
||||
/*if (isBusy()) {
|
||||
log('Hub is busy (program running or file IO), not uploading.');
|
||||
return;
|
||||
}*/
|
||||
|
||||
log(`Uploading ${mpy.length} bytes...`);
|
||||
setStatus('Uploading...');
|
||||
document.getElementById('uploadBtn').disabled = true;
|
||||
try {
|
||||
const resp = await fetch('./main.bin');
|
||||
if (!resp.ok) {
|
||||
throw new Error(`fetch main.bin: ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
const blob = new Uint8Array(await resp.arrayBuffer());
|
||||
log(`\nLoaded main.bin: ${blob.length} bytes`);
|
||||
log(`Preview : ${hexpreview(blob, 20)}`);
|
||||
|
||||
// 1. Metadata
|
||||
const meta = new Uint8Array(5);
|
||||
meta[0] = 0x06; // CMD_WRITE_USER_PROGRAM_META
|
||||
new DataView(meta.buffer).setUint32(1, mpy.length, true);
|
||||
await cmdChar.writeValueWithResponse(meta);
|
||||
|
||||
// 2. Flash chunks
|
||||
const chunkSize = maxDataSize - 5;
|
||||
for (let offset = 0; offset < mpy.length; offset += chunkSize) {
|
||||
const chunk = mpy.slice(offset, offset + chunkSize);
|
||||
const packet = new Uint8Array(5 + chunk.length);
|
||||
packet[0] = 0x07; // CMD_WRITE_USER_PROGRAM (flash)
|
||||
new DataView(packet.buffer).setUint32(1, offset, true);
|
||||
packet.set(chunk, 5);
|
||||
await cmdChar.writeValueWithResponse(packet);
|
||||
const maxProgSize = 261512;
|
||||
if (blob.length > maxProgSize) {
|
||||
throw new Error(`Program too large for hub (max ${maxProgSize} bytes)`);
|
||||
}
|
||||
|
||||
// 3. Checksum
|
||||
const checksum = crc32(mpy);
|
||||
const cksPacket = new Uint8Array(5);
|
||||
cksPacket[0] = 0x08; // CMD_WRITE_USER_PROGRAM_CHECKSUM
|
||||
new DataView(cksPacket.buffer).setUint32(1, checksum, true);
|
||||
await cmdChar.writeValueWithResponse(cksPacket);
|
||||
log(`Checksum: 0x${checksum.toString(16)}`);
|
||||
setStatus('Uploading...');
|
||||
|
||||
const PAYLOAD = maxCharSize - 5; // 1 cmd + 4 offset
|
||||
const chunks = Math.ceil(blob.length / PAYLOAD);
|
||||
log(`\n── Upload ${blob.length} bytes in ${chunks} chunks ──`);
|
||||
|
||||
for (let offset = 0; offset < blob.length; offset += PAYLOAD) {
|
||||
const chunk = blob.slice(offset, Math.min(offset + PAYLOAD, blob.length));
|
||||
const packet = concat(
|
||||
new Uint8Array([CMD_WRITE_USER_RAM]),
|
||||
u32le(offset),
|
||||
chunk
|
||||
);
|
||||
await writeCmd(packet, `RAM@${offset}`);
|
||||
}
|
||||
log('All chunks sent ✓');
|
||||
|
||||
await sleep(150);
|
||||
|
||||
log('\n── Start program ──');
|
||||
await writeCmd(new Uint8Array([CMD_START_USER_PROGRAM, 0]), 'START');
|
||||
|
||||
setStatus('Running');
|
||||
log('Program started ✓ — stdout will show as [stdout] lines');
|
||||
|
||||
setStatus('Upload complete');
|
||||
log('Upload complete. Press the hub button to run.');
|
||||
} catch (e) {
|
||||
setStatus('Upload failed');
|
||||
log('Error: ' + e.message);
|
||||
log(`\nUpload error: ${e.name} ${e.message}`);
|
||||
console.error(e);
|
||||
} finally {
|
||||
document.getElementById('uploadBtn').disabled = false;
|
||||
}
|
||||
}
|
||||
function onNotify(event) {
|
||||
const data = new Uint8Array(event.target.value.buffer);
|
||||
if (data[0] === 0x01) {
|
||||
log('[stdout] ' + new TextDecoder().decode(data.slice(1)));
|
||||
} else if (data[0] === 0x00) {
|
||||
log('[status] Hub status code: ' + data[1]); // 0x00 = idle, etc.
|
||||
} else {
|
||||
log('[event 0x' + data[0].toString(16) + '] ' + Array.from(data).join(','));
|
||||
|
||||
async function stopProgram() {
|
||||
if (!cmdChar) return;
|
||||
try {
|
||||
await writeCmd(new Uint8Array([CMD_STOP_USER_PROGRAM]), 'STOP');
|
||||
log('Stop sent ✓');
|
||||
} catch (e) {
|
||||
log(`Stop error: ${e.name} ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (device && device.gatt.connected) {
|
||||
device.gatt.disconnect();
|
||||
|
||||
8
main.py
8
main.py
@@ -1,6 +1,10 @@
|
||||
from pybricks.hubs import PrimeHub
|
||||
from pybricks.parameters import Color
|
||||
from pybricks.tools import wait
|
||||
import micropython
|
||||
hub = PrimeHub()
|
||||
hub.light.on(Color.GREEN)
|
||||
wait(1000)
|
||||
hub.light.on(Color.RED)
|
||||
wait(100)
|
||||
print("Hello, World!")
|
||||
print("This is the spike prime hub.")
|
||||
print(micropython.mem_info())
|
||||
Reference in New Issue
Block a user