Compare commits
3 Commits
e529ae497a
...
9d59fab9a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d59fab9a6 | |||
| 8c40e111cd | |||
| 092e635fd1 |
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>
|
||||||
343
main.html
343
main.html
@@ -45,44 +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 & 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></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; // not used here
|
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 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function processPynamics(text) {
|
||||||
|
PYNAMICS_RE.lastIndex = 0;
|
||||||
|
let summary = '';
|
||||||
|
let match;
|
||||||
|
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(',')})] `;
|
||||||
|
}
|
||||||
|
return summary + text.replace(PYNAMICS_RE, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
@@ -91,40 +178,36 @@
|
|||||||
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) {
|
for (const a of arrays) { out.set(a, off); off += a.length; }
|
||||||
out.set(a, off);
|
|
||||||
off += a.length;
|
|
||||||
}
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -132,57 +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 ───────────────────────────────────────────────────────
|
||||||
const running = !!(lastStatusFlags & (1 << 6));
|
// MicroPython writes stdout in BLE-sized chunks with no guarantee that a
|
||||||
const fileIO = !!(lastStatusFlags & (1 << 13));
|
// newline lands at a packet boundary. input() prompts never end in \n.
|
||||||
return running || fileIO;
|
//
|
||||||
|
// 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) {
|
if (!data.length) { log('[event] empty notification'); return; }
|
||||||
log('[event] empty notification');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const type = data[0];
|
const type = data[0];
|
||||||
|
|
||||||
if (type === EVT_WRITE_STDOUT) {
|
if (type === EVT_WRITE_STDOUT) {
|
||||||
// Buffer and reassemble split packets
|
// 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() || ''; // Keep incomplete line
|
stdoutBuffer = lines.pop(); // trailing fragment (no \n yet)
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
const clean = processPynamics(line);
|
||||||
log('[stdout] ' + line);
|
if (clean.trim()) 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: [
|
||||||
@@ -190,161 +332,134 @@
|
|||||||
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
|
// 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);
|
||||||
|
|
||||||
// Capabilities
|
|
||||||
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); // wait for first status
|
|
||||||
|
|
||||||
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) {
|
if (!cmdChar) { log('Not connected.'); return; }
|
||||||
log('Not connected to hub.');
|
|
||||||
return;
|
log('Stopping any running program…');
|
||||||
}
|
|
||||||
// Always stop any running program first
|
|
||||||
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)');
|
||||||
}
|
}
|
||||||
/*if (isBusy()) {
|
|
||||||
log('Hub is busy (program running or file IO), not uploading.');
|
|
||||||
return;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
document.getElementById('uploadBtn').disabled = true;
|
document.getElementById('uploadBtn').disabled = true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('./main.bin');
|
const resp = await fetch('./main.bin');
|
||||||
if (!resp.ok) {
|
if (!resp.ok) throw new Error(`fetch main.bin: ${resp.status} ${resp.statusText}`);
|
||||||
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 for hub (max ${maxProgSize} bytes)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus('Uploading...');
|
|
||||||
|
|
||||||
|
setStatus('Uploading…');
|
||||||
const PAYLOAD = maxCharSize - 5; // 1 cmd + 4 offset
|
const PAYLOAD = maxCharSize - 5; // 1 cmd + 4 offset
|
||||||
const chunks = Math.ceil(blob.length / PAYLOAD);
|
const nChunks = Math.ceil(blob.length / PAYLOAD);
|
||||||
log(`\n── Upload ${blob.length} bytes in ${chunks} chunks ──`);
|
log(`\n── Upload ${blob.length} bytes in ${nChunks} chunk(s) ──`);
|
||||||
|
|
||||||
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));
|
||||||
const packet = concat(
|
const packet = concat(new Uint8Array([CMD_WRITE_USER_RAM]), u32le(offset), chunk);
|
||||||
new Uint8Array([CMD_WRITE_USER_RAM]),
|
|
||||||
u32le(offset),
|
|
||||||
chunk
|
|
||||||
);
|
|
||||||
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) {
|
if (device?.gatt.connected) device.gatt.disconnect();
|
||||||
device.gatt.disconnect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
22
main.py
22
main.py
@@ -1,10 +1,28 @@
|
|||||||
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)
|
||||||
|
|
||||||
print("Hello, World!")
|
print("Hello, World!")
|
||||||
print("This is the spike prime hub.")
|
print("This is the spike prime hub.")
|
||||||
print(micropython.mem_info())
|
#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~")
|
||||||
|
|
||||||
|
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
11
project/bundle.py
Normal 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
3
project/imported.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
def foo():
|
||||||
|
x = "importing works :D"
|
||||||
|
print(x)
|
||||||
BIN
project/main.bin
Normal file
BIN
project/main.bin
Normal file
Binary file not shown.
29
project/main.py
Normal file
29
project/main.py
Normal 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 ---")
|
||||||
Reference in New Issue
Block a user