diff --git a/index.html b/index.html index 7537a63..425e67b 100644 --- a/index.html +++ b/index.html @@ -37,6 +37,9 @@ + Disconnected @@ -146,6 +149,23 @@ + + + diff --git a/public/example-addon.js b/public/example-addon.js new file mode 100644 index 0000000..534d3ff --- /dev/null +++ b/public/example-addon.js @@ -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 } } }, + }, + }, + ], + }, +]); diff --git a/public/hbridgemotor-addon.js b/public/hbridgemotor-addon.js new file mode 100644 index 0000000..97df155 --- /dev/null +++ b/public/hbridgemotor-addon.js @@ -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' }, + ], + }, +]); diff --git a/src/addons/loader.js b/src/addons/loader.js new file mode 100644 index 0000000..b6df19d --- /dev/null +++ b/src/addons/loader.js @@ -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); + } + } +} diff --git a/src/devices/registry.js b/src/devices/registry.js index 7d38eef..8fc1569 100644 --- a/src/devices/registry.js +++ b/src/devices/registry.js @@ -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 }; } diff --git a/src/main.js b/src/main.js index 02afda2..b3c53a5 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = '
  • No addons installed
  • '; + 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'; + } +}); diff --git a/src/style.css b/src/style.css index 9d2ceee..5a886a0 100644 --- a/src/style.css +++ b/src/style.css @@ -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); +}