diff --git a/AGENTS.md b/AGENTS.md index d5bd0c66..73fb5895 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Tiny Engineer is an open-source ESP32-C3 Wi-Fi desk robot: 3D-printed mechanics, | --- | --- | | `src/`, `include/`, `lib/`, `data/` | Firmware (PlatformIO) | | `packages/` | HTTP / hook CLIs (Cursor, Antigravity, Claude Code, …) — scope `integrations` | +| `skills/` | Agent skills shared by Claude Code, Cursor and Antigravity — scope `integrations` | | `3d_models/` | CAD and printables (CERN-OHL-S) | | `mods/` | Optional mods; models under `mods//3d_models/` (CERN-OHL-S) | | `hardware/` | KiCad boards (CERN-OHL-S) | @@ -62,7 +63,7 @@ HTML index: one-line route descriptions only (except supported-parameter lists). Format: `type(scope): summary` (imperative, lowercase type, no trailing period; PR title same). Scopes: `firmware`, `http`, `settings`, `anim`, `servos`, `wifi`, `integrations`, `cad`, `mods`, `pcb`, `docs`, `scripts`, `ci`. -`integrations` = anything under `packages/`. Do not add a new scope per package. +`integrations` = anything under `packages/` or `skills/`. Do not add a new scope per package. `mods` = anything under `mods/`. Name the mod in the summary. Do not add a new scope per mod. Breaking rules, SemVer mapping, and examples: [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/README.md b/README.md index 9f7fb95e..3b6c454e 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A small robot on your desk. It moves, changes its face, and rings a bell as your Open source. Print the body, plug the ESP32, servos, OLED, and speaker into the [main control board](docs/hardware/main-control-board.md), flash it, and it joins your Wi-Fi. -**Works with** [Claude Code](docs/integration.md#4-claude-code-dedicated-script) · [Cursor](docs/hooks.md) · [Antigravity](docs/integration.md#3-antigravity-cli-dedicated-script) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md)). +**Works with** [Claude Code](docs/integration.md#4-claude-code-dedicated-script) · [Cursor](docs/hooks.md) · [Antigravity](docs/integration.md#3-antigravity-cli-dedicated-script) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md)), including speech through [`POST /play`](docs/api.md#post-play). ## Build your own diff --git a/docs/api.md b/docs/api.md index ce65cefd..75158e76 100644 --- a/docs/api.md +++ b/docs/api.md @@ -457,7 +457,7 @@ curl http://tiny-engineer.local/anim | Field | Meaning | | --- | --- | -| `animation` | `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` | +| `animation` | `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` | ### `POST /anim` @@ -465,12 +465,13 @@ Request an animation switch. Each animation runs at least **1s**; if the current | Param | Type | Values | | --- | --- | --- | -| `name` | string | `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, `sleep` | +| `name` | string | `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, `sleep` | ```bash curl -X POST "http://tiny-engineer.local/anim?name=typing" curl -X POST "http://tiny-engineer.local/anim?name=reading" curl -X POST "http://tiny-engineer.local/anim?name=thinking" +curl -X POST "http://tiny-engineer.local/anim?name=talking" curl -X POST "http://tiny-engineer.local/anim?name=ring" curl -X POST "http://tiny-engineer.local/anim?name=welcome" curl -X POST "http://tiny-engineer.local/anim?name=wakeup" @@ -496,6 +497,7 @@ Durations and quoted lines below are the stock clips in [`assets/`](../assets/). | `typing` | **Continuous.** Alternating hands with randomness (15° band from hand limits). Head nods slowly on lowest 10° of head limits. Body sways ±5° around mid; neck counters opposite so head stays put. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | | `reading` | **Continuous.** Hands/body park as in `none`. Head nods on same lowest 10° band as typing, but slower. Neck sweeps ±10° around mid; right (angle down) slower than left (angle up). Occasional right-hand down-arrow bursts (1–3 presses, same 15° band as typing). Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | | `thinking` | **Continuous.** Hands/body park as in `none`. Head (pitch) and neck (yaw) ease from the current pose into thinking poses (up + slight left/right). Move → pause → optional micro-adjust (sometimes chained) → pause; nearby pose drift with occasional larger shifts after ~2.2 s, more often over time. Axes stagger start/duration; no periodic sway. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. | +| `talking` | **Continuous.** Speaking fidget with no sound of its own, used by [`POST /play`](#post-play) while a clip plays. Hands and body park as in `none` except for an occasional right-hand gesture. The head faces the user just above mid and nods quickly on random beats, then settles; the neck drifts slowly. **Classic eyes:** a little taller than idle, mostly on the user with short glances away. Runs until replaced, or until `continuous_timeout` triggers `attention`. | | `ring` | **One-shot** service-bell gesture; does not loop. Wind-up (body `min`, neck mid, head mid+10°, both hands `max`) → fast right-hand strike to `min+5°` with head to `min` → plays `bell.wav` once on strike (LittleFS; same `uploadfs` requirement as `/test/audio/bell`) → slower bounce to `min+20°` → return to `none` pose and stop. After completion, `GET /anim` reports `none`. | | `welcome` | **One-shot** hello gesture synced to `welcome.wav` (~2.7 s). Right hand raises during "Hello, human.", holds through the pause, wiggles during "What are we building today?", then lowers. Head nods to mid+10° and returns. Plays automatically after successful Wi-Fi connect at boot; also via API. Requires `welcome.wav` on LittleFS (same `uploadfs` flow as `bell.wav`). After completion, `GET /anim` reports `none`. | | `attention` | Friendly input-request gesture synced to `attention.wav` (~3.0 s, "pst... human.... you might want to take a look"). Moves into a calm prompt pose first (centered body/neck, head slightly up, right hand raised partway), waits until all servos stop, then plays audio with light neck/head/hand motion during playback (whisper hold → lean toward user → glance/point on "take a look"). **Classic eyes:** phased blink/look cues during audio. After audio ends (or if audio fails to start), holds a gentle waiting loop for **1 minute** (soft head/neck drifts plus occasional slight right-hand waves), then returns to `none`. Requires `attention.wav` on LittleFS (same `uploadfs` flow as `bell.wav`). | @@ -515,7 +517,7 @@ When `eyes_style=kaomoji`, the OLED shows looping faces from the expression libr | `typing`, `thinking` | Thinking | | `reading`, `attention` | Curious | | `ring` | Surprise | -| `welcome` | Happy | +| `welcome`, `talking` | Happy | | `error` | Angry | | `abort` | Panic | | `wakeup` | Sleepy | @@ -536,16 +538,48 @@ Wrong params return **400**: | `error` | When | | --- | --- | | `missing name` | Query param `name` absent | -| `unknown animation` | `name` not `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` | +| `unknown animation` | `name` not `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` | + +### `POST /play` + +Streams a WAV to the speaker while one animation runs, then returns to the continuous animation (`typing`, `reading`, `thinking`, `talking`) that was running before the request, otherwise to `none`. The body is played as it arrives, so clips of any length need no storage on the robot. The response comes when the clip ends. + +| Part | Value | +| --- | --- | +| Path | `/play` runs `talking`; `/play/{name}` runs `talking`, `typing`, `reading`, `thinking`, or `none`. Animations with their own sound are rejected so two clips never overlap | +| Body | WAV, 16-bit mono PCM at 22050 Hz (the firmware `SAMPLE_RATE`). `Content-Length` is required. Chunks other than `fmt ` and `data` are skipped | +| `Authorization` | `Bearer ` when `access_token` is set | + +The animation starts with the first audio sample, so a rejected request never moves the robot. While the clip plays the robot keeps its eyes, LED and sleep timer running, and the clip counts as activity. + +```bash +curl -X POST http://tiny-engineer.local/play -H 'Content-Type: audio/wav' --data-binary @clip.wav +curl -X POST http://tiny-engineer.local/play/thinking -H 'Content-Type: audio/wav' --data-binary @hmm.wav +``` + +```json +{ "ok": true, "played_ms": 3000 } +``` + +The Claude Code CLI wraps this for agents: `tiny-engineer-claude-code play clip.wav [--name thinking]` ([integration.md](integration.md#4-claude-code-dedicated-script)). + +Errors arrive before any audio plays: + +| Status | `error` | When | +| --- | --- | --- | +| `400` | `name must be talking, typing, reading, thinking or none` | `{name}` is unknown or has its own sound | +| `415` | `expected 16-bit mono PCM at 22050 Hz` | WAV format or sample rate differs | +| `415` | `not a WAV file`, `no audio data` | Body is not a RIFF/WAVE file, or has no `data` chunk | ## Errors | Status | Body | When | | --- | --- | --- | -| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, or `/anim` params (see tables above) | +| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/anim`, or `/play` params (see tables above) | | `401` | `{"ok":false,"error":"unauthorized"}` | Access token configured and `Authorization: Bearer` missing or wrong | | `404` | `{"ok":false,"error":"not found"}` | Unknown path | -| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/anim`, or `/auth` path | +| `415` | `{"ok":false,"error":"..."}` | `/play` body is not a supported WAV | +| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/anim`, `/play`, or `/auth` path | Test routes are **POST**. GET/prefetch would move hardware. `/anim` allows **GET** (read) and **POST** (set). `/auth` is **GET** only and always public. @@ -553,11 +587,13 @@ Test routes are **POST**. GET/prefetch would move hardware. `/anim` allows **GET Test handlers **block** until the test finishes. The client waits. After each test, OLED returns to `ROBOT READY` plus IP (or `WIFI FAIL`). -`/anim` responses return immediately; typing motion runs in the main loop via non-blocking servo updates. Animation switches may defer up to 1s so the active animation holds its minimum duration; only the latest pending request is applied. Continuous animations (`typing`, `reading`, `thinking`) that stay active longer than `continuous_timeout` minutes switch to `attention`, which holds ~1 minute then finishes to `none`. Re-`POST`ing the same continuous animation (e.g. `typing` while already `typing`) does not restart motion, but resets the continuous-timeout clock. +`/anim` responses return immediately; typing motion runs in the main loop via non-blocking servo updates. Animation switches may defer up to 1s so the active animation holds its minimum duration; only the latest pending request is applied. Continuous animations (`typing`, `reading`, `thinking`, `talking`) that stay active longer than `continuous_timeout` minutes switch to `attention`, which holds ~1 minute then finishes to `none`. Re-`POST`ing the same continuous animation (e.g. `typing` while already `typing`) does not restart motion, but resets the continuous-timeout clock. The onboard RGB LED follows the active animation (see [RGB LED](#rgb-led)). -One request at a time — the Arduino `WebServer` is single-threaded. +`/play` holds the connection until the clip ends. + +One request at a time — the Arduino `WebServer` is single-threaded. While `/play` streams, other requests wait. ## RGB LED diff --git a/docs/hardware/testing.md b/docs/hardware/testing.md index 02e27bf1..28969a18 100644 --- a/docs/hardware/testing.md +++ b/docs/hardware/testing.md @@ -109,6 +109,7 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90" | `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public | | `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) | | `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) | +| `POST` | `/play` | Stream a WAV while `talking` (or `/play/{name}`) runs; answers when the clip ends — full params in [api.md](../api.md#post-play) | | `GET` | `/settings` | Persistent settings (`sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token_set`, `wifi_configured`, `wifi_ssid`, `wifi_password_set`, `servo_mins`, `servo_maxs`, `rgb_order`, `oled_rotate_180`) | | `POST` | `/settings?...&wifi_ssid=&wifi_password=` | Update NVS settings; WiFi params setup-AP-only and tested before save; `reboot_required` if hostname changed | | `POST` | `/settings?...&servo_mins=&servo_maxs=` | Servo min/max comma lists; setup-AP-only | diff --git a/docs/integration.md b/docs/integration.md index 28693fd8..ea7ad410 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -238,6 +238,27 @@ Inside this firmware repository, [`.claude/settings.json`](../.claude/settings.j To use it in every project, copy the `hooks` block into `~/.claude/settings.json` and replace `$CLAUDE_PROJECT_DIR/packages/…` with the absolute path to this repo's bin. +### Speaking through the robot + +The same CLI has a `play` command an agent can run to speak through the robot. It streams a WAV (16-bit mono PCM at 22050 Hz, from any text-to-speech) to [`POST /play`](api.md#post-play) while the robot runs `talking`, prints the robot's reply when the clip ends, and exits 1 with the reason on any error: + +```bash +espeak-ng -w /tmp/raw.wav "Build passed." && ffmpeg -y -loglevel error -i /tmp/raw.wav -ar 22050 -ac 1 -c:a pcm_s16le /tmp/clip.wav +node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js play /tmp/clip.wav +``` + +`--name thinking` (or `typing`, `reading`, `none`) runs another animation for the clip. + +To let an agent do this on its own, install the shared [`tiny-engineer-play`](../skills/tiny-engineer-play/SKILL.md) skill. It needs only `curl` and works the same in Claude Code, Cursor and Antigravity: + +```bash +mkdir -p ~/.claude/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.claude/skills/ # Claude Code +mkdir -p ~/.cursor/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.cursor/skills/ # Cursor +mkdir -p ~/.gemini/antigravity/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.gemini/antigravity/skills/ # Antigravity +``` + +Then ask the agent something like "have the robot tell me the build passed". + ### Smoke test ```bash diff --git a/packages/tiny-engineer-claude-code/src/cli.js b/packages/tiny-engineer-claude-code/src/cli.js index dcf98c94..43b516fa 100644 --- a/packages/tiny-engineer-claude-code/src/cli.js +++ b/packages/tiny-engineer-claude-code/src/cli.js @@ -1,9 +1,13 @@ +import { readFileSync } from "node:fs"; + import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "./env.js"; import { animationForEvent } from "./map.js"; +import { parsePlayArgs, postPlay } from "./play.js"; import { postAnim } from "./post.js"; function printHelp() { console.log(`Usage: tiny-engineer-claude-code [options] + tiny-engineer-claude-code play [--name ] [--url ] Claude Code hook helper: read event JSON from stdin, pick an animation, POST to the robot. @@ -44,7 +48,15 @@ Event map: Stop → ring StopFailure → error +Play mode: + Streams a 16-bit mono PCM WAV (at the robot's sample rate) to POST /play and + runs one animation while it plays: talking (default), typing, reading, + thinking or none. The robot then returns to the loop it was in. + Prints the robot's JSON reply when the clip ends; exits 1 on any error. + Examples: + tiny-engineer-claude-code play hello.wav + tiny-engineer-claude-code play hmm.wav --name thinking echo '{"hook_event_name":"Stop"}' | tiny-engineer-claude-code echo '{"hook_event_name":"PreToolUse","tool_name":"Read"}' | tiny-engineer-claude-code --url http://192.168.1.10 `); @@ -95,12 +107,52 @@ function readStdin() { }); } +/** + * @param {string[]} argv arguments after `play` + */ +async function runPlay(argv) { + const opts = parsePlayArgs(argv); + if (opts.error) { + console.error(opts.error); + process.exitCode = 1; + return; + } + + let wav; + try { + wav = readFileSync(opts.wavPath); + } catch (err) { + console.error(`cannot read ${opts.wavPath}: ${err.message}`); + process.exitCode = 1; + return; + } + + try { + const reply = await postPlay({ + baseUrl: opts.url ?? getBaseUrl(), + wav, + name: opts.name, + token: getToken(), + }); + (reply.ok ? console.log : console.error)(reply.body); + process.exitCode = reply.ok ? 0 : 1; + } catch (err) { + console.error(`robot unreachable: ${err.cause?.message ?? err.message}`); + process.exitCode = 1; + } +} + /** * @param {string[]} argv */ export async function run(argv) { loadDotEnv(); + if (argv[0] === "play") { + await runPlay(argv.slice(1)); + return; + } + const opts = parseArgs(argv); if (opts.error) { console.error(opts.error); diff --git a/packages/tiny-engineer-claude-code/src/play.js b/packages/tiny-engineer-claude-code/src/play.js new file mode 100644 index 00000000..d9abae43 --- /dev/null +++ b/packages/tiny-engineer-claude-code/src/play.js @@ -0,0 +1,50 @@ +import { robotFetch } from "./post.js"; + +// The robot answers once the clip has finished playing. +const PLAY_TIMEOUT_MS = 10 * 60 * 1000; + +/** + * @param {string[]} argv arguments after `play` + * @returns {{ wavPath?: string, name?: string, url?: string, error?: string }} + */ +export function parsePlayArgs(argv) { + /** @type {{ wavPath?: string, name?: string, url?: string }} */ + const opts = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (arg === "--name" || arg === "--url") { + const value = argv[++i]; + if (!value || value.startsWith("-")) return { error: `${arg} requires a value` }; + opts[arg.slice(2)] = value; + continue; + } + if (arg.startsWith("-") || opts.wavPath) return { error: `Unknown argument: ${arg}` }; + opts.wavPath = arg; + } + + if (!opts.wavPath) return { error: "play needs a WAV file" }; + return opts; +} + +/** + * POST a WAV to /play (or /play/). Resolves with the robot's reply. + * @param {{ baseUrl: string, wav: Uint8Array, name?: string, token?: string | null }} request + * @returns {Promise<{ ok: boolean, body: string }>} + */ +export async function postPlay({ baseUrl, wav, name, token }) { + const response = await robotFetch( + baseUrl, + name ? `/play/${encodeURIComponent(name)}` : "/play", + { + method: "POST", + headers: { "Content-Type": "audio/wav" }, + body: wav, + signal: AbortSignal.timeout(PLAY_TIMEOUT_MS), + }, + token, + ); + + return { ok: response.ok, body: await response.text() }; +} diff --git a/packages/tiny-engineer-claude-code/src/post.js b/packages/tiny-engineer-claude-code/src/post.js index ec6d38aa..0be32a1b 100644 --- a/packages/tiny-engineer-claude-code/src/post.js +++ b/packages/tiny-engineer-claude-code/src/post.js @@ -1,3 +1,16 @@ +/** + * fetch() against the robot: joins base URL and path and adds the Bearer token when set. + * @param {string} baseUrl + * @param {string} path + * @param {RequestInit} init + * @param {string | null} [token] + */ +export function robotFetch(baseUrl, path, init, token = null) { + const headers = { ...init.headers }; + if (token) headers.Authorization = `Bearer ${token}`; + return fetch(`${baseUrl.replace(/\/+$/, "")}${path}`, { ...init, headers }); +} + /** * POST /anim?name=… with a 2s timeout. Errors are swallowed. * @param {string} baseUrl @@ -5,18 +18,13 @@ * @param {string | null} [token] */ export async function postAnim(baseUrl, animName, token = null) { - const base = baseUrl.replace(/\/+$/, ""); - const url = `${base}/anim?name=${encodeURIComponent(animName)}`; - /** @type {RequestInit} */ - const init = { - method: "POST", - signal: AbortSignal.timeout(2000), - }; - if (token) { - init.headers = { Authorization: `Bearer ${token}` }; - } try { - await fetch(url, init); + await robotFetch( + baseUrl, + `/anim?name=${encodeURIComponent(animName)}`, + { method: "POST", signal: AbortSignal.timeout(2000) }, + token, + ); } catch { // Robot offline / timeout — never stall the agent. } diff --git a/packages/tiny-engineer-claude-code/test/play.test.js b/packages/tiny-engineer-claude-code/test/play.test.js new file mode 100644 index 00000000..7cb4abba --- /dev/null +++ b/packages/tiny-engineer-claude-code/test/play.test.js @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { parsePlayArgs, postPlay } from "../src/play.js"; + +test("play reads the WAV, animation name and URL", () => { + assert.deepEqual(parsePlayArgs(["clip.wav", "--name", "thinking", "--url", "http://robot"]), { + wavPath: "clip.wav", + name: "thinking", + url: "http://robot", + }); +}); + +test("play rejects bad arguments before contacting the robot", () => { + assert.equal(parsePlayArgs(["--name", "talking"]).error, "play needs a WAV file"); + assert.equal(parsePlayArgs(["clip.wav", "--name"]).error, "--name requires a value"); + assert.equal(parsePlayArgs(["clip.wav", "--name", "--url", "http://robot"]).error, "--name requires a value"); + assert.equal(parsePlayArgs(["a.wav", "b.wav"]).error, "Unknown argument: b.wav"); +}); + +test("postPlay streams the WAV to the named animation's route with the token", async () => { + let received; + const server = createServer((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + received = { url: req.url, headers: req.headers, body: Buffer.concat(chunks) }; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end('{"ok":true,"played_ms":10}'); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address(); + + try { + const reply = await postPlay({ + baseUrl: `http://127.0.0.1:${port}/`, + wav: Buffer.from("RIFFdata"), + name: "thinking", + token: "secret", + }); + + assert.deepEqual(reply, { ok: true, body: '{"ok":true,"played_ms":10}' }); + assert.equal(received.url, "/play/thinking"); + assert.equal(received.headers.authorization, "Bearer secret"); + assert.equal(received.body.toString(), "RIFFdata"); + } finally { + server.close(); + } +}); diff --git a/platformio.ini b/platformio.ini index 920e483e..dd74a70d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -63,6 +63,7 @@ build_src_filter = -<**/*.*> + + + +