addons implemented

main
Jake 2026-02-24 23:43:32 +08:00
parent 432f28ba1e
commit fd5a39db77
7 changed files with 660 additions and 9 deletions

View File

@ -37,6 +37,9 @@
<button id="btn-projects" title="Toggle projects panel">
<span class="icon">&#128194;</span> Projects
</button>
<button id="btn-addons" title="Manage addons">
<span class="icon">&#128268;</span> Addons
</button>
<span id="connection-status" class="status-disconnected">Disconnected</span>
</div>
</header>
@ -146,6 +149,23 @@
</div>
</div>
<!-- Addons manager overlay -->
<div id="addons-overlay" class="hidden">
<div id="addons-modal">
<div class="addons-header">
<h3>Addons</h3>
<button id="addons-close" title="Close">&times;</button>
</div>
<p class="addons-description">Upload <code>.js</code> addon files to add new toolbox categories. Addons are saved in your browser.</p>
<div class="addons-upload-row">
<input type="file" id="addon-file-input" accept=".js" />
<button id="addon-install-btn">Install</button>
</div>
<div id="addon-status" class="addons-status"></div>
<ul id="addons-list" class="addons-list"></ul>
</div>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

83
public/example-addon.js Normal file
View File

@ -0,0 +1,83 @@
// Example addon: adds a "My Robot" toolbox category with two custom blocks.
//
// To use: click "Addons" in the toolbar, upload this file, and the
// "My Robot" category will appear in the toolbox.
// --- Block definitions ---
api.Blockly.Blocks['robot_drive'] = {
init() {
this.appendDummyInput()
.appendField('drive')
.appendField(new api.Blockly.FieldDropdown([
['forward', 'FORWARD'],
['backward', 'BACKWARD'],
['left', 'LEFT'],
['right', 'RIGHT'],
['stop', 'STOP'],
]), 'DIRECTION');
this.appendValueInput('SPEED')
.setCheck('Number')
.appendField('speed');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(45);
this.setTooltip('Drive the robot in a direction at a given speed');
},
};
api.Blockly.Blocks['robot_beep'] = {
init() {
this.appendValueInput('TIMES')
.setCheck('Number')
.appendField('beep');
this.appendDummyInput()
.appendField('times');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(45);
this.setTooltip('Make the robot beep');
},
};
// --- Code generators ---
api.pythonGenerator.forBlock['robot_drive'] = function (block) {
const dir = block.getFieldValue('DIRECTION');
const speed = api.pythonGenerator.valueToCode(
block, 'SPEED', api.pythonGenerator.ORDER_NONE
) || '0';
return `robot.drive("${dir.toLowerCase()}", ${speed})\n`;
};
api.pythonGenerator.forBlock['robot_beep'] = function (block) {
const times = api.pythonGenerator.valueToCode(
block, 'TIMES', api.pythonGenerator.ORDER_NONE
) || '1';
return `robot.beep(${times})\n`;
};
// --- Register toolbox category ---
api.registerCategories([
{
kind: 'category',
name: 'My Robot',
colour: '45',
contents: [
{
kind: 'block',
type: 'robot_drive',
inputs: {
SPEED: { shadow: { type: 'math_number', fields: { NUM: 100 } } },
},
},
{
kind: 'block',
type: 'robot_beep',
inputs: {
TIMES: { shadow: { type: 'math_number', fields: { NUM: 3 } } },
},
},
],
},
]);

View File

