20 KiB
WijiBoard – Project Context for AI Assistants
Purpose: This file is the authoritative project memory. Read it in full before making any changes. It captures hardware, architecture, current state, open work, and every important design decision made so far.
1. Project Overview
WijiBoard is a 5-bar parallel linkage SCARA robot (spirit-board / planchette mover) controlled wirelessly from a browser via Web Bluetooth. There is no mobile app, no WiFi, no server — only BLE between the ESP32 firmware and a local web SPA.
- Inspiration / upstream:
nerd-sniped/WijiBoardon GitHub. We took only the kinematics and lookup tables from that project. Everything else (frontend, BLE protocol, SPA shell, section architecture) was written from scratch.
2. Hardware
| Component | Detail |
|---|---|
| MCU | Tenstar Robot ESP32-C3 Super Mini |
| Framework | PlatformIO + Arduino core |
| Motors | 2× 28BYJ-48 (unipolar stepper via ULN2003 driver) |
| Motor mode | AccelStepper::FULL4WIRE — 2048 steps/rev |
| Motor 1 pins | IN1=0, IN2=1, IN3=3, IN4=4 |
| Motor 2 pins | IN1=5, IN2=6, IN3=7, IN4=10 |
| USB Serial | Native USB CDC (-D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1) |
| Default speed | 600 steps/s, accel 100 steps/s² |
Mechanism geometry (critical — do NOT change without hardware verification)
END EFFECTOR
/ \
l2 (110 mm) l2 (110 mm)
/ \
ELBOW1 ELBOW2
\ /
l1 (85 mm) l1 (85 mm)
\ /
MOTOR1 (-12.9,0) MOTOR2 (+12.9,0)
| |
[===BASE (25.8 mm)===]
⚠️ CRITICAL MOTOR CONVENTION (source of many past bugs):
The original C++ firmware has a variable swap bug where angle2 is passed to stepper1 and angle1 to stepper2. Thus, the physical mapping is:
- Motor 1 is the LEFT motor, pivoting at (-d2, 0) = (-12.9, 0). It uses
xpd = x + d2. - Motor 2 is the RIGHT motor, pivoting at (+d2, 0) = (+12.9, 0). It uses
xmd = x - d2.
Every piece of code that computes elbow or motor-base positions must use M1 at -12.9 and M2 at +12.9 or the arms will appear visually crossed and physically collide.
Arm constants (from PositionControl.cpp)
d = 25.8 mm (full motor separation)
d2 = 12.9 mm (half separation)
l1 = 85.0 mm (proximal link)
l2 = 110.0 mm (distal link)
STEPS_PER_REV = 2048
STEP_ANGLE = 360/2048 ≈ 0.17578125 °/step
3. BLE Protocol
UUIDs (must be identical in firmware AND web/js/ble.js)
Service: 18f3b235-9831-4c75-8ec0-210469b820a0
Command: cd083b06-4447-4cf3-a7c3-322ecf802ce4 (WRITE)
Status: 82e38c5b-d3ab-41d1-861c-b84dc6bb1e03 (NOTIFY)
Name: WijiBoard
Command format (ASCII strings, write-without-response)
| Command | Meaning |
|---|---|
S1+<n> |
Move motor 1 CW by n steps |
S1-<n> |
Move motor 1 CCW by n steps |
S2+<n> |
Move motor 2 CW by n steps |
S2-<n> |
Move motor 2 CCW by n steps |
SPD:<n> |
Set max speed (steps/sec) for both motors |
ACC:<n> |
Set acceleration (steps/sec²) for both motors |
HOMEALL |
Home both motors simultaneously |
HOME1 |
Home motor 1 |
HOME2 |
Home motor 2 |
POS |
Request current positions (triggers NOTIFY) |
Status notifications (firmware → browser)
P:<s1>,<s2> — current step positions for motor 1 and 2, sent every 200 ms while moving.
4. Project Structure
esp32c3-wiji/
├── index.html # SPA shell — ES module bootstrap, sidebar, routing
├── platformio.ini # PlatformIO config (env: esp32-c3-devkitm-1)
├── fivebarIKGame.js # REFERENCE ONLY — original site's kinematics (do not import)
├── src/
│ └── main.cpp # ESP32 firmware (BLE + AccelStepper)
└── web/
├── styles/
│ ├── base.css # CSS variables, reset, typography, layout primitives
│ └── components.css # Cards, buttons, inputs, sidebar, nav items
└── js/
├── ble.js # Web Bluetooth singleton
├── kinematics.js # 5-bar IK/FK (PositionControl.cpp port)
├── router.js # Hash-based SPA router
├── ui.js # UI helpers (log panel, toast, BLE status badge)
└── sections/
├── home.js # Home/landing section (placeholder)
└── stepper-test.js # Stepper test section — SVG arm visualiser + jog + IK
Module loading (index.html)
All ES modules are loaded with static imports in index.html.
CRITICAL: Do NOT use dynamic cache-busting imports (e.g. import('./ble.js?v=123')) for the BLE module. Doing so causes the browser to instantiate multiple copies of the BLE singleton, breaking the event emitter (UI will not update when BLE connects).
5. SPA Architecture
Router (web/js/router.js)
Hash-based SPA router. Sections are mounted/unmounted as the user navigates.
Router.register('home', HomeSection);
Router.register('stepper-test', StepperSection);
Router.init('#view', '#home');
Each section module must export { mount(containerEl), unmount() }.
Section lifecycle
mount(el)— called when navigating TO this section; injects HTML, sets up event listenersunmount()— called when navigating AWAY; removes event listeners fromeventCleanup[]
Pattern used in every section:
let eventCleanup = [];
export default {
async mount(container) {
container.innerHTML = buildHTML();
// grab refs...
const handler = (e) => { ... };
el.addEventListener('click', handler);
eventCleanup.push(() => el.removeEventListener('click', handler));
renderArmFromSteps();
},
unmount() {
eventCleanup.forEach(fn => fn());
eventCleanup = [];
},
};
Sidebar
The sidebar is in index.html (not in any section). It is a collapsible panel opened by a
hamburger (☰) button. It contains <button data-route="..."> nav items that call
Router.navigate(route). The active item gets class="active" via the router.
Kinematic Motion Controller (web/js/motion.js)
Centralised trajectory generation and streaming module.
- Why: Moving side-to-side linearly in joint space causes the arms to arc dangerously out-of-bounds at the top.
- How it works: Intercepts
Motion.goto(x, y), slices the Cartesian straight line into small segments (max25 mmby default, configurable inMotion.CONFIG.SEGMENT_SIZE_MM), and streams them to the ESP32. - Dynamic Blending: It monitors
P:positional updates from the ESP32 and fires the next waypoint beforeAccelStepperdecelerates (when withinBLEND_THRESHOLD_STEPS). This produces a seamless Cartesian trajectory with no MCU code changes and allows for future "visual flavor" movement effects (e.g. snaky, erratic). - Simulation Sync: In simulation mode,
Motion.goto()instantly updates its internal steps and manually dispatches a fakeble:statusevent containingP:s1,s2. This allows all UI components to effortlessly sync their visual state using their existing BLE listeners.
6. Kinematics (web/js/kinematics.js)
IK (solve(x, y))
Exact port of calculateInverseKinematics() from PositionControl.cpp, with motor swap applied:
// Motor 1 (Left, pivot at -d2 = -12.9 mm)
const xpd = x + d2; // Δx from M1 pivot
const t = hypot(xpd, y);
const cosW2 = (l2² - t² - l1²) / (-2·l1·t);
const theta1 = atan2(y, xpd) + acos(cosW2);
// Motor 2 (Right, pivot at +d2 = +12.9 mm)
const xmd = x - d2; // Δx from M2 pivot
const s = hypot(xmd, y);
const cosW1 = (l2² - s² - l1²) / (-2·l1·s);
const theta2 = atan2(y, xmd) - acos(cosW1);
Returns { theta1, theta2, reachable } — angles in radians.
FK (forward(theta1, theta2))
Computes elbow and EE positions for visualisation:
// M1 at -d2, M2 at +d2 ← critical sign convention
elbow1 = { x: -d2 + l1·cos(theta1), y: l1·sin(theta1) }
elbow2 = { x: +d2 + l1·cos(theta2), y: l1·sin(theta2) }
// EE = upward circle-circle intersection of circles (elbow1, l2) and (elbow2, l2)
Returns { elbow1, elbow2, endX, endY, valid }.
Arm crossing check (armsCrossed(theta1, theta2))
Prevents moves that would push an elbow into the mechanism housing from the wrong side. Based on elbow position relative to tunable thresholds (see LIMITS below).
Workspace check (checkWorkspace(x, y))
Four ordered checks:
- Board outer boundary (rectangular)
- Mechanism housing exclusion box (rectangular, centred on origin)
- IK geometric reachability (cosW1/cosW2 range)
- Elbow crossing guard (
armsCrossed)
LIMITS object (all values in mm — tune to hardware)
const LIMITS = {
// Outer board boundary
X_MIN: -150, // ← TUNE
X_MAX: 150, // ← TUNE
Y_MIN: -30, // ← TUNE (numbers start at ~44 mm, signs below 0)
Y_MAX: 145, // ← TUNE (highest letter ~128 mm)
// Centre mechanism housing exclusion box
BOX_HALF_W: 30, // ← TUNE: half-width (motors at ±12.9, housing wider)
BOX_Y_MIN: -10, // ← TUNE: box bottom
BOX_Y_MAX: 40, // ← TUNE: box top
// Elbow crossing thresholds
ELBOW_BOX_X_INNER: 5, // ← TUNE: inner X margin each elbow must stay outside
ELBOW_BOX_Y_MAX: 50, // ← TUNE: Y below which elbow crossing is forbidden
};
Unit converters
IK.stepsToRad(steps) // steps → radians (for FK from jog state)
IK.radToSteps(rad) // radians → steps (for IK result → BLE command)
IK.stepsToDeg(steps) // steps → degrees (for readout display)
IK.radToDeg(rad) // radians → degrees
Lookup table
IK.LOOKUP_TABLE — 36 character positions (A–Z, 0–9, +, -, *, ,) in mm.
IK.lookup('T') → { x: 1.3, y: 97.8 }.
7. Stepper Test Section (web/js/sections/stepper-test.js)
State
let steps1 = 0; // accumulated step count from home for motor 1
let steps2 = 0; // accumulated step count from home for motor 2
Step counts are the authoritative position. All FK calls convert steps → radians first.
SVG viewport
const SV = {
W: 480, H: 380,
SCALE: 1.35, // px/mm
OX: 240, // SVG x of world origin
OY: 295, // SVG y of world origin (base line, motors here)
wx(worldX) { return this.OX + worldX * this.SCALE; },
wy(worldY) { return this.OY - worldY * this.SCALE; }, // Y inverted
svgToWorldX(sx) { return (sx - this.OX) / this.SCALE; },
svgToWorldY(sy) { return -(sy - this.OY) / this.SCALE; },
};
Motor marker positions in SVG (match FK convention)
// M1 at -d2 (left side), M2 at +d2 (right side)
motor1sx = SV.wx(-IK.ARM.d2);
motor2sx = SV.wx(+IK.ARM.d2);
IK click-to-move flow
- SVG click →
SV.svgToWorldX/Y(event coords)→ world(x, y) IK.checkWorkspace(x, y)— reject with log message if invalidIK.solve(x, y)→{ theta1, theta2 }IK.radToSteps(theta1/2)→ new step counts- Compute deltas:
delta1 = newSteps1 - steps1 - Send BLE:
S1+<delta1>,S2+<delta2> - Update
steps1/2, re-render arm
Ghost arm (hover preview)
On mousemove over SVG: calls checkWorkspace → if valid, calls showGhost(theta1, theta2)
which renders a translucent preview arm. If invalid, hides ghost.
Jog controls
Manual per-motor step controls. Delta applied directly to steps1/2, then
renderArmFromSteps() redraws. BLE command sent as S<motor><±delta>.
8. Development Workflow
Running locally
# From project root:
python -m http.server 8080
# Then open http://localhost:8080 in Chrome/Edge
Web Bluetooth requires Chrome or Edge. It does NOT work in Firefox. It works over
localhostwithout HTTPS.
PlatformIO build
pio run # compile
pio run --target upload # flash
pio device monitor # serial monitor (115200 baud)
Cache-buster reminder
If you make JS changes and the browser still shows old behaviour, hard-refresh
(Ctrl+Shift+R) or open DevTools → Application → Clear Storage.
The ?v=Date.now() on imports handles this automatically on page load.
9. What Is Done
- SPA shell (
index.html) with collapsible sidebar, hamburger button, CSS dark theme - Hash-based router (
router.js) with mount/unmount lifecycle - BLE singleton (
ble.js) — connect, write-without-response, NOTIFY subscription - Kinematics module (
kinematics.js) — IK, FK, workspace check, lookup table - Stepper Test section (
stepper-test.js):- SVG SCARA arm visualiser with correct motor convention
- Click-to-move via IK with workspace validation
- Ghost arm hover preview
- Per-motor jog controls (±10, ±50, ±100, ±500 steps)
- Motor zero (HOME) buttons
- Current position readout panel (X/Y, θ1/θ2, steps)
- Move-to-XY numeric input with Go button
- Board boundary + mechanism exclusion zone visualisation
- Simulation badge (yellow) when BLE not connected
- Board Control section (
board-control.js):- Interactive map with clickable spots
- Speed and Acceleration controls
- Background image selector with config memory
- Input Text section (
input-text.js):- Text input to spell words automatically
- Delay, Speed, and Acceleration sliders
- Position overlay marker on background
- Sequence Editor section (
sequence-editor.js) & Runner:- Vertical list builder for custom sequences (Spots or X/Y).
- JSON export to persist sequences to
sequences.json. - Quick Sequences UI in
stepper-test,board-control, andinput-text. - Shared
sequence-runner.jsto handle asynchronous execution and delays.
- Firmware (
src/main.cpp) — AccelStepper + BLE command parser + position NOTIFY
10. What Remains (Planned Sections)
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.
- Click a spot on the map or select from a compact list → IK move to coordinates.
- 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.jsonto have"hasAlphabet": true. - Persisted Speed, Acceleration, and Delay settings.
sequence-editor section (implemented)
- Record and replay a sequence of moves.
- Draft sequences auto-save to
localStorage. - Simple list UI: add Spot step, add XY step, delete step, reorder, play sequence.
- Export as JSON to paste into
web/assets/backgrounds/<bg>/sequences.json.
To add a new section:
- Create
web/js/sections/<name>.jsexporting{ mount(el), unmount() } - In
index.htmladd the import:const { default: XSection } = await import('./web/js/sections/<name>.js?v=${V}'); - Register:
Router.register('<name>', XSection); - Add a nav button in the sidebar HTML inside
index.html
11. Key Design Decisions (Why Things Are The Way They Are)
| Decision | Reason |
|---|---|
| No WiFi / no server | User requirement — BLE only, no hosting needed |
?v=Date.now() cache-buster |
ES module imports are cached by the browser; without this, stale code runs silently after edits |
| Steps as primary state | The firmware tracks absolute step counts. Angles are derived by stepsToRad(). This keeps web ↔ firmware in sync. |
| M1 at -d2, M2 at +d2 | Corrects a hardware variable swap in C++ firmware where angle2 goes to stepper1. Verified by tracing xmd = x - d2 to the right motor and xpd = x + d2 to the left motor. |
forward() picks upward EE |
Two circle-circle intersections exist; the mechanism always operates in the "above" configuration. The lower solution is physically blocked by the board. |
armsCrossed() guard |
Prevents IK moves that would drive an elbow into the mechanism housing from the wrong side. Tunable via LIMITS.ELBOW_BOX_X_INNER and ELBOW_BOX_Y_MAX. |
| Linux/BlueZ stability | pAdv->setMaxPreferred(0x0C) is mandatory in firmware. Without it, Linux/ChromeOS will drop the connection immediately after the handshake. |
| No dynamic BLE imports | index.html must import ble.js via static import. Dynamic cache-busters create multiple instances of the BLE singleton, isolating UI event listeners! |
fivebarIKGame.js is reference only |
The original site's game uses a different angle convention (absolute degrees, different home, with ikElbowSigns tracking). We use the C++ IK formula instead because it directly produces step deltas from home. fivebarIKGame.js is kept in the repo for reference and understanding, not imported. It also shouldn't be IK main reference. Original github project nerd-sniped/WijiBoard should be. |
| Hash-based routing | Keeps the SPA working from file:// and simple static servers without needing a history API setup |
| Persistent Homing State | The UI uses localStorage.getItem('wiji_homed') to track if the arm has been homed during the user's ongoing interaction. This avoids forcing the user to re-home every time they switch tabs or pages. Sending HOMEALL sets this to true. |
| Stateless Sequence Storage | To remain serverless, sequences are stored per-background in web/assets/backgrounds/<bg>/sequences.json. The Sequence Editor generates the JSON payload, which the user manually copy-pastes into the file to persist. Sequences marked "favorite": true appear automatically in Quick Sequences cards across the app. |
| No Emojis in Logging | Emojis in UI.log and console log statements are strictly forbidden. Always use text prefixes like [ERROR], [WARNING], [INFO] instead. |
12. Known Issues / Things to Verify on Hardware
-
Motor direction / sign: The signs of the IK formulas (
theta1 = q - w1,theta2 = r + w2) assume a specific physical motor orientation. If the real arm moves in the wrong direction when clicking a target, negate the step delta for that motor inmoveToXY(). -
Home position: Stall homing is fully implemented matching the original C++ routine but adjusted to align with physical motor mappings. Homing pushes the motors against their physical limits and sets
steps1 = -1024(-180°, points left) andsteps2 = 0(0°, points right). This corresponds to the arms folded OUTWARDS.- The initial web simulation starts with these coordinates, meaning the UI assumes the arm has already been homed before the browser connects.
- Homing can be triggered individually per-motor (
HOME1,HOME2) or combined (HOMEALL) from the web UI.
-
LIMITS tuning: All values in
LIMITSinkinematics.jsare estimates based on the original project's data. They need physical verification:BOX_HALF_W,BOX_Y_MAX: size of the actual motor housingELBOW_BOX_X_INNER,ELBOW_BOX_Y_MAX: elbow clearance above the housing
-
Step speed / acceleration:
DEFAULT_SPEED = 600,DEFAULT_ACCEL = 100in firmware. 28BYJ-48 motors are slow; these may need adjustment. Adjustable at runtime viaSPD:<n>andACC:<n>BLE commands.
13. Reference: fivebarIKGame.js
This file (in the project root) is the original website's game kinematics. Do not import it. Use it as a reference for understanding the mechanism, especially:
S.solveFK(a1_deg, a2_deg)— forward kinematics in absolute degrees, with stateful branch tracking (fkBranchIndex,eeHistory) andisValidElbowcross-product check.S.solveIK(target, currentAngles)— IK withikElbowSignscontinuity tracking and motor angle limits: M1 = [-330°, -90°], M2 = [-90°, +150°] (in absolute angle convention).intersectCircles(p1, p2, r)— correct circle-circle intersection.noGoRadius: 50— the game's centre exclusion radius in mm.- Home state in game:
a1 = -180°,a2 = 0°→ EE at approximately (0, 50.3 mm).
The motor angle limits and no-go radius from the game are good starting points for tuning
the LIMITS object in kinematics.js.