@cheeko-ai/esp32-voice 2026.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NPM_PUBLISH_READINESS.md +299 -0
- package/README.md +226 -0
- package/TODO.md +418 -0
- package/index.ts +128 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +62 -0
- package/src/accounts.ts +110 -0
- package/src/channel.ts +270 -0
- package/src/config-schema.ts +37 -0
- package/src/device/device-otp.ts +173 -0
- package/src/http-handler.ts +154 -0
- package/src/monitor.ts +124 -0
- package/src/onboarding.ts +575 -0
- package/src/runtime.ts +14 -0
- package/src/stt/deepgram.ts +215 -0
- package/src/stt/stt-provider.ts +107 -0
- package/src/stt/stt-registry.ts +71 -0
- package/src/tts/elevenlabs.ts +215 -0
- package/src/tts/tts-provider.ts +111 -0
- package/src/tts/tts-registry.ts +71 -0
- package/src/types.ts +136 -0
- package/src/voice/voice-endpoint.ts +296 -0
- package/src/voice/voice-session.ts +1041 -0
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ESP32 Voice — Setup Wizard (ChannelOnboardingAdapter)
|
|
3
|
+
*
|
|
4
|
+
* Runs automatically when the user does:
|
|
5
|
+
* openclaw channels add --channel esp32voice
|
|
6
|
+
*
|
|
7
|
+
* Guides the user through:
|
|
8
|
+
* Step 1 — Login to Cheeko dashboard (browser link + pairing token)
|
|
9
|
+
* Step 2 — STT setup (Deepgram API key)
|
|
10
|
+
* Step 3 — TTS setup (ElevenLabs API key + voice)
|
|
11
|
+
* Step 4 — Add device (browser link to dashboard)
|
|
12
|
+
*
|
|
13
|
+
* Logout / re-setup:
|
|
14
|
+
* openclaw channels add --channel esp32voice (re-runs this wizard)
|
|
15
|
+
* To fully reset: remove CHEEKO_PAIR from ~/.openclaw/.env
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
formatDocsLink,
|
|
20
|
+
type ChannelOnboardingAdapter,
|
|
21
|
+
type WizardPrompter,
|
|
22
|
+
DEFAULT_ACCOUNT_ID,
|
|
23
|
+
} from "openclaw/plugin-sdk";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { networkInterfaces } from "node:os";
|
|
28
|
+
import { detectLocalIp } from "./voice/voice-endpoint.js";
|
|
29
|
+
|
|
30
|
+
// Dashboard UI URL — shown to user to open in browser (Vue frontend)
|
|
31
|
+
const DASHBOARD_URL = process.env.CHEEKO_DASHBOARD_URL?.replace(/\/$/, "") || "http://64.227.170.31:8001";
|
|
32
|
+
|
|
33
|
+
// Backend API URL — used for REST calls (manager-api-node, port 8002 + /toy context path)
|
|
34
|
+
const BACKEND_API_URL = process.env.CHEEKO_API_URL?.replace(/\/$/, "") || "http://64.227.170.31:8002/toy";
|
|
35
|
+
|
|
36
|
+
const VOICE_PORT = process.env.ESP32_VOICE_PORT || "8765";
|
|
37
|
+
|
|
38
|
+
// ── Env helpers ───────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function readEnvFile(): string[] {
|
|
41
|
+
const envPath = getEnvPath();
|
|
42
|
+
if (!existsSync(envPath)) return [];
|
|
43
|
+
return readFileSync(envPath, "utf8").split("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getEnvPath(): string {
|
|
47
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
|
|
48
|
+
return join(stateDir, ".env");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function saveToEnv(pairs: Record<string, string>): void {
|
|
52
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
|
|
53
|
+
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
const envPath = getEnvPath();
|
|
56
|
+
let lines = readEnvFile();
|
|
57
|
+
|
|
58
|
+
for (const [key, value] of Object.entries(pairs)) {
|
|
59
|
+
const idx = lines.findIndex((l) => l.trimStart().startsWith(`${key}=`));
|
|
60
|
+
const line = `${key}=${value}`;
|
|
61
|
+
if (idx !== -1) {
|
|
62
|
+
lines[idx] = line;
|
|
63
|
+
} else {
|
|
64
|
+
lines.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeFileSync(envPath, lines.join("\n").trimEnd() + "\n", "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clearFromEnv(keys: string[]): void {
|
|
72
|
+
const envPath = getEnvPath();
|
|
73
|
+
if (!existsSync(envPath)) return;
|
|
74
|
+
let lines = readFileSync(envPath, "utf8").split("\n");
|
|
75
|
+
lines = lines.filter((l) => !keys.some((k) => l.trimStart().startsWith(`${k}=`)));
|
|
76
|
+
writeFileSync(envPath, lines.join("\n").trimEnd() + "\n", "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getEnvValue(key: string): string | undefined {
|
|
80
|
+
// Check process.env first (already loaded)
|
|
81
|
+
if (process.env[key]) return process.env[key];
|
|
82
|
+
// Then check .env file directly
|
|
83
|
+
const lines = readEnvFile();
|
|
84
|
+
for (const line of lines) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (trimmed.startsWith(`${key}=`)) {
|
|
87
|
+
return trimmed.slice(key.length + 1).trim();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Step 1 — Cheeko Dashboard Login + Pairing ─────────────────────────────────
|
|
94
|
+
|
|
95
|
+
async function stepCheekoLogin(prompter: WizardPrompter): Promise<boolean> {
|
|
96
|
+
const existingPair = getEnvValue("CHEEKO_PAIR");
|
|
97
|
+
|
|
98
|
+
// Already paired — offer to re-pair or skip
|
|
99
|
+
if (existingPair) {
|
|
100
|
+
await prompter.note(
|
|
101
|
+
[
|
|
102
|
+
"You are already connected to the Cheeko dashboard.",
|
|
103
|
+
`Pairing token: ${existingPair.slice(0, 4)}****`,
|
|
104
|
+
"",
|
|
105
|
+
"To disconnect: clear CHEEKO_PAIR from ~/.openclaw/.env",
|
|
106
|
+
"To re-pair: run openclaw channels add --channel esp32voice",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
"Already connected",
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const rePair = await prompter.confirm({
|
|
112
|
+
message: "Re-connect to Cheeko dashboard with a new token?",
|
|
113
|
+
initialValue: false,
|
|
114
|
+
});
|
|
115
|
+
if (!rePair) return true; // Skip, already paired
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Show login instructions
|
|
119
|
+
await prompter.note(
|
|
120
|
+
[
|
|
121
|
+
"Step 1: Open the Cheeko dashboard and log in.",
|
|
122
|
+
"",
|
|
123
|
+
`${formatDocsLink(`${DASHBOARD_URL}/login`, "Open Cheeko Dashboard →")}`,
|
|
124
|
+
"",
|
|
125
|
+
"After logging in, go to:",
|
|
126
|
+
" Settings → Connect OpenClaw",
|
|
127
|
+
"",
|
|
128
|
+
"The dashboard will show a pairing token like: XK9-2M4",
|
|
129
|
+
"Copy ONLY the short token — not the full command.",
|
|
130
|
+
"",
|
|
131
|
+
"Example: if you see CHEEKO_PAIR=XK9-2M4 just paste XK9-2M4",
|
|
132
|
+
].join("\n"),
|
|
133
|
+
"Connect to Cheeko",
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const rawInput = String(
|
|
137
|
+
await prompter.text({
|
|
138
|
+
message: "Paste your Cheeko pairing token (e.g. XK9-2M4)",
|
|
139
|
+
placeholder: "XK9-2M4",
|
|
140
|
+
validate: (v) => {
|
|
141
|
+
const raw = String(v ?? "").trim();
|
|
142
|
+
if (!raw) return "Required — get it from the Cheeko dashboard";
|
|
143
|
+
// Extract token even if user pasted the full command
|
|
144
|
+
const extracted = extractTokenFromInput(raw);
|
|
145
|
+
if (!extracted || extracted.length < 3) return "Token seems too short — paste just the short code (e.g. XK9-2M4)";
|
|
146
|
+
return undefined;
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
).trim();
|
|
150
|
+
|
|
151
|
+
// Auto-extract token if user pasted the full command string
|
|
152
|
+
// e.g. "CHEEKO_PAIR=8S5-CXU openclaw gateway" → "8S5-CXU"
|
|
153
|
+
const token = extractTokenFromInput(rawInput);
|
|
154
|
+
if (!token) {
|
|
155
|
+
await prompter.note("❌ Could not extract token from input. Please try again.", "Invalid token");
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Save the token locally immediately — works even when the dashboard API isn't live yet.
|
|
160
|
+
// The gateway will attempt to register with the dashboard on every startup.
|
|
161
|
+
const localIp = detectLocalIp();
|
|
162
|
+
const voiceUrl = `ws://${localIp}:${VOICE_PORT}/`;
|
|
163
|
+
|
|
164
|
+
saveToEnv({
|
|
165
|
+
CHEEKO_PAIR: token,
|
|
166
|
+
CHEEKO_DASHBOARD_URL: DASHBOARD_URL,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Try to register with the dashboard (best-effort — non-blocking if API not ready yet)
|
|
170
|
+
await prompter.note(
|
|
171
|
+
[
|
|
172
|
+
"Attempting to register your OpenClaw with the Cheeko dashboard...",
|
|
173
|
+
"(This is optional — your token is already saved locally.)",
|
|
174
|
+
].join("\n"),
|
|
175
|
+
"Connecting...",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const response = await fetch(`${BACKEND_API_URL}/api/openclaw/pair`, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
body: JSON.stringify({ token, url: voiceUrl, localIp }),
|
|
183
|
+
signal: AbortSignal.timeout(10_000),
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Try to parse JSON response, but handle HTML error pages gracefully
|
|
187
|
+
let data: { ok?: boolean; error?: string } = {};
|
|
188
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
189
|
+
if (contentType.includes("application/json")) {
|
|
190
|
+
data = (await response.json()) as { ok?: boolean; error?: string };
|
|
191
|
+
} else {
|
|
192
|
+
// Dashboard returned HTML (endpoint not implemented yet) — treat as pending
|
|
193
|
+
await prompter.note(
|
|
194
|
+
[
|
|
195
|
+
`⚠️ Dashboard API not ready yet (HTTP ${response.status}).`,
|
|
196
|
+
"Your token has been saved locally.",
|
|
197
|
+
"",
|
|
198
|
+
`Token saved: ${token.slice(0, 3)}****`,
|
|
199
|
+
`Voice URL: ${voiceUrl}`,
|
|
200
|
+
"",
|
|
201
|
+
"The gateway will auto-register when the dashboard API is available.",
|
|
202
|
+
"Run: openclaw gateway",
|
|
203
|
+
].join("\n"),
|
|
204
|
+
"Token saved locally",
|
|
205
|
+
);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!response.ok || !data.ok) {
|
|
210
|
+
// API returned an error — still saved locally, warn the user
|
|
211
|
+
await prompter.note(
|
|
212
|
+
[
|
|
213
|
+
`⚠️ Dashboard registration returned an error: ${data.error ?? `HTTP ${response.status}`}`,
|
|
214
|
+
"",
|
|
215
|
+
"Your token has been saved locally and will be used on next gateway start.",
|
|
216
|
+
`Token: ${token.slice(0, 3)}****`,
|
|
217
|
+
].join("\n"),
|
|
218
|
+
"Saved locally (dashboard error)",
|
|
219
|
+
);
|
|
220
|
+
return true; // Continue setup — token is saved, gateway will retry
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await prompter.note(
|
|
224
|
+
[
|
|
225
|
+
`✅ Connected! Your voice URL is registered:`,
|
|
226
|
+
` ${voiceUrl}`,
|
|
227
|
+
"",
|
|
228
|
+
"Your Cheeko devices will now connect to this machine.",
|
|
229
|
+
"Token saved — future gateway starts auto-register.",
|
|
230
|
+
].join("\n"),
|
|
231
|
+
"Dashboard connected",
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
return true;
|
|
235
|
+
} catch (err) {
|
|
236
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
237
|
+
// Network error — token still saved locally
|
|
238
|
+
await prompter.note(
|
|
239
|
+
[
|
|
240
|
+
`⚠️ Could not reach Cheeko dashboard: ${msg}`,
|
|
241
|
+
"",
|
|
242
|
+
"Your token has been saved locally.",
|
|
243
|
+
`Token: ${token.slice(0, 3)}****`,
|
|
244
|
+
`Voice URL: ${voiceUrl}`,
|
|
245
|
+
"",
|
|
246
|
+
"The gateway will auto-register when the dashboard is reachable.",
|
|
247
|
+
"Run: openclaw gateway",
|
|
248
|
+
].join("\n"),
|
|
249
|
+
"Token saved (dashboard unreachable)",
|
|
250
|
+
);
|
|
251
|
+
return true; // Continue setup — token is saved, gateway will retry
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Extract the pairing token from user input.
|
|
257
|
+
* Handles cases where the user pasted the full command:
|
|
258
|
+
* "CHEEKO_PAIR=XK9-2M4 openclaw gateway" → "XK9-2M4"
|
|
259
|
+
* "XK9-2M4" → "XK9-2M4"
|
|
260
|
+
* "export CHEEKO_PAIR=XK9-2M4" → "XK9-2M4"
|
|
261
|
+
*/
|
|
262
|
+
function extractTokenFromInput(raw: string): string | null {
|
|
263
|
+
const trimmed = raw.trim();
|
|
264
|
+
|
|
265
|
+
// Try to extract from CHEEKO_PAIR=<token> pattern
|
|
266
|
+
const envMatch = trimmed.match(/CHEEKO_PAIR=([^\s]+)/);
|
|
267
|
+
if (envMatch) {
|
|
268
|
+
return envMatch[1].trim();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// If it looks like a plain token (no spaces, no equals sign), use it directly
|
|
272
|
+
if (!trimmed.includes(" ") && !trimmed.includes("=")) {
|
|
273
|
+
return trimmed;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Try to find a token-like value (alphanumeric + hyphens, 3-20 chars)
|
|
277
|
+
const tokenMatch = trimmed.match(/\b([A-Z0-9]{2,8}-[A-Z0-9]{2,8})\b/i);
|
|
278
|
+
if (tokenMatch) {
|
|
279
|
+
return tokenMatch[1].trim();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Last resort: take the first whitespace-separated word if it's short enough
|
|
283
|
+
const firstWord = trimmed.split(/\s+/)[0];
|
|
284
|
+
if (firstWord && firstWord.length >= 3 && firstWord.length <= 30) {
|
|
285
|
+
return firstWord;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Step 2 — STT Setup (Deepgram) ─────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
async function stepSttSetup(prompter: WizardPrompter): Promise<void> {
|
|
294
|
+
const existing = getEnvValue("DEEPGRAM_API_KEY");
|
|
295
|
+
|
|
296
|
+
if (existing) {
|
|
297
|
+
const update = await prompter.confirm({
|
|
298
|
+
message: `Deepgram API key already set (${existing.slice(0, 8)}...). Update it?`,
|
|
299
|
+
initialValue: false,
|
|
300
|
+
});
|
|
301
|
+
if (!update) return;
|
|
302
|
+
} else {
|
|
303
|
+
await prompter.note(
|
|
304
|
+
[
|
|
305
|
+
"ESP32 Voice uses Deepgram for Speech-to-Text (STT).",
|
|
306
|
+
"You need a free Deepgram API key.",
|
|
307
|
+
"",
|
|
308
|
+
`${formatDocsLink("https://console.deepgram.com", "Get Deepgram API key →")}`,
|
|
309
|
+
"",
|
|
310
|
+
"Sign up → Create API key → Copy it below.",
|
|
311
|
+
].join("\n"),
|
|
312
|
+
"STT Setup — Deepgram",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const key = String(
|
|
317
|
+
await prompter.text({
|
|
318
|
+
message: "Deepgram API key",
|
|
319
|
+
placeholder: "dg-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
320
|
+
validate: (v) => {
|
|
321
|
+
const k = String(v ?? "").trim();
|
|
322
|
+
if (!k) return "Required";
|
|
323
|
+
if (!k.startsWith("dg-") && k.length < 20) return "Doesn't look like a Deepgram key (should start with dg-)";
|
|
324
|
+
return undefined;
|
|
325
|
+
},
|
|
326
|
+
}),
|
|
327
|
+
).trim();
|
|
328
|
+
|
|
329
|
+
const model = String(
|
|
330
|
+
await prompter.text({
|
|
331
|
+
message: "Deepgram model (optional, press Enter for default)",
|
|
332
|
+
placeholder: "nova-3",
|
|
333
|
+
initialValue: getEnvValue("DEEPGRAM_MODEL") ?? "",
|
|
334
|
+
}),
|
|
335
|
+
).trim();
|
|
336
|
+
|
|
337
|
+
const toSave: Record<string, string> = { DEEPGRAM_API_KEY: key };
|
|
338
|
+
if (model) toSave.DEEPGRAM_MODEL = model;
|
|
339
|
+
saveToEnv(toSave);
|
|
340
|
+
|
|
341
|
+
await prompter.note("✅ Deepgram API key saved.", "STT ready");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Step 3 — TTS Setup (ElevenLabs) ───────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
async function stepTtsSetup(prompter: WizardPrompter): Promise<void> {
|
|
347
|
+
const existing = getEnvValue("ELEVENLABS_API_KEY");
|
|
348
|
+
|
|
349
|
+
if (existing) {
|
|
350
|
+
const update = await prompter.confirm({
|
|
351
|
+
message: `ElevenLabs API key already set (${existing.slice(0, 8)}...). Update it?`,
|
|
352
|
+
initialValue: false,
|
|
353
|
+
});
|
|
354
|
+
if (!update) return;
|
|
355
|
+
} else {
|
|
356
|
+
await prompter.note(
|
|
357
|
+
[
|
|
358
|
+
"ESP32 Voice uses ElevenLabs for Text-to-Speech (TTS).",
|
|
359
|
+
"You need an ElevenLabs API key.",
|
|
360
|
+
"",
|
|
361
|
+
`${formatDocsLink("https://elevenlabs.io/app/settings/api-keys", "Get ElevenLabs API key →")}`,
|
|
362
|
+
"",
|
|
363
|
+
"Sign up → Profile → API Keys → Create → Copy it below.",
|
|
364
|
+
].join("\n"),
|
|
365
|
+
"TTS Setup — ElevenLabs",
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const key = String(
|
|
370
|
+
await prompter.text({
|
|
371
|
+
message: "ElevenLabs API key",
|
|
372
|
+
placeholder: "sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
|
373
|
+
validate: (v) => {
|
|
374
|
+
const k = String(v ?? "").trim();
|
|
375
|
+
if (!k) return "Required";
|
|
376
|
+
return undefined;
|
|
377
|
+
},
|
|
378
|
+
}),
|
|
379
|
+
).trim();
|
|
380
|
+
|
|
381
|
+
const voiceId = String(
|
|
382
|
+
await prompter.text({
|
|
383
|
+
message: "ElevenLabs Voice ID (optional, press Enter for default)",
|
|
384
|
+
placeholder: "21m00Tcm4TlvDq8ikWAM",
|
|
385
|
+
initialValue: getEnvValue("ELEVENLABS_VOICE_ID") ?? "",
|
|
386
|
+
}),
|
|
387
|
+
).trim();
|
|
388
|
+
|
|
389
|
+
const model = String(
|
|
390
|
+
await prompter.text({
|
|
391
|
+
message: "ElevenLabs model (optional, press Enter for default)",
|
|
392
|
+
placeholder: "eleven_flash_v2_5",
|
|
393
|
+
initialValue: getEnvValue("ELEVENLABS_MODEL_ID") ?? "",
|
|
394
|
+
}),
|
|
395
|
+
).trim();
|
|
396
|
+
|
|
397
|
+
const toSave: Record<string, string> = { ELEVENLABS_API_KEY: key };
|
|
398
|
+
if (voiceId) toSave.ELEVENLABS_VOICE_ID = voiceId;
|
|
399
|
+
if (model) toSave.ELEVENLABS_MODEL_ID = model;
|
|
400
|
+
saveToEnv(toSave);
|
|
401
|
+
|
|
402
|
+
await prompter.note("✅ ElevenLabs API key saved.", "TTS ready");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Step 4 — Add Device ────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
async function stepAddDevice(prompter: WizardPrompter): Promise<void> {
|
|
408
|
+
await prompter.note(
|
|
409
|
+
[
|
|
410
|
+
"Now add your Cheeko device:",
|
|
411
|
+
"",
|
|
412
|
+
"1. Power on your Cheeko device",
|
|
413
|
+
"2. Wait for it to connect to WiFi",
|
|
414
|
+
"3. It will speak a 6-digit code",
|
|
415
|
+
"4. Enter that code on the dashboard:",
|
|
416
|
+
"",
|
|
417
|
+
`${formatDocsLink(`${DASHBOARD_URL}/devices/add`, "Add device on dashboard →")}`,
|
|
418
|
+
"",
|
|
419
|
+
"Once added, reboot the device — it will connect to your OpenClaw automatically.",
|
|
420
|
+
].join("\n"),
|
|
421
|
+
"Add your Cheeko device",
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
await prompter.confirm({
|
|
425
|
+
message: "Device added? (press Enter to continue)",
|
|
426
|
+
initialValue: true,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── Logout helper ─────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
async function stepLogout(prompter: WizardPrompter): Promise<void> {
|
|
433
|
+
const existing = getEnvValue("CHEEKO_PAIR");
|
|
434
|
+
if (!existing) {
|
|
435
|
+
await prompter.note("No Cheeko connection found — nothing to disconnect.", "Not connected");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const confirm = await prompter.confirm({
|
|
440
|
+
message: "Disconnect from Cheeko dashboard? (removes saved pairing token)",
|
|
441
|
+
initialValue: false,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (!confirm) return;
|
|
445
|
+
|
|
446
|
+
clearFromEnv(["CHEEKO_PAIR", "CHEEKO_DASHBOARD_URL"]);
|
|
447
|
+
await prompter.note(
|
|
448
|
+
[
|
|
449
|
+
"✅ Disconnected from Cheeko dashboard.",
|
|
450
|
+
"",
|
|
451
|
+
"To reconnect: run openclaw channels add --channel esp32voice",
|
|
452
|
+
].join("\n"),
|
|
453
|
+
"Disconnected",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ── Main onboarding adapter ───────────────────────────────────────────────────
|
|
458
|
+
|
|
459
|
+
export const esp32VoiceOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
460
|
+
channel: "esp32voice",
|
|
461
|
+
|
|
462
|
+
getStatus: async ({ cfg }) => {
|
|
463
|
+
const hasPair = Boolean(getEnvValue("CHEEKO_PAIR"));
|
|
464
|
+
const hasSTT = Boolean(getEnvValue("DEEPGRAM_API_KEY"));
|
|
465
|
+
const hasTTS = Boolean(getEnvValue("ELEVENLABS_API_KEY"));
|
|
466
|
+
const configured = hasPair && hasSTT && hasTTS;
|
|
467
|
+
|
|
468
|
+
const overallStatus = configured ? "configured" : "needs setup";
|
|
469
|
+
const lines: string[] = [];
|
|
470
|
+
lines.push(`ESP32 Voice: ${overallStatus}`);
|
|
471
|
+
lines.push(` Cheeko dashboard: ${hasPair ? "✅ connected" : "❌ not connected"}`);
|
|
472
|
+
lines.push(` STT (Deepgram): ${hasSTT ? "✅ configured" : "❌ missing key"}`);
|
|
473
|
+
lines.push(` TTS (ElevenLabs): ${hasTTS ? "✅ configured" : "❌ missing key"}`);
|
|
474
|
+
|
|
475
|
+
return {
|
|
476
|
+
channel: "esp32voice",
|
|
477
|
+
configured,
|
|
478
|
+
statusLines: lines,
|
|
479
|
+
selectionHint: configured ? "configured" : "needs setup",
|
|
480
|
+
quickstartScore: configured ? 1 : 0,
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
configure: async ({ cfg, prompter }) => {
|
|
485
|
+
// ── Intro ──────────────────────────────────────────────────────
|
|
486
|
+
await prompter.note(
|
|
487
|
+
[
|
|
488
|
+
"This wizard sets up your Cheeko ESP32 voice device.",
|
|
489
|
+
"",
|
|
490
|
+
"Steps:",
|
|
491
|
+
" 1. Connect to Cheeko dashboard",
|
|
492
|
+
" 2. Set up Speech-to-Text (Deepgram)",
|
|
493
|
+
" 3. Set up Text-to-Speech (ElevenLabs)",
|
|
494
|
+
" 4. Add your device",
|
|
495
|
+
"",
|
|
496
|
+
"Run: openclaw gateway when done to start the voice server.",
|
|
497
|
+
].join("\n"),
|
|
498
|
+
"🦞 Cheeko ESP32 Voice Setup",
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
// Check if user wants to logout instead
|
|
502
|
+
const existing = getEnvValue("CHEEKO_PAIR");
|
|
503
|
+
if (existing) {
|
|
504
|
+
const action = await prompter.select({
|
|
505
|
+
message: "What would you like to do?",
|
|
506
|
+
options: [
|
|
507
|
+
{ value: "reconfigure", label: "Reconfigure / update settings" },
|
|
508
|
+
{ value: "logout", label: "Disconnect from Cheeko dashboard" },
|
|
509
|
+
],
|
|
510
|
+
initialValue: "reconfigure",
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (String(action) === "logout") {
|
|
514
|
+
await stepLogout(prompter);
|
|
515
|
+
return { cfg };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ── Step 1: Dashboard login ────────────────────────────────────
|
|
520
|
+
const loginOk = await stepCheekoLogin(prompter);
|
|
521
|
+
if (!loginOk) {
|
|
522
|
+
await prompter.note(
|
|
523
|
+
[
|
|
524
|
+
"Setup incomplete — Cheeko dashboard not connected.",
|
|
525
|
+
"Re-run when ready: openclaw channels add --channel esp32voice",
|
|
526
|
+
].join("\n"),
|
|
527
|
+
"Setup paused",
|
|
528
|
+
);
|
|
529
|
+
return { cfg };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ── Step 2: STT ────────────────────────────────────────────────
|
|
533
|
+
await stepSttSetup(prompter);
|
|
534
|
+
|
|
535
|
+
// ── Step 3: TTS ────────────────────────────────────────────────
|
|
536
|
+
await stepTtsSetup(prompter);
|
|
537
|
+
|
|
538
|
+
// ── Step 4: Add device ─────────────────────────────────────────
|
|
539
|
+
await stepAddDevice(prompter);
|
|
540
|
+
|
|
541
|
+
// ── Done ───────────────────────────────────────────────────────
|
|
542
|
+
const localIp = detectLocalIp();
|
|
543
|
+
await prompter.note(
|
|
544
|
+
[
|
|
545
|
+
"✅ Setup complete!",
|
|
546
|
+
"",
|
|
547
|
+
"Your configuration:",
|
|
548
|
+
` Voice server : ws://${localIp}:${VOICE_PORT}/`,
|
|
549
|
+
` Dashboard : ${DASHBOARD_URL}`,
|
|
550
|
+
` STT : Deepgram ${getEnvValue("DEEPGRAM_MODEL") ?? "(default model)"}`,
|
|
551
|
+
` TTS : ElevenLabs ${getEnvValue("ELEVENLABS_VOICE_ID") ?? "(default voice)"}`,
|
|
552
|
+
"",
|
|
553
|
+
"Start the voice server:",
|
|
554
|
+
" openclaw gateway",
|
|
555
|
+
"",
|
|
556
|
+
"Re-run setup anytime:",
|
|
557
|
+
" openclaw channels add --channel esp32voice",
|
|
558
|
+
].join("\n"),
|
|
559
|
+
"🎉 All done!",
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
disable: (cfg) => ({
|
|
566
|
+
...cfg,
|
|
567
|
+
channels: {
|
|
568
|
+
...(cfg as any).channels,
|
|
569
|
+
esp32voice: {
|
|
570
|
+
...(cfg as any).channels?.esp32voice,
|
|
571
|
+
enabled: false,
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
}),
|
|
575
|
+
};
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setEsp32VoiceRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getEsp32VoiceRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("ESP32 Voice runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|