@ -0,0 +1,167 @@
// h-bridge Motor Driver addon
//
// Adds a "h-bridge Motor" toolbox category with:
// - motor_init: set up a motor with IN1 and IN2 pins
// - motor_speed: set speed from -255 (full reverse) to +255 (full forward)
// - motor_stop: stop a motor
//
// Generates device-appropriate MicroPython:
// - ESP32 / RP2040: machine.Pin + machine.PWM
// - micro:bit: pinN.write_analog()
// --- Block definitions ---
api.Blockly.Blocks['hbridge_motor_init'] = {
init() {
this.appendDummyInput()
.appendField('init motor')
.appendField(new api.Blockly.FieldDropdown([
['A', 'A'],
['B', 'B'],
]), 'MOTOR')
.appendField('IN1 pin')
.appendField(new api.Blockly.FieldNumber(2, 0, 48, 1), 'IN1')
.appendField('IN2 pin')
.appendField(new api.Blockly.FieldNumber(3, 0, 48, 1), 'IN2');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Initialize an h-bridge motor channel with two pins');
},
};
api.Blockly.Blocks['hbridge_motor_speed'] = {
init() {
this.appendDummyInput()
.appendField('set motor')
.appendField(new api.Blockly.FieldDropdown([
['A', 'A'],
['B', 'B'],
]), 'MOTOR');
this.appendValueInput('SPEED')
.setCheck('Number')
.appendField('speed');
this.setInputsInline(true);
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Set motor speed: -255 (full reverse) to +255 (full forward)');
},
};
api.Blockly.Blocks['hbridge_motor_stop'] = {
init() {
this.appendDummyInput()
.appendField('stop motor')
.appendField(new api.Blockly.FieldDropdown([
['A', 'A'],
['B', 'B'],
]), 'MOTOR');
this.setPreviousStatement(true, null);
this.setNextStatement(true, null);
this.setColour(30);
this.setTooltip('Stop an h-bridge motor (brake)');
},
};
// --- Helpers used by generators ---
function isMicrobit() {
return api.getDeviceId() === 'microbit';
}
function mbPin(pin) {
var n = Math.max(0, Math.min(20, parseInt(pin, 10) || 0));
return 'pin' + n;
}
// --- Code generators ---
api.pythonGenerator.forBlock['hbridge_motor_init'] = function (block) {
var motor = block.getFieldValue('MOTOR');
var in1 = block.getFieldValue('IN1');
var in2 = block.getFieldValue('IN2');
var m = motor.toLowerCase();
if (isMicrobit()) {
// micro:bit: no explicit init needed, just store pin numbers for later use.
// We define variables so speed/stop blocks can reference them.
api.pythonGenerator.definitions_['import_microbit'] = 'from microbit import *';
api.pythonGenerator.definitions_['hb_motor_' + m + '_pins'] =
'motor_' + m + '_in1 = ' + mbPin(in1) + '\n' +
'motor_' + m + '_in2 = ' + mbPin(in2);
api.pythonGenerator.definitions_['hb_set_motor_mb'] = [
'def _hb_set_motor(p1, p2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' p1.write_analog(duty)',
' p2.write_analog(0)',
' elif speed < 0:',
' p1.write_analog(0)',
' p2.write_analog(duty)',
' else:',
' p1.write_analog(0)',
' p2.write_analog(0)',
].join('\n');
} else {
// ESP32 / RP2040: machine.PWM
api.pythonGenerator.definitions_['import_machine'] =
'from machine import Pin, PWM';
api.pythonGenerator.definitions_['hb_motor_' + m + '_in1'] =
'motor_' + m + '_in1 = PWM(Pin(' + in1 + '), freq=1000, duty=0)';
api.pythonGenerator.definitions_['hb_motor_' + m + '_in2'] =
'motor_' + m + '_in2 = PWM(Pin(' + in2 + '), freq=1000, duty=0)';
api.pythonGenerator.definitions_['hb_set_motor'] = [
'def _hb_set_motor(pwm1, pwm2, speed):',
' speed = max(-255, min(255, int(speed)))',
' duty = abs(speed) * 4',
' if speed > 0:',
' pwm1.duty(duty)',
' pwm2.duty(0)',
' elif speed < 0:',
' pwm1.duty(0)',
' pwm2.duty(duty)',
' else:',
' pwm1.duty(0)',
' pwm2.duty(0)',
].join('\n');
}
return '';
};
api.pythonGenerator.forBlock['hbridge_motor_speed'] = function (block) {
var motor = block.getFieldValue('MOTOR');
var m = motor.toLowerCase();
var speed = api.pythonGenerator.valueToCode(
block, 'SPEED', api.pythonGenerator.ORDER_NONE
) || '0';
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, ' + speed + ')\n';
};
api.pythonGenerator.forBlock['hbridge_motor_stop'] = function (block) {
var motor = block.getFieldValue('MOTOR');
var m = motor.toLowerCase();
return '_hb_set_motor(motor_' + m + '_in1, motor_' + m + '_in2, 0)\n';
};
// --- Register toolbox category ---
api.registerCategories([
{
kind: 'category',
name: 'H-Bridge Motor',
colour: '30',
contents: [
{ kind: 'block', type: 'hbridge_motor_init' },
{
kind: 'block',
type: 'hbridge_motor_speed',
inputs: {
SPEED: { shadow: { type: 'math_number', fields: { NUM: 200 } } },
},
},
{ kind: 'block', type: 'hbridge_motor_stop' },
],
},
]);

98
src/addons/loader.js Normal file
View File

