added proper kinematics

This commit is contained in:
PROFERIS - Mi³osz Stocki
2026-07-06 15:23:35 +02:00
parent 67b4eda6aa
commit ee8a2d97ed
5 changed files with 810 additions and 100 deletions
+457
View File
@@ -0,0 +1,457 @@
# 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 |
| `HOME1` | Zero motor 1 step counter |
| `HOME2` | Zero motor 2 step counter |
| `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:
```javascript
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.
```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.
---
## 6. Kinematics (`web/js/kinematics.js`)
### IK (`solve(x, y)`)
Exact port of `calculateInverseKinematics()` from `PositionControl.cpp`:
```javascript
// 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:
```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 (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
```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] Home section (`home.js`) — placeholder landing page, same visual style
- [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:
### `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**: `steps1 = 0, steps2 = 0` is wherever the firmware powers up (no homing
routine implemented yet). The arm must be manually set to a known starting configuration
before the IK will be accurate. Suggested home: arms roughly parallel, EE near centre of
the working area.
- **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`.