Files
ESP32-WijiBoard/gemini.md
T
2026-07-08 20:35:59 +02:00

486 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
| **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 |
| `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.
```javascript
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:
```javascript
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 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 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:
```javascript
// 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:
```javascript
// 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**)
```javascript
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
```javascript
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
```javascript
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
```javascript
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)
```javascript
// 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
```bash
# 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
```bash
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
- [x] SPA shell (`index.html`) with collapsible sidebar, hamburger button, CSS dark theme
- [x] Hash-based router (`router.js`) with mount/unmount lifecycle
- [x] BLE singleton (`ble.js`) — connect, write-without-response, NOTIFY subscription
- [x] Kinematics module (`kinematics.js`) — IK, FK, workspace check, lookup table
- [x] 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
- [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] 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.
- [x] 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, 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 (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 = -1024` (-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 = 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`.