@ -0,0 +1,98 @@
import * as Blockly from 'blockly';
import { pythonGenerator } from 'blockly/python';
import {
registerAddonCategories,
clearAddonCategories,
getAddonCategories,
getDeviceId,
} from '../devices/registry.js';
const STORAGE_KEY = 'esp32block_addons';
/** { name: string, source: string }[] — persisted addon scripts */
function loadSavedAddons() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
} catch { return []; }
}
function saveAddonList(list) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
}
export function getInstalledAddons() {
return loadSavedAddons();
}
/**
* The API object passed to every addon's init().
* refreshToolbox is injected later by setRefreshCallback().
*/
let refreshToolboxFn = () => {};
export function setRefreshCallback(fn) {
refreshToolboxFn = fn;
}
function makeAddonApi() {
return {
Blockly,
pythonGenerator,
getDeviceId,
registerCategories(categories) {
registerAddonCategories(categories);
refreshToolboxFn();
},
};
}
/**
* Execute an addon script string in the current page.
* The script should be a self-contained function body or ES module-style code
* that calls `init(api)` where api is the addon API.
*
* We wrap the source in a function that receives the api object.
*/
function runAddonSource(source) {
const fn = new Function('api', source);
fn(makeAddonApi());
}
/** Load a single addon from its source string. */
export function loadAddonFromSource(name, source) {
runAddonSource(source);
}
/** Add an addon from a File object, persist it, and run it. */
export async function installAddonFromFile(file) {
const source = await file.text();
const name = file.name.replace(/\.js$/i, '');
const list = loadSavedAddons();
const existing = list.findIndex(a => a.name === name);
if (existing >= 0) list.splice(existing, 1);
list.push({ name, source });
saveAddonList(list);
loadAddonFromSource(name, source);
return name;
}
/** Remove an addon by name. Requires page reload to fully take effect. */
export function removeAddon(name) {
const list = loadSavedAddons().filter(a => a.name !== name);
saveAddonList(list);
}
/** Run all persisted addons (call once at startup). */
export function loadAllSavedAddons() {
clearAddonCategories();
const list = loadSavedAddons();
for (const addon of list) {
try {
loadAddonFromSource(addon.name, addon.source);
} catch (err) {
console.error(`[addon] Failed to load "${addon.name}":`, err);
}
}
}

View File

