@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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-to-Speech (TTS) provider interface.
|
|
3
|
+
*
|
|
4
|
+
* All TTS providers must implement this interface. Providers can be
|
|
5
|
+
* streaming (WebSocket-based, receiving audio chunks in real time) or
|
|
6
|
+
* batch (send complete text, receive full audio).
|
|
7
|
+
*
|
|
8
|
+
* Modeled after OpenClaw's multi-provider architecture — new providers
|
|
9
|
+
* can be added by implementing this interface and registering with
|
|
10
|
+
* the TTS registry.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type TtsAudioCallback = (pcmChunk: Buffer) => void | Promise<void>;
|
|
14
|
+
export type TtsDoneCallback = () => void | Promise<void>;
|
|
15
|
+
|
|
16
|
+
export interface TtsProviderConfig {
|
|
17
|
+
/** Provider-specific API key. */
|
|
18
|
+
apiKey: string;
|
|
19
|
+
/** Voice identifier (e.g., ElevenLabs voice ID). */
|
|
20
|
+
voiceId?: string;
|
|
21
|
+
/** Model identifier. */
|
|
22
|
+
model?: string;
|
|
23
|
+
/** Language code. */
|
|
24
|
+
language?: string;
|
|
25
|
+
/** Output sample rate in Hz (default: 24000). */
|
|
26
|
+
sampleRate?: number;
|
|
27
|
+
/** Additional provider-specific options. */
|
|
28
|
+
options?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TtsProvider {
|
|
32
|
+
/** Unique provider identifier (e.g., "elevenlabs", "google", "edge-tts"). */
|
|
33
|
+
readonly id: string;
|
|
34
|
+
|
|
35
|
+
/** Human-readable provider name. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
|
|
38
|
+
/** Whether this provider supports real-time streaming. */
|
|
39
|
+
readonly streaming: boolean;
|
|
40
|
+
|
|
41
|
+
/** Output sample rate in Hz. */
|
|
42
|
+
readonly outputSampleRate: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Called when PCM audio data is received from the TTS service.
|
|
46
|
+
* Audio format: 16-bit signed LE PCM, mono, at `outputSampleRate` Hz.
|
|
47
|
+
* Set this before calling `connect()`.
|
|
48
|
+
*/
|
|
49
|
+
onAudio: TtsAudioCallback | null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Called when the TTS synthesis is complete (all audio sent).
|
|
53
|
+
* Set this before calling `connect()`.
|
|
54
|
+
*/
|
|
55
|
+
onDone: TtsDoneCallback | null;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Open a connection to the TTS service.
|
|
59
|
+
* For streaming providers, this opens a WebSocket.
|
|
60
|
+
*/
|
|
61
|
+
connect(): Promise<void>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Send text to be synthesized into speech.
|
|
65
|
+
* For streaming providers: can be called multiple times for partial text.
|
|
66
|
+
* For batch providers: should be called once with the full text.
|
|
67
|
+
*
|
|
68
|
+
* @param text - The text to synthesize.
|
|
69
|
+
*/
|
|
70
|
+
synthesize(text: string): Promise<void>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Signal end of text input and wait for all audio to be delivered.
|
|
74
|
+
* After this resolves, all audio has been sent via `onAudio`.
|
|
75
|
+
*/
|
|
76
|
+
flush(): Promise<void>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Close the connection and release resources.
|
|
80
|
+
*/
|
|
81
|
+
close(): Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Factory function type for creating TTS provider instances.
|
|
86
|
+
*/
|
|
87
|
+
export type TtsProviderFactory = (config: TtsProviderConfig) => TtsProvider;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Metadata about a registered TTS provider.
|
|
91
|
+
*/
|
|
92
|
+
export interface TtsProviderMeta {
|
|
93
|
+
/** Provider ID. */
|
|
94
|
+
id: string;
|
|
95
|
+
/** Human-readable name. */
|
|
96
|
+
name: string;
|
|
97
|
+
/** Short description. */
|
|
98
|
+
description: string;
|
|
99
|
+
/** Whether it supports streaming. */
|
|
100
|
+
streaming: boolean;
|
|
101
|
+
/** Required environment variable for the API key. */
|
|
102
|
+
envVar: string;
|
|
103
|
+
/** Default voice ID. */
|
|
104
|
+
defaultVoiceId?: string;
|
|
105
|
+
/** Default model. */
|
|
106
|
+
defaultModel?: string;
|
|
107
|
+
/** Output sample rate. */
|
|
108
|
+
outputSampleRate: number;
|
|
109
|
+
/** Documentation URL. */
|
|
110
|
+
docsUrl?: string;
|
|
111
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS Provider Registry.
|
|
3
|
+
*
|
|
4
|
+
* Central registry for text-to-speech providers. Providers register
|
|
5
|
+
* themselves with a factory function, and the voice session creates
|
|
6
|
+
* instances as needed.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* ttsRegistry.register(elevenlabsMeta, createElevenLabsTts);
|
|
10
|
+
* const provider = ttsRegistry.create("elevenlabs", { apiKey: "..." });
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { TtsProvider, TtsProviderConfig, TtsProviderFactory, TtsProviderMeta } from "./tts-provider.js";
|
|
14
|
+
|
|
15
|
+
interface RegisteredTtsProvider {
|
|
16
|
+
meta: TtsProviderMeta;
|
|
17
|
+
factory: TtsProviderFactory;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class TtsRegistry {
|
|
21
|
+
private providers = new Map<string, RegisteredTtsProvider>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register a new TTS provider.
|
|
25
|
+
*/
|
|
26
|
+
register(meta: TtsProviderMeta, factory: TtsProviderFactory): void {
|
|
27
|
+
if (this.providers.has(meta.id)) {
|
|
28
|
+
console.warn(`[tts-registry] Provider "${meta.id}" is already registered, overwriting.`);
|
|
29
|
+
}
|
|
30
|
+
this.providers.set(meta.id, { meta, factory });
|
|
31
|
+
console.log(`[tts-registry] Registered TTS provider: ${meta.name} (${meta.id})`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Create an instance of a registered TTS provider.
|
|
36
|
+
*/
|
|
37
|
+
create(providerId: string, config: TtsProviderConfig): TtsProvider {
|
|
38
|
+
const registered = this.providers.get(providerId);
|
|
39
|
+
if (!registered) {
|
|
40
|
+
const available = [...this.providers.keys()].join(", ");
|
|
41
|
+
throw new Error(
|
|
42
|
+
`TTS provider "${providerId}" not found. Available: ${available || "none"}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return registered.factory(config);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get metadata for a registered provider.
|
|
50
|
+
*/
|
|
51
|
+
getMeta(providerId: string): TtsProviderMeta | undefined {
|
|
52
|
+
return this.providers.get(providerId)?.meta;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* List all registered providers.
|
|
57
|
+
*/
|
|
58
|
+
list(): TtsProviderMeta[] {
|
|
59
|
+
return [...this.providers.values()].map((p) => p.meta);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check if a provider is registered.
|
|
64
|
+
*/
|
|
65
|
+
has(providerId: string): boolean {
|
|
66
|
+
return this.providers.has(providerId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Global TTS provider registry. */
|
|
71
|
+
export const ttsRegistry = new TtsRegistry();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { DmPolicy } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// ── STT / TTS Provider Config ─────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type SttProviderChoice = "deepgram" | "google" | "whisper" | "azure" | string;
|
|
6
|
+
export type TtsProviderChoice = "elevenlabs" | "google" | "edge-tts" | "azure" | string;
|
|
7
|
+
|
|
8
|
+
// ── Device / Account Config ───────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Configuration for a single ESP32 Voice device account.
|
|
12
|
+
*/
|
|
13
|
+
export type Esp32VoiceAccountConfig = {
|
|
14
|
+
/** Optional human-readable name for this device. */
|
|
15
|
+
name?: string;
|
|
16
|
+
/** If false, do not accept messages from this device. Default: true. */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
|
|
19
|
+
// ── Authentication ──
|
|
20
|
+
/**
|
|
21
|
+
* Shared secret token for device authentication.
|
|
22
|
+
* Generated during OTP pairing or set manually.
|
|
23
|
+
*/
|
|
24
|
+
deviceToken?: string;
|
|
25
|
+
/** Unique device identifier. */
|
|
26
|
+
deviceId?: string;
|
|
27
|
+
|
|
28
|
+
// ── Security ──
|
|
29
|
+
/** DM security policy. Default: "pairing". */
|
|
30
|
+
dmPolicy?: DmPolicy;
|
|
31
|
+
/** Allowlist of device IDs allowed to communicate. */
|
|
32
|
+
allowFrom?: string[];
|
|
33
|
+
|
|
34
|
+
// ── STT Configuration ──
|
|
35
|
+
/** Speech-to-text provider ID. Default: "deepgram". */
|
|
36
|
+
sttProvider?: SttProviderChoice;
|
|
37
|
+
/** STT API key (overrides env var). */
|
|
38
|
+
sttApiKey?: string;
|
|
39
|
+
/** STT model (e.g., "nova-2" for Deepgram). */
|
|
40
|
+
sttModel?: string;
|
|
41
|
+
|
|
42
|
+
// ── TTS Configuration ──
|
|
43
|
+
/** Text-to-speech provider ID. Default: "elevenlabs". */
|
|
44
|
+
ttsProvider?: TtsProviderChoice;
|
|
45
|
+
/** TTS API key (overrides env var). */
|
|
46
|
+
ttsApiKey?: string;
|
|
47
|
+
/** TTS voice ID. */
|
|
48
|
+
ttsVoiceId?: string;
|
|
49
|
+
/** TTS model ID. */
|
|
50
|
+
ttsModel?: string;
|
|
51
|
+
|
|
52
|
+
// ── Voice Pipeline ──
|
|
53
|
+
/** Max response length in characters. Default: 500. */
|
|
54
|
+
maxResponseLength?: number;
|
|
55
|
+
/** Whether to optimize prompts for voice output. Default: true. */
|
|
56
|
+
voiceOptimized?: boolean;
|
|
57
|
+
/** Language code (ISO 639-1). Default: "en". */
|
|
58
|
+
language?: string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Top-level ESP32 Voice channel configuration.
|
|
63
|
+
* Supports single-device (flat) and multi-device (accounts map) modes.
|
|
64
|
+
*/
|
|
65
|
+
export type Esp32VoiceConfig = {
|
|
66
|
+
/** Per-device configuration (multi-device mode). */
|
|
67
|
+
accounts?: Record<string, Esp32VoiceAccountConfig>;
|
|
68
|
+
} & Esp32VoiceAccountConfig;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolved account with computed defaults and source tracking.
|
|
72
|
+
*/
|
|
73
|
+
export type ResolvedEsp32VoiceAccount = {
|
|
74
|
+
accountId: string;
|
|
75
|
+
name?: string;
|
|
76
|
+
enabled: boolean;
|
|
77
|
+
deviceToken?: string;
|
|
78
|
+
deviceTokenSource: "config" | "env" | "none";
|
|
79
|
+
deviceId?: string;
|
|
80
|
+
sttProvider: string;
|
|
81
|
+
sttApiKey?: string;
|
|
82
|
+
sttModel?: string;
|
|
83
|
+
ttsProvider: string;
|
|
84
|
+
ttsApiKey?: string;
|
|
85
|
+
ttsVoiceId?: string;
|
|
86
|
+
ttsModel?: string;
|
|
87
|
+
maxResponseLength: number;
|
|
88
|
+
voiceOptimized: boolean;
|
|
89
|
+
language: string;
|
|
90
|
+
config: Esp32VoiceAccountConfig;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// ── Voice Protocol Messages ───────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Hello message sent by the ESP32 during WebSocket handshake.
|
|
97
|
+
* Contains OpenClaw credentials, STT/TTS provider config, and optional OTP.
|
|
98
|
+
*/
|
|
99
|
+
export type Esp32VoiceHelloMessage = {
|
|
100
|
+
type: "hello";
|
|
101
|
+
/** Device identifier. */
|
|
102
|
+
deviceId?: string;
|
|
103
|
+
/** Device firmware version. */
|
|
104
|
+
version?: number;
|
|
105
|
+
/** Transport type (always "websocket" for ESP32). */
|
|
106
|
+
transport?: string;
|
|
107
|
+
/** Audio parameters. */
|
|
108
|
+
audio_params?: {
|
|
109
|
+
format: string;
|
|
110
|
+
sample_rate: number;
|
|
111
|
+
channels: number;
|
|
112
|
+
frame_duration?: number;
|
|
113
|
+
};
|
|
114
|
+
/** OTP code for initial pairing. */
|
|
115
|
+
otp?: string;
|
|
116
|
+
/** OpenClaw Gateway credentials. */
|
|
117
|
+
openclaw?: {
|
|
118
|
+
url: string;
|
|
119
|
+
token: string;
|
|
120
|
+
};
|
|
121
|
+
/** STT provider overrides. */
|
|
122
|
+
stt?: {
|
|
123
|
+
provider?: string;
|
|
124
|
+
apiKey?: string;
|
|
125
|
+
model?: string;
|
|
126
|
+
};
|
|
127
|
+
/** TTS provider overrides. */
|
|
128
|
+
tts?: {
|
|
129
|
+
provider?: string;
|
|
130
|
+
apiKey?: string;
|
|
131
|
+
voiceId?: string;
|
|
132
|
+
model?: string;
|
|
133
|
+
};
|
|
134
|
+
/** Language code. */
|
|
135
|
+
language?: string;
|
|
136
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket endpoint for voice streaming.
|
|
3
|
+
*
|
|
4
|
+
* Ported from cheekoclaw_bridge/voice_endpoint.py
|
|
5
|
+
*
|
|
6
|
+
* Runs a **standalone** HTTP + WebSocket server on its own port (default 8765)
|
|
7
|
+
* because the OpenClaw Gateway plugin API does not support WebSocket upgrade
|
|
8
|
+
* registration. The ESP32 connects directly to this server.
|
|
9
|
+
*
|
|
10
|
+
* Routes any WebSocket connection to a VoiceSession per client.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { networkInterfaces, homedir } from "node:os";
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
18
|
+
import type { Duplex } from "node:stream";
|
|
19
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
20
|
+
import { VoiceSession } from "./voice-session.js";
|
|
21
|
+
|
|
22
|
+
// Import providers to trigger auto-registration
|
|
23
|
+
import "../stt/deepgram.js";
|
|
24
|
+
import "../tts/elevenlabs.js";
|
|
25
|
+
|
|
26
|
+
/** Default port for the standalone voice WebSocket server. */
|
|
27
|
+
const DEFAULT_VOICE_PORT = parseInt(process.env.ESP32_VOICE_PORT || "8765", 10);
|
|
28
|
+
|
|
29
|
+
// ── Cheeko Dashboard Pairing (optional) ──────────────────────────────────────
|
|
30
|
+
// If CHEEKO_PAIR env var is set, the plugin registers itself with the Cheeko
|
|
31
|
+
// dashboard on startup so the user does not have to manually type their URL.
|
|
32
|
+
// Set CHEEKO_DASHBOARD_URL to point at your dashboard (default: cheeko cloud).
|
|
33
|
+
//
|
|
34
|
+
// Usage:
|
|
35
|
+
// CHEEKO_PAIR=XK9-2M4 openclaw gateway
|
|
36
|
+
//
|
|
37
|
+
// The dashboard generates the token (GET /user/openclaw-pair/generate) and
|
|
38
|
+
// displays the command for the user to copy and run. Once the plugin calls home,
|
|
39
|
+
// the dashboard stores the voice URL and advances the onboarding flow.
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Auto-detect the machine's LAN IP address.
|
|
43
|
+
* Prefers common WiFi/Ethernet interfaces; falls back to first non-internal IPv4.
|
|
44
|
+
* Exported so the onboarding wizard can use the same detection logic.
|
|
45
|
+
*/
|
|
46
|
+
export function detectLocalIp(): string {
|
|
47
|
+
// Allow manual override (useful if auto-detect picks wrong interface)
|
|
48
|
+
if (process.env.MAC_IP) return process.env.MAC_IP;
|
|
49
|
+
|
|
50
|
+
const nets = networkInterfaces();
|
|
51
|
+
// Prefer these interfaces in order (macOS WiFi, macOS Ethernet, Linux eth, Linux wlan)
|
|
52
|
+
const preferred = ["en0", "en1", "eth0", "wlan0", "wlo1"];
|
|
53
|
+
|
|
54
|
+
for (const iface of preferred) {
|
|
55
|
+
const addrs = nets[iface];
|
|
56
|
+
if (!addrs) continue;
|
|
57
|
+
for (const addr of addrs) {
|
|
58
|
+
if (addr.family === "IPv4" && !addr.internal) {
|
|
59
|
+
return addr.address;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback: first non-internal IPv4 on any interface
|
|
65
|
+
for (const addrs of Object.values(nets)) {
|
|
66
|
+
if (!addrs) continue;
|
|
67
|
+
for (const addr of addrs) {
|
|
68
|
+
if (addr.family === "IPv4" && !addr.internal) {
|
|
69
|
+
return addr.address;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return "127.0.0.1";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Register this plugin with the Cheeko dashboard using a one-time pairing token.
|
|
79
|
+
*
|
|
80
|
+
* Called at server startup when the CHEEKO_PAIR environment variable is set.
|
|
81
|
+
* The plugin POSTs its voice WebSocket URL to the dashboard so the user's
|
|
82
|
+
* devices can be configured without manual URL entry.
|
|
83
|
+
*
|
|
84
|
+
* Environment variables:
|
|
85
|
+
* CHEEKO_PAIR — one-time pairing token (generated by dashboard)
|
|
86
|
+
* CHEEKO_DASHBOARD_URL — dashboard base URL (default: https://cheeko.app)
|
|
87
|
+
* MAC_IP — override auto-detected LAN IP
|
|
88
|
+
* ESP32_VOICE_PORT — voice server port (default: 8765)
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* Persist CHEEKO_PAIR and CHEEKO_DASHBOARD_URL into ~/.openclaw/.env so the
|
|
92
|
+
* plugin auto-registers on every subsequent `openclaw gateway` restart without
|
|
93
|
+
* the user needing to pass the env vars again on the command line.
|
|
94
|
+
*
|
|
95
|
+
* Safely upserts the keys — existing lines are replaced, new ones are appended.
|
|
96
|
+
*/
|
|
97
|
+
function savePairTokenToEnv(token: string, dashboardUrl: string): void {
|
|
98
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
|
|
99
|
+
const envPath = join(stateDir, ".env");
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
// Ensure ~/.openclaw/ exists
|
|
103
|
+
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true });
|
|
104
|
+
|
|
105
|
+
// Read existing .env (or start empty)
|
|
106
|
+
let lines: string[] = [];
|
|
107
|
+
if (existsSync(envPath)) {
|
|
108
|
+
lines = readFileSync(envPath, "utf8").split("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Upsert helper — replaces existing key or pushes new line
|
|
112
|
+
const upsert = (key: string, value: string) => {
|
|
113
|
+
const idx = lines.findIndex((l) => l.trimStart().startsWith(`${key}=`));
|
|
114
|
+
const line = `${key}=${value}`;
|
|
115
|
+
if (idx !== -1) {
|
|
116
|
+
lines[idx] = line;
|
|
117
|
+
} else {
|
|
118
|
+
lines.push(line);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
upsert("CHEEKO_PAIR", token);
|
|
123
|
+
upsert("CHEEKO_DASHBOARD_URL", dashboardUrl);
|
|
124
|
+
|
|
125
|
+
writeFileSync(envPath, lines.join("\n").trimEnd() + "\n", "utf8");
|
|
126
|
+
console.log(`[esp32voice] ✅ Saved CHEEKO_PAIR to ${envPath} — auto-registers on next restart`);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// Non-fatal — token was already used successfully, just won't auto-persist
|
|
129
|
+
console.warn(`[esp32voice] ⚠️ Could not save token to ${envPath}: ${err instanceof Error ? err.message : err}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function registerWithCheekoDashboard(voicePort: number): Promise<void> {
|
|
134
|
+
const pairToken = process.env.CHEEKO_PAIR;
|
|
135
|
+
if (!pairToken) return; // No pairing token — skip silently
|
|
136
|
+
|
|
137
|
+
const dashboardUrl = (process.env.CHEEKO_DASHBOARD_URL || "http://64.227.170.31:8001").replace(/\/$/, "");
|
|
138
|
+
// Backend API runs on port 8002 with /toy context path
|
|
139
|
+
const backendApiUrl = (process.env.CHEEKO_API_URL || "http://64.227.170.31:8002/toy").replace(/\/$/, "");
|
|
140
|
+
const localIp = detectLocalIp();
|
|
141
|
+
const voiceUrl = `ws://${localIp}:${voicePort}/`;
|
|
142
|
+
|
|
143
|
+
console.log(`[esp32voice] CHEEKO_PAIR detected — registering with dashboard API: ${backendApiUrl}`);
|
|
144
|
+
console.log(`[esp32voice] Voice URL to register: ${voiceUrl}`);
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(`${backendApiUrl}/api/openclaw/pair`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
token: pairToken,
|
|
152
|
+
url: voiceUrl,
|
|
153
|
+
localIp,
|
|
154
|
+
}),
|
|
155
|
+
signal: AbortSignal.timeout(10_000), // 10-second timeout
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const errorText = await response.text().catch(() => "(no body)");
|
|
160
|
+
console.warn(`[esp32voice] ⚠️ Dashboard registration failed (HTTP ${response.status}): ${errorText}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const data = (await response.json()) as { ok?: boolean; error?: string };
|
|
165
|
+
if (data.ok) {
|
|
166
|
+
console.log(`[esp32voice] ✅ Registered with Cheeko dashboard — your device will connect to ${voiceUrl}`);
|
|
167
|
+
// Persist token so future restarts auto-register without passing CHEEKO_PAIR again
|
|
168
|
+
savePairTokenToEnv(pairToken, dashboardUrl);
|
|
169
|
+
} else {
|
|
170
|
+
console.warn(`[esp32voice] ⚠️ Dashboard registration rejected: ${data.error ?? "unknown error"}`);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
174
|
+
// Non-fatal — dashboard may be down, user can still use manual URL entry
|
|
175
|
+
console.warn(`[esp32voice] ⚠️ Could not reach Cheeko dashboard: ${message}`);
|
|
176
|
+
console.warn(`[esp32voice] Manual setup: set your OpenClaw URL to ${voiceUrl} in the dashboard`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Create the voice WebSocket server (noServer mode — for manual upgrade).
|
|
182
|
+
*/
|
|
183
|
+
export function createVoiceWebSocketServer(): WebSocketServer {
|
|
184
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
185
|
+
|
|
186
|
+
wss.on("connection", (ws: WebSocket) => {
|
|
187
|
+
const sessionId = crypto.randomUUID().replace(/-/g, "");
|
|
188
|
+
const session = new VoiceSession(ws, sessionId);
|
|
189
|
+
console.log(`[esp32voice] Voice client connected [${sessionId.slice(0, 8)}]`);
|
|
190
|
+
|
|
191
|
+
ws.on("message", async (data: Buffer | string, isBinary: boolean) => {
|
|
192
|
+
// ── Raw message debug logging ─────────────────────────────
|
|
193
|
+
if (isBinary || Buffer.isBuffer(data)) {
|
|
194
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer);
|
|
195
|
+
console.log(`[esp32voice] [${sessionId.slice(0, 8)}] ← BINARY frame: ${buf.length} bytes`);
|
|
196
|
+
await session.handleMessage(buf);
|
|
197
|
+
} else {
|
|
198
|
+
const text = typeof data === "string" ? data : data.toString();
|
|
199
|
+
console.log(`[esp32voice] [${sessionId.slice(0, 8)}] ← TEXT message: ${text.slice(0, 300)}`);
|
|
200
|
+
try {
|
|
201
|
+
await session.handleMessage(text);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error(`[esp32voice] [${sessionId.slice(0, 8)}] Message error: ${err}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ws.on("close", async () => {
|
|
209
|
+
console.log(`[esp32voice] [${sessionId.slice(0, 8)}] Voice client disconnected`);
|
|
210
|
+
await session.cleanup();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
ws.on("error", async (err) => {
|
|
214
|
+
console.error(`[esp32voice] [${sessionId.slice(0, 8)}] WebSocket error: ${err.message}`);
|
|
215
|
+
await session.cleanup();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
console.log("[esp32voice] Voice WebSocket server created");
|
|
220
|
+
return wss;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle an HTTP upgrade request for the voice WebSocket.
|
|
225
|
+
*/
|
|
226
|
+
export function handleVoiceUpgrade(
|
|
227
|
+
wss: WebSocketServer,
|
|
228
|
+
request: IncomingMessage,
|
|
229
|
+
socket: Duplex,
|
|
230
|
+
head: Buffer,
|
|
231
|
+
): void {
|
|
232
|
+
// Accept any upgrade on this server — it's dedicated to voice
|
|
233
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
234
|
+
wss.emit("connection", ws, request);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Start a standalone HTTP + WebSocket server for ESP32 voice streaming.
|
|
240
|
+
*
|
|
241
|
+
* This server runs on its own port (separate from the Gateway) so that
|
|
242
|
+
* WebSocket upgrades are handled directly by the esp32-voice plugin
|
|
243
|
+
* without needing core Gateway changes.
|
|
244
|
+
*
|
|
245
|
+
* @returns The HTTP server instance (for cleanup).
|
|
246
|
+
*/
|
|
247
|
+
export function startStandaloneVoiceServer(port?: number): {
|
|
248
|
+
httpServer: ReturnType<typeof createServer>;
|
|
249
|
+
wss: WebSocketServer;
|
|
250
|
+
port: number;
|
|
251
|
+
} {
|
|
252
|
+
const listenPort = port ?? DEFAULT_VOICE_PORT;
|
|
253
|
+
const wss = createVoiceWebSocketServer();
|
|
254
|
+
|
|
255
|
+
const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
256
|
+
const url = req.url ?? "/";
|
|
257
|
+
|
|
258
|
+
// Health check endpoint
|
|
259
|
+
if (url === "/" || url === "/health") {
|
|
260
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
261
|
+
res.end(
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
ok: true,
|
|
264
|
+
service: "esp32-voice",
|
|
265
|
+
type: "websocket",
|
|
266
|
+
hint: "Connect via WebSocket to this server for voice streaming",
|
|
267
|
+
sttConfigured: Boolean(process.env.DEEPGRAM_API_KEY),
|
|
268
|
+
ttsConfigured: Boolean(process.env.ELEVENLABS_API_KEY || process.env.XI_API_KEY),
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Fallback
|
|
275
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
276
|
+
res.end(JSON.stringify({ error: "Not found. Connect via WebSocket for voice streaming." }));
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Handle WebSocket upgrades on ANY path on this server
|
|
280
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
281
|
+
handleVoiceUpgrade(wss, request, socket as Duplex, head);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
httpServer.listen(listenPort, "0.0.0.0", () => {
|
|
285
|
+
console.log(`[esp32voice] Standalone voice server listening on ws://0.0.0.0:${listenPort}`);
|
|
286
|
+
console.log(`[esp32voice] ESP32 should connect to ws://<your-ip>:${listenPort}/`);
|
|
287
|
+
|
|
288
|
+
// Register with Cheeko dashboard if CHEEKO_PAIR env var is set.
|
|
289
|
+
// Fire-and-forget — startup is not blocked by network call.
|
|
290
|
+
registerWithCheekoDashboard(listenPort).catch(() => {
|
|
291
|
+
// Already logged inside registerWithCheekoDashboard
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return { httpServer, wss, port: listenPort };
|
|
296
|
+
}
|