Files
ESP32-WijiBoard/gemini.md
T
PROFERIS - Mi³osz Stocki 2e3780cb0b added homing.
2026-07-06 16:09:06 +02:00

17 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 Waveshare ESP32-S3-Zero
Framework PlatformIO + Arduino core
Motors 2× 28BYJ-48 (unipolar stepper via ULN2003 driver)
Motor mode AccelStepper::FULL4WIRE — 2048 steps/rev
Motor 1 pins IN1=4, IN2=5, IN3=6, IN4=7
Motor 2 pins IN1=8, IN2=9, IN3=10, IN4=11
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):

PositionControl.cpp from the original project uses:

  • xmd = x - d2 → Motor 1 pivot is at (+d2, 0) = (+12.9, 0) — the RIGHT side
  • xpd = x + d2 → Motor 2 pivot is at (-d2, 0) = (-12.9, 0) — the LEFT side

This is counter-intuitive. Motor 1 is physically on the right. 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 even for valid target positions.

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:  a0b1c2d3-e4f5-6789-abcd-ef0123456700
Command:  a0b1c2d3-e4f5-6789-abcd-ef0123456701   (WRITE | WRITE_NR)
Status:   a0b1c2d3-e4f5-6789-abcd-ef0123456702   (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

esp32s3-wiji/
├── index.html                    # SPA shell — ES module bootstrap, sidebar, routing
├── platformio.ini                # PlatformIO config (env: esp32-s3-devkitc-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 a cache-buster to prevent stale code on refresh:

const V = Date.now();
const { default: Router }         = await import(`./web/js/router.js?v=${V}`);
const { default: BLE }            = await import(`./web/js/ble.js?v=${V}`);
const { default: UI }             = await import(`./web/js/ui.js?v=${V}`);
const { default: HomeSection }    = await import(`./web/js/sections/home.js?v=${V}`);
const { default: StepperSection } = await import(`./web/js/sections/stepper-test.js?v=${V}`);

The ?v=${V} suffix on ES module imports is mandatory. Without it, browsers aggressively cache modules and you will see stale code running despite file changes.


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.


6. Kinematics (web/js/kinematics.js)

IK (solve(x, y))

Exact port of calculateInverseKinematics() from PositionControl.cpp:

// Motor 1 (pivot at +d2 = +12.9 mm)
const xmd = x - d2;                  // Δx from M1 pivot
const s   = hypot(xmd, y);
const cosW1 = (l2² -  - l1²) / (-2·l1·s);
const theta1 = atan2(y, xmd) - acos(cosW1);

// Motor 2 (pivot at -d2 = -12.9 mm)
const xpd = x + d2;                  // Δx from M2 pivot
const t   = hypot(xpd, y);
const cosW2 = (l2² -  - l1²) / (-2·l1·t);
const theta2 = atan2(y, xpd) + acos(cosW2);

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 (right side), M2 at -d2 (left 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
  • Home section (home.js) — placeholder landing page, same visual style
  • 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:

board-control section (not started)

  • Full keyboard layout overlaid on a top-view board image
  • Click a letter/number → IK move to LOOKUP_TABLE[char]
  • Perhaps a "type a word" sequential move feature

sequence-editor section (not started)

  • Record and replay a sequence of moves
  • Save/load sequences to localStorage
  • Simple list UI: add step, delete step, reorder, run

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 This is the PositionControl.cpp convention. Reversing it causes visual arm crossing. Verified by tracing xmd = x - d2 (offset from +d2 pivot) and xpd = x + d2 (offset from -d2 pivot).
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.
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.
Hash-based routing Keeps the SPA working from file:// and simple static servers without needing a history API setup

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 PositionControl.cpp motor mappings. Homing pushes the motors against their physical limits and sets steps1 = 0 (0°, points right) and steps2 = -1024 (-180°, points left). 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 = 600, DEFAULT_ACCEL = 100 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.