@ -60,16 +60,33 @@ export function canFlashInBrowser() {
// --- toolbox builder ---
const addonCategories = [];
export function registerAddonCategories(categories) {
for (const cat of categories) {
if (cat && cat.kind === 'category') addonCategories.push(cat);
}
}
export function getAddonCategories() {
return addonCategories;
}
export function clearAddonCategories() {
addonCategories.length = 0;
}
export function buildToolbox(deviceId) {
const profile = devices[deviceId || currentId];
if (!profile) return { kind: 'categoryToolbox', contents: [] };
return {
kind: 'categoryToolbox',
contents: [
...profile.categories,
{ kind: 'sep' },
...builtinCategories,
],
};
const contents = [...profile.categories];
if (addonCategories.length) {
contents.push({ kind: 'sep' });
contents.push(...addonCategories);
}
contents.push({ kind: 'sep' });
contents.push(...builtinCategories);
return { kind: 'categoryToolbox', contents };
}

View File

@ -9,6 +9,13 @@ import {
canFlashInBrowser,
buildToolbox,
} from './devices/registry.js';
import {
setRefreshCallback,
loadAllSavedAddons,
installAddonFromFile,
removeAddon,
getInstalledAddons,
} from './addons/loader.js';
import { connect, disconnect, isConnected, onData, writeString } from './serial/connection.js';
import { executeCode, stopExecution, saveToDevice, writeFileToDevice } from './serial/repl.js';
import { flashFirmware } from './serial/flasher.js';
@ -19,6 +26,9 @@ import './style.css';
// ─── Blockly Workspace ───────────────────────────────────
// Load saved addons before building toolbox so their categories are included
loadAllSavedAddons();
const workspace = Blockly.inject('blockly-div', {
toolbox: buildToolbox(getDeviceId()),
theme: Blockly.Themes.Dark,
@ -28,6 +38,11 @@ const workspace = Blockly.inject('blockly-div', {
renderer: 'zelos',
});
// Now that workspace exists, set the refresh callback for addons
setRefreshCallback(() => {
workspace.updateToolbox(buildToolbox(getDeviceId()));
});
// ─── Live Code Preview ───────────────────────────────────
const codeOutput = document.getElementById('code-output');
@ -132,7 +147,7 @@ function hideSendProgress() {
deviceSelect.value = getDeviceId();
deviceSelect.addEventListener('change', () => {
setDeviceId(deviceSelect.value);
workspace.updateToolbox(buildToolbox(getDeviceId()));
refreshToolbox();
updateCodePreview();
// Update Flash button tooltip/label based on device
btnFlash.title = canFlashInBrowser()
@ -143,6 +158,10 @@ btnFlash.title = canFlashInBrowser()
? 'Flash MicroPython firmware'
: 'Download firmware (drag to device)';
function refreshToolbox() {
workspace.updateToolbox(buildToolbox(getDeviceId()));
}
function setConnectedUI(connected) {
btnConnect.textContent = connected ? '⏏ Disconnect' : '▶ Connect';
btnRun.disabled = !connected;
@ -386,3 +405,76 @@ terminalInput.addEventListener('keydown', async (e) => {
await writeString(text + '\r\n');
}
});
// ─── Addons Manager UI ──────────────────────────────────
const btnAddons = document.getElementById('btn-addons');
const addonsOverlay = document.getElementById('addons-overlay');
const addonsClose = document.getElementById('addons-close');
const addonFileInput = document.getElementById('addon-file-input');
const addonInstallBtn = document.getElementById('addon-install-btn');
const addonStatus = document.getElementById('addon-status');
const addonsList = document.getElementById('addons-list');
function renderAddonsList() {
const addons = getInstalledAddons();
addonsList.innerHTML = '';
if (!addons.length) {
addonsList.innerHTML = '<li class="addons-empty">No addons installed</li>';
return;
}
for (const addon of addons) {
const li = document.createElement('li');
li.className = 'addons-item';
const nameSpan = document.createElement('span');
nameSpan.className = 'addons-item-name';
nameSpan.textContent = addon.name;
const removeBtn = document.createElement('button');
removeBtn.className = 'addons-item-remove';
removeBtn.textContent = 'Remove';
removeBtn.title = 'Remove addon (reload required)';
removeBtn.addEventListener('click', () => {
removeAddon(addon.name);
renderAddonsList();
addonStatus.textContent = `"${addon.name}" removed. Reload the page to apply.`;
addonStatus.className = 'addons-status status-warn';
});
li.appendChild(nameSpan);
li.appendChild(removeBtn);
addonsList.appendChild(li);
}
}
btnAddons.addEventListener('click', () => {
addonsOverlay.classList.remove('hidden');
renderAddonsList();
addonStatus.textContent = '';
addonStatus.className = 'addons-status';
});
addonsClose.addEventListener('click', () => {
addonsOverlay.classList.add('hidden');
});
addonsOverlay.addEventListener('click', (e) => {
if (e.target === addonsOverlay) addonsOverlay.classList.add('hidden');
});
addonInstallBtn.addEventListener('click', async () => {
const file = addonFileInput.files[0];
if (!file) {
addonStatus.textContent = 'Select a .js file first.';
addonStatus.className = 'addons-status status-err';
return;
}
try {
const name = await installAddonFromFile(file);
addonStatus.textContent = `"${name}" installed successfully!`;
addonStatus.className = 'addons-status status-ok';
renderAddonsList();
addonFileInput.value = '';
} catch (err) {
addonStatus.textContent = `Error: ${err.message}`;
addonStatus.className = 'addons-status status-err';
}
});

View File

@ -82,6 +82,24 @@ html, body {
outline: none;
}
.toolbar-icon-btn {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.toolbar-icon-btn:hover {
background: var(--bg-primary);
border-color: var(--accent);
color: var(--accent);
}
.toolbar-actions {
display: flex;
align-items: center;
@ -658,3 +676,159 @@ html, body {
color: var(--text-secondary);
font-weight: 600;
}
/* --- Addons Manager Overlay --- */
#addons-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
#addons-modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px 28px;
width: 440px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
}
.addons-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.addons-header h3 {
margin: 0;
color: var(--accent);
font-size: 16px;
}
.addons-header button {
background: none;
border: none;
color: var(--text-muted);
font-size: 22px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.addons-header button:hover {
color: var(--text-primary);
}
.addons-description {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.addons-description code {
background: var(--bg-secondary);
padding: 1px 5px;
border-radius: 3px;
font-size: 12px;
}
.addons-upload-row {
display: flex;
gap: 8px;
align-items: center;
}
.addons-upload-row input[type="file"] {
flex: 1;
font-size: 12px;
color: var(--text-primary);
}
.addons-upload-row button {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.addons-upload-row button:hover {
background: var(--accent);
color: var(--bg-toolbar);
border-color: var(--accent);
}
.addons-status {
font-size: 12px;
min-height: 18px;
color: var(--text-muted);
}
.addons-status.status-ok { color: var(--green); }
.addons-status.status-err { color: var(--red); }
.addons-status.status-warn { color: var(--yellow); }
.addons-list {
list-style: none;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
min-height: 60px;
max-height: 200px;
overflow-y: auto;
padding: 4px;
}
.addons-list .addons-empty {
padding: 12px 10px;
color: var(--text-muted);
font-size: 12px;
font-style: italic;
text-align: center;
}
.addons-list .addons-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 4px;
}
.addons-list .addons-item:hover {
background: var(--bg-surface);
}
.addons-item-name {
font-size: 13px;
color: var(--text-primary);
}
.addons-item-remove {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--red);
font-size: 11px;
padding: 3px 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.addons-item-remove:hover {
background: var(--red);
color: var(--bg-toolbar);
border-color: var(--red);
}