Files
ESP32-WijiBoard/gemini.md
T
PROFERIS - Mi³osz Stocki 4600e871c1 add more boards. Closes #8
2026-07-16 08:29:39 +02:00

22 KiB
Raw Blame History

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/WijiBoard on 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 (also supports ESP32-S3 and ESP32-WROOM)
Framework PlatformIO + Arduino core
Motors 2× 28BYJ-48 (unipolar stepper via ULN2003 driver)
Motor mode AccelStepper::HALF4WIRE — 4096 steps/rev
Motor 1 pins IN1=0, IN2=3, IN3=1, IN4=4
Motor 2 pins IN1=5, IN2=7, IN3=6, IN4=10
USB Serial Native USB CDC (-D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1)
Default speed 800 steps/s, accel 400 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 = 4096
STEP_ANGLE    = 360/4096 ≈ 0.087890625 °/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
DISABLE Disable steppers (un-hold) and require rehoming. Auto-triggers after 30 mins (30 * 60 * 1000 ms) of inactivity
POS Request current positions (triggers NOTIFY)

Status notifications (firmware → browser)

P:<s1>,<s2>,[<held>] — current step positions for motor 1 and 2, sent every 200 ms while moving, or on request. 3rd parameter is 1 if holding, 0 if disabled. SYS:IDLE_TIMEOUT — Sent when motors are automatically disabled due to 30 mins of inactivity.


4. Project Structure

esp32c3-wiji/
├── index.html                    # SPA shell — ES module bootstrap, sidebar, routing
├── platformio.ini                # PlatformIO config (env: esp32-c3-devkitm-1)
├── reference/
│   └── fivebarIKGame.js          # REFERENCE ONLY — original site's kinematics (do not import)
├── scripts/                      # Helper scripts for testing, plotting, and generation
├── src/
│   ├── Config.h                  # Pins, Constants, UUIDs
│   ├── BleManager.cpp            # BLE setup, callbacks, notifications
│   ├── CommandParser.cpp         # Parses BLE string commands
│   ├── MotorController.cpp       # AccelStepper wrap, homing state, idle timeout
│   ├── StatusLed.cpp             # Onboard LED feedback logic
│   └── main.cpp                  # Main app glue, command router
└── 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 listeners
  • unmount() — called when navigating AWAY; removes event listeners from eventCleanup[]

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 (max 25 mm by default, configurable in Motion.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 before AccelStepper decelerates (when within BLEND_THRESHOLD_STEPS). This produces a seamless Cartesian trajectory with no MCU code changes.
  • Motion Effects (web/js/motion-effects.js): Visual flavor modes (snaky, erratic, jumpy, hesitant, overshoot, random, direct) are encapsulated in their own module to keep the core orchestrator clean. The MotionEffects singleton intercepts trajectory generation (applyPerturbation) and queue execution (onWaypointSend, getQueueAction) to dynamically inject BLE speed commands (SPD:) or alter queue blending logic (e.g. lowering the blend threshold to force physical synchronization of mid-move speed changes without stuttering).
  • Simulation Sync: In simulation mode, Motion.goto() instantly updates its internal steps and manually dispatches a fake ble:status event containing P: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² -  - 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² -  - 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:

  1. Board outer boundary (rectangular)
  2. Mechanism housing exclusion box (rectangular, centred on origin)
  3. IK geometric reachability (cosW1/cosW2 range)
  4. 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 (AZ, 09, +, -, *, ,) 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

  1. SVG click → SV.svgToWorldX/Y(event coords) → world (x, y)
  2. IK.checkWorkspace(x, y) — reject with log message if invalid
  3. IK.solve(x, y){ theta1, theta2 }
  4. IK.radToSteps(theta1/2) → new step counts
  5. Compute deltas: delta1 = newSteps1 - steps1
  6. Send BLE: S1+<delta1>, S2+<delta2>
  7. 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 localhost without 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, and input-text.
    • Shared sequence-runner.js to handle asynchronous execution and delays.
  • Firmware (Modularized) — BleManager, MotorController, StatusLed, and main.cpp for routing.

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, Snaky, Erratic, Jumpy, Hesitant, Overshoot, Random).
  • 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 (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:

  1. Create web/js/sections/<name>.js exporting { mount(el), unmount() }
  2. In index.html add the import: const { default: XSection } = await import('./web/js/sections/<name>.js?v=${V}');
  3. Register: Router.register('<name>', XSection);
  4. 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 in moveToXY().

  • 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 = 2048 (+180°, points left) and steps2 = 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 LIMITS in kinematics.js are estimates based on the original project's data. They need physical verification:

    • BOX_HALF_W, BOX_Y_MAX: size of the actual motor housing
    • ELBOW_BOX_X_INNER, ELBOW_BOX_Y_MAX: elbow clearance above the housing
  • Step speed / acceleration: DEFAULT_SPEED = 800, DEFAULT_ACCEL = 400 in firmware. 28BYJ-48 motors are slow; these may need adjustment. Adjustable at runtime via SPD:<n> and ACC:<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) and isValidElbow cross-product check.
  • S.solveIK(target, currentAngles) — IK with ikElbowSigns continuity 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.