# Pardalote — AI coding guide
> Pardalote is a JavaScript ↔ Arduino library: write browser JavaScript (p5.js-friendly) that drives Arduino hardware — pins, PWM servos, stepper motors, Feetech serial bus servos, NeoPixels, and sensors — over WiFi (WebSocket) or USB serial, with no server and no Node.js. This file is the complete reference for generating Pardalote code. **Read the gotchas below first** — they prevent the mistakes an LLM makes by default. Everything after the preamble is the full generated API reference.
## The 60-second model
- Two sides: **browser JavaScript** (an `Arduino` object plus extensions) and an **Arduino sketch** (the `Pardalote` firmware). They share the same hardware as equals — **last writer wins**.
- You register devices in JS, then drive them by name: `arduino.pan.write(90)`.
- **Motion and reads run ON the Arduino.** You send targets, motion profiles, and gesture schedules; the board does the timing. You never stream individual steps or positions over the link.
## Minimal working project
Three files. `pardalote.js` is the all-in-one bundle (core + every device extension); include it before your sketch.
```html index.html
```
```javascript sketch.js (browser)
const arduino = new Arduino();
arduino.add('pan', new Servo()); // register BEFORE connect()
arduino.connect('192.168.1.42'); // or arduino.connectSerial() for USB
arduino.on('ready', () => { // ALL setup goes here, after the board syncs
arduino.pan.attach(9);
arduino.pan.write(90);
});
```
```cpp sketch.ino (Arduino)
#include
#include // one include per extension you use
void setup() { Pardalote.begin(); } // WiFi + listens on USB; WiFi set at compile-time or
// via the Serial menu. begin(PARDALOTE_WIFI) = WiFi only,
// begin(PARDALOTE_SERIAL) = USB only. Call
// Pardalote.requireKey("key") before begin() to require a key.
void loop() { Pardalote.run(); }
```
## Gotchas — get these wrong and the code silently fails
1. **All browser setup goes inside `arduino.on('ready', …)`.** Calling `attach()` / `pinMode()` before the board has synced does nothing. Pin aliases like `'A0'` also only resolve after `ready`.
2. **Register extensions BEFORE `connect()`** with `arduino.add(name, new Type())`. `arduino.` exists immediately, but can't touch hardware until `ready`.
3. **`read()` is a poll, not a getter.** The first `read()` starts a board-side poll and returns the *cached* value (0 until the first reading arrives). Call it once to start, then read the cached value or the property (e.g. `servo.angle`, `stepper.position`) in your draw loop. Do **not** expect a fresh synchronous value from each call. `read(END)` stops the poll.
4. **Never stream motion.** For smooth movement use `writeTimed(target, ms)` or `gesture([...])` — the board interpolates. Do **not** write a `setInterval` / draw-loop that streams positions; it floods the link and is the exact anti-pattern the library exists to avoid.
5. **Units are native per actuator:** PWM servo = degrees (0–180), stepper = steps, bus servo = raw counts (ST 0–4095, SC 0–1023). Use the degree/revolution helpers where provided.
6. **Await real arrival with `whenDone()`**, not a timer: `await arduino.x.moveTo(2000).whenDone();`. It works on every actuator and on groups, and resolves on the actuator's real `done` (feedback-confirmed).
7. **State is shared and multi-client.** The sketch and every connected browser see the same hardware; a value you write can be overwritten by the sketch or another browser (last writer wins).
## Actuators at a glance
| Actuator | JS class | Unit | Move now | Timed move | Expressive |
|---|---|---|---|---|---|
| PWM servo | `Servo` | degrees | `write(a)` | `writeTimed(a, ms)` | `gesture([...])` |
| Stepper | `Stepper` | steps | `moveTo(n)` / `move(d)` | `moveToTimed(n, ms)` | `gesture([...])` |
| Bus servo | `BusServo` | counts | `write(c)` | `writeTimed(c, ms)` | `gesture([...])` |
Drive several together: `arduino.group('arm', { shoulder: arduino.s1, elbow: arduino.s2 })` → `group.write()` / `writeTimed()` / `gesture()` / `whenDone()`.
## Gestures (expressive motion) in one paragraph
`gesture(segments)` plays an authored list of eased moves the board runs back-to-back on its own clock. Each segment is `{ dur, curve, and either by (relative delta — the default) or to (absolute target) }`; curves are `linear`, `easeIn`, `easeOut`, `easeInOut`, `back` (overshoot). Relative gestures are portable and need no homing — a `back` overshoot on an open-loop stepper is a real over-travel-and-return (e.g. a lead-screw bounce). `group.gesture({ name: segments, ... })` coordinates per-member lanes, padding short ones so every member arrives together. Full details in the Servo / Stepper / Bus servo / Groups sections below.
---
# Reference
> The full technical documentation — every function on both sides of the wire, the protocol, and the fine print.
## Getting started
Setting up boards, libraries and the connection:
- Installation — hardware, software, and where the two libraries go
- WiFi configuration — compile-time credentials or the Serial Monitor menu (skip this page for USB serial)
- Coding with AI — the whole reference as one file to paste into an AI assistant
## Core — JavaScript
The browser side of every Pardalote project:
- Connecting — `connect()` and `connectSerial()` (WiFi or USB), connection keys, events, reconnection, and status
- Pins and reading — `pinMode`, `digitalWrite`, polled reads, callbacks, and pin aliases
## Core — Arduino
Writing sketch code alongside the browser:
- The Arduino sketch — `begin()` / `run()`, `share()` / `send()`
## Core — Messaging
A key/value channel for everything that isn't a pin or a device — symmetric between browser and sketch:
- Messaging — `send()` / `watch()`, retain and broadcast, and the frame monitor (JavaScript to Arduino · Arduino to JavaScript)
## Extensions
Hardware support, one include at a time:
- Extensions overview — registering, script order, firmware includes, creating objects from the sketch, and reading actuators
- Servo · Stepper · Bus servo · Groups
- NeoPixel · Ultrasonic · Rotary encoder · IMU · Camera
## Under the hood
- Protocol — the binary frame format and state sync
- Pin capabilities — what each pin can do, per board, and the silicon gotchas
- Troubleshooting — common issues and their usual fixes
## Conventions used throughout
A few patterns repeat across the whole API:
- **Reads are polls.** The first call to any `read()`-style function starts a periodic poll on the Arduino; subsequent calls return the cached value instantly. Pass an interval to change the rate, or `END` to stop.
- **`target` vs `position`.** Commanded values update immediately; feedback values arrive with polling.
- **Last writer wins.** The browser and the sketch share the same hardware as equals.
- **Setup goes in `on('ready')`.** Attach, init and start polls after the Arduino has synced its state.
- **Motion runs on the board.** Timed moves and `gesture()` schedules are played on the Arduino's own clock — never streamed step-by-step — so they stay smooth and survive a WiFi blip. See each actuator's `gesture()` and Groups.
---
# Installation
> What you need and where to put it — hardware, software, and the two Pardalote libraries.
## Hardware
- **Arduino UNO R4 WiFi**, **ESP32 development board**, or **Arduino UNO R4 Minima** (USB serial only — it has no radio)
- Over WiFi: the Arduino and your browser must be on the same network. Over USB serial: just the cable, using Chrome or Edge (details)
## Software
- Arduino IDE ([arduino.cc](https://www.arduino.cc))
- A web browser
- A code or text editor
## The Pardalote Arduino library
The easiest way is the **Library Manager**: in the Arduino IDE open **Tools → Manage Libraries…**, search for **Pardalote**, and click **Install**. The IDE offers to also install the required dependency (`WebSockets` by Markus Sattler) — accept it. Pardalote then appears under **File → Examples → Pardalote**, and future updates arrive through the Library Manager too.
**Manual install** — for a specific version, or a build not yet in the index. Download the Arduino library ZIP from the download page, then either:
- **Sketch → Include Library → Add .ZIP Library…** and select the ZIP, or
- unzip the `Pardalote` folder into your Arduino libraries folder and restart the IDE:
| OS | Libraries folder |
|---|---|
| macOS | `~/Documents/Arduino/libraries/` |
| Windows | `Documents\Arduino\libraries\` |
| Linux | `~/Arduino/libraries/` |
## Additional Arduino libraries
Some hardware will require additional Arduino libraries. Install these via Arduino IDE → Tools → Manage Libraries. Only install what your hardware needs:
| Library | Needed for |
|---|---|
| `Adafruit NeoPixel` | LED strips |
| `ESP32Servo` | Servos on ESP32 |
| `AccelStepper` (Mike McCauley) — 1.64 or newer | Stepper motors |
| `SCServo` (Feetech/Waveshare) | Serial bus servos — install **"SCServo" by FT&WS** from the Library Manager, or a ZIP from the Waveshare wiki / Feetech SDK |
No extra library is needed for the IMU extension — it reads sensor registers directly over I2C. The camera extension needs only the ESP32 Arduino core.
## The Pardalote JavaScript library
No install step: copy `pardalote.js` next to your web page and include it with a script tag. `pardalote.js` is the all-in-one bundle — it contains the core **plus every device extension** (Servo, Stepper, BusServo, NeoPixel, Ultrasonic, IMU, Encoder, Camera), so it is the only Pardalote file you need. It must load before your sketch:
```html index.html — script loading order
```
Named pins (`D13`, `A0`, `SDA`, …) work out of the box — see Pins. Add `data-pins="off"` to the `pardalote.js` script tag if those names collide with your own code.
Advanced: the modular sources live in `lib/src/` (`pardalote-core.js` plus one `pardalote-.js` per extension) if you would rather include only specific files. The bundle is simply those concatenated, rebuilt with `build_pardalote.py`.
## A minimal sketch
A complete Pardalote sketch is two lines of `setup()` and one line of `loop()`:
```cpp sketch.ino — a complete Pardalote sketch
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
Extensions self-register when included — see Extensions.
## Finding the board's IP address
After uploading and joining WiFi (see WiFi configuration):
- **UNO R4 WiFi:** the IP address scrolls across the LED matrix
- **ESP32:** the IP is printed in the Serial Monitor at 115200 baud
Put that address into your JavaScript: `arduino.connect('192.168.1.42')`.
---
# WiFi configuration
> Two ways to give Pardalote your network details — use either, or both.
WiFi is Pardalote's default transport, but not the only one — the same code runs over the USB cable. The default `begin()` even listens on both: a board on WiFi will switch to USB when a browser connects over the cable. With no network at all, `Pardalote.begin(PARDALOTE_SERIAL)` skips WiFi entirely and the browser connects with `connectSerial()` — nothing on this page is needed in that mode.
## Option A — compile-time (secrets.h)
Create a `secrets.h` file in the same folder as your sketch:
```cpp secrets.h
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
The credentials are baked into the firmware. Simple — but if you share or publish your code, add `secrets.h` to `.gitignore` first. Pardalote picks the file up automatically via `__has_include`; no other changes are needed.
## Option B — EEPROM (Serial Monitor)
If no `secrets.h` is present, Pardalote detects that no credentials are stored on first boot and prompts you via the Serial Monitor at 115200 baud:
```text Serial Monitor — first boot
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials survive re-uploads and power cycles. Up to **5 networks** can be stored — useful for moving between home, studio, and classroom. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
> **Native-USB boards (ESP32-C5 / C3 / S3):** if the Serial Monitor stays blank and this menu never appears, set **Tools → USB CDC On Boot → `Enabled`** and re-upload — otherwise `Serial` isn't routed to the USB port. See Troubleshooting.
## Both options together
If `SECRET_SSID` is defined and EEPROM networks are also stored, Pardalote tries `secrets.h` first, then falls back to the EEPROM networks in order.
## How the board connects
At boot the board goes straight to connecting — there's no pause. It tries each network for up to 10 seconds, in order, and if none connects it loops back to the first one and keeps going until one does. So a board that powers up before its router is ready will join as soon as the network appears.
Before each attempt it prints a reminder:
```text Serial Monitor — connecting
=== Pardalote ===
Stored networks:
1. Studio
2. HomeWiFi
Press 'w' to configure WiFi (or connect over USB)
Trying: Studio
Failed.
Press 'w' to configure WiFi (or connect over USB)
Trying: HomeWiFi
```
Press `w` at any time to stop and open the configuration menu. Connecting stays paused while you're in the menu. When you exit with `x`, it starts again from the first network.
## After connecting
The board announces its IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor at 115200 baud
That's the address your browser connects to — see Connecting.
---
# Connecting
> Opening the connection from JavaScript — WiFi or USB serial — plus connection keys, events, reconnection, and status.
All examples assume `const arduino = new Arduino();`.
## connect()
Opens a WebSocket connection to the Arduino. Call once; reconnection after drops is automatic.
`arduino.connect(ip, [portOrOptions])`
| Parameter | Type | Description |
|---|---|---|
| `ip` | string | The Arduino's IP address, e.g. `'192.168.1.42'`. Shown on the LED matrix (UNO R4) or in the Serial Monitor (ESP32). |
| `portOrOptions` | number \| object | Optional. A number is the WebSocket port (default `81`). An object accepts `{ port, key }` — `key` is the board's connection key (see below). |
```javascript
arduino.connect('192.168.1.42'); // WebSocket on port 81
arduino.connect('192.168.1.42', 8081); // custom port
arduino.connect('192.168.1.42', { key: 'robot-arm-3' }); // board requires a key
```
Calling `connect()` (or `connectSerial()`) again starts a fresh session: pin modes, polled reads, and write listeners from the previous board are cleared. Each registered extension is reset to its just-constructed state, so attached servos, initialised strips, IMU calibration and camera streams are released — call `attach()` / `init()` again inside the new `on('ready')` handler. Event listeners attached with `on('change', …)` etc. survive, as do user-tuned settings like `setWriteThrottle`, `setWriteThreshold`, `setWriteRepeat`, `setQuality` and `setFrameRate`.
### Connection keys
A board that called `Pardalote.requireKey("robot-arm-3")` before `begin()` only completes the handshake for clients that present the same key — pass it as `connect(ip, { key: "robot-arm-3" })` or `connectSerial({ key: "robot-arm-3" })`. A client with the wrong key (or none) receives one `'authFail'` event with a readable message and auto-reconnect stops, so a typo surfaces as a single clear error rather than a silent retry loop.
The key works over **both** transports. Over WiFi it's a latch against connecting to a neighbour's board on a shared classroom network. Over USB — where the cable already picks the board — it's a **board-identity check**: a student who grabbed the wrong physical board gets "wrong key for this board" instead of silently driving it. Either way it's an accident-prevention measure, **not security**: it crosses the wire unencrypted (`ws://`, or in the clear over USB). Boards that never call `requireKey()` accept everyone.
## connectSerial()
Connects over the USB cable instead of WiFi, using the browser's Web Serial API (Chrome and Edge only, including Chromebooks). Everything downstream of connecting is identical to WiFi, so a project can migrate between the two by changing one line on each side. Which boards accept a USB connection depends on how the sketch called `begin()`: `begin()` (the default) and `begin(PARDALOTE_SERIAL)` both do; `begin(PARDALOTE_WIFI)` does not. On a UNO R4 Minima every form is serial (no radio).
`arduino.connectSerial([options])`
| Parameter | Type | Description |
|---|---|---|
| `options` | object | Optional. `{ prompt: true }` (also exported as `PROMPT`) forces the browser's port picker even when a previously granted port exists. `{ key }` sets the board's [connection key](#connection-keys). |
```javascript
connectButton.addEventListener('click', () => {
arduino.connectSerial(PROMPT); // Chrome shows a port picker
});
```
Notes:
- The **first** call must come from a user gesture (a click) — the browser requires it for the port picker. The permission is remembered, so a returning visit can call `connectSerial()` without a gesture and reconnect silently. Tool examples pass `PROMPT` so every click raises the picker.
- The sketch's `Serial.print` output is delivered to the page as the `'log'` event, one line per event (with no listener it goes to the console prefixed `[board]`) — debug prints are visible without opening the Arduino IDE. Avoid `Serial.write` of raw binary from the sketch; text is fine.
- The camera extension needs WiFi (its video travels over HTTP); everything else works over serial.
- Multi-browser sharing is a WiFi feature — a USB cable has one end.
### Switching to USB
A board started with the default `begin()` runs on WiFi but also listens on USB. Connecting over USB with `connectSerial()` makes the board **drop WiFi and switch to the cable** — handy when there's no WiFi handy or you just want a quick, direct session. It's one-way: to go back to WiFi, reset the board.
The switch only happens on a **deliberate user gesture** — a `connectSerial()` called in response to a real click (whether or not a port picker appears; a click that silently reuses an already-granted port still counts). An **automatic** connect with no user action — a page reloading and reconnecting, or a background tab — will **not** pull a board off WiFi; instead the page gets a `'usbBusy'` event meaning "this board is on WiFi — click Connect to switch it to USB." This keeps a background tab, or a board that's merely cabled for power, from yanking a shared WiFi board away from everyone else.
On a classic ESP32, opening the USB port resets the board (a hardware auto-reset the firmware can't decline), so the switch is really *reboot → WiFi → switch to USB*, a few seconds. On the UNO R4's native USB there's no reset — the handoff is immediate. A board started with `begin(PARDALOTE_WIFI)` ignores USB entirely; a `connectSerial()` attempt gets no reply and the console notes it may be a WiFi-only board.
## on()
Registers a handler for a connection event.
`arduino.on(event, handler)`
| Parameter | Type | Description |
|---|---|---|
| `event` | string | One of `'ready'`, `'connect'`, `'disconnect'`, `'reconnecting'`, `'authFail'`, `'usbBusy'`, `'log'`, `'warn'`, `'error'`. |
| `handler` | function | Called when the event fires. `'reconnecting'` receives `{ attempt, delay }`; `'authFail'` and `'usbBusy'` receive `{ …, message }`; `'log'` receives a string; `'warn'`/`'error'` receive `{ source, message }`. |
| Event | Fires when |
|---|---|
| `'ready'` | The Arduino has connected **and** sent its current state — pins, extensions, pixel colours. Do your setup here. |
| `'connect'` | The WebSocket opens — before `ready`. |
| `'disconnect'` | The connection is lost. |
| `'reconnecting'` | A reconnect attempt is scheduled; the next retry is in `delay` ms. |
| `'authFail'` | The board refused this client's connection key (or required one that wasn't sent) — over WiFi or USB. Auto-reconnect stops — reconnect with the right key. |
| `'usbBusy'` | Serial only: a `connectSerial()` without a picker gesture reached a board that's live on WiFi, which won't switch silently. Auto-reconnect stops — click Connect (which raises the picker) to switch it to USB. See [switching to USB](#switching-to-usb). |
| `'log'` | Serial transport only: one line of the sketch's `Serial.print` output. |
| `'warn'` | Something went wrong but Pardalote carried on — a write to an unattached servo, an oversized message key, a heartbeat timeout. `source` says who's speaking (`"Pardalote"`, `"Servo 'pan'"`, `"Group 'arm'"`). |
| `'error'` | Something failed — a send error, a camera snapshot failure. Device errors also fire `'error'` on the device instance itself. |
Every emitter (the core, devices, pin handles) also has `once(event, handler)` — a one-shot listener that removes itself after firing — and `off(event, handler)` to unsubscribe (`handler` omitted: remove all).
```javascript Example — connection events
arduino.once('ready', () => {
arduino.pinMode(13, OUTPUT); // attach, init, start polls here
});
arduino.on('reconnecting', ({ attempt, delay }) => {
console.log(`retry ${attempt} in ${delay} ms`);
});
```
## Warnings and errors in your UI
Every warning and error in the library funnels through the `'warn'` and `'error'` events, so a page can surface problems where users will actually see them — a status banner instead of a devtools console nobody has open:
```javascript Example — surface problems on the page
arduino.on('warn', ({ source, message }) => showBanner(`${source}: ${message}`));
arduino.on('error', ({ source, message }) => showBanner(`${source}: ${message}`, 'red'));
```
If you never subscribe, messages fall back to `console.warn` / `console.error` exactly as before — the events are opt-in.
Any client connecting to a running system immediately sees the live state — that's what makes Pardalote multi-user by default.
## Automatic reconnection
Reconnection is automatic, with exponential backoff, and continues for as long as the page is open — you don't need to do anything. The first ten attempts are logged in the console; after that the library falls quiet. Subscribe to `'reconnecting'` for per-attempt updates. Silent auto-reconnect to the same Arduino preserves all state.
## disconnect()
Closes the connection and disables auto-reconnect.
`arduino.disconnect()`
## getStatus()
Returns a snapshot of the connection.
`arduino.getStatus()`
**Returns** `{ connected, isReconnecting, reconnectAttempts, deviceIP, availableExtensions }`.
## Properties
| Property | Type | Description |
|---|---|---|
| `arduino.connected` | boolean | `true` after `'ready'`. |
| `arduino.board` | string | Board name from the HELLO handshake, e.g. `'UNO R4 WiFi'`. |
| `arduino.analogMax` | number | ADC range: `1023` (UNO R4 WiFi / Minima) or `4095` (ESP32). |
See also: Pins and reading · Extensions overview
---
# Pins and reading
> The core pin API — modes, writes, polled reads, change callbacks, and pin aliases.
Anywhere a `pin` is accepted you can pass a number (`13`), a board constant (`A0`, `LED_BUILTIN` — see [Pin aliases](#pin-aliases)), or a string alias (`'A0'`).
## pinMode()
Sets a pin's mode. For input modes, an optional interval starts periodic reads immediately.
`arduino.pinMode(pin, mode, [interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | The pin to configure. |
| `mode` | constant | `OUTPUT`, `INPUT`, `INPUT_PULLUP`, `INPUT_PULLDOWN`, or `ANALOG_INPUT_MODE`. |
| `interval` | number | Optional. Starts watching the pin straight away (input modes only); acts as the analog rate limit. |
| `threshold` | number | Optional. Change threshold — see [Thresholds](#thresholds). |
```javascript
arduino.pinMode(13, OUTPUT);
arduino.pinMode(7, INPUT_PULLUP);
arduino.pinMode(A0, ANALOG_INPUT_MODE, 50); // watch A0, update at most every 50 ms
arduino.pinMode(A0, ANALOG_INPUT_MODE, 50, 8); // …transmitting changes of 8+ counts
```
## digitalWrite()
Sets a digital output pin high or low.
`arduino.digitalWrite(pin, value)`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | The pin to write. |
| `value` | constant | `HIGH` or `LOW`. |
A **change** is sent immediately, so even a quick pulse reaches the board. Writing the **same value again** is rate-limited: it's re-sent at most every 250 ms per pin. That makes it safe to call `digitalWrite()` on every frame of a draw loop — 60 identical writes a second become about 4 messages — while still re-asserting your value if the board sketch or another browser changes the pin. Tune it with [`setWriteRepeat()`](#setwritethrottle--setwritethreshold--setwriterepeat).
While the board is disconnected, writes aren't queued. The latest value on each pin is remembered and sent once when the connection comes back.
## analogWrite()
Writes a PWM value to a pin.
`arduino.analogWrite(pin, value)`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | The pin to write. |
| `value` | number | Duty cycle, `0`–`255`. |
Writes are **rate-limited per pin** so you can safely drive `analogWrite()` from a slider or a draw loop:
- **Changes:** the first write goes out immediately, and rapid follow-ups are coalesced into a single send that carries the latest value — the resting value is never lost. The default window is 20 ms (~50 changes/s per pin), which is imperceptible on ESP32 and keeps the slower UNO R4 WiFi from being flooded off the socket.
- **Repeats:** writing the same duty again is re-sent at most every 250 ms, the same rule as [`digitalWrite()`](#digitalwrite).
While the board is disconnected, writes aren't queued; the latest duty on each pin is sent once when the connection comes back.
## setWriteThrottle() / setWriteThreshold() / setWriteRepeat()
Rate-limit outgoing pin writes — the outbound counterpart to `setReadInterval()`. Useful when `digitalWrite()` or `analogWrite()` is driven from mouse movement or a draw loop, and essential for keeping the UNO R4 WiFi — or an ESP32 busy streaming camera video — responsive.
`arduino.setWriteThrottle(ms) · arduino.setWriteThreshold(value) · arduino.setWriteRepeat(ms)`
| Method | Applies to | Description |
|---|---|---|
| `setWriteThrottle(ms)` | `analogWrite()` | Minimum ms between duty **changes** on a pin; faster changes coalesce, latest value wins. Default `20`. `0` = off (send every change). |
| `setWriteThreshold(value)` | `analogWrite()` | Minimum duty change worth sending. Default `0` (send all). |
| `setWriteRepeat(ms)` | both | Minimum ms between re-sends of an **unchanged** value on a pin. Default `250`. `0` = off (send every repeat). |
All three apply to every pin and are chainable. `digitalWrite()` changes are never throttled.
## analogRead()
Reads the value of an analog pin. The first call starts a periodic poll on the Arduino; every later call returns the cached value instantly — so it's safe to call on every frame of a draw loop with no extra network traffic.
`arduino.analogRead(pin, [interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | The analog pin to read, e.g. `A0`. |
| `interval` | number | Optional. Minimum ms between updates to this browser (default `200`) — a rate limit, not a sampling clock; see [How watching works](#how-watching-works--intervals-and-thresholds). Pass `END` to stop. Calling with the settings already in effect just returns the cache. |
| `threshold` | number | Optional. Change threshold — see [How watching works](#how-watching-works--intervals-and-thresholds). |
**Returns** the most recent value, `0` to `arduino.analogMax`. Returns `0` until the first reading arrives.
```javascript Example — circle that follows a knob
function draw() {
let v = arduino.analogRead(A0, 50); // updates at most every 50 ms
let size = map(v, 0, arduino.analogMax, 10, width);
circle(width / 2, height / 2, size);
}
```
## digitalRead()
Reads a digital pin. Same poll-and-cache pattern as `analogRead()`.
`arduino.digitalRead(pin, [interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | The digital pin to read. |
| `interval` | number | Optional. Registers the watch (default `200`). Digital changes are transmitted immediately regardless of the interval — edges are never delayed. Pass `END` to stop. |
| `threshold` | number | Optional. Change threshold (digital default `1` — any change). |
**Returns** `HIGH` (1) or `LOW` (0); `0` until the first reading arrives.
## How watching works — intervals and thresholds
The board watches pins continuously and transmits **meaningful changes**; the `interval` is a per-browser **rate limit** (minimum ms between updates), not a sampling clock:
- **Digital pins** are checked on every pass of the board's loop. A level change is transmitted **immediately** — a button press never waits for a timer, and even a very short tap delivers both edges (a 15 ms lockout absorbs contact bounce). Idle pins transmit nothing.
- **Analog pins** are sampled every 10 ms internally. A change on a dormant pin goes out at once; a continuously moving pin updates each browser at most once per its `interval`.
The Arduino only transmits a reading when it has changed by at least `threshold` since the value this browser last saw. That keeps the channel quiet while a sensor sits still: a raw ADC jitters by a count or two constantly, so without a threshold "send on change" sends nearly every sample.
Defaults (used when the threshold is `0` or omitted):
| Kind | Default threshold |
|---|---|
| Digital | `1` — any change |
| Analog | the ADC noise floor: `analogMax >> 8`, min `1` (UNO ≈ 4 counts, ESP32 ≈ 16) |
Thresholds and intervals are **per browser**: each connected page gets its own rate limit and its own idea of a meaningful change, without disturbing anyone else's. The pin itself is watched once, on the board.
Reading a precise sensor? Set the threshold to `1` to receive every count of change.
## setReadInterval() / setReadThreshold()
Set a pin's poll interval or change threshold directly — before polling starts (stored and applied when the read registers) or while it runs (applied immediately).
`arduino.setReadInterval(pin, ms) · arduino.setReadThreshold(pin, threshold)`
```javascript
arduino.setReadThreshold(A0, 1); // precision mode: every count matters
arduino.setReadInterval(A0, 25); // …and poll fast
let v = arduino.analogRead(A0); // uses the stored settings
```
The global defaults are also settable: `arduino.defaultInterval` (ms, `200`) and `arduino.defaultThreshold` (`0` = board default).
## pin() — the listening handle
The verbs above are how you **do** things. To **listen** to a pin, take its handle — it speaks the same grammar as every device (`arduino.pan`, `arduino.sonar`, …):
`arduino.pin(ref)`
| Parameter | Type | Description |
|---|---|---|
| `ref` | number \| string | Pin number or alias (`'A0'`). Handles are cached — `pin(9)` twice returns the same object. |
```javascript Example — react to changes
const knob = arduino.pin('A0');
knob.on('change', ({ value, pin }) => console.log('A0 →', value));
knob.off('change', fn); // unsubscribe one handler (omit fn: all)
knob.setReadInterval(25); // per-pin poll config
knob.setReadThreshold(4);
knob.value; // the mirrored value
```
`'change'` fires for **any** pin-state change: input readings that moved by at least the threshold, and output writes from any browser or the sketch — you don't need to care which kind of pin it is. For inputs, registering a listener auto-starts a default poll if none is running.
Alias handles resolve **lazily**: `arduino.pin('A0')` works before `'ready'`, even though the board's alias table hasn't arrived yet — the listener starts firing once it has. (The verbs still need resolvable pins at call time.)
Listeners survive `connect()` to a new board, like device extensions; polls are re-established automatically on the new board's sync.
The handle also carries conveniences — `knob.read()`, `knob.write(HIGH)`, `knob.mode(OUTPUT)` — but the Arduino-mirroring verbs remain the documented way to do things.
## end() / endAll()
Stops polling one pin, or all pins.
`arduino.end(pin) · arduino.endAll()`
```javascript
arduino.end(A0); // stop one pin
arduino.endAll(); // stop all pins
```
## analogMax
The board's ADC range, set automatically from the HELLO handshake. Use it to map correctly on any board.
| Board | `arduino.analogMax` |
|---|---|
| UNO R4 WiFi | 1023 |
| ESP32 | 4095 |
```javascript Example — map correctly on any board
let mapped = map(arduino.analogRead(A0), 0, arduino.analogMax, 0, width);
```
## Pin aliases
Rather than raw numbers, use named pins — `D13`, `A0`, `SDA`, `LED_BUILTIN` — exactly as you would in Arduino code. They work out of the box; nothing extra to include.
```javascript
arduino.on('ready', () => {
arduino.pinMode(A0, ANALOG_INPUT_MODE, 50);
arduino.digitalWrite(LED_BUILTIN, HIGH);
arduino.imu.attach(SDA);
});
```
Each name resolves to the correct physical pin **for the board this `arduino` is actually connected to**, looked up when the `ready` event fires. The *same* `D13` therefore maps to the right pin on each board — and two `Arduino()` instances driving different boards each resolve it correctly, which a fixed global number never could. Because resolution happens at `ready`, reference pins from inside an `arduino.on('ready', …)` handler (or later), not at the top level before the board is known.
If a name collides with your own code or another library, Pardalote quietly steps aside (it never overwrites a name you've already defined). To switch the named pins off entirely, add `data-pins="off"` to the script tag:
```html
```
The **string form** works anywhere a pin is accepted and needs no global at all — handy with `data-pins="off"`, or for a name your board doesn't predefine:
```javascript
arduino.analogRead('A0'); // resolved from the board's alias table
arduino.digitalWrite('SDA', HIGH);
```
See also: Connecting · The Arduino sketch
---
# The Arduino sketch
> Writing Arduino code alongside the browser — sharing pins, sending values, and driving actuators from the sketch.
## The model
The minimal sketch is `Pardalote.begin()` + `Pardalote.run()`, but you can also write Arduino code that reads sensors, drives pins, and runs a state machine — alongside the browser.
> **The Arduino is just another voice in a flat command structure.** Both the Arduino sketch and the browser can read and write any pin using the standard Arduino / JS APIs. Whoever wrote last wins on the actual pin state. There's no pin reservation, no negotiation — your sketch and your JS code share the same hardware and you keep them coherent.
## Pardalote.begin()
Starts Pardalote. Call once in `setup()`. Three forms:
`Pardalote.begin()`
`Pardalote.begin(PARDALOTE_WIFI)`
`Pardalote.begin(PARDALOTE_SERIAL)`
| Form | What it does |
|---|---|
| `begin()` | The default: joins WiFi (see WiFi configuration) and starts the WebSocket server, **and** listens on the USB cable. If a browser connects over USB with a deliberate port-picker gesture, the board **drops WiFi and switches to USB** (see switching to USB). One-way — reboot to return to WiFi. |
| `begin(PARDALOTE_WIFI)` | WiFi only. Same as `begin()` but **does not** listen on USB — the opt-out for "nobody grabs my board over the cable." |
| `begin(PARDALOTE_SERIAL)` | Skips WiFi entirely and talks over the **USB cable** only — the browser connects with `connectSerial()`. |
The one exception is the **UNO R4 Minima** — it has no radio, so every `begin()` form starts the serial transport.
Only the first call counts. If `begin()` appears twice, for example after combining two example sketches, the extra call is ignored and the Serial Monitor shows `[Pardalote] begin() was called more than once — ignoring the extra call`. Running the startup twice would restart WiFi and destabilise the connection.
In serial mode, `Serial.print` from your sketch still works — the output travels between protocol messages and appears in the browser as the `'log'` event (and in the Serial Monitor as usual when the browser isn't connected). Don't `Serial.write` raw binary; text is fine.
## Pardalote.requireKey()
Optional. Call **before** `begin()` to require a **connection key** — only browsers that pass the same key to `connect(ip, { key })` or `connectSerial({ key })` get in. It works over **both** transports and composes with every `begin()` form.
`Pardalote.requireKey(key)`
```cpp
void setup() {
Pardalote.requireKey("robot-arm-3"); // optional — before begin()
Pardalote.begin(); // or begin(PARDALOTE_WIFI) / begin(PARDALOTE_SERIAL)
}
```
Over WiFi it's an accident-prevention latch against joining a neighbour's board on a shared network. Over USB — where the cable already picks the board — the key becomes a **board-identity check**: a student who grabbed the wrong physical board gets a clear "wrong key for this board" error instead of silently driving it. **Not security** — the key travels unencrypted (over the network, or in the clear on USB). Up to 32 characters.
## Pardalote.setTxPower()
Optional, ESP32 only. Sets the WiFi transmit power. Call it **before** `begin()` so the board also joins the network at that power.
`Pardalote.setTxPower(power)`
```cpp
void setup() {
Pardalote.setTxPower(WIFI_POWER_15dBm); // optional — before begin()
Pardalote.begin();
}
```
`power` is one of the ESP32 core's `WIFI_POWER_*` values, from `WIFI_POWER_19_5dBm` (full power) down to `WIFI_POWER_2dBm`.
**Seeed XIAO ESP32-S3 and ESP32-C3 boards default to `WIFI_POWER_8_5dBm`.** At full power their antenna misbehaves: they join slowly or not at all, the connection lags and drops, and camera video stalls. Lowering the transmit power is the widely reported fix, and it made a large difference in our tests. The Serial Monitor shows the setting at boot (`WiFi transmit power: 8.5 dBm`). If a XIAO is far from the router and the signal is weak, try `WIFI_POWER_15dBm` or `WIFI_POWER_17dBm`. `WIFI_POWER_19_5dBm` restores full power.
Other ESP32 boards use full power unless you call it. See Troubleshooting.
## Pardalote.run()
Services the connection: handles incoming commands, runs polls and timed moves. Call every pass of `loop()` — keep the loop non-blocking so it runs often.
`Pardalote.run()`
## Pardalote.share()
Declares a pin's mode to the browser: "this pin exists, it's in this mode." Doesn't touch the hardware — you still call `pinMode()` yourself.
`Pardalote.share(pin, mode, [interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | int | The pin to declare. |
| `mode` | constant | `INPUT`, `OUTPUT`, `INPUT_PULLUP`, `INPUT_PULLDOWN`, or Pardalote's `ANALOG_INPUT_MODE`. |
| `interval` | int | Optional. Registers a **board-owned watch**: values flow to every browser. For analog pins it's the browsers' update rate limit; digital changes transmit immediately. |
| `threshold` | int | Optional. Minimum change worth transmitting (`0` = default: `1` for digital, the ADC noise floor for analog). |
**With an `interval`, the board owns the watch**: values flow to every browser — including ones that connect later — with no JS call and no round trip, and only when the reading has changed by at least `threshold`. The watch survives browser disconnects.
```cpp
Pardalote.share(A0, ANALOG_INPUT_MODE, 50); // browsers hear changes at most every 50 ms
Pardalote.share(A0, ANALOG_INPUT_MODE, 50, 8); // …and only changes of 8+ counts
```
**Without an `interval`** (input modes), the browser auto-starts a default-interval (200 ms) poll for the pin — so it still receives values without declaring anything itself. For `OUTPUT` it's purely a declaration (no polling).
## Pardalote.send()
Pushes a value to the browser. The browser caches it, fires `arduino.pin(pin)`'s `'change'` listeners, and makes it available via `arduino.digitalRead(pin)` / `analogRead(pin)`. Doesn't touch the hardware.
`Pardalote.send(pin, value)`
| Parameter | Type | Description |
|---|---|---|
| `pin` | int | The pin the value belongs to. |
| `value` | int | The value to push. |
## Example — light switch
An LED controlled by two physical buttons *and* two browser buttons. Either side flips it; both stay in sync. The Arduino calls `share` once and `send` whenever its buttons fire:
```cpp sketch.ino — light switch, Arduino side
#include
const int LIGHT = 13;
const int BTN_ON = 4;
const int BTN_OFF = 5;
void setup() {
Pardalote.begin();
pinMode(LIGHT, OUTPUT);
pinMode(BTN_ON, INPUT_PULLUP);
pinMode(BTN_OFF, INPUT_PULLUP);
Pardalote.share(LIGHT, OUTPUT);
}
void loop() {
Pardalote.run();
if (button_on_pressed) {
digitalWrite(LIGHT, HIGH);
Pardalote.send(LIGHT, HIGH);
}
// ... mirror for off ...
}
```
The browser writes the same pin and listens for changes, so a press on either side keeps both in sync:
```javascript sketch.js — light switch, browser side
// Browser → Arduino: the on-screen buttons write the pin
onBtn.onclick = () => arduino.digitalWrite(LIGHT, HIGH);
offBtn.onclick = () => arduino.digitalWrite(LIGHT, LOW);
// Arduino → browser: a change from either side lands here
arduino.pin(LIGHT).on('change', ({ value }) => {
lightEl.textContent = value ? 'ON' : 'OFF';
});
```
## Example — potentiometer
The Arduino announces an analog input; the browser receives values automatically with no JS-side setup:
```cpp sketch.ino — announce an analog input
#include
void setup() {
Pardalote.begin();
pinMode(A0, INPUT);
Pardalote.share(A0, ANALOG_INPUT_MODE); // browser auto-starts polling
}
void loop() {
Pardalote.run(); // that's it — browser's polls are handled here
}
```
```javascript sketch.js — receive with no setup
arduino.pin('A0').on('change', ({ value }) => updateDisplay(value)); // no pinMode, no analogRead
```
## When not to share
Not every pin needs to be shared. In the light-switch example the two button pins are only used by the Arduino — the browser has its own buttons, so there's no reason to tell it about the physical ones. Share only the pins you want the browser to see.
`Pardalote.send(pin, value)` above is really one special case of a more general idea. The same `send` verb, given a **string key** instead of a pin number, becomes the **message channel**: `send(key, value)` pushes any named key/value to the browser — symmetric in both directions and carrying every basic type. Pushing a pin's value is just the pin-shaped form of it; use a string key for anything that isn't tied to a pin or a device.
See also: Messaging · Extensions overview · Servo · Stepper · Bus servo
---
# Messaging
> A key/value channel for everything that isn't a pin or a device — symmetric between the browser and the sketch, plus a frame monitor that sees all traffic.
`share`/`send` and the extensions all move *hardware* state. **Messaging** is the channel for everything else: named key/value messages that aren't tied to any pin or device. One side sends a value under a string key; the other side watches keys and reads values. It's symmetric — the same idea works both ways — and it carries every basic type: `int`, `bool`, `float`, `char`, text, and binary blobs.
Because the key is a **string**, `send` never collides with the pin `digitalWrite`/`analogWrite` calls — pins stay on their own verbs.
Each direction below shows both halves: the **send** call on one side and the **receive** call on the other. Watch the badge on each code block for the language.
## JavaScript to Arduino
Send from the browser with `arduino.send(key, value)`; receive it in the sketch with `watch()` (one key) or `onMessage()` (every key).
`arduino.send(key, value, [options])`
The value's type is inferred from the JavaScript value; `options` is an optional `{ retain, broadcast }` (see below).
```javascript Send — in the browser
arduino.send('mode', 'idle'); // text
arduino.send('temp', 22.5); // float (inferred from the value)
arduino.send('count', 42); // int
arduino.send('enabled', true); // bool
arduino.send('frame', pixels); // Uint8Array → blob
```
On the sketch side, callbacks receive a `Message`; `m.type` says which accessor is valid (`asInt` / `asBool` / `asFloat` / `asChar`, or `text` / `blob` with `length`).
```cpp Receive — in the sketch
void setup() {
Pardalote.begin();
Pardalote.watch("mode", onMode); // handle one key
Pardalote.onMessage(onAny); // handle every key
}
void onMode(const Message& m) {
// m.type is MSG_TYPE_TEXT here
setState(m.text);
}
void onAny(const Message& m) {
// m.asInt() / m.asBool() / m.asFloat() / m.asChar() / m.text / m.blob (m.length)
}
```
## Arduino to JavaScript
Send from the sketch with `Pardalote.send(key, value)`; receive it in the browser with `watch()` or `on('message')`. `arduino.messages` also caches the last value seen for every key.
`Pardalote.send(key, value, [flags]) · Pardalote.sendBlob(key, data, len, [flags])`
Typed overloads accept `int`, `double`, `bool`, `char`, and `const char*`; `sendBlob` sends raw bytes. `flags` is `MSG_FLAG_RETAIN`, `MSG_FLAG_BROADCAST`, or the two OR'd together.
```cpp Send — in the sketch
Pardalote.send("temp", 22.5); // double
Pardalote.send("count", 42); // int
Pardalote.send("mode", "idle", MSG_FLAG_RETAIN);
```
In the browser, `watch(key, fn)` fires for one key, `on('message', …)` catches them all, and `unwatch(key, [fn])` removes a watcher.
```javascript Receive — in the browser
// Watch one key — the callback fires whenever a message with that key arrives
arduino.watch('temp', (value, key, type) => console.log(key, '=', value));
// Or catch every message
arduino.on('message', ({ key, value, type }) => { /* type: 'int','float',… */ });
// Last received value for any key
let mode = arduino.messages['mode'];
```
A sketch `send` reaches every browser. The board's outgoing frame buffer caps a single Arduino→browser text/blob at about 240 bytes; browser→Arduino is not limited that way.
## Retain and broadcast
Two optional flags shape delivery — set them on the sender (`{ retain, broadcast }` from JavaScript, `MSG_FLAG_*` from the sketch):
```javascript Sending with flags — in the browser
arduino.send('mode', 'idle', { retain: true }); // stored on the board,
// replayed to browsers that connect later
arduino.send('cursor', 120, { broadcast: true }); // relayed to the OTHER browsers too
arduino.send('mode', 'run', { retain: true, broadcast: true });
```
- **`retain`** — the board keeps the latest value for that key and re-sends it to any client that connects, in the same sync step as pin and extension state. New clients immediately see the current value; without it, a message is a one-off event. (Scalars are always retained; a retained text/blob must be ≤ 48 bytes, else the board warns and skips it.)
- **`broadcast`** — the board relays a browser's message to the *other* connected browsers (it's the hub), so multiple browsers can coordinate. Without it, a browser message goes only to the Arduino sketch. A sketch `send` always reaches every browser.
## Inspecting all traffic
The **frame monitor** is a superset of message-watching — it sees **every** frame in and out, decoded and named, so it doubles as a live protocol inspector. It costs nothing until a handler is registered.
`arduino.on('frame', handler) · arduino.monitor(handler)`
```javascript Watch all traffic — in the browser
arduino.on('frame', ({ dir, cmdName, target, params, payload }) => {
console.log(dir, cmdName, params); // 'in' 'SERVO_WRITE' [0, 90], 'out' 'MESSAGE', …
});
arduino.monitor(fn); // shorthand for on('frame', fn)
arduino.off('frame', fn); // stop
```
The handler gets `{ dir, cmd, cmdName, target, params, payload }` — `dir` is `'in'` or `'out'`, `cmdName` is the decoded name (hex fallback for unknown commands). On the sketch side:
```cpp Watch all traffic — in the sketch
Pardalote.onFrame([](const FrameEvent& ev) {
Serial.println(ev.name); // "MESSAGE", "DIGITAL_WRITE", …
});
```
See **File → Examples → Pardalote → messaging** and `examples/messaging/` for a browser example with a live traffic inspector.
See also: The Arduino sketch · Connecting · Protocol
---
# Extensions overview
> Extensions add support for hardware devices — servos, steppers, LED strips, sensors and cameras. Register them in JS, include them in the sketch.
## Using extensions in JavaScript
Register extensions before connecting, and use them by name:
```javascript sketch.js — register and use extensions
const arduino = new Arduino();
arduino.add('myServo', new Servo());
arduino.add('strip', new NeoPixel());
arduino.add('sonar', new Ultrasonic());
arduino.connect('192.168.1.42');
arduino.on('ready', () => {
arduino.myServo.attach(9);
arduino.strip.init(6, 30);
arduino.sonar.attach(7, 8);
});
```
Each extension automatically gets a **logical id** — its slot number on the board, counted **per type** from 0 (the first servo is servo 0 and the first stepper is stepper 0, whatever else the page adds). Multiple instances of the same type are supported, up to the board's slot count for that type:
| Type | Instances at once |
|---|---|
| Servo | 8 |
| Stepper | 6 |
| Bus servo | 16 |
| NeoPixel, Ultrasonic, Encoder | 4 each |
| IMU | 2 |
Adding one more than the board holds prints a warning — the board would ignore it.
### remove()
`arduino.remove(name)` is the inverse of `add()`: it releases the device on the board (detaches it, freeing its pin, bus ID or PWM channel), drops it from any groups, and frees its logical id for the next `add()` to reuse. Use it when a page changes what hardware it drives while running:
```javascript sketch.js — swap a servo for a stepper
arduino.remove('arm'); // detach the servo on the board
arduino.add('arm', new Stepper()); // a new stepper takes its place
arduino.arm.attach(2, 3);
```
`add()` with a name that's already in use does the `remove()` for you, so the two lines above can be just the `add()`. The removed object is left **inert** — calling anything on it warns and sends nothing, because its old id may now belong to another device. Objects the **sketch** created (see below) belong to the sketch and can't be removed from the browser.
## Script loading order
`pardalote.js` is the all-in-one bundle — core plus every device extension — so a single script tag is all you need, loaded before your sketch:
```html index.html — script loading order
```
Every extension (Servo, Stepper, BusServo, NeoPixel, Ultrasonic, IMU, Encoder, Camera) is already inside the bundle — you just `arduino.add(...)` the ones you use. (Advanced: the modular sources in `lib/src/` — `pardalote-core.js` plus one `pardalote-.js` each — can be included individually instead; the bundle is exactly those concatenated.)
## Enabling extensions in the firmware
Extensions are opt-in on the Arduino side too. Add the headers you need to your sketch:
```cpp sketch.ino — opt-in extensions
#include
#include
#include
// #include
// #include
// #include
// #include
// #define CAMERA_MODEL_XIAO_ESP32S3
// #include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
Each extension self-registers when included — no other changes required. Only the extensions you `#include` get compiled into the binary.
## Creating extension objects in the firmware
Extension objects don't have to be created in the browser with `arduino.add()` — **the sketch can create them too**, and every browser receives them automatically. `PardaloteServo.attach("pan", 9)` on the Arduino makes `arduino.pan` appear in every connected browser as a full `Servo` instance, identical to one created with `add()` — present before `'ready'` fires, and pushed to browsers that connect later.
Every multi-instance device supports this — Servo, Stepper, Bus servo, NeoPixel, Ultrasonic and IMU (only the singleton Camera is browser-only):
`PardaloteServo.attach(name, pin, [minPulse], [maxPulse])`
`PardaloteStepper.attach(name, step, dir, [en]) · attach4wire(name, p1, p2, p3, p4)`
`PardaloteBusServo.attach(name, servoId, [series])`
`PardaloteNeoPixel.attach(name, pin, count, [type])`
`PardaloteUltrasonic.attach(name, trig, [echo])`
`PardaloteIMU.attach(name, [model], [addr], [sda], [scl])`
`name` is the browser-side handle (`arduino.`, max 15 chars). Each `attach` **returns the logical id** used by the sketch-side calls below, or −1 if no slot is free.
```cpp sketch.ino — the sketch creates a servo
#include
#include
int pan;
void setup() {
Pardalote.begin();
pan = PardaloteServo.attach("pan", 9); // arduino.pan now exists in every browser
PardaloteServo.write(pan, 90);
}
```
**Creating an extension object *is* sharing it** — unlike raw pins there's no separate `share()` step, because a Pardalote extension has no life outside Pardalote. `arduino.on('share', ({ name, extension }) => …)` fires the moment it appears; calling `attach` again with the same name reuses the same object; names that would collide with the browser core API (like `"connect"`) are refused with a console warning. A device that should stay private to the sketch shouldn't go through Pardalote at all — drive it with the plain library directly (a raw `Servo` on its own pin, a raw `SCServo` bus on its own UART), the way unshared pins just use `pinMode()`.
## Reading and writing actuators from the sketch
`share`/`send` cover raw pins. For the **extension actuators** — browser-created or sketch-created — each type gives the sketch a small **bus object** (`PardaloteServo`, `PardaloteStepper`, `PardaloteBusServo`). The browser and the sketch share the same actuator (last writer wins), just as they share raw pins. Everything is addressed by **logical id** — what `attach` returns, and the same id the browser and groups use.
| Object | `scan()` returns | `read(id)` returns | `id` is |
|---|---|---|---|
| `PardaloteServo` | attached servo ids | angle (0–180) | logical id (the browser's per-type slot, or returned by sketch `attach`) |
| `PardaloteStepper` | attached stepper ids | position (steps) | logical id |
| `PardaloteBusServo` | responding **hardware** ids on the bus (discovery) | position (counts) | logical id (returned by `attach`) |
### scan()
Lists what's there.
`PardaloteType.scan(buffer, maxCount)`
| Parameter | Type | Description |
|---|---|---|
| `buffer` | int[] / uint8_t[] | Array to fill with ids. |
| `maxCount` | int | Size of the array. |
**Returns** the number of ids written. For `PardaloteBusServo`, `scan()` pings the bus and reports the **hardware** ids that respond — that's discovery; `attach()` the ones you want to drive, then address them by the logical id it returns.
```cpp
int ids[8];
int n = PardaloteServo.scan(ids, 8);
for (int i = 0; i < n; i++) Serial.println(PardaloteServo.read(ids[i]));
```
### read()
Reads one actuator's position by logical id.
`PardaloteType.read(id)`
**Returns** angle (servo), steps (stepper), or counts (bus servo); negative on failure.
```cpp
#include
#include
int wrist;
void setup() {
Pardalote.begin();
wrist = PardaloteBusServo.attach("wrist", 1); // adopt bus id 1 → logical id
}
void loop() {
Pardalote.run();
int pos = PardaloteBusServo.read(wrist); // logical id → position (counts)
if (pos >= 0) digitalWrite(LED_BUILTIN, pos > 2048 ? HIGH : LOW);
}
```
### write() and friends
The same objects command the actuators, all by logical id:
```cpp
PardaloteServo.write(id, 90); // angle 0–180
PardaloteServo.writeTimed(id, 90, 1000); // over 1 s (board-interpolated)
PardaloteServo.stop(id);
PardaloteStepper.moveTo(id, 2000); // steps
PardaloteStepper.move(id, -400);
PardaloteStepper.stop(id);
PardaloteBusServo.write(id, 2048); // counts (optional speed, acc)
PardaloteBusServo.torque(id, false); // release / hold
```
All three run through the **same command path the browser uses** — so they respect soft limits, cancel timed moves, and auto-echo the commanded value back to the browser so its record stays in sync.
### Board-authored gestures
"Whoever speaks is in control": the browser can compose-and-play a gesture, and so can the sketch. The board authors a gesture as a `PardaloteSeg[]` — `{ curve, dur, value }` in the actuator's native unit — and plays the schedule on its **own clock, no browser needed**. Because Pardalote is 32-bit only, a `static const PardaloteSeg[]` lives in flash and costs no RAM.
`PardaloteServo.gesture(id, segments, count, [flags])`
```cpp
static const PardaloteSeg NOD[] = {
{ CURVE_EASE_OUT, 250, 60 }, // to 60° (absolute by default)
{ CURVE_EASE_IN_OUT, 400, 120 },
};
PardaloteServo.gesture(pan, NOD, 2); // servo degrees
PardaloteStepper.gesture(lift, LIFT, 3); // stepper steps
PardaloteBusServo.gesture(grip, GRIP, 2); // bus-servo counts
```
Segments are **absolute** by default (`value` is a target); pass `flags = 0` for relative (`value` is a delta off the live position). Curves are the `CURVE_*` ids (`CURVE_LINEAR`, `CURVE_EASE_IN`, `CURVE_EASE_OUT`, `CURVE_EASE_IN_OUT`, `CURVE_BACK`). This is the sketch twin of JavaScript `servo.gesture()` — same segment model, same on-board player, so a board- and a browser-authored gesture play identically.
**Coordinate several actuators** with `Pardalote.gesture()` — the board-side twin of `arduino.gesture()`. Add one lane per actuator by `DEVICE_*` id; shorter lanes are padded so every lane **arrives together**, and all start on one clock:
```cpp
Pardalote.gesture() // each array a PardaloteSeg[] as above
.add(DEVICE_SERVO, shoulder, SHOULDER, 2)
.add(DEVICE_SERVO, wrist, WRIST, 1) // shorter → padded to arrive together
.add(DEVICE_STEPPER, lift, LIFT, 3)
.play();
```
**Reshape a gesture** — the board-side twin of the JS `scale` / `speed` / `crop`. Pass a `PardaloteGestureMod` to a single actuator's `gesture()`, or chain `.scale()` / `.speed()` / `.crop()` on the coordinated builder. The transform runs once, at gesture start, over the RAM copy each actuator already keeps — no extra buffer — and produces the byte-identical result to the browser doing the same:
```cpp
PardaloteServo.gesture(pan, NOD, 3, GESTURE_FLAG_ABSOLUTE, { 0.7f, 0.5f, 0.2f, 0.8f }); // {scale,speed,crop}
Pardalote.gesture()
.add(DEVICE_SERVO, shoulder, SHOULDER, 2)
.add(DEVICE_SERVO, wrist, WRIST, 1, true, 1.3f) // per-lane amplitude (laneScale)
.speed(1.5f).crop(0.0f, 0.75f) // group-wide: keeps lanes phase-locked
.play();
```
`speed` and `crop` are group-wide (one factor, one window on the shared timeline) so the lanes stay in step; `scale` may differ per lane. `scale` multiplies a **relative** delta directly and scales an **absolute** target around the lane's starting target (`origin + (to − origin) × k`); a cropped **relative** gesture ends off-home. It's applied board-side, so the on-wire frame is unchanged.
**Coordinated `write` / `writeTimed`** — the twins of `arduino.write()` / `arduino.writeTimed()` — take one target per actuator instead of a schedule:
```cpp
Pardalote.write().add(DEVICE_SERVO, pan, 90).add(DEVICE_STEPPER, lift, 0).play(); // immediate
Pardalote.writeTimed(1500).add(DEVICE_SERVO, pan, 45).add(DEVICE_SERVO, tilt, 135).play(); // arrive together
```
**Chain gestures headlessly** with `onGestureDone(id, cb)` — the board-side `whenDone()`. The callback fires when the last segment lands, so the sketch can run a sequence (or an idle loop) with no browser at all:
```cpp
void onNodDone(int id) { PardaloteServo.gesture(id, IDLE, 2); } // loop back to idle
PardaloteServo.onGestureDone(pan, onNodDone);
```
Watchers stay in sync: a board-authored gesture broadcasts its **existence** (never its shape) to every browser, which sees the actuator's `isGesturing` flag flip and `gesturestart` / `gestureend` fire — the same signals a browser-authored gesture gives. See the **`board-gestures-PWM-servos`** and **`board-gestures-bus-servos`** sketches in the Arduino examples for a complete headless creature-head (both step through the scale/speed/crop shaping cycle on a button), and the Gesture page for the concept.
### Status helpers
```cpp
PardaloteServo.isMoving(id); // timed move in progress
PardaloteStepper.distanceToGo(id);
PardaloteStepper.isRunning(id);
PardaloteBusServo.feedback(id); // position, load, voltage, temperature in one read
PardaloteBusServo.isMoving(id); // the servo's own Moving flag
PardaloteBusServo.arrived(id);
```
For bus servos, `isMoving(id)` / `arrived(id)` read the servo's own **Moving flag** — its honest "am I still moving?", accounting for deadband and settling. It's one bus read (the servo can't notify you — you ask when you want to know):
```cpp Example — wait for a bus servo to arrive
PardaloteBusServo.write(wrist, 3000);
while (PardaloteBusServo.isMoving(wrist)) { /* do other work */ }
// arrived — trigger the next thing
```
- A **bus servo read/scan/write is a blocking bus transaction** — fine in `setup()` or a throttled `loop()`, not a tight high-rate loop competing with the browser's own polling.
- **Sketch writes update the browser's record automatically.** A sketch write echoes the commanded value to the browser exactly as if the browser had issued it — a PWM servo sets the browser's `angle`, a stepper or bus servo sets its `target`. The live `position` feedback is separate — that still comes from polling.
## The extensions
| Extension | Supports | Limit |
|---|---|---|
| Servo | PWM hobby servos | 8 |
| Stepper | STEP/DIR and 4-wire drivers via AccelStepper | 6 |
| Bus servo | Feetech ST/SMS and SC/SCS serial servos | 16 |
| NeoPixel | WS2812B-style LED strips | 4 strips |
| Ultrasonic | HC-SR04 and similar distance sensors | 4 |
| IMU | InvenSense MPU and STMicro LSM6 families | 2 |
| Camera | ESP32 camera modules (MJPEG over HTTP) | 1 stream |
Groups let you drive several actuators together — one message, coordinated arrival, and coordinated gestures.
## Rolling your own
An extension is a JS file paired with an Arduino header: the JS side sends commands over the shared protocol, the Arduino side registers handlers for them. The built-in extensions are the best templates — pick the one closest to your hardware and start from its source. See Protocol for the frame format.
---
# Servo
> Drive up to 8 PWM hobby servos from the browser — with smooth sweeps, timed moves, and angle read-back.
Requires `` in the sketch (plus the `ESP32Servo` library on ESP32). Register instances before connecting; examples assume:
```javascript
arduino.add('pan', new Servo());
```
Alternatively, the **sketch** can create the servo — `PardaloteServo.attach("pan", 9)` on the Arduino — and `arduino.pan` appears automatically, no `add()` needed: a full `Servo` instance, identical to the above, present before `'ready'` fires. See The Arduino sketch.
## attach()
Attaches the servo to a pin. Call inside `on('ready')`.
`arduino.pan.attach(pin, [minPulse], [maxPulse])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | PWM-capable pin the servo signal wire is on. |
| `minPulse` | number | Optional. Pulse width at 0°, in µs (default `544`). |
| `maxPulse` | number | Optional. Pulse width at 180°, in µs (default `2400`). |
```javascript Example — attach a servo
arduino.pan.attach(9);
arduino.pan.attach(9, 544, 2400); // custom pulse range
```
## detach()
Releases the pin.
`arduino.pan.detach()`
## write()
Moves the servo to an angle. Cancels any in-progress sweep or timed move.
`arduino.pan.write(angle)`
| Parameter | Type | Description |
|---|---|---|
| `angle` | number | Target angle, `0`–`180` degrees. |
## writeMicroseconds()
Fine-grained control via raw pulse width.
`arduino.pan.writeMicroseconds(us)`
| Parameter | Type | Description |
|---|---|---|
| `us` | number | Pulse width in microseconds, typically `544`–`2400`. |
## center() / min() / max()
Shorthand moves.
`arduino.pan.center() · arduino.pan.min() · arduino.pan.max()`
Move to 90°, 0°, and 180° respectively.
## read()
Reads the servo's angle. Same poll-and-cache pattern as `analogRead()`: the first call starts a poll, later calls return the cached value. The board runs the poll (per-browser interval and threshold) and only transmits changes of threshold+ degrees.
`arduino.pan.read([interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Poll interval in ms. Pass `END` to stop. |
| `threshold` | number | Optional. Minimum change worth transmitting, in degrees (`0` = default: `1`). Also settable via `setReadInterval(ms)` / `setReadThreshold(degrees)`. |
**Returns** the cached angle in degrees.
What the Arduino reports depends on the platform:
- **UNO R4 WiFi:** the last commanded angle (the Arduino Servo library's `read()` is just a getter).
- **ESP32:** the angle decoded from the LEDC PWM duty register. Usually matches the commanded angle, but the hardware round-trip can return values slightly off, and a `write()` during a `read()` can briefly return a transitional value.
If you just want "what did I tell the servo to do?" — use the `angle` property (locally cached, updated on every `write()`).
## sweep()
Sweeps between two angles over a duration, streaming intermediate writes. Returns a promise. Any `write()` cancels it.
`await arduino.pan.sweep(from, to, duration, [steps])`
| Parameter | Type | Description |
|---|---|---|
| `from` | number | Start angle. |
| `to` | number | End angle. |
| `duration` | number | Sweep time in ms. |
| `steps` | number | Optional. Number of intermediate writes (default `50`). More = smoother. |
```javascript Example — smooth sweep
await arduino.pan.sweep(0, 180, 2000);
```
## writeTimed()
Moves to an angle over a set time — the Arduino interpolates on-board (smooth, no WiFi streaming) and fires `done` on arrival. The modern replacement for `sweep()`, and what lets PWM servos **arrive together** inside a group.
`arduino.pan.writeTimed(angle, duration)`
| Parameter | Type | Description |
|---|---|---|
| `angle` | number | Target angle, `0`–`180`. |
| `duration` | number | Move time in ms. |
```javascript Example — timed move with a done event
arduino.pan.writeTimed(120, 1500); // move to 120° over 1.5 s
arduino.pan.on('done', ({ angle }) => { /* arrived */ });
```
An immediate `write()` cancels an in-progress timed move.
## gesture()
Plays an authored **segment schedule** — an ordered list of eased moves the Arduino runs back-to-back on its own clock (on-board, no WiFi streaming). Where `writeTimed()` is one eased move, a gesture is many: the primitive for *expressive* motion — anticipation, overshoot, holds, follow-through. Fires `done` (resolves `whenDone()`) when the last segment lands. Timing follows the shared gesture timing rules: segments start on schedule, and the last key is landed or given up on 2 s after its expected arrival.
`arduino.pan.gesture(segments, [opts])`
Each segment is an object with a duration, a curve, and a displacement expressed **either** relatively (`by`) **or** absolutely (`to`):
| Field | Type | Description |
|---|---|---|
| `dur` | number | Segment duration in ms. |
| `curve` | string | Easing: `'linear'`, `'easeIn'`, `'easeOut'`, `'easeInOut'`, or `'back'` (overshoot). Default `'linear'`. |
| `by` | number | **Relative** displacement in degrees — the default, portable frame. |
| `to` | number | **Absolute** target angle — use in place of `by`. |
The reference frame is inferred (`to` → absolute, `by` → relative) or forced with `opts.absolute`. Relative is the default: the board captures the start angle at each segment, so a gesture needs no absolute position truth. Up to **16** segments (extras dropped with a warning); a `back` overshoot is re-clamped to the servo range / `setLimits()`.
```javascript Example — a nod with follow-through
arduino.pan.gesture([
{ by: 25, dur: 250, curve: 'easeOut' },
{ by: -25, dur: 400, curve: 'easeInOut' },
{ by: 6, dur: 180, curve: 'back' }, // small overshoot settle
]);
await arduino.pan.gesture([ /* … */ ]).whenDone();
```
To play coordinated gestures across several actuators at once, see group.gesture().
## whenDone()
Promise for the most recent timed move — resolves `true` on the servo's `done` (or immediately if no move is pending), `false` if it didn't arrive (a gesture that gave up, or the safety timeout — default `max(duration × 2, 10000)` ms; pass `{ timeout }` or a bare number to override, `0` to wait forever). The same method exists on steppers, bus servos, and groups.
`await arduino.pan.whenDone([{ timeout }])`
```javascript Example — sequence timed moves
await arduino.pan.writeTimed(120, 1500).whenDone();
await arduino.pan.writeTimed(60, 1500).whenDone();
```
## stop()
Cancels a timed move and holds the current angle.
`arduino.pan.stop()`
## setLimits() / clearLimits()
Soft angle limits, enforced **on the Arduino** — every commanded angle (browser write, sketch write, timed or group move) is clamped to the range before it reaches the servo. Same shape as `setLimits` on the stepper and bus servo.
`arduino.pan.setLimits(min, max) · arduino.pan.clearLimits()`
| Parameter | Type | Description |
|---|---|---|
| `min`, `max` | number | Allowed angle range, `0`–`180`. |
## setHome() / home()
`setHome(angle)` declares the home angle — no-arg means "the current angle is home". `home()` snaps there; `home(duration)` glides there over `duration` ms. Default home is `90` (centre). Same pair as the stepper and bus servo.
`arduino.pan.setHome([angle]) · arduino.pan.home([duration])`
```javascript Example — ease home
arduino.pan.setHome(45);
await arduino.pan.home(1000).whenDone();
```
## attached()
Asks the **board** whether this servo is attached. Resolves `true`/`false` (`false` after a 2 s timeout — dead link or unresponsive board). Query → promise, like `busServo.ping()`; for the cached mirror, read `arduino.pan.isAttached`.
`await arduino.pan.attached()`
```javascript Example — preflight check
if (await arduino.pan.attached()) beginSequence();
else showError('pan servo not responding');
```
## Events
`arduino.pan.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ angle }` | The angle changed by at least the threshold. |
| `'write'` | `{ angle }` | A write is issued. |
| `'gesture'` | `{ segments, absolute, duration }` | *This* browser sends a gesture (local echo). |
| `'gesturestart'` | `{}` | The board starts playing a schedule on this servo — **any** origin (this browser, another, or the sketch). |
| `'gestureend'` | `{}` | …that schedule ends — completion or a superseding write. |
| `'done'` | `{ angle, arrived }` | A timed move or gesture finishes. `arrived` is `false` only for a gesture that gave up. |
Shorthand: `onChange(fn)`, `onWrite(fn)`, `onDone(fn)`, `onGestureStart(fn)`, `onGestureEnd(fn)`. The board reports whether a schedule is playing (any origin) via the `isGesturing` property; a browser that connects mid-gesture learns it on sync.
## setWriteThrottle() / setWriteThreshold()
Rate-limits outgoing writes — useful when driving the servo from mouse movement or a draw loop.
`arduino.pan.setWriteThrottle(ms) · arduino.pan.setWriteThreshold(degrees)`
The write family paces what you **send** (min ms between writes; skip deltas under `degrees`); the `setRead*` family gates what you **hear**.
| Parameter | Type | Description |
|---|---|---|
| `ms` | number | Minimum ms between writes (default `20`). |
| `degrees` | number | Minimum change in degrees to send (default `1`). |
## Properties and state
| Property | Description |
|---|---|
| `arduino.pan.angle` | Locally cached snapshot, updated on every `write()`. |
| `arduino.pan.isGesturing` | `true` while the board is playing a segment schedule on this servo (any origin). |
`arduino.pan.getState()`
**Returns** a snapshot of all servo state.
See also: Groups · Bus servo · Servo example
---
# Stepper
> Up to 6 stepper motors with accel-limited moves, continuous rotation, soft limits and live position — motion runs on the Arduino.
Requires the `AccelStepper` library (**version 1.64 or newer** — the Library Manager version is fine) and `` in the sketch. Works with STEP/DIR drivers (**TMC2208**, **TMC2209**, **A4988**, **EasyDriver**) and 4-wire coil drivers (28BYJ-48 via ULN2003, bipolar via H-bridge).
**Motion runs on the Arduino.** You send targets and motion profiles; the board generates the step pulses with AccelStepper, timed so the real speed matches the speed you ask for (on its own, AccelStepper rounds speeds down to whatever the sketch's loop can keep up with). You never stream individual steps over WiFi — that's why the API mirrors AccelStepper (non-blocking) rather than the built-in `Stepper` library, whose `step()` blocks and would stall the connection mid-move.
Give the motor its own power supply — don't run the coils off the board's 5 V rail. Examples assume:
```javascript sketch.js — register a stepper
arduino.add('x', new Stepper());
```
## attach()
Attaches a STEP/DIR driver. Call inside `on('ready')`.
`arduino.x.attach(step, dir, [en], [options])`
| Parameter | Type | Description |
|---|---|---|
| `step` | number \| string | STEP pin. |
| `dir` | number \| string | DIR pin. |
| `en` | number \| string | Optional. Enable pin (treated as active-LOW by default). |
| `options` | object | Optional. `{ invertDir, invertEnable }` booleans to flip signal polarity. |
```javascript Example — attach a driver
arduino.x.attach(2, 3); // no enable pin
arduino.x.attach(2, 3, 4); // with enable pin
arduino.x.attach(2, 3, 4, { invertDir: true }); // runs backwards? flip it
```
## attach4wire()
Attaches a 4-wire coil driver (28BYJ-48 via ULN2003, or bipolar via H-bridge).
`arduino.x.attach4wire(in1, in2, in3, in4)`
| Parameter | Type | Description |
|---|---|---|
| `in1`–`in4` | number \| string | The four coil driver pins. |
## detach()
Releases the pins.
`arduino.x.detach()`
## setMaxSpeed() / setAcceleration()
Sets the motion profile. Both must be non-zero before any move happens.
`arduino.x.setMaxSpeed(stepsPerSec) · arduino.x.setAcceleration(stepsPerSec2)`
| Parameter | Type | Description |
|---|---|---|
| `stepsPerSec` | number | Speed ceiling for moves. Software step generation shares the CPU with WiFi and tops out at a few kHz. |
| `stepsPerSec2` | number | Acceleration / deceleration rate. |
## moveTo() / move()
Accel-limited moves to an absolute or relative target.
`arduino.x.moveTo(target) · arduino.x.move(delta)`
| Parameter | Type | Description |
|---|---|---|
| `target` | number | Absolute position in steps. |
| `delta` | number | Steps relative to the current position (negative = reverse). |
## whenDone()
Promise for the most recent move — resolves `true` when the board reports `done` (or immediately if the move already finished), `false` if it didn't arrive (a gesture that gave up, or the safety timeout — default `max(duration × 2, 10000)` ms; pass `{ timeout }` or a bare number to override, `0` to wait forever). Handy for sequencing. The same method exists on servos, bus servos, and groups.
`await arduino.x.whenDone([{ timeout }])`
```javascript Example — sequence moves
await arduino.x.moveTo(2000).whenDone();
await arduino.x.moveTo(0).whenDone();
```
## moveToTimed()
Runs a **constant-speed** move sized to arrive in about `duration` ms — the board computes the speed from its own exact position. This is what lets steppers **arrive together** inside a group. Constant speed skips the acceleration ramp, so keep the speed reasonable to avoid stalling; use `moveTo()` when arrival timing doesn't matter.
`arduino.x.moveToTimed(target, duration)`
| Parameter | Type | Description |
|---|---|---|
| `target` | number | Absolute position in steps. |
| `duration` | number | Approximate arrival time in ms. |
## gesture()
Plays an authored **segment schedule** — an ordered list of eased moves the board runs back-to-back on its own clock. Where `moveToTimed()` is one constant-speed move, a gesture is a sequence of *eased* ones: the primitive for expressive motion. Each segment's velocity follows its curve (a dedicated on-board eased mode, not AccelStepper's fixed ramp), so you get anticipation, overshoot, and holds a plain ramp can't produce. Values are in **steps**. Fires `done` when the last segment lands. Timing follows the shared gesture timing rules: segments start on schedule, and the last key is landed or given up on 2 s after its expected arrival.
`arduino.x.gesture(segments, [opts])`
Each segment carries a duration, a curve, and a displacement expressed **either** relatively (`by`) **or** absolutely (`to`):
| Field | Type | Description |
|---|---|---|
| `dur` | number | Segment duration in ms. |
| `curve` | string | `'linear'`, `'easeIn'`, `'easeOut'`, `'easeInOut'`, or `'back'` (overshoot). Default `'linear'`. |
| `by` | number | **Relative** displacement in steps — the default, portable frame. |
| `to` | number | **Absolute** target in steps — use in place of `by`. |
Relative by default (the board captures its live position at the start of each segment). **No homing needed** — a relative bounce works on an open-loop stepper with no position truth, which is the point: a `back` segment drives *past* the target then reverses, a real over-travel (e.g. a lead-screw bounce). Absolute targets are clamped to `setLimits()`. Up to **16** segments. An eased move's velocity peaks above its average, so the board briefly raises the speed cap to hit the authored duration, then restores your `setMaxSpeed()` value.
```javascript Example — a lead-screw bounce, no homing
arduino.x.gesture([
{ by: 800, dur: 350, curve: 'easeOut' },
{ by: -800, dur: 550, curve: 'easeInOut' },
{ by: 120, dur: 200, curve: 'back' }, // small overshoot settle
]);
await arduino.x.gesture([ /* … */ ]).whenDone();
```
Any explicit move (`moveTo`, `move`, `runSpeed`, `stop`, `hardStop`, `home`) cancels a running gesture. To coordinate several actuators at once, see group.gesture().
## runSpeed()
Continuous rotation at a constant speed until stopped. Sign sets direction.
`arduino.x.runSpeed(stepsPerSec)`
```javascript Example — continuous rotation
arduino.x.runSpeed(600); // spin at 600 steps/sec
arduino.x.runSpeed(-600); // reverse
```
## stop()
Decelerates to a stop (position mode) or stops a velocity-mode spin. The motor keeps its coordinate and a `done` follows, so `whenDone()` settles.
`arduino.x.stop()`
## hardStop()
Instant halt with **no deceleration ramp** — unlike `stop()`, the board zeroes speed and distance-to-go in one call. The current position is kept and a `done` follows. Use it for an e-stop, or to end a `runSpeed()` spin without the decel travel. An abrupt stop above the acceleration limit can lose steps, so re-zero afterwards (`setPosition()`) or home if the coordinate matters.
`arduino.x.hardStop()`
## read()
Starts polling position and status. Same poll-and-cache pattern as everywhere else. The board runs the poll (per-browser interval and threshold) and only transmits changes of threshold+ steps.
`arduino.x.read([interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Poll interval in ms. Pass `END` to stop. |
| `threshold` | number | Optional. Minimum position change worth transmitting, in steps (`0` = default: `1`). Also settable via `setReadInterval(ms)` / `setReadThreshold(steps)`. |
## setPosition()
Declares the current physical position to be a given value — steppers have no absolute feedback, so move to a reference point (by hand or against a stop) and zero there. For automatic zeroing against an end-stop, see `home()` below.
`arduino.x.setPosition(steps)`
```javascript Example — declare home
arduino.x.setPosition(0); // "here is home"
```
## enable() / disable()
Drives the enable pin: energise the coils to hold position, or release them so the shaft turns freely by hand (and stops heating up).
`arduino.x.enable() · arduino.x.disable()`
## setLimits() / clearLimits()
Soft limits, enforced **on the Arduino** — every target is clamped to the range before the board acts on it. The browser (or an LLM driving it) can't send the motor past the set bounds.
`arduino.x.setLimits(min, max) · arduino.x.clearLimits()`
| Parameter | Type | Description |
|---|---|---|
| `min`, `max` | number | Allowed position range in steps. |
## setLimitSwitch() / clearLimitSwitch()
Hardware end-stops — none, one, or two, one call per switch. The trip happens **on the board** (no WiFi round-trip): moving into a pressed switch stops the motor *instantly* (no deceleration ramp); moving away is always allowed, so you can back off. When a switch trips, a `'limit'` event fires, `limitHit` is set (`'min'`/`'max'`, cleared by the next move), and the normal `'done'` follows — `whenDone()` still settles.
`arduino.x.setLimitSwitch(which, pin, [trigger]) · arduino.x.clearLimitSwitch(which)`
| Parameter | Type | Description |
|---|---|---|
| `which` | constant | `LIMIT_MIN` or `LIMIT_MAX` — which end of travel the switch guards (same constants as the Arduino side). |
| `pin` | number/string | Switch pin. `-1` clears. |
| `trigger` | constant | `LOW` (default — internal pull-up, wire the switch to GND) or `HIGH`. |
```javascript Example — two end-stops
arduino.x.setLimitSwitch(LIMIT_MIN, 9); // active LOW
arduino.x.setLimitSwitch(LIMIT_MAX, 10, HIGH); // active HIGH
arduino.x.on('limit', ({ which, position }) => {
console.log(`hit the ${which} switch at ${position}`);
});
```
After a trip the step counter is suspect (an instant stop above the acceleration limit can lose steps) — that's what homing is for.
## setSwitchPosition()
Declares where a limit switch physically sits, **as a coordinate**, independent of the soft limits. Homing adopts this value the moment the switch trips, so you can re-establish an accurate counter even after lost steps. The default is `0` — i.e. the switch *is* the origin — so you only need this when home should sit somewhere other than the switch.
`arduino.x.setSwitchPosition(which, coord)`
| Parameter | Type | Description |
|---|---|---|
| `which` | constant | `LIMIT_MIN` or `LIMIT_MAX` — which switch this coordinate belongs to. |
| `coord` | number | The step position the switch sits at. |
```javascript Example — switch 500 steps below home
arduino.x.setLimitSwitch(LIMIT_MIN, 9); // min-end switch on pin 9
arduino.x.setSwitchPosition(LIMIT_MIN, -500);// it sits 500 steps below home (0)
```
## setHome() / home()
**Home is the origin (`0`).** `setHome()` **re-zeros the frame**: the current spot becomes `0` and the enabled soft limits and switch positions all shift by the same offset, so they keep pointing at the same physical places. `setHome(value)` re-zeros to `value` instead of `0` (rarely needed). `home()` travels back to the origin:
- **With a limit switch** — a board-side routine: seek the switch (MIN if configured, else MAX) at a homing speed (default `maxSpeed/4`, override with `{ speed }`), set the counter to the switch's coordinate (`setSwitchPosition`, default `0`) when it trips, back off until it releases, then travel to the origin. Re-establishes the counter from the switch, so it works even when the counter is wrong.
- **Without a switch** — a plain accel move to the origin (counter trusted).
The seek/back-off legs are **capped** (default 30 s, `{ timeout }` to override): if the switch never trips or never releases, the board hard-stops, fires `'home:fail'` `{ position }`, then `'done'` — nothing spins forever, and `whenDone()` still settles. `done` fires when the travel leg arrives; any explicit move cancels an in-progress routine.
`arduino.x.setHome([value]) · arduino.x.home([{ speed, timeout }])`
```javascript Example — home against the min switch
arduino.x.setLimitSwitch(LIMIT_MIN, 9); // min-end switch on pin 9
arduino.x.setSwitchPosition(LIMIT_MIN, -500); // it sits 500 steps below home
await arduino.x.home().whenDone({ timeout: 30000 }); // seeks it, then travels to 0
```
This is the CNC/work-coordinate split: the switch is a fixed physical reference, home is your `0` at a known offset from it. With the default switch position (`0`) the switch simply *is* home.
## Degrees and revolutions
Convenience helpers convert to raw steps on the JS side. Set steps-per-revolution to match your microstepping first (a 1.8° motor at 16 microsteps = 200 × 16 = 3200):
`arduino.x.setStepsPerRev(steps)`
```javascript Example — work in degrees
arduino.x.setStepsPerRev(200 * 16);
arduino.x.moveToDegrees(90); // absolute
arduino.x.moveDegrees(-45); // relative
arduino.x.moveToRevolutions(2);
arduino.x.moveRevolutions(0.5);
```
## Events
`arduino.x.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ position, distanceToGo, speed, isRunning }` | The position changed by at least the threshold. |
| `'done'` | `{ position, arrived }` | A position-mode target, timed move, or gesture finishes (or motion ends at a limit switch). `arrived` is `false` only for a gesture that gave up. |
| `'move'` | `{ target }` | THIS page issued a move (including group moves it participates in) — a command echo, like servo `'write'`. Other browsers' moves arrive via the read stream and `'done'`/`'limit'`, not `'move'`. `runSpeed()` emits nothing (continuous rotation has no destination). |
| `'limit'` | `{ which, position }` | A limit switch tripped and the board hard-stopped the motor. |
| `'home:fail'` | `{ position }` | Homing gave up (seek/back-off timeout) — the switch never responded. |
| `'gesturestart'` / `'gestureend'` | `{}` | The board starts / ends playing a schedule on this stepper — **any** origin (this browser, another, or the sketch). End = completion or a superseding move. |
Shorthand: `onChange(fn)`, `onDone(fn)`, `onMove(fn)`, `onLimit(fn)`, `onHomeFail(fn)`, `onGestureStart(fn)`, `onGestureEnd(fn)`. The `isGesturing` property tracks whether a schedule is playing (any origin).
## Properties and state
| Property | Description |
|---|---|
| `arduino.x.position` | Cached current position (steps) — real feedback, needs polling. |
| `arduino.x.target` | Commanded destination — set immediately by `moveTo()`. |
| `arduino.x.distanceToGo` | Steps remaining to target. |
| `arduino.x.speed` | Current speed (steps/sec). |
| `arduino.x.isRunning` | Boolean. |
| `arduino.x.isGesturing` | `true` while the board is playing a segment schedule on this stepper (any origin). |
`target` vs `position`: `moveTo(n)` sets `target` to `n` **right away**, so you can show where it's headed without waiting for a poll; `position` is real feedback and only advances toward `target` as read polls (or the `done` event) arrive. `target` also self-corrects from read feedback — so it stays right even when the *Arduino sketch* issued the move.
`arduino.x.getState()`
**Returns** `{ logicalId, interface, pins, enPin, attached, maxSpeed, acceleration, stepsPerRev, target, position, distanceToGo, speed, isRunning, limits, interval }`.
See also: Groups · Stepper example · Troubleshooting
---
# Bus servo
> Up to 16 smart serial servos on one shared UART — position, speed, torque control and rich feedback, Feetech ST and SC series.
Requires the Feetech/Waveshare `SCServo` library and `` in the sketch. Supports Feetech **ST / SMS** series (0–4095 counts, e.g. the **STS3215** used in the LeRobot SO-100/SO-101 arms) and **SC / SCS** series (0–1023 counts).
Unlike PWM servos, every bus servo shares **one UART** and is addressed by a hardware **servo ID (1–253)**, and **positions are raw encoder counts, not degrees**. Wiring is via a Waveshare Serial Bus Servo Driver board (or equivalent) on a hardware serial port: UNO R4 WiFi → `Serial1` (D0/D1), ESP32 → `Serial1`/`Serial2`.
Give the servos their own power supply (6–7.4 V typical) — not the board's 5 V rail. Examples assume:
```javascript sketch.js — register a bus servo
arduino.add('shoulder', new BusServo());
```
## configureBus()
Optional bus configuration — defaults to `Serial1` at 1 Mbps, brought up lazily on first `attach()`. Call once from any instance; it affects all bus servos.
`arduino.shoulder.configureBus(options)`
| Option | Type | Description |
|---|---|---|
| `serial` | number | Hardware serial port number (default `1`). |
| `baud` | number | Bus speed (default `1000000`). |
| `rxPin`, `txPin` | number | ESP32 only: custom UART pins. |
```javascript Example — configure the bus
arduino.shoulder.configureBus({ serial: 1, baud: 1000000 });
arduino.shoulder.configureBus({ rxPin: 18, txPin: 19 }); // ESP32 custom pins
```
A **headless sketch** has the same control: `PardaloteBusServo.configureBus(rxPin, txPin, serialIndex = 1, baud = 0)` — call it in `setup()` before `attach()`. The rx/tx pins are ESP32-only (UNO R4 is fixed to Serial1 = D0/D1 and ignores them).
## attach()
Binds this instance to a servo ID on the bus. Call inside `on('ready')`.
`arduino.shoulder.attach(id, [series])`
| Parameter | Type | Description |
|---|---|---|
| `id` | number | Hardware servo ID, `1`–`253`. Servos ship as ID 1. |
| `series` | string | Optional. `'ST'` (0–4095, default) or `'SC'` (0–1023). |
```javascript Example — attach by servo ID
arduino.shoulder.attach(1);
arduino.wrist.attach(3, 'SC');
```
## detach()
Releases torque and unbinds the instance.
`arduino.shoulder.detach()`
## write()
Moves to a position in raw counts, with optional per-move speed and acceleration.
`arduino.shoulder.write(counts, [options])`
| Parameter | Type | Description |
|---|---|---|
| `counts` | number | Target position: ST `0`–`4095`, SC `0`–`1023`. |
| `options` | object | Optional. `{ speed, acc }` for this move. |
```javascript Example — position moves
arduino.shoulder.write(2048); // centre
arduino.shoulder.write(3000, { speed: 3000, acc: 50 });
```
## writeDegrees()
Degree convenience wrapper — accurate for ST (4096/360°), nominal for SC.
`arduino.shoulder.writeDegrees(degrees)`
## center()
Moves to the middle of the range (`resolution / 2`, i.e. 2048 for ST).
`arduino.shoulder.center()`
## setMoveDefaults()
Sets the default speed and acceleration used by plain `write()` and group `write()` / `writeTimed()`.
`arduino.shoulder.setMoveDefaults(speed, acc)`
## writeTimed()
Reaches a position in about a set time — the speed is picked from the move distance. What lets bus servos **arrive together** in a group.
`arduino.shoulder.writeTimed(counts, duration)`
| Parameter | Type | Description |
|---|---|---|
| `counts` | number | Target position in counts. |
| `duration` | number | Approximate arrival time in ms. |
## gesture()
Plays an authored **segment schedule** — an ordered list of moves the board runs back-to-back on its own clock. Values are in **counts**. A bus servo takes a *(position, speed)* target and runs its own move, so the board renders each segment's easing `curve` with a **streaming interpolator**: it samples the curve on a fixed clock and streams look-ahead setpoints (batched across a group into one phase-locked write), so the shape you author — overshoot included — is the shape the servo runs. Fires `done` when the last segment lands. Timing follows the shared gesture timing rules: segments start on schedule, and the last key is landed or given up on 2 s after its expected arrival. Coordinate several lanes with a group.
`arduino.shoulder.gesture(segments, [opts])`
Each segment carries a duration and a displacement expressed **either** relatively (`by`) **or** absolutely (`to`):
| Field | Type | Description |
|---|---|---|
| `dur` | number | Segment duration in ms — sizes the segment's speed. |
| `by` | number | **Relative** displacement in counts — the default frame. |
| `to` | number | **Absolute** target in counts — use in place of `by`. |
| `curve` | string | Easing shape for the segment — `linear` (default), `easeIn`, `easeOut`, `easeInOut`, `back`. Rendered on the board by the streaming interpolator. A segment shorter than ~600 ms plays **linear** regardless (too few stream ticks to render an easing shape cleanly; the difference is imperceptible at that duration). |
Relative by default (the board reads the live start position, then chains from each target). Absolute targets are clamped to the series range / `setLimits()`. Up to **12** segments. The board paces the stream to the servo's real position: if a segment's implied speed exceeds what the servo can deliver, the schedule holds until the hardware catches up rather than running ahead of it, so the timeline stays honest instead of drifting.
```javascript Example — reach out, ease back, small settle
arduino.shoulder.gesture([
{ by: 600, dur: 400 },
{ by: -600, dur: 600 },
{ by: 80, dur: 200 },
]);
await arduino.shoulder.gesture([ /* … */ ]).whenDone();
```
A direct `write()`, `runSpeed()`, or `setMode()` cancels a running gesture. To coordinate several actuators at once, see group.gesture().
## whenDone()
Promise for the most recent move — resolves `true` when the board reports the servo settled (its own `Moving` flag — real arrival, not a timer), `false` on the safety timeout (default `max(duration × 2, 10000)` ms; pass `{ timeout }` or a bare number to override, `0` to wait forever). The same method exists on servos, steppers, and groups.
`await arduino.shoulder.whenDone([{ timeout }])`
```javascript Example — sequence moves
await arduino.shoulder.writeTimed(3000, 1500).whenDone();
await arduino.shoulder.writeTimed(1000, 1500).whenDone();
```
## stop()
Halts the servo — holds the last-read position.
`arduino.shoulder.stop()`
## setMode() / runSpeed()
Wheel mode for continuous rotation — the same verb as the stepper's `runSpeed()`.
`arduino.shoulder.setMode(mode) · arduino.shoulder.runSpeed(speed)`
| Parameter | Type | Description |
|---|---|---|
| `mode` | string | `'wheel'` or `'position'`. |
| `speed` | number | Rotation speed; sign sets direction, `0` stops. |
```javascript Example — wheel mode
arduino.wheel.setMode('wheel');
arduino.wheel.runSpeed(2000);
arduino.wheel.runSpeed(0);
arduino.wheel.setMode('position');
```
## enableTorque() / disableTorque()
Hold position, or go limp. Disabling torque lets you move a joint by hand while `read()` streams the position — the basis of the teach-a-trajectory workflow in projects like LeRobot.
`arduino.shoulder.enableTorque() · arduino.shoulder.disableTorque()`
## read()
Starts polling feedback. ONE bus transaction per poll tick — however many browsers are connected — returns position, velocity, load, voltage, temperature, and (ST) current. The board runs the poll (per-browser interval and threshold) and only transmits changes of threshold+ counts (gated on position).
`arduino.shoulder.read([interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Poll interval in ms. Pass `END` to stop. |
| `threshold` | number | Optional. Minimum position change worth transmitting, in counts (`0` = default: `2` — the encoder noise floor). Also settable via `setReadInterval(ms)` / `setReadThreshold(counts)`. |
## setLimits() / clearLimits()
Soft position limits, enforced **on the Arduino** — every commanded position (browser write, sketch write, or group SyncWrite) is clamped to the range before it reaches the servo. Software-only by design: the servo's own EEPROM limit registers are never written. Same shape as `setLimits` on the stepper and PWM servo.
`arduino.shoulder.setLimits(min, max) · arduino.shoulder.clearLimits()`
| Parameter | Type | Description |
|---|---|---|
| `min`, `max` | number | Allowed position range in counts. |
## readFirmwareLimits()
The **servo's own** angle limits, stored in its EEPROM — separate from the software `setLimits()` above. The board reads them **once when the servo attaches** and caches them, so the cached value is usually already available as the `firmwareLimits` property (see below); it also replays on reconnect. Call `readFirmwareLimits()` only to force a fresh read — e.g. after changing the limits on the servo itself with the vendor tool.
Read-only here: Pardalote never writes these registers. By Feetech convention, `min` and `max` both `0` means the limits are **disabled** (multi-turn / continuous), reported as `enabled: false`.
`await arduino.shoulder.readFirmwareLimits()`
**Returns** `{ min, max, enabled }` (counts), or `null` if the servo didn't answer (unpowered or absent). `enabled` is `false` when the servo reports `min === 0 && max === 0`.
```javascript Example — read once, then use the cached value
await arduino.shoulder.readFirmwareLimits();
const fw = arduino.shoulder.firmwareLimits; // cheap cached read, no bus traffic
if (fw && fw.enabled) console.log(`firmware limits: ${fw.min}–${fw.max}`);
```
## setHome() / home()
`setHome(position)` declares the home position — no-arg means "the current position is home", which pairs naturally with torque-off hand-posing (pose the joint, `read()`, `setHome()`). `home()` moves there; `home(duration)` moves there smoothly. Default home is the centre of range (2048 ST / 512 SC). Same pair as the stepper and PWM servo.
`arduino.shoulder.setHome([position]) · arduino.shoulder.home([duration])`
```javascript Example — pose by hand, then make it home
arduino.shoulder.disableTorque();
// ...move the joint by hand...
arduino.shoulder.read();
arduino.shoulder.setHome().enableTorque();
await arduino.shoulder.home(1000).whenDone();
```
## calibrate()
Declares the servo's current physical position as centre. Move the joint to its zero pose by hand (torque off) first.
`arduino.shoulder.calibrate()`
## scan() / ping() / setId()
Bus utilities. Servos ship as ID 1 — to renumber, put a **single** servo on the bus and call `setId()`, one servo at a time.
`await arduino.shoulder.scan(from, to) · await arduino.shoulder.ping(id) · arduino.shoulder.setId(newId)`
| Function | Returns |
|---|---|
| `scan(from, to)` | Array of responding IDs in the range, e.g. `[1, 2, 3]`. |
| `ping(id)` | `true` / `false`. |
| `setId(newId)` | Renumbers the currently addressed servo. **One servo on the bus only** — with several servos sharing an ID, they'd all take the new one. |
## Properties and state
| Property | Description |
|---|---|
| `arduino.shoulder.position` | Raw counts — real feedback, needs polling. |
| `arduino.shoulder.target` | Last commanded goal — set immediately by `write()`. |
| `arduino.shoulder.hasTarget` | `true` while `target` is a live goal; `false` when the servo has no active target (freed, in wheel mode, or never commanded). Check this before trusting `target`. |
| `arduino.shoulder.positionDegrees` | Position converted to degrees. |
| `arduino.shoulder.velocity` | From feedback. |
| `arduino.shoulder.load` | From feedback. |
| `arduino.shoulder.voltage` | Volts. |
| `arduino.shoulder.temperature` | °C. |
| `arduino.shoulder.current` | Raw units (ST only). |
| `arduino.shoulder.firmwareLimits` | The servo's own EEPROM angle limits: `{ min, max, enabled }` or `null` (not yet read / no answer). Cached from the attach-time read — see `readFirmwareLimits()`. |
| `arduino.shoulder.present` | Is the servo answering? `true` = found, `false` = no response (check wiring / ID / baud), `null` = not yet attached. Seeded by the attach-time ping (the browser equivalent of the serial monitor's `[found]` / `[NO RESPONSE]` line), then kept **live from `read()` feedback** — a servo powered up or unplugged mid-session updates it. See the `'presence'` event. |
| `arduino.shoulder.isGesturing` | `true` while the board is playing a segment schedule on this servo (any origin). |
`target` vs `position`: `write(n)` sets `target` immediately (where you told it to go); `position` is real encoder feedback and only tracks toward `target` while you're polling. Unlike the stepper, bus-servo `target` is browser-side only — the servo has no board-replayed goal, so it isn't restored after a board reset.
`hasTarget` guards `target`: a position `write()` or `gesture()` makes it `true`; **disabling torque clears it** (a freed servo isn't holding a goal — you're hand-posing it), as does entering wheel mode. `target` keeps its last number for reference, but `hasTarget` tells you whether that number is still meaningful. Re-enabling torque does *not* set a new target — the servo holds where it is, and `hasTarget` stays `false` until you command a position again.
## Events
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ position, velocity, load, voltage, temperature }` | A polled reading changed by at least the threshold. |
| `'write'` | `{ position }` | A move is issued (including a gesture start — `position` is the predicted rest). |
| `'done'` | `{ position, arrived }` | A move or timed move settles (the servo's `Moving` flag cleared), or a gesture lands. `arrived` is `false` only for a gesture that gave up. |
| `'fwlimits'` | `{ min, max, enabled }` or `null` | The servo's firmware angle limits arrived — at attach, on reconnect, or after `readFirmwareLimits()`. |
| `'presence'` | `{ servoId, present }` | The servo's answering state changed. Fires on the attach-time ping, on reconnect, and whenever `read()` feedback shows the servo appearing or disappearing (`present: false` = no answer — wrong ID, wiring, baud, or unpowered). Needs `read()` polling active to track mid-session changes. |
| `'gesturestart'` / `'gestureend'` | `{}` | The board starts / ends playing a schedule on this servo — **any** origin (this browser, another, or the sketch). End = the last segment settles or a superseding write. |
Shorthand: `onChange(fn)`, `onWrite(fn)`, `onDone(fn)`, `onPresence(fn)`, `onGestureStart(fn)`, `onGestureEnd(fn)`. The `isGesturing` property tracks whether a schedule is playing (any origin).
`arduino.shoulder.getState()`
**Returns** `{ logicalId, servoId, series, attached, present, mode, torque, resolution, target, hasTarget, position, velocity, load, voltage, temperature, current, limits, firmwareLimits, home, interval }`.
See also: Groups · Bus servo example · Troubleshooting
---
# Groups
> Drive several actuators as one — a single message moves every member, and coordinated moves arrive together.
A **group** is a named collection of actuators you drive together, with methods that mirror the single actuators. `write()` writes every member in a **single WebSocket message**, `writeTimed()` coordinates a move so all members **arrive together**, `gesture()` plays coordinated expressive motion, and `whenDone()` awaits real completion. Groups currently take **Servo**, **BusServo**, and **Stepper** members (pins and NeoPixels are planned).
## arduino.group()
Creates a group from named members. Call inside `on('ready')`, after attaching each member.
`arduino.group(name, members)`
| Parameter | Type | Description |
|---|---|---|
| `name` | string | The group's name. The group also becomes available as `arduino[name]`. |
| `members` | object | Map of member names to extension instances. |
**Returns** the group object.
```javascript Example — build an arm group
arduino.add('shoulder', new BusServo());
arduino.add('elbow', new BusServo());
arduino.add('base', new Stepper());
arduino.add('wrist', new Servo());
arduino.on('ready', () => {
// ... attach each member ...
const arm = arduino.group('arm', {
shoulder: arduino.shoulder,
elbow: arduino.elbow,
base: arduino.base,
wrist: arduino.wrist,
});
});
```
## write()
Writes all named members at once — the group counterpart of `servo.write()` / `stepper.moveTo()`. Every member's frame is packed into **one** WebSocket message, so the board applies them back-to-back within a single receive — they move together. Bus servos of the same series are additionally coalesced into a single hardware **SyncWrite** packet (a truly simultaneous latch).
`arm.write(targets)`
| Parameter | Type | Description |
|---|---|---|
| `targets` | object | Member names mapped to target values, in each member's native unit (counts, steps, degrees). |
```javascript Example — write everyone at once
arm.write({ shoulder: 2048, elbow: 3000, base: 1600, wrist: 90 });
```
To write several actuators at once **without** holding a named group, use `arduino.write({ name: value, … })` — the same batched write through a transient group (it returns that group, so `whenDone()` chains). Hold a named group instead when you'll write the set repeatedly (a control loop, scrubbing) or need `read()` / `stop()` on it. Same pattern as `arduino.gesture()`.
## writeTimed()
Moves every member to its target so they all finish after about `duration` ms — the same shape as a single actuator's `writeTimed(value, duration)`. Each actuator type uses its own native mechanism:
| Member | How it arrives together |
|---|---|
| Bus servo | one SyncWrite with per-servo speeds matched to distance |
| PWM servo | on-board interpolation over the shared duration |
| Stepper | constant-speed move, the board sizing speed from its own position |
All of it still goes out in **one** batched message — including mixed groups.
`arm.writeTimed(targets, duration)`
| Parameter | Type | Description |
|---|---|---|
| `targets` | object | Member names mapped to target values. |
| `duration` | number | Approximate arrival time in ms (default 1000). |
```javascript Example — arrive together
arm.writeTimed({ shoulder: 3000, elbow: 1200, base: 0, wrist: 120 }, 1500);
```
`duration` is approximate — it's the *arrival synchronisation* that's exact. For an accurate first move from an unknown pose, either poll `read()` first or start from a known pose (`center()` / `write()`), since `writeTimed` measures distance from each member's last commanded position.
For a one-shot coordinated move without a named group, use `arduino.writeTimed({ name: value, … }, duration)` — the transient-group counterpart (returns the group for `whenDone()`).
## gesture()
Coordinated **expressive motion** — each member plays its own segment schedule, all pushed in **one batched message** and played on the board's own clock. Lanes are per-member, so overlapping timings give coordination and follow-through. Uneven lanes are padded with a trailing hold so every member still **arrives together** — the expressive counterpart of `writeTimed()`.
`arm.gesture(lanes, [opts])`
| Parameter | Type | Description |
|---|---|---|
| `lanes` | object | Member names mapped to segment arrays — each the same shape a single actuator's `gesture()` takes. |
| `opts` | object | Optional. `{ absolute }` forces the reference frame for all lanes. |
Each lane is relative (`by`) by default, or absolute (`to`) per lane. Lanes of unequal total duration are padded (a trailing hold) to the longest so they finish together. A lane naming a member that doesn't support `gesture()`, an unknown member, or an empty array is skipped with a warning; the rest still play.
To fire a multi-channel gesture **without** holding a named group, use `arduino.gesture(lanes)` — it does the same batching through a transient group. See the Gesture page for the segment format and all three forms.
```javascript Example — a coordinated reach with follow-through
arm.gesture({
shoulder: [{ by: 300, dur: 400, curve: 'easeOut' },
{ by:-300, dur: 600, curve: 'easeInOut' }], // 1000 ms
wrist: [{ by: 20, dur: 250, curve: 'back' }], // 250 ms → padded to 1000
});
await arm.gesture({ /* … */ }).whenDone();
```
Mixed groups work: servos, steppers, and bus servos in the same call each play via their own on-board mechanism, all coordinated on the board clock. See each actuator's `gesture()` for what a segment holds — servo, stepper, bus servo.
## whenDone()
Promise for the group's most recent `write()` / `writeTimed()` / `gesture()`. Resolves `true` when **every** moved member reports it actually **arrived** (each actuator's real `done` — feedback-confirmed, not a timer), or `false` on the safety timeout if a member never reports (dead servo, lost link). The same method exists on every single actuator.
`await arm.whenDone([{ timeout }])`
| Parameter | Type | Description |
|---|---|---|
| `timeout` | number | Safety timeout in ms (default `max(duration × 2, 10000)`; `0` waits forever). A bare number also works: `whenDone(5000)`. |
```javascript Example — sequence poses
await arm.writeTimed({ shoulder: 2048, elbow: 2048 }, 1000).whenDone();
await arm.writeTimed({ shoulder: 1000, elbow: 3000 }, 800).whenDone();
```
## read() / values()
Polling and snapshots across every member.
`arm.read([interval]) · arm.values()`
| Call | Effect |
|---|---|
| `arm.read(100)` | Start polling every member at 100 ms. |
| `arm.read()` | Snapshot of cached values → `{ shoulder, elbow, base, wrist }`. |
| `arm.read(END)` | Stop polling every member. |
| `arm.values()` | Same snapshot, no polling change. |
## home()
Sends every member to its stored home — servos and bus servos as (optionally timed) moves, steppers via their limit-switch homing routine (a routine has no duration). Not arrive-together: each member homes at its own pace. `whenDone()` resolves when every member settles; homing can be slow, so raise the timeout.
`arm.home([duration])`
```javascript Example — everything to home pose
await arm.home(1500).whenDone({ timeout: 30000 });
```
## stop() / getState()
`arm.stop() · arm.getState()`
`stop()` halts every member's motion — the group counterpart of `member.stop()` (to stop polling, use `read(END)`). `getState()` returns `{ name, members: { shoulder: {...}, base: {...}, ... } }`.
See also: Servo · Stepper · Bus servo · Coordinated motion example
---
# Gesture
> Author a timed motion — a segment schedule the board runs on its own clock. One channel, or many together in a single message.
A **gesture** is a **segment schedule**: a list of timed moves — each a target, a duration, and an easing curve — that the board plays on its **own clock** after one message. You send it once; the board sequences the segments locally and fires a real `done` when the last one finishes. Author it for **one** actuator with its own `gesture()`, or for **many at once** with `arduino.gesture()` — same segment format either way.
## Segments
Every gesture is an array of segments. Each segment is one leg of the motion:
| Field | Type | Description |
|---|---|---|
| `to` / `by` | number | Absolute target (`to`) or relative delta (`by`), in the actuator's native unit — servo counts, stepper steps, or degrees. |
| `dur` | number | Segment duration in ms. This **sizes the move's speed**; if the authored duration is faster than the actuator can go, the segment simply takes longer and the next still fires on true arrival. |
| `curve` | string | Easing shape: `linear`, `easeIn`, `easeOut`, `easeInOut`, or `back` (a slight overshoot). |
A gesture is **relative** by default — each `by` segment chains from wherever the actuator is when it starts, so the same gesture replays from any pose. Use `to` for absolute targets, or force it for a whole gesture with `{ absolute: true }`. Absolute targets are clamped to the actuator's soft limits.
## Relative or absolute?
Both frames play through the same engine — the difference is what a value *means*, and it changes how a gesture replays, scales, and crops. Choose per gesture.
**Relative (`by`) — expressive motion you replay and reshape.** A `by` segment *adds* a delta to wherever the actuator already is, so:
- The same gesture runs correctly **from any pose**, and a net-zero sequence returns home. It **layers on top of** other positioning (a bias, a current pose) rather than overriding it.
- The modifiers need no anchor to reason about: [`scale`](#shaping-a-gesture) multiplies the deltas directly (around the implicit home), and `crop` slices faithfully (a clipped segment keeps the right fraction of its eased travel).
- It's portable — you don't need to know absolute positions or a home.
- Reach for it for a **vocabulary of reusable moves** — nods, wiggles, glances — that should read the same wherever the actuator happens to be, and that you want to scale or crop.
**Absolute (`to`) — go to exact positions.** A `to` segment drives to a specific target regardless of the starting pose, so:
- The **end pose is definite** and every target is clamped to the soft limits. An absolute gesture **overrides** where the actuator was rather than adding to it.
- It's **what-you-author-is-what-you-get** when the positions are captured against real hardware (e.g. a visual keyframe tool) — no home or reference-frame to get right.
- The modifiers work here too, with one wrinkle: they reckon from the gesture's **starting target** (`origin`, the first `to`) rather than a home. `scale` pivots on it — `origin + (to − origin) × k`, growing or shrinking the excursions *from the opening pose* (the opening move itself isn't scaled). `crop` slices absolute targets by the same curve-fraction maths as relative — a clipped target lands at its eased mid-position — except a cut *inside the very first segment* holds at the origin, since the pose before it isn't known.
- Reach for it when you need the actuator **at** a particular place — a definite rest/end pose, or a mechanism that must hit exact points — or when you're authoring positions visually and want them played back verbatim.
Rule of thumb: **relative** for reusable, layer-on-top, fully-reshapeable expression; **absolute** for exact, deterministic positioning.
## One actuator — `gesture()`
Play a schedule on a single actuator. The segment shape and any on-hardware notes are per actuator — see servo, bus servo, and stepper.
`servo.gesture(segments, [opts])`
```javascript Example — one actuator
grip.gesture([
{ by: 600, dur: 400, curve: 'easeOut' },
{ by: -600, dur: 600, curve: 'easeInOut' },
]);
await grip.gesture([ /* … */ ]).whenDone();
```
## Many at once — `arduino.gesture()`
Play a **multi-channel** gesture in **one batched message** — every channel starts together, and shorter lanes are padded with a trailing hold so they all **arrive together**. Lanes are per-channel, so overlapping timings give coordination and follow-through. This is the expressive counterpart of `writeTimed()`, and the multi-actuator counterpart of a single `gesture()`.
`arduino.gesture(lanes, [opts])`
| Parameter | Type | Description |
|---|---|---|
| `lanes` | object | Actuator names (the names you passed to `add()`) mapped to segment arrays — each the same shape a single actuator's `gesture()` takes. |
| `opts` | object | Optional. `{ absolute }` forces the reference frame for **all** lanes; `{ scale, speed, crop }` reshape the motion — see [Shaping a gesture](#shaping-a-gesture). |
It runs through a **transient, anonymous group** — nothing is stored on the `arduino` — and **returns that group**, so `whenDone()` and `stop()` chain. A lane naming an actuator that doesn't support `gesture()`, an unknown name, or an empty array is skipped with a warning; the rest still play. **Mixed types work**: servos, steppers, and bus servos in one call each play via their own on-board mechanism, all coordinated on the board clock.
```javascript Example — a coordinated reach with follow-through
arduino.add('shoulder', new BusServo());
arduino.add('wrist', new Servo());
arduino.on('ready', () => {
arduino.shoulder.attach(1, 'ST');
arduino.wrist.attach(9);
arduino.gesture({
shoulder: [{ by: 300, dur: 400, curve: 'easeOut' },
{ by:-300, dur: 600, curve: 'easeInOut' }], // 1000 ms
wrist: [{ to: 120, dur: 250, curve: 'back' }], // 250 ms → padded to 1000
});
});
await arduino.gesture({ /* … */ }).whenDone();
```
For a **single** actuator, prefer its own `gesture([ … ])` — there's nothing to coordinate.
## On a held group — `group.gesture()`
If you're already holding a group to drive a set of actuators live — `write()`, `writeTimed()`, `read()`, `stop()` — then `group.gesture(lanes)` does exactly the same thing on its members. Reach for `arduino.gesture()` when you just want to fire a gesture once; reach for a named group when you also need the live-control methods on the same set.
## Shaping a gesture
Any gesture can be reshaped as it plays, without editing its segments — so one authored motion can read gentle or emphatic, fast or slow, or play just a slice of itself.
| Transform | Effect |
|---|---|
| `scale` | Amplitude. Relative `by` is multiplied; an absolute `to` scales around the gesture's **starting target** — `origin + (to − origin) × k`, where `origin` is the lane's first `to` — so the start pose is kept and only the excursions from it grow or shrink. `1` = unchanged. |
| `speed` | Tempo. `2` = twice as fast, `0.5` = half. Amplitude and curves are untouched. |
| `crop` | `[from, to]` — play only that fraction (`0`–`1`) of the timeline; boundary segments are split. **Breaks the round-trip:** a relative gesture no longer returns home, so send it home afterwards. |
Pass them as **opts** on any `gesture()` call:
```javascript Example — opts
head.gesture(lanes, { scale: 0.7, speed: 1.5 });
grip.gesture(reachSegs, { crop: [0, 0.5] }); // just the first half
head.gesture(lanes, { scale: { antL: 1.3, antR: 1.3 } }); // per-lane amplitude
```
…or build a reusable, chainable **Gesture** with `arduino.makeGesture()` and hand it to any `gesture()`:
`arduino.makeGesture(spec) → Gesture`
```javascript Example — a reusable, tunable gesture
const nod = arduino.makeGesture({ tilt: nodSegs, antL: earSegs, antR: earSegs });
await head.gesture(nod.scale(0.7).speed(0.5).crop(0.2, 0.8)).whenDone();
head.gesture(nod.scale(1.2).speed(1.5)); // a fresh variant — `nod` itself is unchanged
nod.cropped; // true when the crop window would end off-home
nod.duration(); // total ms after the current mods
```
A `Gesture` is **immutable**: every `.scale()` / `.speed()` / `.crop()` returns a new one, so a base gesture is safe to keep and vary. `scale` and `speed` compose (multiply) across calls; a later `crop` replaces an earlier window.
**Coordination.** In a multi-lane gesture, `speed` and `crop` act on the whole thing — one factor, one window on the shared timeline — so the lanes stay phase-locked. `scale` may differ per lane (a `{ name: k }` map), since amplitude never touches timing.
The transforms are applied in the browser and sent as an ordinary gesture frame — nothing new crosses the wire, and the board plays the reshaped schedule like any other. A sketch can reshape its own gestures the same way — see Board-authored gestures.
## Timing — gestures keep to their schedule
Every actuator type plays a gesture the same way: **timing wins**.
- **Segments start on schedule.** Each segment starts when the previous one's authored time is up. Nothing waits for an actuator to arrive, or for another lane.
- **Each segment starts from where the actuator actually is.** If a motor fell short of a key, the next segment covers the extra distance in its own time, so it catches up as it goes.
- **Keys come from what you authored.** Absolute keys are the values you wrote. In a relative gesture, each key is the previous *intended* key plus its delta (only the first is relative to the live position), so a shortfall never becomes a lasting offset.
- **The last key is landed.** When the final segment's time is up, the actuator keeps going until it reaches the final target. If it still hasn't arrived **2 seconds** after its expected arrival, it gives up and holds where it got to.
So a gesture an actuator can keep up with plays exactly as authored. One that asks for more speed than the hardware has stays on time but cuts corners: peaks get clipped and fast back-and-forth shakes get smaller. Lanes in a multi-lane gesture stay aligned because they share one clock. A struggling or stuck actuator never holds the others up.
## Awaiting completion — `whenDone()`
Every form returns something you can await. `whenDone()` resolves `true` when **every** channel reports it actually **arrived** (each actuator's feedback-confirmed `done`, not a timer). It resolves `false` if any channel didn't: its landing gave up 2 seconds after the expected arrival (it's holding short of the target), or a safety timeout fired because a channel never reported (dead servo, lost link). Each actuator's `done` event also says which: `{ …, arrived: true | false }`.
`await arduino.gesture({ … }).whenDone([{ timeout }])`
```javascript Example — play, then continue
await arduino.gesture({ shoulder: reachSegs, wrist: flickSegs }).whenDone();
console.log('pose reached');
```
## From the sketch — the board authors gestures too
Gestures aren't only a browser thing. Because a gesture plays entirely **on the board**, the **sketch** can compose and fire one with no browser present — the same segment model, the same on-board player. `PardaloteServo.gesture(id, segs, count)` plays one actuator; `Pardalote.gesture().add(…).play()` coordinates several; `onGestureDone(id, cb)` chains one into the next for a headless sequence. A board-authored gesture broadcasts its **existence** to connected browsers — each sees the actuator's `isGesturing` flag and a `gesturestart` / `gestureend` event — so a browser can show "busy" without ever receiving the schedule. See Board-authored gestures.
## Under the hood
On the wire a gesture is already **multi-channel**: one `CMD_*_GESTURE` frame carries a block per channel, and the board runs each block on its own clock. `arduino.gesture()` and `group.gesture()` produce byte-identical frames — "group" is purely a JavaScript convenience for coordinating and naming a set. A running gesture also broadcasts a lightweight `CMD_*_GESTURE_STATE [id, active]` (existence, never the schedule), which drives the `isGesturing` flag and is replayed on reconnect. See the Protocol page for the frame layout.
See also: Servo · Bus servo · Stepper · Groups · Coordinated motion example
---
# NeoPixel
> Up to 4 addressable LED strips — buffered pixel changes, brightness control, and throttled updates for smooth animation.
Requires the `Adafruit NeoPixel` library and `` in the sketch. Examples assume:
```javascript
arduino.add('strip', new NeoPixel());
```
## init()
Initialises the strip. Call inside `on('ready')`.
`arduino.strip.init(pin, numPixels, [type])`
| Parameter | Type | Description |
|---|---|---|
| `pin` | number \| string | Data pin the strip is wired to. |
| `numPixels` | number | Number of LEDs on the strip. |
| `type` | constant | Optional. Colour order + speed, e.g. `NEO_GRB + NEO_KHZ800` (the default, right for most WS2812B strips). |
```javascript Example — init two strips
arduino.strip.init(6, 30); // pin 6, 30 pixels
arduino.strip.init(7, 16, NEO_GRB + NEO_KHZ800);
```
## setPixelColor()
Sets one pixel in the local buffer. Nothing reaches the LEDs until `show()`.
`arduino.strip.setPixelColor(index, r, g, b, [w])`
| Parameter | Type | Description |
|---|---|---|
| `index` | number | Pixel index, `0`-based. |
| `r`, `g`, `b` | number | Colour components, `0`–`255`. |
| `w` | number | Optional white channel (RGBW strips). |
Also accepts a packed colour: `setPixelColor(index, color)`.
## Color()
Packs r, g, b into a single 32-bit colour value.
`arduino.strip.Color(r, g, b)`
```javascript Example — pack a colour
let red = arduino.strip.Color(255, 0, 0);
arduino.strip.setPixelColor(0, red);
```
## fill()
Fills the whole strip, or a range, with one colour (buffered).
`arduino.strip.fill(color, [first], [count])`
| Parameter | Type | Description |
|---|---|---|
| `color` | number | Packed colour from `Color()`. |
| `first` | number | Optional. First pixel to fill. |
| `count` | number | Optional. Number of pixels to fill. |
```javascript Example — fill a range
arduino.strip.fill(red); // entire strip
arduino.strip.fill(red, 0, 10); // pixels 0–9
```
## clear()
Sets all pixels to off (buffered).
`arduino.strip.clear()`
## setBrightness()
Global brightness scale.
`arduino.strip.setBrightness(level)`
| Parameter | Type | Description |
|---|---|---|
| `level` | number | `0`–`255`. Default `255` — strips vary, start lower. |
## show()
Sends the buffered changes to the LEDs. Debounced: rapid draw-loop calls coalesce into a single send (the latest pending state wins).
`arduino.strip.show()`
## getPixelColor() / numPixels()
Reading back the local buffer.
`arduino.strip.getPixelColor(index) · arduino.strip.numPixels()`
**Returns** a 32-bit packed colour / the pixel count.
## setWriteThreshold()
Pixel changes below the colour distance threshold are ignored — useful for animation loops that might send identical values.
`arduino.strip.setWriteThreshold(distance)`
| Parameter | Type | Description |
|---|---|---|
| `distance` | number | Minimum colour distance to count as a change. Default `5`. |
## setWriteThrottle()
Minimum time between `show()` flushes. Default 20 ms (~50 Hz max). Raise it if you see queue-buildup lag on a slow link; set `0` to disable debouncing.
`arduino.strip.setWriteThrottle(ms)`
```javascript Example — tune the flush rate
arduino.strip.setWriteThrottle(50); // gentler on the UNO R4's WiFi
arduino.strip.setWriteThrottle(0); // every show() flushes
```
## Type constants
```javascript
// Colour order
NEO_RGB NEO_RBG NEO_GRB NEO_GBR NEO_BRG NEO_BGR
// Speed
NEO_KHZ800 // most strips (default)
NEO_KHZ400 // older strips
// Combine:
arduino.strip.init(6, 30, NEO_GRB + NEO_KHZ800);
```
`arduino.strip.getState()`
**Returns** a snapshot of all strip state.
See also: NeoPixel example · Troubleshooting
---
# Ultrasonic
> Up to 4 HC-SR04-style distance sensors, in 3-wire or 4-wire configurations, with polled reads in centimetres or inches.
Requires `` in the sketch. No third-party library needed. Examples assume:
```javascript
arduino.add('sonar', new Ultrasonic());
```
## attach()
Attaches the sensor. Call inside `on('ready')`.
`arduino.sonar.attach(trig, [echo])`
| Parameter | Type | Description |
|---|---|---|
| `trig` | number \| string | Trigger pin. For 3-wire sensors this is also the echo pin. |
| `echo` | number \| string | Optional. Echo pin (4-wire sensors). |
```javascript Example — 3-wire and 4-wire
arduino.sonar.attach(7, 8); // 4-wire: trig 7, echo 8
arduino.sonar.attach(6); // 3-wire: trig and echo on one pin
```
## detach()
Releases the pins.
`arduino.sonar.detach()`
## setTimeout()
How long the Arduino waits for an echo before giving up. Maximum range follows from it: 30 ms ≈ 500 cm.
`arduino.sonar.setTimeout(ms)`
| Parameter | Type | Description |
|---|---|---|
| `ms` | number | Echo timeout in ms. Default `30`. |
## read()
Reads the distance. Same poll-and-cache pattern as `analogRead()`: the first call starts a poll, later calls return the cached value — safe to call every frame. The board runs the poll (one measurement per interval, however many browsers are connected) and only transmits readings that changed **meaningfully** — by at least the threshold.
`arduino.sonar.read([interval], [unit], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Poll interval in ms. Pass `END` to stop. |
| `unit` | constant | Optional. `CM` or `INCH` — applies to all subsequent reads. |
| `threshold` | number | Optional. Minimum change worth transmitting, in the sensor's units (`0` = default: `0.3` — the HC-SR04's noise floor). Per browser: other pages keep their own settings. |
**Returns** the cached distance, or `-1` if the echo timed out (nothing in range).
```javascript Example — distance in a draw loop
arduino.sonar.read(200, CM); // start polling every 200 ms, in cm
arduino.sonar.read(200, CM, 0.5); // …only reporting changes of 0.5+ cm
function draw() {
let cm = arduino.sonar.read(); // cached — no extra traffic
if (cm === -1) { /* out of range */ }
}
```
Point the sensor at flat, hard surfaces — fabric and foam absorb ultrasound and can read as "nothing there".
## setReadInterval() / setReadThreshold()
Set the poll settings directly — applied immediately if polling, stored for the next `read()` otherwise.
`arduino.sonar.setReadInterval(ms) · arduino.sonar.setReadThreshold(units)`
## Events
`arduino.sonar.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ distance, unit }` | The distance changed by at least the threshold. `unit` is `CM` or `INCH`. |
Shorthand: `onChange(fn)`.
## Properties and state
| Property | Description |
|---|---|
| `arduino.sonar.distance` | Last reading. |
`arduino.sonar.getState()`
**Returns** `{ trigPin, echoPin, attached, timeoutMs, distance, unit, interval, threshold }`.
See also: Ultrasonic example · Troubleshooting
---
# Rotary encoder
> Quadrature encoders — KY-040 knobs and motor shaft encoders, counted in interrupts on the board.
Quadrature encoders can't be read by polling: direction lives in the *order* the two pins change, and a hand-flicked knob produces ~1,600 edges a second. The board counts them in interrupt handlers with a 4x state-table decoder — invalid transitions (contact bounce) sum to zero, so the count never drifts and no debounce tuning is needed. The browser receives the **absolute position**, rate-limited per browser.
Positions are **raw quadrature steps** — a KY-040 detent is 4 steps. Use `setStepsPerDetent()` to scale the `detents` convenience.
## attach()
`arduino.knob.attach(pinA, pinB)`
| Parameter | Type | Description |
|---|---|---|
| `pinA`, `pinB` | number \| string | The two quadrature signal pins (CLK/DT on a KY-040). The board enables pullups and attaches CHANGE interrupts on both. |
```javascript
arduino.add('knob', new Encoder());
arduino.on('ready', () => {
arduino.knob.attach(D2, D3);
arduino.knob.read(50); // stream, at most every 50 ms
});
```
**The push button** on a KY-040 is not part of this extension — it's just a switch. Wire it to any pin and use a pin handle, which delivers debounced edges instantly:
```javascript
arduino.pin(SW).on('change', ({ value }) => { if (!value) togglePlayback(); });
```
## read()
Streams the position. The board counts continuously either way (interrupts); `interval` is purely a per-browser rate limit, and because position is absolute the latest value always carries the full state — a fast spin can't overflow the channel or lose track.
`arduino.knob.read([interval], [threshold])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Minimum ms between updates to this browser (default `200`). Pass `END` to stop. |
| `threshold` | number | Optional. Minimum change worth transmitting, in steps (`0` = default `1`; use `4` for one KY-040 detent per update). Also settable via `setReadInterval(ms)` / `setReadThreshold(steps)`. |
**Returns** the cached position (raw steps).
## setPosition() / zero()
Re-declares the current physical position. The board sets its counter and echoes the new position to **every** browser, so all mirrors adopt the new frame together.
`arduino.knob.setPosition(value) · arduino.knob.zero()`
## Events
`arduino.knob.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ position, delta, detents }` | The position changed by at least the threshold. `delta` is the movement since the last event this browser saw (sign = direction). |
Shorthand: `onChange(fn)`.
```javascript Example — a volume knob
arduino.knob.on('change', ({ delta }) => {
volume = constrain(volume + delta, 0, 100);
});
```
## Properties and state
| Property | Description |
|---|---|
| `arduino.knob.position` | Current position in raw steps (draw()-loop safe). |
| `arduino.knob.detents` | Position scaled by `stepsPerDetent` (default 4). |
`arduino.knob.setStepsPerDetent(n) · arduino.knob.getState()`
## From the sketch
Create an encoder in the sketch and every browser sees it as `arduino.` automatically — same pattern as the other extensions:
```cpp
#include
#include
int knob;
void setup() {
Pardalote.begin();
knob = PardaloteEncoder.attach("knob", 2, 3); // name, pinA, pinB
}
void loop() {
Pardalote.run();
long pos = PardaloteEncoder.read(knob); // raw steps, from the ISR counter
// PardaloteEncoder.zero(knob); // re-zero (echoed to browsers)
}
```
Up to 4 encoders (`MAX_ENCODERS`). Motor shaft encoders up to ~10–20 kHz edge rates are fine; beyond that (high-resolution encoders on fast spindles), a hardware pulse-counter backend would be the next step — ask if you need it.
See also: Pins and reading · Extensions overview
---
# IMU
> Up to 2 inertial measurement units — accelerometer, gyroscope and temperature at up to 50 Hz, with on-board calibration. No third-party library required.
Requires `` in the sketch. One extension covers multiple InvenSense MPU and STMicroelectronics LSM6 families — it reads sensor registers directly over I2C.
## Supported sensors
| Model string | Chip | DOF | Default address |
|---|---|---|---|
| `'6050'` | InvenSense MPU-6050 | 6 | 0x68 |
| `'6500'` | InvenSense MPU-6500 | 6 | 0x68 |
| `'9250'` | InvenSense MPU-9250 | 9 | 0x68 |
| `'9255'` | InvenSense MPU-9255 | 9 | 0x68 |
| `'LSM6DS3'` | STMicro LSM6DS3 | 6 | 0x6A |
| `'LSM6DSOX'` | STMicro LSM6DSOX | 6 | 0x6A |
## new IMU()
The constructor takes the model string.
`arduino.add('imu', new IMU(model))`
| Parameter | Type | Description |
|---|---|---|
| `model` | string | One of the model strings above. Wrong model → `WHO_AM_I mismatch` in the Serial Monitor. |
```javascript Example — choose the model
arduino.add('imu', new IMU('6050'));
```
## attach()
Connects to the sensor over I2C. Call inside `on('ready')`.
`arduino.imu.attach([address], [sda], [scl])`
| Parameter | Type | Description |
|---|---|---|
| `address` | number | Optional. I2C address — `0x68` default, `0x69` with AD0 high (LSM6: `0x6A`/`0x6B`). |
| `sda`, `scl` | number \| string | Optional. Custom I2C pins (ESP32 only). |
```javascript Example — attach over I2C
arduino.imu.attach(0x68);
arduino.imu.attach(0x68, 21, 22); // ESP32 custom SDA/SCL
```
## read()
Starts polling the sensor. Handlers receive **every** sample — there is deliberately no threshold on the IMU: its streams are usually integrated or smoothed in JS, where dropped samples cause drift. The board runs the poll: one I2C transaction per tick, each browser at its own rate (`setReadInterval(ms)` also works).
`arduino.imu.read([interval])`
| Parameter | Type | Description |
|---|---|---|
| `interval` | number | Optional. Poll interval in ms. Pass `END` to stop. |
```javascript Example — stream at 50 Hz
arduino.imu.onChange(({ accel, gyro, temp }) => {
// accel: { x, y, z } in g
// gyro: { x, y, z } in °/s
// temp: °C
});
arduino.imu.read(20); // 50 Hz
```
The original ESP32 chip's I²C peripheral can stall under sustained high-rate reads — on ESP32-WROVER and other older ESP32 boards keep the interval at 50 ms or higher. Newer ESP32 boards (S3, C3, …) and the UNO R4 poll reliably at 20 ms.
## setAccelRange() / setGyroRange()
Measurement ranges. Higher range handles bigger motion but reduces resolution. Unknown values are rejected with a console warning; the current range is left unchanged.
`arduino.imu.setAccelRange(g) · arduino.imu.setGyroRange(dps)`
| Parameter | Type | Description |
|---|---|---|
| `g` | number | Accel range as ±g: `2` (default), `4`, `8`, or `16`. |
| `dps` | number | Gyro range as ±°/s: `250` (default), `500`, `1000`, or `2000`. |
## calibrate()
Measures and applies zero offsets. Place the sensor flat with Z pointing up first. Offsets are stored **on the Arduino** and re-sent to any browser that reconnects — no need to recalibrate after a page reload.
`arduino.imu.calibrate(samples)`
| Parameter | Type | Description |
|---|---|---|
| `samples` | number | Number of samples to average, e.g. `200` (≈ 400 ms). |
```javascript Example — calibrate flat
arduino.imu.calibrate(200);
arduino.imu.onCalibrate(offsets => {
// { ax, ay, az, gx, gy, gz } — applied automatically
// afterwards: accel.z ≈ +1g, gyro ≈ 0
});
```
## Events
`arduino.imu.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'change'` | `{ accel, gyro, temp }` | A sample arrives (every sample — no threshold on the IMU). |
| `'calibrate'` | `{ ax, ay, az, gx, gy, gz }` | Calibration completes. |
Shorthand: `onChange(fn)`, `onCalibrate(fn)`.
## State snapshot
`arduino.imu.getState()`
**Returns** `{ logicalId, model, dof, address, attached, accelRange, gyroRange, accel, gyro, temp, calibrated, calibration }`.
See also: IMU example · Troubleshooting
---
# Camera
> MJPEG video and JPEG snapshots from ESP32 camera boards, served over HTTP so video never competes with control messages.
The camera extension streams MJPEG video and serves JPEG snapshots over a **separate HTTP server** — video never flows through the WebSocket, so it doesn't compete with control messages.
Requires no third-party library beyond the ESP32 Arduino core (which includes `esp_camera` and `esp_http_server`). PSRAM must be present **and enabled in the build** — on the Seeed XIAO ESP32S3 that means **Tools → PSRAM → `OPI PSRAM`**. Without it the camera falls back to a single QQVGA buffer in internal RAM (poor image, `setResolution()` triggers `cam_hal: FB-OVF`); see Troubleshooting.
## Arduino setup
Define your board model **before** including the camera header:
```cpp sketch.ino — camera setup
#define CAMERA_MODEL_WROVER_KIT
#include
```
Board define names match the ESP CameraWebServer example:
| Board | Define |
|---|---|
| Freenove ESP32-WROVER-DEV | `CAMERA_MODEL_WROVER_KIT` |
| AI-Thinker ESP32-CAM | `CAMERA_MODEL_AI_THINKER` |
| Seeed Studio XIAO ESP32S3 Sense | `CAMERA_MODEL_XIAO_ESP32S3` |
| Espressif ESP32-S3-EYE | `CAMERA_MODEL_ESP32S3_EYE` |
| Freenove ESP32-S3-WROOM CAM | `CAMERA_MODEL_ESP32S3_EYE` (same camera pins) |
| Espressif ESP-EYE | `CAMERA_MODEL_ESP_EYE` |
| M5Stack PSRAM | `CAMERA_MODEL_M5STACK_PSRAM` |
| M5Stack V2 PSRAM | `CAMERA_MODEL_M5STACK_V2_PSRAM` |
| M5Stack Wide | `CAMERA_MODEL_M5STACK_WIDE` |
| M5Stack ESP32CAM | `CAMERA_MODEL_M5STACK_ESP32CAM` |
| M5Stack UnitCam | `CAMERA_MODEL_M5STACK_UNITCAM` |
| M5Stack CamS3 Unit | `CAMERA_MODEL_M5STACK_CAMS3_UNIT` |
| TTGO T-Journal | `CAMERA_MODEL_TTGO_T_JOURNAL` |
| ESP32-CAM Board | `CAMERA_MODEL_ESP32_CAM_BOARD` |
| ESP32-S3 CAM LCD | `CAMERA_MODEL_ESP32S3_CAM_LCD` |
| ESP32-S2 CAM Board | `CAMERA_MODEL_ESP32S2_CAM_BOARD` |
| DFRobot FireBeetle 2 ESP32-S3 | `CAMERA_MODEL_DFRobot_FireBeetle2_ESP32S3` |
| DFRobot Romeo ESP32-S3 | `CAMERA_MODEL_DFRobot_Romeo_ESP32S3` |
**Freenove ESP32-S3-WROOM CAM:** set Tools → PSRAM → `OPI PSRAM`. The board has two USB-C ports — on the one marked **UART**, set Tools → USB CDC On Boot → **Disabled**; on the one marked **USB**, set it to **Enabled** (the wrong setting shows only the boot banner in the Serial Monitor, with no WiFi `w` menu). Some of these boards ship a GC0308 camera, which can't make JPEGs in hardware: Pardalote encodes its frames in software instead (Serial prints `No hardware JPEG — encoding in software`), so keep it at `FRAMESIZE_QVGA` or `FRAMESIZE_HVGA` for a usable frame rate.
Examples below assume:
```javascript
arduino.add('cam', new Camera());
```
## attach()
Starts the camera HTTP server. Call inside `on('ready')`.
`arduino.cam.attach(port)`
| Parameter | Type | Description |
|---|---|---|
| `port` | number | HTTP port for the camera server, e.g. `82`. |
```javascript Example — start the stream
arduino.on('ready', () => {
arduino.cam.attach(82);
});
arduino.cam.on('stream', ({ url, element }) => {
// element is a live
— append to DOM or use url with p5.js createImg()
document.body.appendChild(element);
});
```
## setResolution()
Frame size, using constants that match the ESP32 camera `framesize_t` enum.
`arduino.cam.setResolution(framesize)`
| Constant | Resolution |
|---|---|
| `FRAMESIZE_QQVGA` | 160×120 |
| `FRAMESIZE_QVGA` | 320×240 (default) |
| `FRAMESIZE_HVGA` | 480×320 |
| `FRAMESIZE_VGA` | 640×480 |
| `FRAMESIZE_SVGA` | 800×600 |
| `FRAMESIZE_HD` | 1280×720 |
Call it before or after `attach()` — either way the chosen size is applied once the stream starts, and it persists across reconnects.
With PSRAM, the board sets aside room for frames up to 1600×1200 when the camera starts (about 770 KB of PSRAM), so switching to a bigger size while streaming, or a very detailed scene at high quality, still fits. The exception is a sensor without hardware JPEG (such as the GC0308): its frame size is fixed when the camera starts, so a new size takes effect on the next `attach()` (for example after a page reload), not mid-stream.
`FRAMESIZE_HD` streams on the XIAO ESP32S3's OV2640, including when you switch to it mid-stream. The largest sizes need PSRAM and push the sensor hard. If a size drops frames (`cam_hal: FB-OVF` in the Serial Monitor), first update the Pardalote Arduino library — older versions left too little buffer room for large frames — then step down a size. See Troubleshooting.
## setQuality()
JPEG compression level.
`arduino.cam.setQuality(quality)`
| Parameter | Type | Description |
|---|---|---|
| `quality` | number | `0` = best image / highest bandwidth, `63` = worst / lowest. Default `12` — a good streaming balance. |
## setFrameRate()
Sets a maximum frame rate for the stream.
`arduino.cam.setFrameRate(fps)`
| Parameter | Type | Description |
|---|---|---|
| `fps` | number | Maximum frames per second, `1`–`60`. `0` removes the limit. By default there's no fixed limit. |
You usually don't need it: the board paces the stream to suit the network. After sending each frame, it pauses for half the time that frame took to send. On a slow or busy link, such as a phone hotspot, this leaves headroom, so a lost packet doesn't freeze the video while it's resent. On a fast network, frames send quickly and the rate rises towards what the camera can capture. Use `setFrameRate()` when you want a fixed rate, for example to save power or leave more room for other traffic.
While a stream is running, the Serial Monitor prints what the board achieved every 10 seconds, for example `[Camera] 8.4 fps, 25 KB/frame, 80 ms to send (640x480)`. Call `setFrameRate()` before or after `attach()`. The setting persists across reconnects.
## snapshot()
Fetches a single JPEG still over HTTP.
`await arduino.cam.snapshot()`
**Returns** a `blob:` URL — use as an image src, revoke with `URL.revokeObjectURL(url)` when done. Also fires the `'snapshot'` event with `{ url, blob }`.
## detach() / getElement()
`arduino.cam.detach() · arduino.cam.getElement()`
`detach()` clears the local stream reference (the HTTP server keeps running on the board). `getElement()` returns the live `
` element.
## Events
`arduino.cam.on(event, handler)`
| Event | Payload | Fires when |
|---|---|---|
| `'stream'` | `{ url, element }` | The stream is confirmed live. |
| `'snapshot'` | `{ url, blob }` | A still is received. |
| `'error'` | `{ message }` | A fetch failed. |
Shorthand: `onStream(fn)`, `onSnapshot(fn)`, `onError(fn)`.
## p5.js integration
```javascript sketch.js — draw the stream to a p5.js canvas
let camEl;
arduino.cam.on('stream', ({ url }) => {
if (camEl) camEl.remove();
camEl = createImg(url, ''); // wrap in p5.Element so image() accepts it
camEl.hide(); // keep it out of the DOM layout
});
function draw() {
if (camEl) {
camEl.width = camEl.elt.naturalWidth; // p5 records the size of the first frame only;
camEl.height = camEl.elt.naturalHeight; // keep it current across resolution changes
image(camEl, 0, 0, width, height); // draw MJPEG frame to canvas
loadPixels(); // pixels[] available for manipulation
}
}
```
`loadPixels()` works because `PardaloteCamera.h` sets `Access-Control-Allow-Origin: *` on both HTTP endpoints automatically.
## HTTP endpoints
| Endpoint | Description |
|---|---|
| `http://:/stream` | MJPEG stream |
| `http://:/snapshot` | Single JPEG, answered even while a stream is running. |
`snapshot()` builds these URLs for you. Snapshots have a web server of their own on the next port (`attach(82)` → `83`; if that's the WebSocket's `81`, the one after), because the board's stream server is busy for the whole time a stream is open — a snapshot on the stream's port would wait until the stream closed. `/snapshot` also still answers on the stream port, for older versions of the JS library, but only while no stream is running.
## State snapshot
`arduino.cam.getState()`
**Returns** `{ logicalId, port, streamUrl, snapshotUrl, framesize, quality, frameRate }`. `frameRate` is `null` until `setFrameRate()` is called.
## Limitations
Only one browser can receive the MJPEG stream at a time. This is a fundamental limitation of the ESP32 camera driver — `esp_camera_fb_get()` is single-consumer by design. A second browser connecting will see a black screen and only receives the feed once the first disconnects. This behaviour is identical to Espressif's own CameraWebServer example.
See also: Camera example
---
# Protocol
> The compact binary WebSocket protocol underneath everything — for the curious, and for anyone building their own extension.
## Frame format
Pardalote uses a compact binary WebSocket protocol. Each frame is:
```
Byte 0 CMD — command code
Bytes 1–2 TARGET — pin number or extension device ID
Byte 3 NPARAMS — number of parameters
Bytes 4–5 TYPE_MASK — bit per param: 0 = int32, 1 = float32
Bytes 6–7 PAYLOAD_LEN — length of optional trailing string/blob
Bytes 8+ PARAMS — NPARAMS × 4 bytes
Bytes 8+N×4 PAYLOAD — optional string or binary data
```
## Batching
Multiple frames are batched into a single WebSocket message before sending. The `FrameBuilder` class (Arduino) and `encodeFrame()` / `encodeBatch()` functions (JS) handle this automatically. Batching is what makes group writes land together on the board.
## Gesture frames
Expressive motion is pushed as a **segment schedule** the board plays on its own clock — never streamed step-by-step. One frame per actuator type (`CMD_SERVO_GESTURE` `0x58`, `CMD_STEPPER_GESTURE` `0x59`, `CMD_BUSSERVO_GESTURE` `0x5A`) carries one or more channel blocks in its payload:
```
Per channel: [ logicalId u8 ][ flags u8 ][ count u8 ] then count × segment
Per segment: [ curve u8 ][ dur u16, ms ][ value i32 ] (big-endian)
```
`flags` bit 0 selects the reference frame (relative delta vs absolute target); `value` is a signed displacement or target in the actuator's native unit (degrees, steps, counts); `curve` indexes the shared easing table (`linear`, `easeIn`, `easeOut`, `easeInOut`, `back`). A group gesture batches every type's frame into one message. This same record layout is what an on-device sequencer replays — one gesture vocabulary shared across the browser and the board — so a sketch can author gestures with the identical frames.
When a schedule finishes, the actuator's normal DONE frame (`CMD_SERVO_DONE`, `CMD_STEPPER_DONE`, `CMD_BUSSERVO_DONE`) carries an extra **outcome** param: `[id, position, outcome]`, where `0` = arrived and `1` = gave up (the final landing timed out 2 s after the expected arrival, and the actuator is holding where it got to). Plain moves keep the two-param `[id, position]` frame, and a missing outcome means arrived, so older firmware and older clients interoperate. Added in protocol **v1.3**.
While a schedule plays, the board emits a lightweight `CMD_*_GESTURE_STATE` (`0x64`/`0x65`/`0x66`, `[id, active]`) on the start and end of the motion — **existence, never the schedule** — so every browser can reflect an `isGesturing` state (and a reconnecting one learns it in the state sync below). It fires whoever authored the gesture, browser or sketch. Added in protocol **v1.1** (backward-compatible: older clients ignore the unknown code).
## State sync on connect
On connect, the Arduino sends its full current state — pin modes, output values, current readings of every polled input, extension configuration, NeoPixel colours — before signalling `ready`. Any browser connecting to a running system immediately sees live state. This is why Pardalote is multi-user by default: every client starts from the same picture.
## Periodic reads
A read registration (`CMD_DIGITAL_READ` / `CMD_ANALOG_READ`, params `[interval, threshold]`) is **per client**: the board keeps a separate interval, change threshold, and last-sent value for each connected browser, and only transmits a reading to a browser when it has changed by at least that browser's threshold since the value that browser last saw. Looking is decoupled from telling: digital pins are watched on every loop pass and edges transmit immediately (15 ms bounce lockout; the interval never delays them); analog pins are sampled every 10 ms, with each client's interval acting as a minimum spacing between its updates. A threshold of `0` selects the board default (`1` for digital; the analog noise floor, `analogMax >> 8`, min `1`). `CMD_END` removes only the sending client's registration, and a client's registrations are dropped when it disconnects. Board-owned polls (`share()` with an interval) are announced in `CMD_PIN_MODE` as `[mode, interval, threshold]` and survive disconnects. Older clients and older firmware simply omit or ignore the extra params.
## Building your own extension
Extensions live at both ends: a JS file that encodes frames for your commands, and an Arduino header that registers a handler for them. The built-in extensions are working references — `ultrasonic` is the smallest, `busServo` the most complete. See Extensions overview.
---
# Pin capabilities
> What each pin can actually do on the supported boards — analog in, digital in/out, PWM — and the gotchas that come from the silicon, not from Pardalote.
Not every pin does everything. A pin's capabilities come from the chip and the board, not from Pardalote, so `analogRead()` on the wrong pin returns `0` and `analogWrite()` on an input-only pin does nothing — even though the call itself is valid. These tables say which pins to reach for.
The legend for every table: **✓** works, **—** not available. "Analog in" means `analogRead()` / `ANALOG_INPUT_MODE`; "PWM" means `analogWrite()`.
> **Bench status.** The **ESP32-WROVER** and **UNO R4 WiFi** tables are confirmed on real hardware (Scott's rig, 2026-07). The **FireBeetle 2 ESP32-C5** table is derived from the datasheet and the Pardalote pin-alias file — correct by the docs, but not yet bench-verified pin by pin.
## Concepts that explain the tables
**Input-only pins.** On the classic ESP32, GPIO 34–39 can only read — no `digitalWrite`, no `analogWrite`, and crucially **no internal pull-up or pull-down**, so a floating input-only pin needs an external resistor. This is why those pins show read ✓ but output —.
**ADC2 dies while WiFi is on.** The original ESP32 has two ADC units. **ADC1** works whenever; **ADC2** is borrowed by the WiFi radio and reads `0` the entire time WiFi is connected. Pardalote is *always* on WiFi, so on the WROVER every ADC2 pin is effectively analog-dead — that's expected, not a fault. Only the ADC1 pins give real readings. The UNO R4 and the ESP32-C5 don't have this split on their exposed analog pins, so their analog pins stay readable with WiFi up.
**PWM is a shared resource, not a pin property.** On the ESP32 the LEDC peripheral can route PWM to almost any output-capable pin, so "PWM" tracks "can this pin output at all." On the ESP32-C5 there are only **6 LEDC channels**, so at most six pins can PWM at once. On the UNO R4 WiFi, PWM is fixed to the six timer-backed header pins marked `~` (3, 5, 6, 9, 10, 11).
**Strapping and reserved pins.** A few pins are read by the chip at boot to decide boot mode (strapping pins: WROVER 0, 2, 5, 12, 15) and can misbehave if you hold them at the wrong level during reset. Others are physically unavailable because they're wired to flash or PSRAM (WROVER GPIO 6–11 for flash, 16–17 for PSRAM) — those aren't broken out for general use and aren't listed below.
## ESP32-WROVER-DEV
The **Alias** names below work by name in your sketch (`arduino.analogRead(A0)`) — no include needed. `arduino.analogMax` = **4095**. Confirmed on hardware.
| GPIO | Alias | Analog in | Digital in | Digital out | PWM | Notes |
|---|---|:---:|:---:|:---:|:---:|---|
| 0 | A11 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); strapping (boot button) |
| 2 | A12 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); strapping; onboard LED (`LED_BUILTIN`) |
| 4 | A10 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi) |
| 5 | SS | — | ✓ | ✓ | ✓ | No ADC; strapping |
| 12 | A15 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); strapping (must be LOW at boot) |
| 13 | A14 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi) |
| 14 | A16 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi) |
| 15 | A13 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); strapping |
| 18 | SCK | — | ✓ | ✓ | ✓ | No ADC |
| 19 | MISO| — | ✓ | ✓ | ✓ | No ADC |
| 21 | SDA | — | ✓ | ✓ | ✓ | No ADC; default I2C SDA |
| 22 | SCL | — | ✓ | ✓ | ✓ | No ADC; default I2C SCL |
| 23 | MOSI| — | ✓ | ✓ | ✓ | No ADC |
| 25 | A18 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); true DAC out (`DAC1`) |
| 26 | A19 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi); true DAC out (`DAC2`) |
| 27 | A17 | — | ✓ | ✓ | ✓ | ADC2 (dead on WiFi) |
| 32 | A4 | ✓ | ✓ | ✓ | ✓ | ADC1 — analog works on WiFi |
| 33 | A5 | ✓ | ✓ | ✓ | ✓ | ADC1 — analog works on WiFi |
| 34 | A6 | ✓ | ✓ | — | — | Input-only; no pull-up/pull-down |
| 35 | A7 | ✓ | ✓ | — | — | Input-only; no pull-up/pull-down |
| 36 | A0 | ✓ | ✓ | — | — | Input-only; no pull-up/pull-down (SVP) |
| 39 | A3 | ✓ | ✓ | — | — | Input-only; no pull-up/pull-down (SVN) |
The one thing worth memorising: **the only pins that read analog under WiFi are 32, 33, 34, 35, 36, 39** (the ADC1 set). Everything labelled "ADC2" reads `0` while connected. GPIO 1/3 (UART to USB), 6–11 (flash) and 16–17 (PSRAM) are not broken out.
## Arduino UNO R4 WiFi
The **Alias** names below work by name in your sketch (`arduino.digitalWrite(D13, HIGH)`) — no include needed. `arduino.analogMax` = **1023** (10-bit default; the RA4M1 ADC can do 12/14-bit). Confirmed on hardware. No input-only pins, and no ADC/WiFi conflict.
| Pin | Alias | Analog in | Digital in | Digital out | PWM | Notes |
|---|---|:---:|:---:|:---:|:---:|---|
| 0 | D0 | — | ✓ | ✓ | — | Serial1 RX |
| 1 | D1 | — | ✓ | ✓ | — | Serial1 TX |
| 2 | D2 | — | ✓ | ✓ | — | |
| 3 | D3 | — | ✓ | ✓ | ✓ | `~` PWM |
| 4 | D4 | — | ✓ | ✓ | — | |
| 5 | D5 | — | ✓ | ✓ | ✓ | `~` PWM |
| 6 | D6 | — | ✓ | ✓ | ✓ | `~` PWM |
| 7 | D7 | — | ✓ | ✓ | — | |
| 8 | D8 | — | ✓ | ✓ | — | |
| 9 | D9 | — | ✓ | ✓ | ✓ | `~` PWM |
| 10 | D10 | — | ✓ | ✓ | ✓ | `~` PWM |
| 11 | D11 | — | ✓ | ✓ | ✓ | `~` PWM |
| 12 | D12 | — | ✓ | ✓ | — | |
| 13 | D13 | — | ✓ | ✓ | — | Onboard LED (`LED_BUILTIN`) |
| 14 | A0 | ✓ | ✓ | ✓ | — | Also the board's true DAC output |
| 15 | A1 | ✓ | ✓ | ✓ | — | Op-amp + input (if enabled) |
| 16 | A2 | ✓ | ✓ | ✓ | — | Op-amp − input (if enabled) |
| 17 | A3 | ✓ | ✓ | ✓ | — | Op-amp output (if enabled) |
| 18 | A4 | ✓ | ✓ | ✓ | — | I2C SDA — avoid analog while I2C is in use |
| 19 | A5 | ✓ | ✓ | ✓ | — | I2C SCL — avoid analog while I2C is in use |
## FireBeetle 2 ESP32-C5
The **Alias** names below work by name in your sketch (`arduino.analogRead(A1)`) — no include needed. `arduino.analogMax` = **4095**. Datasheet-derived — not yet bench-verified. A single 12-bit SAR ADC (no ADC2/WiFi split), and a 6-channel LED PWM controller (at most six PWM outputs at once).
> **Native USB:** the C5 talks to the Serial Monitor over native USB, so set **Tools → USB CDC On Boot → `Enabled`** — without it the Serial Monitor stays blank and the WiFi `w` menu never shows (the board is fine, you just can't see it). Leave **PSRAM → Disabled**. See Troubleshooting.
| GPIO | Alias | Analog in | Digital in | Digital out | PWM | Notes |
|---|---|:---:|:---:|:---:|:---:|---|
| 2 | A1 | ✓ | ✓ | ✓ | ✓ | ADC — readable on WiFi |
| 3 | A2 | ✓ | ✓ | ✓ | ✓ | ADC — readable on WiFi |
| 4 | A3 | ✓ | ✓ | ✓ | ✓ | ADC — readable on WiFi |
| 5 | A4 | ✓ | ✓ | ✓ | ✓ | ADC — readable on WiFi |
| 6 | D12 | — | ✓ | ✓ | ✓ | |
| 7 | D11 | — | ✓ | ✓ | ✓ | |
| 8 | D2 | — | ✓ | ✓ | ✓ | |
| 9 | SDA | — | ✓ | ✓ | ✓ | Default I2C SDA |
| 10 | SCL | — | ✓ | ✓ | ✓ | Default I2C SCL |
| 11 | TX | — | ✓ | ✓ | ✓ | UART TX |
| 12 | RX | — | ✓ | ✓ | ✓ | UART RX |
| 15 | D13 | — | ✓ | ✓ | ✓ | Onboard LED (`LED_BUILTIN`) |
| 23 | SCK | — | ✓ | ✓ | ✓ | SPI clock |
| 24 | MOSI| — | ✓ | ✓ | ✓ | SPI MOSI |
| 25 | MISO| — | ✓ | ✓ | ✓ | SPI MISO |
| 26 | D3 | — | ✓ | ✓ | ✓ | |
| 27 | D6 | — | ✓ | ✓ | ✓ | Also `SS` |
| 28 | D9 | — | ✓ | ✓ | ✓ | BOOT button (strapping) |
Board-level pins to know: **GPIO 0** switches the on-board `3V3_C` power rail (default off, drive HIGH to enable), and **IO1** senses the battery voltage — both are wired to on-board functions rather than free header I/O.
## Picking a pin quickly
For an **analog sensor** with WiFi running, use an ADC1 pin — WROVER 32/33/34/35/36/39, or any exposed analog pin on the UNO R4 (A0–A5) and the C5 (A1–A4). For a **button**, any pin with digital-in ✓; prefer one with an internal pull-up (i.e. *not* WROVER 34–39) so you can wire the switch to GND with no external resistor. For **PWM** (LED dimming, plain servo pins), any pin with PWM ✓ — on the C5, remember only six can run at once. Avoid the strapping pins for anything that's driven at boot.
See also: Pins and reading · Protocol · Troubleshooting
---
# Troubleshooting
> Common issues and their usual fixes, roughly in the order people hit them.
## "Can't connect"
- Check the IP address in `sketch.js` matches what the Arduino printed
- Arduino and browser must be on the same WiFi network
- Try refreshing — the Arduino may still be starting up
## "WiFi is slow, drops, or won't join on a XIAO ESP32-S3 or C3"
Seeed XIAO ESP32-S3 and ESP32-C3 boards transmit badly at full power because their antenna isn't well matched. The typical signs: the board takes several tries to join (you may see `wifi:sta is connecting, cannot set config` in the Serial Monitor), the connection lags and drops, `ping` to the board jumps between a few milliseconds and over 100, and camera video stalls. The signal strength the board *reports* (RSSI) can look perfectly good, because it only measures what the board receives.
Pardalote lowers the transmit power on these boards to 8.5 dBm automatically. **Update the Pardalote Arduino library** if the Serial Monitor doesn't show `WiFi transmit power: 8.5 dBm` at boot. In our tests on a XIAO ESP32-S3 Sense, this cut ping times from 68 ± 57 ms to about 8 ± 2 ms and roughly doubled camera throughput. If the board is far from the router, try a little more power with `Pardalote.setTxPower(WIFI_POWER_15dBm)` before `begin()`.
## "Serial Monitor is blank on an ESP32-C5, C3 or S3 (no `w` menu, board seems dead)"
On chips with native USB (ESP32-**C5**, **C3**, **S3**), **Tools → USB CDC On Boot** decides whether your sketch's `Serial` is routed to the USB port or to hardware UART0. With it **Disabled**, `Serial.print` goes out the UART pins and never reaches the USB Serial Monitor — so the WiFi setup `w` menu never appears and the board looks hung, even though Pardalote is booting and running fine. **Set USB CDC On Boot → `Enabled`** and re-upload.
An early `E (…) MSPI Timing: Failed to allocate dummy cacheline for PSRAM` line can still print — that's harmless ROM-bootloader noise emitted *before* the CDC setting takes effect, not a PSRAM fault. Leave **PSRAM → Disabled** unless you're driving a camera. (Confirmed on the FireBeetle 2 ESP32-C5.)
## "Connection drops every few seconds" (UNO R4)
- This is a known UNO R4 WiFi behaviour
- Pardalote handles reconnection automatically — your sketch keeps working
## "WebSocket is unstable on the UNO R4 — drops, freezes, or reconnects"
- **Keep your `loop()` tight.** The UNO R4 WiFi's WebSocket stack (WiFiS3 + arduinoWebSockets) is sensitive to how often it is serviced. Anything that blocks or slows the loop — a `delay()`, a long computation, a busy blocking peripheral — starves the WebSocket of servicing, and the connection stalls, drops, or churns "client connection lost / new client" and reconnects. A single blocking `delay(100)` in `loop()` is enough to trigger it. (See [arduinoWebSockets issue #909](https://github.com/Links2004/arduinoWebSockets/issues/909).)
- The symptom is often **indirect**. For example, dragging an `analogWrite()` PWM slider may lag and then drop the socket — not because `analogWrite()` is slow, but because something else in the loop is blocking long enough that the extra slider traffic can't be serviced in time. Fix the blocking, not the slider.
- Fixes: remove `delay()` calls from `loop()`; move slow work off the hot path; if you must do something lengthy, call `Pardalote.run()` (which services the WebSocket) periodically while it runs. The ESP32 is far less sensitive to this.
- As a smaller, defensive measure, Pardalote also rate-limits pin writes by default so a slider or draw loop can't flood the loop with parse work. PWM changes coalesce (a leading write goes out immediately, rapid follow-ups coalesce into one send carrying the final value), and writing an unchanged value — `digitalWrite()` or `analogWrite()` — is re-sent at most every 250 ms. If you've set `setWriteThrottle(0)` or `setWriteRepeat(0)`, raise them — `arduino.setWriteThrottle(20)` caps PWM changes to ~50/s per pin; `arduino.setWriteThreshold(4)` skips sub-4 duty changes; `arduino.setWriteRepeat(250)` restores the repeat limit.
## "connectSerial() can't find or open the port"
- Web Serial only exists in **Chrome and Edge** (Chromebooks included) — Safari and Firefox can't do serial; use WiFi there.
- **Close the Arduino IDE's Serial Monitor** (and Serial Plotter) first. Only one program can hold the port — if the IDE has it, the browser's picker shows the port but opening it fails, and vice versa: while the browser is connected, the IDE can't open the Serial Monitor.
- The first `connectSerial()` must be triggered by a click — browsers only show the port picker from a user gesture.
- Unplugged and replugged? Pardalote re-acquires the port automatically on the next retry; if it doesn't, click Connect again.
## "The board resets when the browser connects over serial" (ESP32)
Expected. Opening the serial port toggles the DTR/RTS lines, and the auto-reset circuit on classic ESP32 dev boards restarts the chip — the same thing the IDE does before uploading. Pardalote's probe simply waits through the reboot and connects when the board comes back (a few seconds). The UNO R4's native USB doesn't reset on open. A board switching from WiFi to USB (the default `begin()`) goes through this same reboot on ESP32 — reboot, WiFi comes up, then the switch — so a WiFi→USB switch on ESP32 takes a few seconds; on the R4 it's immediate.
## "connectSerial() connects but nothing happens / I get a 'usbBusy' message"
The board is running on WiFi and won't hand itself to USB without a deliberate user action — a safety so a background tab (or a board that's merely cabled for power) can't yank a shared WiFi board away from everyone. Click your **Connect** button: a `connectSerial()` called from a real click authorises the switch (even one that silently reuses an already-granted port, so no picker needs to appear). What *doesn't* authorise it is an **automatic** connect with no user action — a page reloading and reconnecting, or a background tab. If you're seeing this after a click, make sure `connectSerial()` is called directly from the click handler (not after an `await`, which can expire the browser's user-activation).
## "connectSerial() gets no response at all"
The console logs a hint after a few seconds. Likely causes: the board is still booting (an ESP32 resets when the port opens — wait a moment); it isn't running a Pardalote sketch; or it was started with `Pardalote.begin(PARDALOTE_WIFI)`, which is **WiFi-only and ignores USB**. Use `begin()` (WiFi + USB) or `begin(PARDALOTE_SERIAL)` (USB only) if you want to connect over the cable.
## "Wrong key for this board" over USB
You passed a connection key (`connectSerial({ key })`) that doesn't match the one the board set with `requireKey()` — you're likely cabled to the wrong physical board. Over USB the key is a board-identity check, so it catches exactly this. Connect to the board your sketch expects, or fix the key.
## "I reset the board while connected over USB"
It recovers on its own. The board announces the reboot over serial, and the browser — still holding the port — resumes connecting, so a board on the default `begin()` switches straight back to USB within a second or two. No click needed. (If the browser ever misses the announce it still recovers the slower way, via its heartbeat timeout.)
## "Serial connection works, then dies after a NeoPixel animation"
`show()` disables interrupts while it streams the strip, which can drop incoming serial bytes. Pardalote's serial framing detects the corruption (CRC) and drops that message rather than desyncing — occasional lost frames during heavy animation are expected; the link itself recovers on its own.
## "Camera: poor image quality, stuck at low resolution, or `cam_hal: FB-OVF` in the Serial Monitor"
Almost always **PSRAM isn't enabled in the build.** ESP32 camera boards need PSRAM for anything above the tiniest frame size. Look for this on the Serial Monitor at startup:
```
[Camera] No PSRAM — using DRAM, forced to QQVGA
```
If you see it, the camera has fallen back to a single tiny frame buffer in internal RAM: the image is locked to QQVGA (160×120), it looks poor, and any `setResolution()` to something larger overflows that buffer — the driver then spams `cam_hal: FB-OVF` and the stream breaks.
- **Seeed XIAO ESP32S3 Sense:** it has 8 MB of *octal* PSRAM, but you must select it. **Tools → PSRAM → `OPI PSRAM`** (not "QSPI PSRAM", not "Disabled"), with the board set to XIAO ESP32S3. Re-upload — the "No PSRAM" line should be gone.
- **Other boards:** enable PSRAM under Tools (the option name varies — "OPI PSRAM", "QSPI PSRAM", or "Enabled"). Match it to what your module actually has.
Once PSRAM is detected the camera double-buffers in PSRAM and `setResolution()` works normally.
## "Camera: `FRAMESIZE_HD` gives `cam_hal: FB-OVF` / `net::ERR_INCOMPLETE_CHUNKED_ENCODING` even with PSRAM on"
`FB-OVF` means a JPEG frame was bigger than the buffer the camera driver set aside for it, so the frame was dropped; in the browser console that shows as `net::ERR_INCOMPLETE_CHUNKED_ENCODING`. The driver sizes those buffers once, when the camera starts. Older Pardalote versions started the camera at the requested resolution, which left too little room for HD frames (or for a bigger size chosen after `attach()`). **Update the Pardalote Arduino library**: it now reserves room for frames up to 1600×1200 on boards with PSRAM, and HD streams on the XIAO ESP32S3. Older versions also sent the wrong frame size on ESP32 core 3.x (asking for HD gave 800×600), so earlier reports of which sizes work may be off by a size or two. If you still see `FB-OVF`, check that PSRAM is enabled (see above), then step down a size.
## "Camera: the video stays blank in a second window or tab"
The board sends **one video stream at a time**. While another page is streaming, a second page's stream doesn't fail, it just waits, so its canvas stays blank with no error. The browser console shows a warning when this happens:
```
another page is already streaming from this camera. The board sends one video stream at a time, so this page's video will stay blank until that page is closed.
```
Close the other window or tab (or stop its stream with `detach()`) and the waiting page starts streaming by itself — no reload needed. Snapshots aren't affected — `snapshot()` has its own server and works while another page streams.
## "Camera: the video freezes for a second or two, then carries on"
Short freezes with the board still connected are usually **lost network packets**, most often on a **phone hotspot**. Each video frame travels as a series of packets. When one is lost, the ESP32 waits a fixed time (about 1–2 s, set inside the ESP32 core) before resending it, and the video pauses until it arrives. The connection itself isn't affected: controls keep working, and nothing is logged in the browser.
- **Use a smaller frame size.** `FRAMESIZE_QVGA` or `FRAMESIZE_HVGA` sends much less data than `FRAMESIZE_VGA` or `FRAMESIZE_HD`, so there are fewer packets to lose and more room to recover. VGA and larger are best kept for a good router.
- **On a XIAO**, check the Serial Monitor shows the reduced transmit power (see above). At full power its losses take twice as long to recover.
- **Watch the Serial Monitor.** While streaming, the board prints `[Camera] 8.4 fps, 25 KB/frame, 80 ms to send, max 1500 ms (640x480)` every 10 s. A `max` above about 1000 ms is one of these freezes. The board already paces the stream to the network (see `setFrameRate()`), so it doesn't make the link worse. It can't prevent packets being lost in the air, though.
## "NeoPixels don't light up"
- Verify the pixel type: `NEO_GRB` works for most WS2812B strips, `NEO_RGB` for some others
- Call `setBrightness()` — default is 255 but strips vary
- Always call `show()` after setting pixel colours
## "IMU not responding"
- Check SDA and SCL wiring and confirm the I2C address (AD0/SA0 pin state)
- Check Serial Monitor for `IMU WHO_AM_I mismatch` — the model string or wiring is wrong
- Verify your sketch has `#include `
## "IMU readings drift when stationary"
- Run calibration with the sensor flat and still: `arduino.imu.calibrate(200)`
- The complementary filter's `ALPHA` parameter gradually pulls angles back; lower it for faster drift correction
## "Board hangs after a few seconds with the IMU example" (ESP32-WROVER and other older ESP32 boards)
- The original ESP32 chip's I²C peripheral can stall under sustained high-rate reads, hanging the main loop. Reduce the JS poll interval to 50 ms or higher: `arduino.imu.read(50)`
- Newer ESP32 boards (ESP32-S3, C3, etc.) and the UNO R4 don't have this limitation and can poll the IMU at 20 ms (50 Hz) reliably
## "Servo jitters"
- Use `setWriteThrottle()` to limit write frequency
- Make sure the servo has adequate power (not just USB)
## "Stepper doesn't move / moves the wrong way"
- Confirm the AccelStepper library is installed and the sketch has `#include `
- Give the motor its own supply — the coils can't run off the board's 5 V
- Nothing happens on `moveTo()`? Set a non-zero `setMaxSpeed()` and `setAcceleration()` first
- Runs backwards: swap the direction with `attach(STEP, DIR, EN, { invertDir: true })`
- Motor buzzes but won't turn: lower `setMaxSpeed()` — software step generation shares the CPU with WiFi and tops out at a few kHz
- Won't move past a point: check you haven't hit a `setLimits()` boundary
## "Stepper motor is hot / won't turn by hand when idle"
- That's the enable pin holding torque. Call `disable()` to release the coils; `enable()` to hold again
## "Bus servo doesn't respond / [NO RESPONSE] in Serial Monitor"
- Confirm the `SCServo` library is installed and the sketch has `#include `
- Check the servo ID matches what you passed to `attach()` — run `scan()` to list responding IDs
- Baud mismatch: bus servos default to 1,000,000; set it with `configureBus({ baud })` if yours differs
- Wrong UART or swapped RX/TX — UNO R4 uses `Serial1` (D0/D1); on ESP32 set the pins with `configureBus({ rxPin, txPin })`
- Give the servos their own 6–7.4 V supply with a common ground to the board
- Using the wrong series: ST/SMS servos are `'ST'` (0–4095), SC/SCS are `'SC'` (0–1023)
## "Two bus servos both moved when I set an ID"
- `setId()` addresses the current ID — with several servos sharing it, they all take the new ID. Renumber with a single servo on the bus at a time
## "Ultrasonic returns -1"
- Increase timeout: `arduino.sonar.setTimeout(50)`
- Point at a flat, hard surface (fabric and foam absorb ultrasound)
- Maximum range depends on timeout: 30 ms ≈ 500 cm
---
# Examples
> Complete, working Pardalote projects — each has an Arduino sketch, a web page, and a wiring note. The sections below are the per-example READMEs (what each does and how it works); full source for every example is on GitHub: https://github.com/ScottMit/Pardalote/tree/main/examples.
---
# Control Panel
A browser-based control panel for interacting with Arduino pins in real time. Each pin is displayed as a row aligned to a photo of the board. Select a mode from the dropdown and the controls appear. Use the control panel to test your Arduino and circuit.
---
## What You Can Do
- Read digital inputs and see HIGH / LOW update live
- Toggle digital outputs HIGH or LOW from the browser
- Drive PWM outputs with a slider
- Watch analog inputs as a live bar graph
- Open the panel in multiple browser windows simultaneously — all windows stay in sync
---
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- Arduino and browser must be on the same WiFi network
No additional components are needed to use the panel — just an Arduino with Pardalote firmware running.
---
## Setup
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → minimal-pardalote** (or any other Pardalote example that includes the extensions you need)
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Uncomment the two lines in `secrets.h`:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. No code editing needed
The IP is entered on the page, and the browser remembers it (along with the
last board shown) — a returning visit reconnects automatically.
### 3. Open the panel
Open `index.html` in a browser, then click **Connect**.
---
## Using the Panel
### Connecting
Enter your Arduino's IP address in the **Board IP** field (if it's not already there) and click **Connect**. The status line reads `ready` once the connection is up; **Disconnect** stops it and pauses auto-reconnect.
**No WiFi? Use the cable.** Switch the dropdown from **WiFi** to **USB** and click **Connect** — the browser (Chrome or Edge) shows a port picker the first time and remembers your choice afterwards. The sketch side must start the serial transport: `Pardalote.begin(PARDALOTE_SERIAL)` (one commented line in `minimal-pardalote`). Everything else on the panel works identically.
The board layout switches automatically to match the connected Arduino. If you want to view a different board layout, use the board selector dropdown.
### Board selector
Three boards are included:
| Board | Notes |
|---|---|
| UNO R4 WiFi | Analog pins A0–A5, digital D0–D13 |
| ESP32-WROVER-DEV | Full GPIO header with analog-capable pins |
| FireBeetle 2 ESP32-C5 | Compact ESP32-C5 board |
Selecting a board from the dropdown manually overrides the auto-detection — the panel will stay on your chosen board even if the Arduino auto-reconnects. Clicking **Connect** again re-enables auto-detection of the board.
### Pin modes
Each interactive pin has a mode dropdown. Select a mode to activate it:
| Mode | What it does |
|---|---|
| **off** | Pin is inactive — no mode is set |
| **input** | Sets the pin as a digital input with pull-up. Shows HIGH (green) or LOW (red) |
| **output** | Sets the pin as a digital output. HI and LO buttons write to the pin |
| **pwm out** | Sets the pin as a PWM output. A slider controls the value (0–255) |
| **analog in** | Reads the analog value and shows it as a live bar graph with a numeric readout |
Pins that only support some modes show only those options in the dropdown.
### Multiple windows
Opening the panel in more than one browser window shows the same live state in all windows. Clicking HI in one window updates the output indicator in every other window.
### Reconnecting
If the Arduino resets or the connection drops, Pardalote reconnects automatically. Any pin modes you had active are re-applied as soon as the connection is restored.
---
## Supported Boards
The panel layout is driven by the `BOARDS` table in `boards.js`. Each entry defines the board image, the display width, and the position and modes of every pin.
To add a new board, add a new entry to `BOARDS` with the same name string that the Arduino firmware reports (the `PARDALOTE_BOARD` value). The control panel auto-detects by matching the name exactly — if no match is found, a warning is printed to the browser console.
---
## Troubleshooting
**"Won't connect"**
- Check the IP address matches what the Arduino printed
- Arduino and browser must be on the same WiFi network
- Try refreshing — the Arduino may still be starting up
**"Board doesn't switch automatically"**
- Check the browser console for `unrecognised board "..."` — the name the firmware reports must exactly match a key in `BOARDS`
- If you've already manually selected a board, auto-detection is suppressed until you click Connect again
**"Analog bar doesn't move"**
- Confirm the pin supports analog input on your board (not all pins do)
- Check that nothing is driving the pin — a floating pin may read erratically
**"Output state shows — after setting a mode"**
- The indicator shows `—` until the first write. Click HI or LO to set a known state
**"Pin mode resets after reconnect"**
- Modes are re-applied automatically on reconnect. If a mode does not come back, check the Serial Monitor for errors
---
## File Structure
```
control-panel/
├── index.html # Page shell
├── sketch.js # Board selector + status (connection is connect.js)
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
├── control-panel.js # Panel layout and pin mode logic
├── boards.js # Board definitions (pins, positions, modes)
├── style.css # Panel styling
├── boards/
│ ├── uno-r4-wifi.png
│ ├── esp32-wrover-dev.png
│ └── firebeetle2-esp32-c5.png
└── README.md # This file
```
---
# Basic light switch
The simplest possible Pardalote sketch. A web page with two buttons that turn the Arduino's built-in LED on and off over WiFi — no p5.js, no framework.
## What This Example Does
- **Turn LED ON** — sends `digitalWrite(13, HIGH)` to the Arduino
- **Turn LED OFF** — sends `digitalWrite(13, LOW)` to the Arduino
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- Arduino and browser must be on the same WiFi network
- **UNO R4 WiFi:** the built-in LED on pin 13 is all you need
- **ESP32:** connect an LED + 220 Ω resistor between pin 13 and GND (long leg to pin 13)
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → minimal-pardalote**
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Uncomment the two lines in `secrets.h`:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
If both are configured, `secrets.h` is tried first.
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example and connect
Open `index.html` in a browser. In the **Board** row, enter the Arduino's IP
(or switch to **USB**) and press **Connect** — the button turns green when it's
up, and your choice is remembered per browser (a return visit reconnects with
one click). The LED pin is set at the top of `sketch.js` (`const LED`, 13 = the
built-in LED). Click **Turn LED ON** and **Turn LED OFF**.
## How It Works
```javascript
const LED = 13; // 13 is the board's built-in LED
const arduino = new Arduino();
setupConnection(arduino, { store: 'pardalote-basic-light-switch' }); // Board row + connection (connect.js)
arduino.on('ready', () => arduino.pinMode(LED, OUTPUT)); // set up the pin once connected
// Button handlers
arduino.digitalWrite(LED, HIGH); // ON
arduino.digitalWrite(LED, LOW); // OFF
```
`pinMode` is called inside the `ready` handler — this ensures the WebSocket is open before the frame is sent. Calls made before `ready` would be silently dropped.
Reconnection is automatic. If the Arduino resets or the connection drops, Pardalote reconnects and restores the pin configuration.
## Troubleshooting
**"Buttons don't work"**
- Check the IP in the **Board** field matches the Arduino's IP, and that you pressed **Connect**
- Arduino and browser must be on the same WiFi network
- Open the browser console (F12) for connection messages
**"IP shows as 0.0.0.0" (UNO R4)**
- WiFi connection failed — check credentials via Serial Monitor (press `w` on boot)
**"LED doesn't light up" (ESP32)**
- Check wiring: long leg of LED to pin 13, short leg through 220 Ω resistor to GND
## File Structure
```
basic-light-switch/
├── index.html # Two-button web interface
├── sketch.js # Arduino connection and button handlers
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Read a sensor: try the `potentiometer-p5js`
- Control a servo: try the `servo-control`
- Control LED strips: try the `neopixel`
---
# Potentiometer
A minimal Pardalote sketch using p5.js. Reads an analog sensor (potentiometer) and uses the value to draw a circle whose size follows the sensor in real time.
## What This Example Does
- Reads a potentiometer connected to an analog pin
- Draws a circle on a p5.js canvas — larger when the sensor reads high, smaller when it reads low
- Circle is green when the Arduino is connected, red when disconnected
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- **Potentiometer** (10 kΩ recommended)
- **Breadboard and jumper wires**
- Arduino and browser must be on the same WiFi network
### Wiring
| Potentiometer pin | Arduino |
|---|---|
| Center (wiper) | Pin 33 (ESP32) or A0 (UNO R4) |
| One outer pin | 3.3 V (ESP32) or 5 V (UNO R4) |
| Other outer pin | GND |
Set the wiper's pin at the top of `sketch.js` (`const PIN`, default 14 = A0 on the UNO R4).
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → minimal-pardalote**
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Uncomment the two lines in `secrets.h`:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
If both are configured, `secrets.h` is tried first.
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example and connect
Open `index.html` in a browser. In the **Board** row, enter the Arduino's IP
(or switch to **USB**) and press **Connect** — your choice is remembered per
browser. The pot's pin is set at the top of `sketch.js` (`const PIN`). Turn the
potentiometer — the circle should grow and shrink.
Named pins like `A0` and `D13` also work by name in your own sketches, each
resolving to the right physical pin for the board you connect to (see the
Pins reference); this page uses a raw GPIO
number in the pin field.
## How It Works
```javascript
const PIN = 14; // the pot's wiper pin
arduino = new Arduino();
setupConnection(arduino, { store: 'pardalote-potentiometer' }); // Board row + connection (connect.js)
// Configure the pin once the board is ready — 'ready' re-fires on every reconnect
arduino.on('ready', () => arduino.pinMode(PIN, ANALOG_INPUT_MODE, 50)); // 50 ms poll
```
In `draw()`, `analogRead()` returns the latest cached value — no extra network traffic:
```javascript
const dial = arduino.analogRead(PIN);
const circleRadius = map(dial, 0, arduino.analogMax, 10, 300);
```
`arduino.analogMax` is set automatically from the board type:
| Board | `analogMax` |
|---|---|
| UNO R4 WiFi | 1023 |
| ESP32 | 4095 |
Use `arduino.analogMax` rather than a hard-coded value to make your sketch portable between boards.
## Troubleshooting
**"Circle doesn't change size"**
- Check the potentiometer wiring (center to analog pin, outers to power and GND)
- Verify `PIN` (top of `sketch.js`) matches the pin you wired
- Open the browser console (F12) for connection messages
**"Circle is always red"**
- Arduino is not connected — check the IP address and WiFi
**"Circle jumps around erratically"**
- Power issue: make sure the potentiometer outer pins are connected to a stable rail
## File Structure
```
potentiometer-p5js/
├── index.html # Canvas page (loads p5.js and the pardalote.js bundle)
├── sketch.js # p5.js setup() and draw()
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
├── style.css # Pardalote example house style
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Add digital output: try the `basic-light-switch`
- Read distance: try the `ultrasonic-sensor`
- Control a servo from a sensor value: try the `servo-control`
---
# Shared light switch
A light controlled by **both** physical buttons on the Arduino **and** on/off buttons in the browser. Press either side; both stay in sync.
## What this example demonstrates
The library refactor lets your Arduino sketch coexist with the browser as equal peers on the same pins. This example shows the two halves of that contract:
- **Arduino code can change a pin and tell the browser** — `Pardalote.send(pin, value)`
- **Browser can change a pin** — the regular `arduino.digitalWrite(pin, value)` already works
- **Both sides see every change** — whoever was last wins, both UIs reflect it
The Arduino sketch also calls `Pardalote.share(LIGHT, OUTPUT)` once during `setup()` so the browser knows pin 13 is an output without having to declare it itself.
## Hardware
- Arduino UNO R4 WiFi or ESP32 development board
- LED on **pin 13** (built-in on most boards; otherwise add an external LED + 220 Ω resistor)
- Pushbutton on **pin 7** → GND (the "on" button)
- Pushbutton on **pin 8** → GND (the "off" button)
Buttons use `INPUT_PULLUP` — no external resistors needed.
## Run it
1. In Arduino IDE: **File → Examples → Pardalote → shared-light-switch**, select your board, upload.
2. Find the Arduino's IP from the LED matrix (UNO R4) or Serial Monitor (ESP32).
3. Open `index.html` and press **Connect** — enter the IP (or switch to **USB**). No code editing needed; the setting is remembered per browser.
5. Press the physical buttons → the on-screen light follows. Click the browser buttons → the LED follows.
## What's happening under the hood
```cpp
// Arduino side, in loop():
if (button pressed) {
digitalWrite(LIGHT, HIGH); // Arduino's own pin API — does the hardware
Pardalote.send(LIGHT, HIGH); // tells the browser the new value
}
```
```js
// Browser side:
document.getElementById('on-btn').onclick = () => arduino.digitalWrite(LIGHT, HIGH);
arduino.pin(LIGHT).on('change', ({ value }) => updateLightDisplay(value));
```
`Pardalote.send()` from the Arduino and `arduino.digitalWrite()` from the browser both end up broadcasting the same `CMD_DIGITAL_WRITE` frame. Whoever calls last wins on the actual pin state; every connected browser sees every change.
## Companion example
shared-potentiometer goes the other direction — the Arduino calls `Pardalote.share(A0, ANALOG_INPUT_MODE)` once, and the browser starts receiving values without writing any `pinMode` / `analogRead` code itself.
---
# Shared potentiometer
The Arduino tells the browser "I have an analog input on A0 — start polling it." The browser does nothing more than listen. A turn of the knob shows up live on screen.
## What this example demonstrates
The companion to shared-light-switch. That one showed the Arduino *pushing* values to the browser; this one shows the Arduino *announcing* a pin so the browser auto-starts polling — without any `pinMode` or `analogRead` call on the JS side.
The minimal Arduino sketch:
```cpp
void setup() {
Pardalote.begin();
pinMode(POT, INPUT);
Pardalote.share(POT, ANALOG_INPUT_MODE); // ← tells the browser
}
void loop() {
Pardalote.run();
// nothing else — the browser polls, Pardalote.run() handles it
}
```
The minimal browser sketch:
```js
arduino.pin('A0').on('change', ({ value }) => updateDisplay(value));
```
No `arduino.pinMode`, no `arduino.analogRead`. The polling pipeline is set up automatically when the Arduino's `share(A0, ANALOG_INPUT_MODE)` arrives.
## Hardware
- Arduino UNO R4 WiFi or ESP32 development board
- Potentiometer (10 kΩ is fine; anything in the 1 kΩ – 100 kΩ range works)
Wire it as a voltage divider:
| Pot pin | Connect to |
|---|---|
| Outer 1 | 3.3 V |
| Wiper (centre) | A0 |
| Outer 2 | GND |
## Run it
1. In Arduino IDE: **File → Examples → Pardalote → shared-potentiometer**, select your board, upload.
2. Find the Arduino's IP from the LED matrix (UNO R4) or Serial Monitor (ESP32).
3. Open `index.html` and press **Connect** — enter the IP (or switch to **USB**). No code editing needed; the setting is remembered per browser.
5. Turn the pot. The number on screen and the bar should follow it in real time.
## What's happening on the wire
```
Arduino: Pardalote.share(A0, ANALOG_INPUT_MODE)
→ CMD_PIN_MODE A0 = ANALOG_INPUT_MODE
Browser receives CMD_PIN_MODE:
→ starts a default-interval (200 ms) analogRead poll
→ CMD_ANALOG_READ A0 [interval=200]
Arduino registers the periodic action; every 200 ms:
→ reads A0, broadcasts CMD_ANALOG_READ A0 =
Browser receives CMD_ANALOG_READ:
→ updates cache, fires arduino.pin('A0').on('change', …)
→ display updates
```
The JS code on the page is just the pin handle's `'change'` listener. The rest of the pipeline was set up by the Arduino's one-line call to `share()`.
## Why this is useful
In a project where the **Arduino sketch is the source of truth** about which pins are wired to what (and the browser is just a viewer), `share()` lets the Arduino describe its hardware setup *to* the browser, instead of every browser sketch having to repeat the wiring knowledge.
Polling rate is 200 ms here (the browser's default). If you want different rates, the browser can override after connect with `arduino.analogRead('A0', 50)` — that just updates the interval; no need to re-declare the mode.
---
# Messaging
Named **key/value messages** between the browser and the Arduino sketch — no
pin, no hardware device. Send a value under a key, watch keys for changes, and
inspect *all* WebSocket traffic with the frame monitor.
## What it shows
- **Send** any value type from the browser — `int`, `float`, `bool`, or `text`
(auto-typed from the text box) — with optional `retain` and `broadcast`.
- **Receive** every message via `arduino.on('message', …)`, and one specific
key (`uptime`) via `arduino.watch(…)`.
- **Frame monitor** — tick the checkbox to log every frame in and out
(`SERVO_WRITE`, `DIGITAL_WRITE`, `MESSAGE`, …), decoded and named.
The sketch (`messaging.ino`) drives the built-in LED from a `led` message, logs
every message to Serial, and sends a **retained** `uptime` counter once a second
— retained means a browser that connects later immediately gets the current value.
## Run it
1. In Arduino IDE: **File → Examples → Pardalote → messaging**, select your board, upload.
2. Open `index.html` in a browser.
3. Type the board's IP into the **Board IP** field and press **Connect** —
this is a **tool**, so no code editing is needed and the browser remembers
the IP for next time.
Try sending `led` = `true` / `false` to toggle the LED. Open the page in **two
browser tabs** and send with **broadcast** ticked — the other tab sees it too
(the board relays it). Tick **retain**, reload, and the value is still there.
## API used
```js
arduino.send('mode', 'idle', { retain: true, broadcast: true });
arduino.send('count', 42); // int (auto-typed)
arduino.send('temp', 22.5); // float
arduino.send('on', true); // bool
arduino.watch('uptime', (v, key, type) => …);
arduino.on('message', ({ key, value, type }) => …);
arduino.messages['mode']; // last received value
arduino.on('frame', ({ dir, cmdName, target, params }) => …); // inspect all traffic
```
```cpp
Pardalote.send("uptime", secs, MSG_FLAG_RETAIN);
Pardalote.watch("led", onLed); // void onLed(const Message& m)
Pardalote.onMessage(onAny);
Pardalote.onFrame(onFrame); // void onFrame(const FrameEvent& ev)
```
---
# Shared servo
A servo the **Arduino sketch creates** and the browser receives automatically. One line on the board:
```cpp
pan = PardaloteServo.attach("pan", 9);
```
and `arduino.pan` exists in every connected browser — a full `Servo` instance, identical to one created with `arduino.add('pan', new Servo())`. No JS-side setup.
## What this example demonstrates
- **The sketch creates the servo** — `PardaloteServo.attach("pan", pin)` attaches it on the board and announces it to browsers in the same act. Browsers that connect later get it too.
- **The browser object is the real thing** — `arduino.pan.write()`, `writeTimed()`, `whenDone()`, groups, limits: everything a browser-created servo has.
- **Both sides drive it, both stay in sync** — the sketch nods the servo every 4 s (`PardaloteServo.writeTimed(pan, …)`), the browser grabs it with the mouse. Sketch writes are auto-echoed, so `arduino.pan.angle` always tracks whoever wrote last.
This is the actuator counterpart of `Pardalote.share(pin, mode)` — except a servo has no life outside Pardalote, so there's no separate "share" step: creating it *is* sharing it. A servo that should stay private to the sketch shouldn't go through Pardalote at all — use the plain `Servo`/`ESP32Servo` library, the way unshared pins just use `pinMode()`.
## Hardware
- Arduino UNO R4 WiFi or ESP32 development board
- Hobby servo: signal on **pin 9**, plus 5 V and GND (power a real servo from a supply, not the board's 5 V pin)
## Run it
1. In Arduino IDE: **File → Examples → Pardalote → shared-servo**, select your board, upload.
2. Find the Arduino's IP from the LED matrix (UNO R4) or Serial Monitor (ESP32).
3. Open `index.html` and press **Connect** — enter the IP (or switch to **USB**). No code editing needed; the setting is remembered per browser.
5. Watch the sketch move the servo; press the mouse to take over.
## What's happening under the hood
On attach, the board broadcasts a `SHARE` frame (`[logicalId]` + the name) followed by the servo's attach state and angle. The JS core intercepts `SHARE`, constructs a `Servo`, and binds it as `arduino.pan`; the state frames then sync it through the normal announce machinery. The same sequence replays for every browser that connects later, so the object is always there before `'ready'` fires.
```javascript
arduino.on('share', ({ name, extension }) => { ... }); // optional: the moment it appears
arduino.on('ready', () => { arduino.pan.write(90); }); // it's simply there
```
---
# Servo control
A p5.js **control panel** for one servo — a Pardalote tool that works out of the box (the connection and servo pin are set on the page and remembered by the browser, no code editing). A live gauge draws the arm and every command updates it in real time. Use it to exercise the modern servo API, or to drive the servo directly by dragging the arm.
## What This Example Does
- **Drag the arm:** grab the arm on the gauge and drag to aim the servo — a direct `write()` by pointer. The arm tracks the *applied* angle, so a drag past a soft limit stops at the allowed band.
- **Immediate writes:** the `0°`, `center 90°`, and `180°` buttons call `write()`.
- **Timed move + arrival:** `writeTimed(target, duration)` interpolates on the board; an awaited `whenDone()` logs `arrived` / `TIMEOUT` with the elapsed time.
- **Soft limits:** `setLimits(min, max)` clamps on the Arduino — the clamp shows on the gauge (teal band) and in the log; `clearLimits()` removes them.
- **Home:** `setHome()` (here) or `setHome(value)`, then `home()` to snap back or `home(1000)` to glide.
- **Live gauge + call log:** the arm shows `arduino.myServo.angle` — the value the library actually applied — alongside the current limits and home angle, with a rolling log of the last few calls underneath.
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- **Servo motor** (standard 5 V hobby servo)
- Arduino and browser must be on the same WiFi network
### Wiring
| Servo wire | Arduino |
|---|---|
| Power (red) | 5 V |
| Ground (brown/black) | GND |
| Signal (orange/yellow) | Pin 11 (set on the page — the **Wiring** row) |
For more than one or two servos, power them from an external 5 V supply rather than the Arduino's 5 V pin.
### Arduino libraries
Install via Arduino IDE → Tools → Manage Libraries:
- `WebSocketsServer` (by Markus Sattler)
- `ESP32Servo` (ESP32 only — not needed for UNO R4)
Install Pardalote itself by copying `pardalote-arduino/library/Pardalote/` into your Arduino libraries folder (see the top-level README).
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → servo-control**. The sketch is two lines:
```cpp
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Create a `secrets.h` file in the sketch folder with:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
If both are configured, `secrets.h` is tried first.
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example
This is a **tool** — it works out of the box, no code editing:
1. Open `index.html` in a browser.
2. Type the board's IP into the **Board IP** row and press **Connect**.
3. Set the servo pin in the **Wiring** row under the gauge (default 11) —
changing it reconnects and re-attaches.
The browser remembers the IP and pin, so next visit it reconnects with one
click. Each control row exercises one part of the servo API — immediate
writes, timed moves with an awaited `whenDone()`, soft limits (watch the
clamp appear in the log and on the gauge), and set-home / go-home — with a
live gauge and call log underneath.
## How It Works
```javascript
arduino = new Arduino();
arduino.add('myServo', new Servo()); // register the extension
setupConnection(arduino, { store: 'pardalote-servo-control' }); // Board row + connection (connect.js)
// attach + centre once the board is ready (the servo pin is the Wiring row's field)
arduino.on('ready', () => {
arduino.myServo.attach(11); // servo pin
arduino.myServo.center(); // 90°
});
```
A timed move with awaited arrival:
```javascript
const move = arduino.myServo.writeTimed(target, duration); // interpolated on-board
const ok = await move.whenDone(); // true = arrived, false = safety timeout
```
The gauge tracks `arduino.myServo.angle` — the value the library actually
applied, already clamped to any soft limits — so a clamped command is visible
on screen (and noted in the log). A PWM servo has no position feedback:
`angle` is the commanded value, which for a hobby servo *is* its state.
## Script loading order
Include the `pardalote.js` bundle (core + every extension) before your sketch:
```html
```
## Troubleshooting
**"Servo doesn't move"**
- Check wiring: signal to the pin set in the **Wiring** row (default 11), power to 5 V, ground to GND
- Verify the firmware sketch has `#include `
- Check the browser console for connection errors
**"Servo jitters"**
- Dragging the arm calls `write()` rapidly — the library coalesces these with a built-in 20 ms throttle, but if the servo still jitters check its power supply (give it its own 5 V and a common ground)
- Raise the change threshold to ignore sub-degree wobble: `arduino.myServo.setWriteThreshold(2)`
**"Servo moves to wrong position on startup"**
- `center()` moves to 90° — if the servo arm is mounted off-centre this is mechanical, not a code issue
## File Structure
```
servo-control/
├── index.html # Canvas page (loads p5.js and the pardalote.js bundle)
├── sketch.js # Servo control and visualisation
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Up to 8 servos simultaneously: `arduino.add('tilt', new Servo())`
- Fine-grained control: `arduino.myServo.writeMicroseconds(1500)`
- Custom pulse range: `arduino.myServo.attach(7, 544, 2400)`
- Combine with a sensor: map `analogRead()` directly to servo angle
---
# Stepper motor
Drive a stepper motor from the browser with Pardalote. Demonstrates
accel-limited position moves, continuous rotation, live position readout,
and the `done` completion event.
## Hardware
- Arduino UNO R4 WiFi or ESP32
- A STEP/DIR stepper driver: **TMC2208**, **TMC2209**, **A4988**, or
**EasyDriver** (or a 28BYJ-48 + ULN2003 — see `attach4wire` below)
- A stepper motor with its **own power supply** (do not run the motor coils
off the board's 5 V rail)
Wiring for a STEP/DIR driver:
| Driver pin | Arduino |
|---|---|
| STEP | pin 2 (default — change it in the page's Wiring row) |
| DIR | pin 3 (default) |
| EN | pin 4 (default) — optional, active-LOW on most drivers; −1 = none |
| GND | shared ground with the Arduino |
Set microstepping with the driver's MS pins as usual, then tell the JS side
how many steps that makes per revolution with `setStepsPerRev()`.
## Arduino
Install the **AccelStepper** library (Arduino IDE → Manage Libraries), then
upload **File → Examples → Pardalote → stepper-motor**. Note the IP address the
board reports (LED matrix on UNO R4, Serial Monitor on ESP32).
## Browser
This is a **tool** — it works out of the box, no code editing:
1. Open `index.html` in a browser.
2. Type the board's IP into the **Board** row and press **Connect**.
3. Pick your driver type and pins in the **Wiring** row (STEP/DIR or 4 coil
pins), then press **Connect** again to apply.
The browser remembers your IP, wiring, and motion profile, so next visit it
reconnects with one click. Every stepper feature has a control row — position
and timed moves, continuous spin, torque on/off, soft limits, limit switches,
and the homing routine — with a live dial and a call log underneath.
## Notes
- **Motion runs on the board.** The browser sends targets and motion
profiles; the Arduino generates the step pulses via AccelStepper. You never
stream individual steps over WiFi.
- **Soft limits** (`setLimits(-6400, 6400)`) are enforced on the Arduino — the
board clamps every target, so the browser (or an LLM driving it) can't send
the motor past the set range.
- **`done`** fires when a position move completes. `await arduino.x.moveTo(n).whenDone()`
resolves at the same moment — handy for sequencing moves.
---
# Bus servos
Drive two Feetech ST-series serial bus servos from the browser: drag to
command position, read live position / load / voltage / temperature back,
and free a joint to hand-pose it (torque off) then re-hold it.
## Hardware
- Arduino UNO R4 WiFi or ESP32
- A **Waveshare Serial Bus Servo Driver** board (or equivalent half-duplex
UART adapter), wired to a hardware serial port:
- UNO R4 WiFi → **Serial1** (D0 = RX, D1 = TX)
- ESP32 → Serial1 or Serial2 (set the pins with `configureBus({ rxPin, txPin })`)
- Two **Feetech STS3215** (or other ST/SMS) servos, daisy-chained on the bus,
set to IDs **1** and **2**
- A servo power supply (6–7.4 V typical) — not the board's 5 V rail
The STS3215 is the same servo used in the LeRobot SO-100/SO-101 arms.
### Setting servo IDs
Servos ship as ID 1. To renumber, put a **single** servo on the bus, attach
it, and call `setId(newId)` — then repeat one at a time. `scan()` lists the
IDs currently responding on the bus.
## Arduino
Install the **SCServo** library (from the Waveshare Bus Servo Adapter wiki or
the Feetech SDK — usually a ZIP, not in the Library Manager), then upload
**File → Examples → Pardalote → bus-servos**. Note the IP the board reports.
## Browser
This is a **tool** — it works out of the box, no code editing:
1. Open `index.html` in a browser.
2. Type the board's IP into the **Board IP** row and press **Connect**.
3. Set the two joints' bus IDs in the **Servos** row under the display
(defaults 1 and 2) — changing an ID reconnects and re-attaches.
The browser remembers the IP and IDs, so next visit it reconnects with one
click.
| Input | Action |
|---|---|
| Drag a dial | command that joint's position |
| Torque buttons or `1` / `2` | free that joint (torque off) to pose by hand; press again to re-hold |
## Notes
- **Positions are raw encoder counts**, not degrees — ST series is 0–4095
(centre 2048), SC series is 0–1023. `write()` takes counts; `writeDegrees()`
and `.positionDegrees` convert (accurate for ST, nominal for SC).
- **Limits are enforced by the servo itself.** `setLimits(min, max)` writes the
servo's own Min/Max position registers, so the range holds even without the
browser in the loop.
- **Torque off = teaching by demonstration.** `disableTorque()` lets you move
the joint by hand while `read()` streams the position back — the basis of the
record-a-trajectory workflow in projects like LeRobot.
- **Calibration:** move a joint to its zero pose (torque off), then
`calibrate()` sets that as centre (like LeRobot centring on `resolution / 2`).
---
# Coordinated motion
Two motors sweep **in unison** using a Pardalote group.
Each motor's type — **PWM servo**, **serial bus servo**, or **stepper** — is
chosen independently from the popup under its dial, so the pair can be **mixed**
(the default is a servo + a stepper). Both motors ping-pong between two positions,
arriving together on every leg. Each dial visualises the motion — a **180° gauge**
for PWM servos, a **360° dial** for bus servos and steppers.
This is the p5.js demonstration of `group.writeTimed()` / `whenDone()`: one call
moves both motors, and each motor type reaches its target its own way (bus
servos via matched-speed SyncWrite, PWM servos via on-board interpolation,
steppers via a board-timed constant-speed move) — all in one WebSocket message.
## Hardware
Any Pardalote board (UNO R4 WiFi or ESP32) plus **two motors of any type**:
| Type | Wiring (defaults — editable under each dial) |
|---|---|
| Servo (PWM) | two servos on pins **5** and **6** |
| Bus Servo (ST) | two Feetech ST servos, IDs **1** and **2**, on the serial bus |
| Stepper (driver) | two STEP/DIR drivers on pins **2,3** and **8,9** |
| Stepper (4-wire) | e.g. 28BYJ-48 via ULN2003 — IN1–IN4 on **8,9,10,11** and **4,5,6,7** |
The Arduino sketch just needs the matching extension(s) included — the simplest
is to include all three so you can switch type without re-flashing:
```cpp
#include
#include
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
## Browser
This is a **tool** — it works out of the box, no code editing:
1. Open `index.html` in a browser.
2. Type the board's IP into the field at the top and press **Connect**.
3. Pick each motor's type from the popup under its dial and set its pin(s)
(or bus ID) in the fields below — changes rebuild the group and reconnect.
The browser remembers the IP, motor types, and pins for next time. Without a
connection the dials still preview the sweep.
| Control | Action |
|---|---|
| Popup + pin fields under each dial | choose Servo / Bus Servo / Stepper and its pins/ID for that motor (rebuilds the group) |
| Pause / Resume | stop and start the sweep |
| Duration slider | time per leg — both motors still arrive together |
## Notes
- The dials animate a **display interpolation** of the commanded sweep, so they
move smoothly even before the board connects (you'll see `status:
reconnecting…` until it does). The `feedback` readout shows each motor's real
position once polling returns values (bus servo / stepper).
- The sweep starts each leg from the last commanded position, so the motors are
snapped to the low pose once on connect (`group.write`) to establish a known
start — see the note on first moves in the Groups docs.
- Edit the `low` / `high` values in `TYPES` to change each type's sweep range.
---
# Leader → Follower
Teleoperate a robot arm by hand. Move a **leader** arm and a second **follower**
arm mirrors it live, joint for joint — the leader/follower setup from the
LeRobot project, running entirely in the browser. Two boards, two arms, one
web page.
The leader is free (torque off), so you back-drive it by hand; the browser
streams its six joint positions to the follower, which holds and follows.
## Hardware
- **Two** WiFi Arduinos — UNO R4 WiFi or ESP32, one per arm.
- **Two identical arms**, each with six Feetech **STS3215** bus servos on a
serial-bus driver board, servo IDs **1–6**. (The STS3215 is the servo in the
LeRobot SO-100 / SO-101 arms.)
- A servo power supply per arm (6–7.4 V) — not the board's 5 V rail.
Bus wiring is the same as the Bus servos example: UNO R4 →
Serial1 (D0/D1); ESP32 → set the pins with the **bus** RX/TX fields on the page.
## Arduino
Both boards run the **same** bus-servo firmware — upload **File → Examples →
Pardalote → bus-servos** to each, and note the IP each reports. Nothing arm-
specific lives on the board; the leader/follower logic is all in the browser.
## Browser
This is a **tool** — no code editing. Open `index.html` and:
1. Enter each board's address in the **Leader** and **Follower** rows (WiFi IP,
or switch a row to **USB**) and press **Connect** on each — the button turns
green when it's up.
2. Line the arms up joint for joint and press **sync all** to match every
joint's origin (this cancels any difference in how the servos were installed).
3. Press **start relay**. Move the leader by hand; the follower mirrors it.
| Control | What it does |
|---|---|
| **sync all** | capture both arms' current positions as matched origins for every joint (press again to clear) |
| **on/off** (per pair) | the checkbox above each column excludes that joint pair from the system — its follower goes limp |
| **flip** (per joint) | mirror that joint — leader forward → follower backward |
| **set limits** | free the follower, hand-move it to record safe soft limits, press again to lock |
| **free follower** | release the follower's torque to pose it by hand |
| **start relay** | stream leader → follower |
Every setting is remembered per browser, so a return visit reconnects with one
click.
## How it works
**Two boards means two `Arduino` instances** — one connection each:
```javascript
const leader = new Arduino();
const follower = new Arduino();
leader.connect('192.168.1.41');
follower.connect('192.168.1.42');
```
**Six bus servos per arm.** Each arm gets six `BusServo`s, attached to IDs 1–6.
The leader is freed and polled so we get live positions; the follower is held:
```javascript
for (let i = 1; i <= 6; i++) {
leader.add('L' + i, new BusServo());
follower.add('F' + i, new BusServo());
}
leader.on('ready', () => {
for (let i = 1; i <= 6; i++) {
const s = leader['L' + i];
s.attach(i, 'ST');
s.disableTorque(); // back-drivable — move it by hand
s.read(50); // stream its position ~20×/sec
}
});
```
**The follower moves as a group.** A group writes all
six joints in one Feetech SyncWrite packet (one WebSocket message), so they move
together instead of trickling in one at a time:
```javascript
const arm = follower.group('arm', {
1: follower.F1, 2: follower.F2, 3: follower.F3,
4: follower.F4, 5: follower.F5, 6: follower.F6,
});
```
**The relay** copies the leader's live positions to the follower on a timer:
```javascript
setInterval(() => {
const vals = {};
for (let i = 1; i <= 6; i++) vals[i] = leader['L' + i].position;
arm.write(vals); // one coordinated move
}, 50);
```
**Sync and flip** are just a per-joint remap. Each joint remembers a *leader
origin* and a *follower origin* (captured when you toggle **sync**, or the servo
centre otherwise), and the follower target is the leader's movement *relative* to
that origin — optionally mirrored:
```javascript
const delta = leaderPos - leaderOrigin;
const target = followerOrigin + (flip ? -delta : delta);
```
Because the target is relative to a matched origin, the arms don't need to be
built or mounted identically — you line them up once, sync, and the offset
cancels out.
## Notes
- **Positions are raw encoder counts** (0–4095 for ST servos, centre 2048), not
degrees.
- **Soft limits are enforced on the follower's board.** "set limits" records the
hand-moved range and applies it with `setLimits()`, so a relayed move can't
drive the follower past it.
- The leader only ever reads; the follower only ever writes — so there's no
fighting between hand-posing and the relay.
---
# Gesture Builder
Author expressive motion on a timeline, then play it back through Pardalote's
`gesture()` — an on-board **segment schedule** each output runs on its own clock.
One web page, one Arduino, any mix of outputs.
Each **row** is one **output** (labelled **Output N**, editable). Click its gutter
summary to open a **settings dialog** — choose **Bus servo**, **PWM servo**, or
**Stepper** and set its connection pins there; the value axis and units follow the
type (counts / degrees / steps). Each **keyframe** is a time × a value; the line
between two keyframes is a **segment** you can shape with an easing curve. **Play** sends every row as one lane of a single `arduino.gesture({…})`
so they all start together and arrive together — playing forward from the **playhead**
you position on the ruler. The playhead is a **remote control**: drag it any time and
the outputs follow the sequence to that point, live.
## Hardware
- **One** WiFi Arduino — UNO R4 WiFi or ESP32.
- Any mix of outputs the firmware supports:
- **Bus servos** — Feetech **ST-series** on a serial-bus driver board, each a distinct
bus **ID** (1–253); a servo supply (6–7.4 V), not the board's 5 V rail. (ESP32 → set
the **bus** RX/TX pins on the page; UNO R4 → Serial1 on D0/D1.)
- **PWM servos** — a signal **Pin** each.
- **Steppers** — a **STEP** and **DIR** pin, and optionally an **EN** (enable) pin
(`-1` for none). Only bus servos report position, so **pose** (hand-authoring) and
**free** are bus-servo features; PWM servos always hold, and steppers free only with
an EN pin.
Wiring for bus servos is exactly the Bus servos example.
## Arduino
Upload a Pardalote sketch that registers the outputs you'll use — bus servos, PWM
servos, and/or steppers — and note the IP it reports. (The
Coordinated motion example is a ready-made mixed rig — its
sketch already includes the `PardaloteServo` / `PardaloteBusServo` / `PardaloteStepper`
headers, so you can attach whatever mix of outputs you need.) Nothing tool-specific
lives on the board: the timeline is all in the
browser and the board just plays the gestures it's sent, on whatever outputs it has.
## Browser
This is a **tool** — no code editing. Open `index.html` and:
1. Connect (WiFi IP or **USB**) — the button turns green when it's up.
2. For each row, **click its gutter summary** (**⚙ Unassigned** on a new row) to open the
settings dialog — pick the output type and enter its pins (ID / Pin / STEP·DIR·EN). Add or
remove rows with the **+ / −** buttons in the gutter. New rows start **unassigned** and the
fields start **blank** — the tool never guesses which pin or ID your hardware is on. Until
a row is fully set up it's held **off** (its **on** box is locked) and never talks to the
board — you can still sketch its motion (on a 0–100 % scale, converted when you pick a
type). Fill in the last field and it switches **on**.
3. Build the motion on the timeline:
| Action | What it does |
|---|---|
| **click a row's gutter summary** | open the **settings** dialog — output type + connection pins (draggable; OK keeps, cancel/Esc revert) |
| **double-click a lane** | add a **keyframe** at that time and value |
| **drag a keyframe** | set its value (up/down) and time (left/right). If the output isn't **free**, its motor follows the edit live — but only when the edit changes the value **at the playhead** (drag a keyframe the playhead sits on, or a segment touching it; otherwise the motor holds) |
| **right-click a keyframe** | **delete** it, or **pose servo** (bus servos only) — free the servo, hand-move it, click to commit (Esc cancels) |
| **right-click a segment** | set its **shape** (easing) — linear / easeIn / easeOut / easeInOut / back |
| **drag the last keyframe** past the right edge | extend the timeline |
| **drag a max / min value** (gutter) | set that output's soft limit (the value axis spans min–max, in the type's unit) |
| **right-click a max / min value** | **reset** to the type's default range — or (bus servos only) **pose limits** by sweeping the range by hand |
| **drag the playhead** (ruler) | **drive the outputs** to that point in the sequence, live — a remote control (grabbing it during play takes over). Play resumes from wherever it rests |
| **▶ play** | play the sequence forward from the playhead (one batched `arduino.gesture({…})`). Every output keeps to the schedule; when playback ends the **info** line says `done`, or names any output that couldn't reach its last key within 2 s and gave up, and where it stopped |
| **■ stop** | cancel the gesture and hold where it is — the playhead stays put (the robot doesn't rewind) |
| **\|◀◀ start** / **end ▶▶\|** | send the playhead **and the outputs** to the start / end |
| **free** / **free all** | release an output's torque to hand-pose it — bus servos and steppers-with-EN only (greyed otherwise) |
The teal marker in each row's gutter is that output's **live position** (sensed for bus
servos; last-commanded for PWM servos and steppers).
Everything (rows, keyframes, shapes, connection) is remembered per browser.
## Export — and import
Four code panels below the timeline. For each language, the **gesture** panel is the
definition (one segment array per output) and is **editable**; the **example use** panel
beside it is a read-only, **self-contained** runnable program — it includes the gesture data
inline plus setup and connection (or `setup()`/`loop()`), so you can copy that one panel and
run it as-is. Switched-off rows (including ones whose output isn't set up yet) are omitted, matching what
**play** sends.
**It's two-way.** Edit the numbers in a gesture panel — or paste in a definition you saved
earlier — and **click away**: the timeline rebuilds from it, and every other panel (and the
other language) regenerates to match. So you can bring an already-authored gesture back in and
keep editing it visually. The definition is the source of truth: each lane matches an existing
row by name (keeping its output type / pins / limits), a lane you add becomes a new **unassigned**
row (its values kept as written — pick its output after), and a lane you remove drops that row. If the code can't be read, the
timeline is left untouched and the panel reverts.
- **JavaScript** — a **`gesture`** object (one segment array per output, keyed by your
`arduino.add(name, …)` names). The example includes that object, connects, attaches, and
plays it with `arduino.gesture(gesture, { absolute: true })`.
- **C++** (board-side) — the gesture as `static const PardaloteSeg …Segs[]` schedules in flash;
the example is a full sketch (`setup()`/`loop()`, `attach()`) that includes those arrays and
plays them with `Pardalote.gesture().add(…).play()` (or `Pardalote.gesture(id, segs, count)` for one output).
Editing **either** language's definition rewrites the timeline — and the other language follows.
## How it works
**Each row becomes a `gesture()`.** A row's keyframes are turned into an absolute
segment schedule — a lead-in from the servo's live position to the first keyframe,
then one segment per gap. Each segment carries the *left* keyframe's easing curve:
```javascript
// row points [{t, v, curve}, …] → gesture segments (values in servo counts)
const segs = [{ to: pts[0].v, dur: pts[0].t, curve: 'linear' }];
for (let i = 1; i < pts.length; i++)
segs.push({ to: pts[i].v, dur: pts[i].t - pts[i-1].t, curve: pts[i-1].curve });
servo.gesture(segs, { absolute: true });
```
**All rows play together.** Instead of firing each `gesture()` separately, the
rows are sent as one multi-channel `arduino.gesture({…})` — a single batched
message where every channel starts together, and shorter lanes are padded with a
trailing hold so every servo arrives together:
```javascript
arduino.gesture({ seq0: segsA, seq1: segsB }, { absolute: true });
```
(The keys are the actuator names you passed to `arduino.add(name, …)`. Under the
hood this runs through a transient group; when you're
already holding a named group for live control, `group.gesture()` does the same.)
**The preview uses the same easing math as the board** (`curveShape()`), so the
shape you draw on screen is the shape the schedule encodes.
## Notes
- **Angles are raw ST encoder counts** under the hood (0–4095 over a nominal
360°); the read-outs show degrees.
- **Board limits:** up to **12 segments per servo** and (here) **10 keyframes
per row** — plenty for a gesture, and it keeps room for the group's arrive-
together padding.
- **Bus servos render the curve on the board.** A Feetech bus servo takes a
*(position, speed)* target and runs its own move, so — unlike a PWM `Servo`,
whose controller takes a raw angle — Pardalote renders the easing with a
board-side **streaming interpolator**: it samples the curve on a fixed clock and
streams look-ahead setpoints (batched across a group into one phase-locked
write). The shape you draw, overshoot included, is the shape the servo runs — no
need to hand-decompose a move into extra keyframes to fake it. (A segment shorter
than ~600 ms plays linear — too brief to render an easing shape, and imperceptibly so.)
---
# NeoPixel
An interactive p5.js colour mixer that drives a NeoPixel LED strip in real time. Move the mouse across the canvas — left/right for hue, up/down for brightness — and the strip follows the colour under the cursor. Hover the centre for white.
## What This Example Does
- Draws an HSB colour field on a 600×400 p5.js canvas
- Samples the canvas colour under the mouse and sets all 8 LEDs to match — or white when the cursor is over the centre circle
- The circle in the middle previews the colour currently sent to the strip
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- **NeoPixel LED strip** — WS2812B or compatible, 8 LEDs (configurable)
- Arduino and browser must be on the same WiFi network
### Wiring
| NeoPixel wire | Arduino |
|---|---|
| VCC (red) | 5 V |
| GND (black) | GND |
| Data (white/yellow) | data pin (`const PIN` in sketch.js, default 27) |
For strips longer than ~30 LEDs use an external 5 V supply. Connect all grounds (Arduino, strip, supply) together. The data line still goes to the Arduino pin.
### Arduino libraries
Install via Arduino IDE → Tools → Manage Libraries:
- `WebSocketsServer` (by Markus Sattler)
- `Adafruit NeoPixel` (by Adafruit)
Install Pardalote itself by copying `pardalote-arduino/library/Pardalote/` into your Arduino libraries folder (see the top-level README).
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → neopixel**. The sketch is two lines:
```cpp
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Create a `secrets.h` file in the sketch folder with:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
If both are configured, `secrets.h` is tried first.
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example and connect
Open `index.html` in a browser. In the **Board** row, enter the Arduino's IP
(or switch to **USB**) and press **Connect** — your choice is remembered per
browser. The strip's data pin and pixel count are set at the top of `sketch.js`
(`const PIN` / `const COUNT`). Move the mouse across the colour field — the LEDs
follow the colour under the cursor, and the circle in the middle previews it.
Hover the centre for white.
## How It Works
```javascript
const PIN = 27, COUNT = 8; // strip data pin, number of pixels
arduino = new Arduino();
arduino.add('strip', new NeoPixel()); // register the extension before connecting
setupConnection(arduino, { store: 'pardalote-neopixel' }); // Board row + connection (connect.js)
// configure the strip once the board is ready ('ready' re-fires on every reconnect)
arduino.on('ready', () => {
arduino.strip.init(PIN, COUNT);
arduino.strip.setBrightness(50); // 0–255
arduino.strip.clear();
arduino.strip.show();
});
```
In `draw()`, the colour under the mouse is sampled from the canvas and sent to the strip (or white when the cursor is over the centre circle):
```javascript
let pixelColor = get(mouseX, mouseY);
let neoColor = arduino.strip.Color(red(pixelColor), green(pixelColor), blue(pixelColor));
arduino.strip.fill(neoColor, 0, numPixels);
arduino.strip.show(); // must call show() to push changes to the LEDs
```
Changes are buffered locally until `show()` is called — this means you can set many pixels in one `draw()` pass and send them all in a single message.
## Script loading order
Include the `pardalote.js` bundle (core + every extension) before your sketch:
```html
```
## Troubleshooting
**"LEDs don't light up"**
- Check VCC → 5 V, GND → GND, data → pin 11
- Verify the sketch has `#include `
- Try a lower brightness: `arduino.strip.setBrightness(20)`
- Always call `show()` after setting colours
**"Wrong colours"**
- Most WS2812B strips use `NEO_GRB`. Try:
```javascript
arduino.strip.init(11, 8, NEO_GRB + NEO_KHZ800);
```
- SK6812 RGBW strips use `NEO_GRBW + NEO_KHZ800`
**"LEDs flicker"**
- Power issue — add an external 5 V supply for strips longer than ~30 LEDs
- Add a 470 Ω resistor between the Arduino data pin and the strip for longer runs
**"Performance is slow" or "colours lag behind the cursor" (UNO R4)**
- The UNO R4 WiFi WebSocket implementation occasionally drops connections — Pardalote reconnects automatically
- Raise the colour-distance threshold to skip tiny changes: `arduino.strip.setThreshold(10)`
- Raise the show throttle to cap how often updates are sent: `arduino.strip.setThrottle(50)` (default 20 ms = ~50 Hz; 50 ms = ~20 Hz is gentler on slow WiFi)
## File Structure
```
neopixel/
├── index.html # Canvas page (loads p5.js and the pardalote.js bundle)
├── sketch.js # NeoPixel colour picker
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Add a second strip: `arduino.add('strip2', new NeoPixel())` — up to 4 strips simultaneously
- Drive colours from a sensor: combine with `analogRead()` or the Ultrasonic extension
- Try the `control-panel` example for a multi-device dashboard
---
# Ultrasonic sensor
A p5.js sketch that reads an HC-SR04 ultrasonic distance sensor and visualises the result as a colour bar. Distance and connection status are shown on screen in real time.
## What This Example Does
- Polls the ultrasonic sensor every 200 ms
- Draws a vertical bar whose height represents distance (0–200 cm)
- Bar colour shifts from red (close) to green (far) using HSB colour mode
- Shows distance in cm and Arduino connection status
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- **HC-SR04** ultrasonic sensor (or compatible: JSN-SR04T, HY-SRF05, etc.)
- Arduino and browser must be on the same WiFi network
### Wiring (4-wire, HC-SR04)
| Sensor pin | Arduino |
|---|---|
| VCC | 5 V |
| GND | GND |
| TRIG | Pin 7 |
| ECHO | Pin 8 |
Set the **trig** and **echo** pins at the top of `sketch.js` (`const TRIG` / `const ECHO`, defaults 12 and 14).
**3-wire sensor** (trig and echo on the same pin): set both fields to the same pin.
### Arduino libraries
Install via Arduino IDE → Tools → Manage Libraries:
- `WebSocketsServer` (by Markus Sattler)
Install Pardalote itself by copying `pardalote-arduino/library/Pardalote/` into your Arduino libraries folder (see the top-level README).
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → ultrasonic-sensor**. The sketch is two lines:
```cpp
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Create a `secrets.h` file in the sketch folder with:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
If both are configured, `secrets.h` is tried first.
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example and connect
Open `index.html` in a browser. In the **Board** row, enter the Arduino's IP
(or switch to **USB**) and press **Connect** — your choice is remembered per
browser. The trig/echo pins are set at the top of `sketch.js` (`const TRIG` /
`const ECHO`). Move an object in front of the sensor — the bar should respond.
## How It Works
```javascript
const TRIG = 12, ECHO = 14; // HC-SR04 trigger / echo pins
arduino = new Arduino();
arduino.add('ultrasonicSensor', new Ultrasonic()); // register the extension
setupConnection(arduino, { store: 'pardalote-ultrasonic' }); // Board row + connection (connect.js)
// Attach + start polling once the board is ready ('ready' re-fires on every reconnect)
arduino.on('ready', () => {
arduino.ultrasonicSensor.attach(TRIG, ECHO);
arduino.ultrasonicSensor.setTimeout(40); // 40 ms echo timeout ≈ 686 cm max range
arduino.ultrasonicSensor.read(200, CM); // poll every 200 ms, return cm
});
```
In `draw()`, `read()` returns the latest cached value — no extra network traffic:
```javascript
let cm = arduino.ultrasonicSensor.read();
```
A return value of `-1` means the echo timed out (nothing in range, or object too far away).
### Echo timeout and range
The echo timeout determines maximum detectable range:
| Timeout | Approximate max range |
|---|---|
| 30 ms (default) | ~500 cm |
| 40 ms | ~686 cm |
Increase with `setTimeout()` for longer range; decrease to speed up failed reads.
## Script loading order
Include the `pardalote.js` bundle (core + every extension) before your sketch:
```html
```
## Troubleshooting
**"Always reads -1"**
- Echo timeout too short — try `arduino.ultrasonicSensor.setTimeout(50)`
- Point the sensor at a flat, hard surface (fabric and foam absorb ultrasound)
- Check wiring: TRIG and ECHO to the correct pins
**"Readings jump around"**
- Normal for ultrasound — add smoothing in `draw()` with a rolling average
- Make sure the sensor has a clear line of sight with no nearby reflective surfaces
**"Sensor doesn't respond at all"**
- Verify the sketch has `#include `
- Check VCC → 5 V and GND → GND
**"Arduino won't connect"**
- Check the IP in the **Board** field and that you pressed **Connect**
- Arduino and browser must be on the same WiFi network
## File Structure
```
ultrasonic-sensor/
├── index.html # Canvas page (loads p5.js and the pardalote.js bundle)
├── sketch.js # Sensor setup and distance visualisation
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Read in inches: `arduino.ultrasonicSensor.read(200, INCH)`
- Use as a trigger: fire an event when distance crosses a threshold with `onChange()`
- Up to 4 sensors simultaneously: `arduino.add('front', new Ultrasonic())`, `arduino.add('side', new Ultrasonic())`
- Combine with servos or NeoPixels to build reactive physical installations
---
# Motion sensor (IMU)
A p5.js sketch that reads a 6-DOF inertial measurement unit and renders a live 3D model that rotates with the physical sensor in all three axes. Roll, pitch and yaw are shown on a HUD overlay.
## What This Example Does
- Polls the IMU at 20 Hz (every 50 ms by default — gentle on UNO R4 WiFi and older ESP32 boards)
- Computes **roll** and **pitch** via a complementary filter that combines gyroscope integration (fast response) with accelerometer tilt (gravity-anchored drift correction)
- Computes **yaw** via pure gyro integration. The MPU-6050 has no magnetometer, so there's no gravity-like reference for heading — yaw will drift a few degrees per minute and only `c`-calibration resets it
- Renders a 3D model in WEBGL that rotates to match the physical sensor orientation in all three axes
- Displays roll, pitch, yaw, accelerometer, gyroscope, and temperature readings live with color-coded axis labels (red/green/blue → X/Y/Z)
- Supports in-browser calibration with a button or the `C` key
## Hardware Requirements
- **Arduino UNO R4 WiFi** or **ESP32 development board**
- **MPU-6050** (or other supported IMU — see below)
- Arduino and browser must be on the same WiFi network
### Wiring (MPU-6050 → Arduino)
| Sensor pin | Arduino |
|---|---|
| VCC | 3.3 V |
| GND | GND |
| SDA | SDA pin |
| SCL | SCL pin |
| AD0 | GND (sets address to 0x68) |
> **Note:** Most Arduino boards have dedicated SDA and SCL pins. On the UNO R4 WiFi these are A4/A5; on ESP32 boards they vary — check your pinout. On ESP32 you can use any GPIO pair with the optional `sda` / `scl` params.
### Arduino libraries
Install via Arduino IDE → Tools → Manage Libraries:
- `WebSocketsServer` (by Markus Sattler)
No separate IMU library is required — `PardaloteIMU.h` reads the sensor registers directly over I2C.
Install Pardalote itself by copying `pardalote-arduino/library/Pardalote/` into your Arduino libraries folder (see the top-level README).
## Supported Sensors
The `IMU` extension supports multiple sensor families. Pass the model name to the constructor:
| Model string | Chip | DOF | Default address |
|---|---|---|---|
| `'6050'` | InvenSense MPU-6050 | 6 | 0x68 |
| `'6500'` | InvenSense MPU-6500 | 6 | 0x68 |
| `'9250'` | InvenSense MPU-9250 | 9 | 0x68 |
| `'9255'` | InvenSense MPU-9255 | 9 | 0x68 |
| `'LSM6DS3'` | STMicro LSM6DS3 | 6 | 0x6A |
| `'LSM6DSOX'` | STMicro LSM6DSOX | 6 | 0x6A |
The I²C **address** is set by the sensor's `AD0` (MPU) / `SA0` (LSM6) pin: tie
it to GND for the default (`0x68`), or to 3V3 to shift it up by one (`0x69`) —
handy for two sensors on one bus. Set the address at the top of `sketch.js` (`const ADDR`).
The **SDA/SCL pins** are software-definable **on ESP32** (Pardalote passes them
to `Wire.begin(sda, scl)`), so the page's SDA/SCL fields let you pick any pair.
On **UNO R4** the I²C pins are fixed hardware, so those fields lock automatically
when an R4 connects.
## Quick Start
### 1. Upload the firmware
1. In Arduino IDE: **File → Examples → Pardalote → IMU**. The sketch is two lines:
```cpp
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
2. Select your board and upload
3. Open the Serial Monitor at 115200 baud — on first boot Pardalote asks for your WiFi credentials:
```
=== Pardalote ===
No WiFi networks stored.
=== WiFi Configuration ===
[a]dd [d]elete [c]lear all [s]how [x] exit
> a
SSID: YourWiFiName
Password: ********
Saved: YourWiFiName
> x
```
Credentials are saved to EEPROM and survive re-uploads. Press `w` in the Serial Monitor at any point while the board is trying to connect to update them.
**Prefer compile-time credentials?** Create a `secrets.h` file in the sketch folder with:
```cpp
#define SECRET_SSID "YourWiFiName"
#define SECRET_PASS "YourWiFiPassword"
```
4. Find your Arduino's IP address:
- **UNO R4 WiFi:** scrolls across the LED matrix
- **ESP32:** printed in the Serial Monitor
### 2. Open the example and connect
Open `index.html` in a browser. In the **Board** row, enter the Arduino's IP
(or switch to **USB**) and press **Connect** — your choice is remembered per
browser. The I²C **address** (`0x68` with AD0 → GND, `0x69` with AD0 → 3V3) and,
on ESP32, the **SDA/SCL** pins are set at the top of `sketch.js` (`const ADDR` /
`const SDA` / `const SCL`; a UNO R4 ignores SDA/SCL — it uses its fixed hardware
pins). Tilt the sensor — the 3D model should follow.
(This page targets the MPU-6050. To use a different sensor model in your own
copy, change the constructor argument in `sketch.js`, e.g.
`new IMU('LSM6DS3')`, and set the matching `ADDR`.)
## How It Works
```javascript
const ADDR = 0x68, SDA = 21, SCL = 22; // I²C address (0x69 if AD0 HIGH); ESP32 SDA/SCL
arduino = new Arduino();
arduino.add('imu', new IMU('6050'));
setupConnection(arduino, { store: 'pardalote-imu' }); // Board row + connection (connect.js)
arduino.on('ready', () => {
arduino.imu.attach(ADDR, SDA, SCL); // address, then ESP32 SDA/SCL (UNO R4 ignores these)
arduino.imu.onChange(onReading); // fires each time a reading arrives
arduino.imu.onCalibrate(onCalibrate);
arduino.imu.read(POLL_MS); // start polling
});
```
Each reading delivers `{ accel, gyro, temp }`:
```javascript
function onReading({ accel, gyro, temp }) {
// accel: { x, y, z } in g
// gyro: { x, y, z } in °/s
// temp: in °C
}
```
### Complementary filter (roll & pitch)
The raw accelerometer gives a stable tilt angle but is noisy during movement. The gyroscope gives smooth, fast response but drifts over time. The complementary filter blends them — gyro integration dominates short-term response; accelerometer tilt slowly pulls the estimate back toward the true gravity-down direction:
```javascript
const ALPHA = 0.96; // 96% gyro, 4% accel correction
roll = ALPHA * (roll + gyro.x * toRad * dt) + (1 - ALPHA) * aRoll;
pitch = ALPHA * (pitch + gyro.y * toRad * dt) + (1 - ALPHA) * aPitch;
```
Increase `ALPHA` for faster response (more gyro trust, more drift). Decrease it for more stability (more accel, noisier during motion).
### Yaw (pure gyro integration)
```javascript
yaw += gyro.z * toRad * dt;
```
No accelerometer correction is possible — gravity tells you which way is down, but doesn't tell you which way is north. The MPU-6050 has no magnetometer, so yaw drifts. Press `C` to recalibrate and zero it back to its reference heading.
### Calibration
Click the **CALIBRATE** button or press **C** while the sensor is flat and still. The Arduino collects 200 samples (~400 ms) and computes offset corrections. After calibration:
- Accel: x ≈ 0 g, y ≈ 0 g, z ≈ +1 g (gravity preserved)
- Gyro: x ≈ 0, y ≈ 0, z ≈ 0
Offsets are stored on the Arduino and re-sent to any browser that reconnects — you don't need to calibrate again after a page reload.
## Tuning the orientation
If the 3D model rotates in the wrong direction, flip the sign in `draw()`. The current sketch uses aerospace order (yaw → pitch → roll):
```javascript
rotateY(yaw); // → rotateY(-yaw) to flip heading direction
rotateX(-roll); // → rotateX(roll) to flip left/right tilt
rotateZ(pitch); // → rotateZ(-pitch) to flip forward/back tilt
```
For non-standard sensor mounting, you may also need to swap which gyro axis feeds which angle in the filter calculation.
## Changing the poll rate
```javascript
const POLL_MS = 50; // 50 ms = 20 Hz
```
Lower values give smoother motion but use more I²C bandwidth and WiFi. **On the UNO R4 WiFi and older ESP32 boards (WROVER class) keep this at 50 ms or higher** — faster polling can hang the I²C bus on those chips. Newer ESP32-S3 / C3 boards can handle 20 ms (50 Hz) reliably.
## Script loading order
Include the `pardalote.js` bundle (core + every extension) before your sketch:
```html
```
## Troubleshooting
**"IMU not responding"**
- Check SDA and SCL wiring; confirm the I2C address (AD0/SA0 state)
- Check Serial Monitor for `IMU WHO_AM_I mismatch` — this means the wrong model string was passed or the wiring is incorrect
- Verify the sketch has `#include `
**"3D model drifts over time when stationary"**
- For roll/pitch drift: normal gyro bias — run calibration. The accelerometer correction factor `ALPHA` gradually pulls the angle back; lowering it (e.g. to 0.90) makes correction faster at the cost of more noise during motion
- For yaw drift specifically: this is unavoidable on the MPU-6050 (no magnetometer). A few degrees per minute is normal. Press `C` to recalibrate and zero the heading
**"Board hangs after a few seconds"**
- Most common on UNO R4 WiFi and older ESP32 boards (WROVER class). The I²C peripheral can stall under sustained high-rate reads. Raise `POLL_MS` to 50 or higher
**"Readings are noisy during fast motion"**
- Normal — accelerometer tilt angles are unreliable when the board is accelerating
- Increase `ALPHA` slightly so the filter trusts the gyro more during motion
**"Temperature reading seems wrong"**
- The MPU-6050 die temperature runs ~10–15 °C above ambient — this is expected
- Use it for relative drift compensation, not as an ambient thermometer
**"Arduino won't connect"**
- Check the IP address in `sketch.js`
- Arduino and browser must be on the same WiFi network
## File Structure
```
IMU/
├── index.html # Canvas page (loads p5.js and the pardalote.js bundle)
├── sketch.js # IMU setup, complementary filter, 3D render
├── connect.js # Board connection UI (WiFi / USB, remembered per browser)
├── style.css # Dark HUD overlay
└── README.md # This file
dist/
└── pardalote.js # Pardalote bundle (core + all extensions)
```
## Next Steps
- Change model: `new IMU('9250')` for a 9-DOF MPU-9250
- Two sensors on the same bus: use different addresses (0x68 and 0x69) and `arduino.add('imu1', new IMU('6050'))`, `arduino.add('imu2', new IMU('6050'))`
- Set accel/gyro range: `arduino.imu.setAccelRange(4)` (±4g), `arduino.imu.setGyroRange(1000)` (±1000°/s)
- Use on ESP32 with custom I2C pins: `arduino.imu.attach(0x68, 21, 22)`
- Build a full AHRS by adding a magnetometer reading from a 9-DOF variant
---
# Camera stream
Streams live MJPEG video from an ESP32 camera module into a p5.js canvas over WiFi.
Video is served over a separate HTTP connection — it does not flow through the WebSocket control channel, so camera streaming and Arduino control can run simultaneously without interfering with each other.
---
## What you need
- An ESP32 board with a camera module and PSRAM (see supported boards below)
- The board flashed with a sketch that `#include`s `` (the bundled `camera` example fits the bill)
- A web browser on the same WiFi network
---
## Files
| File | Purpose |
|---|---|
| `index.html` | Loads p5.js, Pardalote, and the sketch |
| `sketch.js` | p5.js sketch — connects, streams, draws |
| `style.css` | Minimal page reset |
---
## Arduino setup
### 1. Open the library example
In Arduino IDE: **File → Examples → Pardalote → camera-stream**. The sketch looks like:
```cpp
// Uncomment ONE:
#define CAMERA_MODEL_WROVER_KIT // Freenove ESP32-WROVER-DEV
// #define CAMERA_MODEL_AI_THINKER // AI-Thinker ESP32-CAM
// #define CAMERA_MODEL_XIAO_ESP32S3 // Seeed XIAO ESP32S3 Sense
// #define CAMERA_MODEL_ESP32S3_EYE // Espressif ESP32-S3-EYE
// #define CAMERA_MODEL_M5STACK_PSRAM // M5Stack PSRAM
// #define CAMERA_MODEL_M5STACK_WIDE // M5Stack Wide
// ... (see PardaloteCamera.h for all supported models)
#include
#include
void setup() { Pardalote.begin(); }
void loop() { Pardalote.run(); }
```
Pick the `CAMERA_MODEL_*` define that matches your hardware before the include.
### 2. Upload
Flash the sketch to your board. Once connected to WiFi, the IP address is printed in the Serial Monitor (115200 baud).
---
## Browser setup
### 1. Connect on the page
Open `index.html`, then in the **Board** row enter the Arduino's IP (or switch
to **USB**) and press **Connect** — no code editing needed, and the setting is
remembered per browser. The camera's stream port is fixed by the firmware
(`82`), so there's nothing else to set.
### 2. Open the example
Serve the folder from a local web server and open `index.html` in a browser. You can use any static server — for example:
```
npx serve .
```
If you are using a code editor you can run the web server there.
> **Why a server?** Browsers block WebSocket connections from `file://` pages on some platforms. A local server avoids this.
---
## How it works
```
Arduino Browser
────────────────────────────────────────────────────────
WebSocket :81 ←──── control ────→ pardalote.js (Camera extension)
HTTP :82 ──── MJPEG stream ──→
```
1. `arduino.connect()` opens a WebSocket on port 81.
2. On `ready`, `arduino.cam.attach(CAMERA_PORT)` sends a `CMD_CAMERA_INIT` frame to the Arduino over the WebSocket. The Arduino initialises the camera hardware and starts an HTTP server on the requested port.
3. The Arduino echoes the confirmed port back. The Camera extension sets an `
` element's `src` to `http://:/stream` and emits the `stream` event.
4. The browser fetches the MJPEG stream directly over HTTP — the WebSocket is only used for the initial handshake and any subsequent control commands (resolution, quality).
5. In `draw()`, `image(camEl, 0, 0, width, height)` renders the current frame to the p5.js canvas. Just before it, the sketch copies the frame's real size into `camEl.width` / `camEl.height`: p5 records an image's size only when its first frame loads, so without this a resolution change leaves a black band at the bottom.
---
## Adjusting the stream
```javascript
// Resolution — call before or after attach()
arduino.cam.setResolution(FRAMESIZE_QVGA); // 320×240 ← default
arduino.cam.setResolution(FRAMESIZE_VGA); // 640×480
arduino.cam.setResolution(FRAMESIZE_HD); // 1280×720
// JPEG quality: 0 = best image / highest bandwidth
// 63 = worst image / lowest bandwidth
arduino.cam.setQuality(12); // default
```
| Constant | Resolution |
|---|---|
| `FRAMESIZE_QQVGA` | 160×120 |
| `FRAMESIZE_QVGA` | 320×240 |
| `FRAMESIZE_HVGA` | 480×320 |
| `FRAMESIZE_VGA` | 640×480 |
| `FRAMESIZE_SVGA` | 800×600 |
| `FRAMESIZE_HD` | 1280×720 |
Higher resolutions require more bandwidth and may reduce frame rate. QVGA is a good starting point for most WiFi environments. Anything above QQVGA also needs **PSRAM enabled**, and the largest sizes don't work on every sensor — see Troubleshooting below.
---
## Snapshots
Capture a single JPEG still without interrupting the stream:
```javascript
const url = await arduino.cam.snapshot();
// url is a blob: URL — use as an
src or save it
// revoke when done to free memory:
URL.revokeObjectURL(url);
```
The snapshot is served from `http://:/snapshot` — its own small web server on the next port (83 for this example), so it answers even while the stream is running.
---
## Pixel manipulation
Because the camera HTTP server sends `Access-Control-Allow-Origin: *`, p5.js can read pixel data from the stream:
```javascript
arduino.cam.on('stream', ({ url }) => {
camEl = createImg(url, '');
camEl.hide();
});
function draw() {
if (camEl) {
camEl.width = camEl.elt.naturalWidth; // keep p5's idea of the size current
camEl.height = camEl.elt.naturalHeight;
image(camEl, 0, 0, width, height);
loadPixels();
// pixels[] is now available — RGBA values for every canvas pixel
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 255 - pixels[i]; // invert red
pixels[i+1] = 255 - pixels[i+1]; // invert green
pixels[i+2] = 255 - pixels[i+2]; // invert blue
}
updatePixels();
}
}
```
Note: `loadPixels()` on every frame is CPU-intensive. For heavy processing, consider drawing to an off-screen `createGraphics()` buffer and only calling `loadPixels()` on that.
---
## Troubleshooting
**Stream doesn't appear**
- Check that the IP in the **Board** field is correct and you pressed **Connect**
- Open `http://:/stream` directly in a browser tab — if you see video there, the Arduino is working and the issue is in the JS
- Check the browser console for errors
**"Camera Init failed: 0x106 (ESP_ERR_NOT_SUPPORTED)"**
- Wrong board define — the camera sensor PID didn't match. Select the correct `CAMERA_MODEL_*` for your hardware and re-flash.
**Low frame rate**
- Reduce resolution with `setResolution(FRAMESIZE_QVGA)` or increase quality number (lower quality) with `setQuality(20)`
- Move the Arduino closer to the WiFi access point
- Disable WiFi modem sleep — Pardalote does this automatically with `WiFi.setSleep(false)`
**Image is stuck at 160×120 (QQVGA) and `setResolution()` won't change it**
- Anything above QQVGA needs the board's **PSRAM enabled and working**. Without it the camera falls back to a single QQVGA (160×120) frame buffer in internal RAM, and any `setResolution()` to a larger size is refused — you'll see `[Camera] No PSRAM — using DRAM, forced to QQVGA` in the Serial Monitor. Fix PSRAM and the higher resolutions come back.
- **Seeed XIAO ESP32S3:** it has 8 MB of octal PSRAM, but you must select it — **Tools → PSRAM → `OPI PSRAM`** (not "QSPI PSRAM", not "Disabled"). Re-flash; the "No PSRAM" line should be gone.
- **ESP32 WROVER:** PSRAM is enabled automatically by the "ESP32 Wrover Module" board, so there's no PSRAM menu to set. If the Serial Monitor shows `esp_psram: SPI SRAM memory test fail … writes failed`, PSRAM *is* enabled but the module's chip is failing its power-on test — usually marginal power or a faulty module. Try a full power-cycle with a different USB cable/port, drop **Tools → Flash Frequency → 40 MHz**, and if it persists use a different WROVER (that PSRAM chip is likely bad).
**`FRAMESIZE_HD` stutters or drops the stream (`cam_hal: FB-OVF` / `net::ERR_INCOMPLETE_CHUNKED_ENCODING`)**
- `FB-OVF` means a frame was bigger than the buffer the camera driver set aside for it, so it was dropped (the browser console shows `net::ERR_INCOMPLETE_CHUNKED_ENCODING`). Older Pardalote versions left too little room for HD frames. **Update the Pardalote Arduino library**: it now reserves room for frames up to 1600×1200 on boards with PSRAM, and HD streams on the XIAO ESP32S3. If it still happens, check PSRAM is enabled (above), then step down a size (e.g. **`FRAMESIZE_SVGA`**, 800×600).
**XIAO ESP32S3 won't connect to WiFi**
- The Sense board has a u.FL connector for an external antenna. If the antenna switch is set to external and nothing is plugged in, the radio has no antenna and cannot connect. Either attach an external antenna or move the antenna switch to the internal position.
**Canvas size doesn't match stream**
- The canvas size and stream resolution are independent. `image(camEl, 0, 0, width, height)` scales the stream to fill the canvas. Adjust `createCanvas()` dimensions to match your desired aspect ratio.
**Black band at the bottom after changing resolution**
- p5's `createImg()` records the image's size once, from the stream's first frame. When a new resolution arrives with a different shape, `image()` still uses the old size and the picture no longer fills the canvas. Copy the real size in before drawing: `camEl.width = camEl.elt.naturalWidth; camEl.height = camEl.elt.naturalHeight;` (the example does this).
**Only one browser can receive the stream at a time**
- This is a fundamental limitation of the ESP32 camera driver. The camera's `esp_camera_fb_get()` function is single-consumer by design — only one HTTP client can receive MJPEG frames at a time. A second browser connecting will see a black screen and will only get the feed once the first disconnects. This behaviour is identical to Espressif's own CameraWebServer example. Multi-client streaming requires a dedicated FreeRTOS task that owns the camera, copies each frame to a shared PSRAM buffer, and fans it out to all connected clients — this is not currently implemented in Pardalote.
---
# Camera Pose
Runs Google's [MediaPipe](https://ai.google.dev/edge/mediapipe/solutions/vision/pose_landmarker)
**Pose Landmarker** on the live MJPEG video from an ESP32 camera — green dots
track a person's body landmarks and white lines connect them into a skeleton,
all in a p5.js canvas.
It's the camera-stream example with pose detection layered
on top: instead of pointing the pose model at a webcam (`createCapture(VIDEO)`),
it points it at the board's video stream.
---
## What you need
- An ESP32 board with a camera module and PSRAM, flashed with the **camera-stream**
firmware (see camera-stream — the same ``
sketch drives this example; there's nothing new to upload)
- A web browser on the same WiFi network, with internet access the first time
(MediaPipe and its model are loaded from a CDN)
The stream is served over HTTP straight from the board's IP, so this example
needs a **WiFi** connection — USB carries only the control link, not video.
---
## Files
| File | Purpose |
|---|---|
| `index.html` | Loads p5.js, Pardalote, and the sketch |
| `sketch.js` | Loads MediaPipe, connects, streams, runs pose detection, draws landmarks + skeleton |
| `connect.js` | Board connection UI (WiFi / USB, remembered per browser) |
| `style.css` | Pardalote example house style |
---
## Browser setup
Open `index.html` from a local web server (browsers block some connections from
`file://` pages — `npx serve .` works). In the **Board** row enter the board's
IP and press **Connect** — the setting is remembered per browser. The model
loads on its own; once video arrives, landmarks appear over it.
The settings live at the top of `sketch.js`:
```javascript
const FRAME_SIZE = FRAMESIZE_VGA; // 640×480 — matches the canvas
const MIRROR_VIDEO = true; // flip left-to-right, like a webcam selfie
const POSE_MODEL = 'lite'; // 'lite' (fastest), 'full', or 'heavy' (most accurate)
const NUM_POSES = 1; // how many people to track
const DETECT_FPS = 15; // detections per second
const MIN_VISIBILITY = 0.5; // hide landmarks the model can't really see
```
An outward-facing ESP32 camera usually shouldn't be mirrored — set
`MIRROR_VIDEO` to `false` if movement looks backwards.
---
## How it works
### Loading MediaPipe
There's no `