diff --git a/TODO.md b/TODO.md index 8c92dd0..718a519 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,4 @@ -- Background Graphic Section -- Input section +- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input) Both of these should also have effects like: Normal movement, erratic, jumpy, random - No Disable steppers but - Idle timeout diff --git a/gemini.md b/gemini.md index 251afce..70b3132 100644 --- a/gemini.md +++ b/gemini.md @@ -366,14 +366,21 @@ The `?v=Date.now()` on imports handles this automatically on page load. - Move-to-XY numeric input with Go button - Board boundary + mechanism exclusion zone visualisation - Simulation badge (yellow) when BLE not connected -- [x] Home section (`home.js`) — placeholder landing page, same visual style +- [x] Board Control section (`board-control.js`): + - Interactive map with clickable spots + - Speed and Acceleration controls + - Background image selector with config memory +- [x] Input Text section (`input-text.js`): + - Text input to spell words automatically + - Delay, Speed, and Acceleration sliders + - Position overlay marker on background - [x] Firmware (`src/main.cpp`) — AccelStepper + BLE command parser + position NOTIFY --- ## 10. What Remains (Planned Sections) -The router currently only has two routes. These sections need to be created: +The router currently only has three routes. These sections need to be created/expanded: ### `board-control` section (implemented) - Uses predefined background maps (SVG/PNG) with corresponding JSON config files specifying clickable coordinates in physical mm. @@ -381,6 +388,11 @@ The router currently only has two routes. These sections need to be created: - Supports movement modes (e.g., Direct, Erratic, Random, Snaky). - Requires arm to be homed; homing state is persisted across sessions via `localStorage`. +### `input-text` section (implemented) +- Keyboard input that sequentially spells out words on the board. +- Requires background `spots.json` to have `"hasAlphabet": true`. +- Persisted Speed, Acceleration, and Delay settings. + ### `sequence-editor` section (not started) - Record and replay a sequence of moves - Save/load sequences to localStorage diff --git a/index.html b/index.html index 110a0ea..3630e05 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,14 @@ Board Control + + + + + Input Text + + @@ -108,11 +116,13 @@ import HomeSection from './web/js/sections/home.js'; import StepperSection from './web/js/sections/stepper-test.js'; import BoardControlSection from './web/js/sections/board-control.js'; + import InputTextSection from './web/js/sections/input-text.js'; // ── Register routes ──────────────────────────────────────────── Router.register('home', HomeSection); Router.register('stepper-test', StepperSection); Router.register('board-control', BoardControlSection); + Router.register('input-text', InputTextSection); // ── Sidebar toggle logic ─────────────────────────────────────── const sidebar = document.getElementById('sidebar'); diff --git a/web/assets/backgrounds/default/spots.json b/web/assets/backgrounds/default/spots.json index 24f270c..e9c3b00 100644 --- a/web/assets/backgrounds/default/spots.json +++ b/web/assets/backgrounds/default/spots.json @@ -1,4 +1,5 @@ { + "hasAlphabet": true, "bounds": { "xMin": -150, "xMax": 150, "yMin": -30, "yMax": 145 }, "spots": [ { "id": "YES", "label": "YES", "x": -70, "y": 90 }, diff --git a/web/assets/backgrounds/image_spec.md b/web/assets/backgrounds/image_spec.md index 4d3e74e..a00c0fa 100644 --- a/web/assets/backgrounds/image_spec.md +++ b/web/assets/backgrounds/image_spec.md @@ -45,6 +45,7 @@ This file tells the web UI where your letters/targets are located in **physical ```json { + "hasAlphabet": true, "bounds": { "xMin": -150, "xMax": 150, @@ -58,6 +59,7 @@ This file tells the web UI where your letters/targets are located in **physical ] } ``` +- **hasAlphabet**: (Optional, boolean) If `true`, this background is used for the "Input Text" feature. The letters in `spots` are used to spell out typed input. - **bounds**: Defines the physical bounding box (in mm) that your background image represents. This automatically adjusts the UI's aspect ratio and coordinate mapping! - **spots**: Array of target objects. - **id**: Unique identifier for the dropdown list. diff --git a/web/assets/backgrounds/portrait-right/spots.json b/web/assets/backgrounds/portrait-right/spots.json index 3736a5f..6036468 100644 --- a/web/assets/backgrounds/portrait-right/spots.json +++ b/web/assets/backgrounds/portrait-right/spots.json @@ -1,4 +1,5 @@ { + "hasAlphabet": true, "bounds": { "xMin": 30, "xMax": 150, "yMin": -30, "yMax": 145 }, "spots": [ { "id": "A", "label": "Aa", "x": 55, "y": 120 }, diff --git a/web/js/sections/board-control.js b/web/js/sections/board-control.js index 7c213b4..459cd03 100644 --- a/web/js/sections/board-control.js +++ b/web/js/sections/board-control.js @@ -1,6 +1,7 @@ import BLE from '../ble.js'; import UI from '../ui.js'; import IK from '../kinematics.js'; +import Settings from '../settings.js'; let eventCleanup = []; let isHomed = false; @@ -109,6 +110,21 @@ function buildHTML() { + +
+
+ + ${Settings.speed} +
+ + +
+ + ${Settings.accel} +
+ +
+
+ + + +
+ + +
+
+ + ${Settings.speed} +
+ + +
+ + ${Settings.accel} +
+ + +
+ + ${(Settings.delay/1000).toFixed(1)} +
+ +
+ +
+ + +
+ + + + + + + + + `; +} + +function updatePosMarker() { + const marker = document.getElementById('pos-marker'); + if (!marker) return; + + const t1 = IK.stepsToRad(steps1); + const t2 = IK.stepsToRad(steps2); + const { endX, endY, valid } = IK.forward(t1, t2); + + if (valid && isFinite(endX) && isFinite(endY)) { + const px = document.getElementById('pos-x'); + const py = document.getElementById('pos-y'); + if (px) px.textContent = endX.toFixed(1); + if (py) py.textContent = endY.toFixed(1); + + const w = bounds.xMax - bounds.xMin; + const h = bounds.yMax - bounds.yMin; + + // Bounds check to avoid marker flying off screen entirely + if (endX >= bounds.xMin && endX <= bounds.xMax && endY >= bounds.yMin && endY <= bounds.yMax) { + const xPct = ((endX - bounds.xMin) / w) * 100; + const yPct = ((bounds.yMax - endY) / h) * 100; + + marker.style.left = `${xPct}%`; + marker.style.top = `${yPct}%`; + marker.style.display = 'block'; + } else { + marker.style.display = 'none'; // Out of bounds visually + } + } else { + marker.style.display = 'none'; + } +} + +function updateHomingUI() { + const banner = document.getElementById('homing-banner'); + const btn = document.getElementById('btn-force-home'); + if (!banner || !btn) return; + + if (isHomed) { + banner.className = 'homing-banner homed'; + banner.textContent = '✓ HOMED'; + btn.textContent = 'FORCE HOME ALL'; + } else { + banner.className = 'homing-banner not-homed'; + banner.textContent = '⚠ NOT HOMED! Movement Locked.'; + btn.textContent = 'HOME ALL MOTORS'; + } +} + +async function loadSpots() { + try { + const res = await fetch(`web/assets/backgrounds/${currentBg}/spots.json?v=${Date.now()}`); + const data = await res.json(); + + if (!data.hasAlphabet) { + UI.log(`Background '${currentBg}' does not support an alphabet.`, 'warn'); + } + + if (Array.isArray(data)) { + spots = data; + } else { + spots = data.spots || []; + if (data.bounds) bounds = data.bounds; + } + + document.getElementById('bg-image').src = `web/assets/backgrounds/${currentBg}/bg.svg?v=${Date.now()}`; + + const mapContainer = document.getElementById('map-container'); + if (mapContainer) { + const w = bounds.xMax - bounds.xMin; + const h = bounds.yMax - bounds.yMin; + mapContainer.style.aspectRatio = `${w}/${h}`; + } + updatePosMarker(); + } catch(e) { + UI.log('Failed to load spots.json', 'error'); + } +} + +async function sendSettings() { + if (!BLE.isConnected()) return; + try { + await BLE.write(`SPD:${Settings.speed}`); + await BLE.write(`ACC:${Settings.accel}`); + } catch (e) { + console.error('Failed to send settings', e); + } +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function executeMove(spot) { + const wsCheck = IK.checkWorkspace(spot.x, spot.y); + if (!wsCheck.ok) { + UI.log(`⛔ ${wsCheck.reason}`, 'error'); + return false; + } + + const res = IK.solve(spot.x, spot.y); + if (!res.reachable || IK.armsCrossed(res.theta1, res.theta2)) { + UI.log(`Target ${spot.label} is unreachable or crosses arms.`, 'error'); + return false; + } + + const newSteps1 = IK.radToSteps(res.theta1); + const newSteps2 = IK.radToSteps(res.theta2); + const delta1 = newSteps1 - steps1; + const delta2 = newSteps2 - steps2; + + steps1 = newSteps1; + steps2 = newSteps2; + saveSteps(); + updatePosMarker(); + + const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`; + const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`; + + UI.log(`Spelling: ${spot.label}`, 'info'); + + if (BLE.isConnected()) { + try { + await BLE.write(cmd1); + await BLE.write(cmd2); + } catch (e) { + UI.log(`BLE error: ${e.message}`, 'error'); + return false; + } + } else { + console.log(`[sim] ${cmd1} ${cmd2}`); + } + return true; +} + +async function spellText(text) { + const btnSpell = document.getElementById('btn-spell'); + const btnStop = document.getElementById('btn-stop'); + const inputEl = document.getElementById('text-input'); + + if (!isHomed) { + UI.log('Cannot move: System is not homed! Please click HOME ALL.', 'error'); + return; + } + + text = text.toUpperCase().replace(/\s+/g, ''); + if (!text) return; + + isSpelling = true; + btnSpell.style.display = 'none'; + btnStop.style.display = 'block'; + inputEl.disabled = true; + + // Send speed/accel right before moving just in case + await sendSettings(); + + for (let i = 0; i < text.length; i++) { + if (!isSpelling) break; + + const char = text[i]; + // Find spot by ID (assuming ID is the uppercase letter) + const spot = spots.find(s => s.id.toUpperCase() === char); + + if (spot) { + await executeMove(spot); + // Wait for delay + await delay(Settings.delay); + } else { + UI.log(`Character '${char}' not found on this board.`, 'warn'); + } + } + + isSpelling = false; + btnSpell.style.display = 'block'; + btnStop.style.display = 'none'; + inputEl.disabled = false; + inputEl.value = ''; + UI.log('Finished spelling text', 'success'); +} + +export default { + mount(container) { + syncStateFromStorage(); + container.innerHTML = buildHTML(); + + updateHomingUI(); + loadSpots(); + + // ── Background Selection ────────────────────────────────── + const bgSelect = document.getElementById('bg-select'); + if (bgSelect) { + bgSelect.value = currentBg; + bgSelect.addEventListener('change', () => { + currentBg = bgSelect.value; + localStorage.setItem('wiji_input_bg', currentBg); + loadSpots(); + }); + } + + // ── Settings Sliders ────────────────────────────────────── + const sSpeed = document.getElementById('slider-speed'); + const sAccel = document.getElementById('slider-accel'); + const sDelay = document.getElementById('slider-delay'); + + sSpeed.addEventListener('input', (e) => { + Settings.speed = parseInt(e.target.value); + document.getElementById('speed-val').textContent = Settings.speed; + sendSettings(); + }); + + sAccel.addEventListener('input', (e) => { + Settings.accel = parseInt(e.target.value); + document.getElementById('accel-val').textContent = Settings.accel; + sendSettings(); + }); + + sDelay.addEventListener('input', (e) => { + Settings.delay = parseInt(e.target.value); + document.getElementById('delay-val').textContent = (Settings.delay/1000).toFixed(1); + }); + + // ── Homing Logic ────────────────────────────────────────── + document.getElementById('btn-force-home').addEventListener('click', async () => { + isHomed = true; + localStorage.setItem('wiji_homed', 'true'); + updateHomingUI(); + + steps1 = IK.ARM.HOME_STEPS.m1; + steps2 = IK.ARM.HOME_STEPS.m2; + saveSteps(); + updatePosMarker(); + + UI.log('Homing all motors...', 'info'); + if (BLE.isConnected()) { + try { + await BLE.write('HOMEALL'); + } catch (e) { + UI.log(e.message, 'error'); + } + } else { + UI.log('[sim] HOMEALL', 'info'); + } + }); + + // ── Spelling Logic ──────────────────────────────────────── + const btnSpell = document.getElementById('btn-spell'); + const btnStop = document.getElementById('btn-stop'); + const inputEl = document.getElementById('text-input'); + + btnSpell.addEventListener('click', () => { + spellText(inputEl.value); + }); + + inputEl.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + spellText(inputEl.value); + } + }); + + btnStop.addEventListener('click', () => { + isSpelling = false; + UI.log('Spelling stopped', 'info'); + }); + + // ── Sync Steps from BLE ─────────────────────────────────── + const onBLEStatus = (e) => { + const msg = e.detail; + if (msg.startsWith('P:')) { + const [s1, s2] = msg.slice(2).split(',').map(Number); + steps1 = s1; steps2 = s2; + saveSteps(); + updatePosMarker(); + } + }; + document.addEventListener('ble:status', onBLEStatus); + + // Sync position on mount if connected + if (BLE.isConnected()) { + BLE.write('POS').catch(() => {}); + sendSettings(); + } else { + updatePosMarker(); + } + + eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus)); + }, + + unmount() { + isSpelling = false; + eventCleanup.forEach(fn => fn()); + eventCleanup = []; + } +}; diff --git a/web/js/settings.js b/web/js/settings.js new file mode 100644 index 0000000..7f97079 --- /dev/null +++ b/web/js/settings.js @@ -0,0 +1,25 @@ +/** + * WijiBoard – Global Settings + * web/js/settings.js + * + * Manages persisted application settings and defaults. + */ + +const DEFAULTS = { + speed: 600, + accel: 100, + delay: 2500 +}; + +export default { + get speed() { return parseInt(localStorage.getItem('wiji_speed') || DEFAULTS.speed); }, + set speed(val) { localStorage.setItem('wiji_speed', val); }, + + get accel() { return parseInt(localStorage.getItem('wiji_accel') || DEFAULTS.accel); }, + set accel(val) { localStorage.setItem('wiji_accel', val); }, + + get delay() { return parseInt(localStorage.getItem('wiji_delay') || DEFAULTS.delay); }, + set delay(val) { localStorage.setItem('wiji_delay', val); }, + + DEFAULTS +};