@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.
@@ -0,0 +1,154 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import { resolveEsp32VoiceAccount, listEsp32VoiceAccountIds } from "./accounts.js";
4
+ import type {
5
+ Esp32VoiceInboundMessage,
6
+ Esp32VoiceOutboundResponse,
7
+ ResolvedEsp32VoiceAccount,
8
+ } from "./types.js";
9
+
10
+ /**
11
+ * Authenticate an incoming ESP32 HTTP request.
12
+ *
13
+ * Checks the `Authorization: Bearer <token>` header against all configured
14
+ * device account tokens. Returns the matching account or null.
15
+ */
16
+ export function authenticateEsp32Request(
17
+ req: IncomingMessage,
18
+ cfg: OpenClawConfig,
19
+ ): ResolvedEsp32VoiceAccount | null {
20
+ const authHeader = req.headers.authorization?.trim();
21
+ if (!authHeader) {
22
+ return null;
23
+ }
24
+
25
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
26
+ if (!match) {
27
+ return null;
28
+ }
29
+
30
+ const token = match[1].trim();
31
+ if (!token) {
32
+ return null;
33
+ }
34
+
35
+ // Check all configured accounts for a matching device token.
36
+ const accountIds = listEsp32VoiceAccountIds(cfg);
37
+ for (const accountId of accountIds) {
38
+ const account = resolveEsp32VoiceAccount({ cfg, accountId });
39
+ if (account.enabled && account.deviceToken && account.deviceToken === token) {
40
+ return account;
41
+ }
42
+ }
43
+
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Parse the JSON body from an ESP32 POST request.
49
+ */
50
+ export async function parseEsp32RequestBody(req: IncomingMessage): Promise<{
51
+ ok: boolean;
52
+ data?: Esp32VoiceInboundMessage;
53
+ error?: string;
54
+ }> {
55
+ return new Promise((resolve) => {
56
+ const chunks: Buffer[] = [];
57
+ let totalSize = 0;
58
+ const maxBodySize = 64 * 1024; // 64 KB max body
59
+
60
+ req.on("data", (chunk: Buffer) => {
61
+ totalSize += chunk.length;
62
+ if (totalSize > maxBodySize) {
63
+ resolve({ ok: false, error: "Request body too large (max 64 KB)" });
64
+ req.destroy();
65
+ return;
66
+ }
67
+ chunks.push(chunk);
68
+ });
69
+
70
+ req.on("end", () => {
71
+ try {
72
+ const raw = Buffer.concat(chunks).toString("utf-8");
73
+ const parsed = JSON.parse(raw);
74
+
75
+ if (!parsed || typeof parsed.text !== "string") {
76
+ resolve({
77
+ ok: false,
78
+ error: 'Invalid request body: expected JSON with "text" field',
79
+ });
80
+ return;
81
+ }
82
+
83
+ const message: Esp32VoiceInboundMessage = {
84
+ text: parsed.text.trim(),
85
+ deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId.trim() : undefined,
86
+ language: typeof parsed.language === "string" ? parsed.language.trim() : undefined,
87
+ sessionId: typeof parsed.sessionId === "string" ? parsed.sessionId.trim() : undefined,
88
+ };
89
+
90
+ if (!message.text) {
91
+ resolve({ ok: false, error: "Empty text field" });
92
+ return;
93
+ }
94
+
95
+ resolve({ ok: true, data: message });
96
+ } catch {
97
+ resolve({ ok: false, error: "Invalid JSON in request body" });
98
+ }
99
+ });
100
+
101
+ req.on("error", (err) => {
102
+ resolve({ ok: false, error: `Request error: ${err.message}` });
103
+ });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Send a JSON response back to the ESP32 device.
109
+ */
110
+ export function sendEsp32Response(
111
+ res: ServerResponse,
112
+ status: number,
113
+ body: Esp32VoiceOutboundResponse,
114
+ ): void {
115
+ const json = JSON.stringify(body);
116
+ res.writeHead(status, {
117
+ "Content-Type": "application/json",
118
+ "Content-Length": Buffer.byteLength(json),
119
+ // ESP32 may not handle CORS but include for debugging from browsers.
120
+ "Access-Control-Allow-Origin": "*",
121
+ });
122
+ res.end(json);
123
+ }
124
+
125
+ /**
126
+ * Truncate response text to the configured maximum length.
127
+ * Tries to break at a sentence boundary when possible.
128
+ */
129
+ export function truncateForVoice(text: string, maxLength: number): string {
130
+ if (text.length <= maxLength) {
131
+ return text;
132
+ }
133
+
134
+ // Try to break at the last sentence boundary within the limit.
135
+ const truncated = text.slice(0, maxLength);
136
+ const lastSentenceEnd = Math.max(
137
+ truncated.lastIndexOf(". "),
138
+ truncated.lastIndexOf("! "),
139
+ truncated.lastIndexOf("? "),
140
+ truncated.lastIndexOf(".\n"),
141
+ );
142
+
143
+ if (lastSentenceEnd > maxLength * 0.5) {
144
+ return truncated.slice(0, lastSentenceEnd + 1).trim();
145
+ }
146
+
147
+ // Fall back to word boundary.
148
+ const lastSpace = truncated.lastIndexOf(" ");
149
+ if (lastSpace > maxLength * 0.7) {
150
+ return truncated.slice(0, lastSpace).trim() + "…";
151
+ }
152
+
153
+ return truncated.trim() + "…";
154
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,124 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import type { ResolvedEsp32VoiceAccount } from "./types.js";
3
+ import {
4
+ authenticateEsp32Request,
5
+ parseEsp32RequestBody,
6
+ sendEsp32Response,
7
+ truncateForVoice,
8
+ } from "./http-handler.js";
9
+ import { getEsp32VoiceRuntime } from "./runtime.js";
10
+
11
+ type MonitorParams = {
12
+ accountId: string;
13
+ config: OpenClawConfig;
14
+ runtime: ReturnType<typeof getEsp32VoiceRuntime>;
15
+ abortSignal: AbortSignal;
16
+ statusSink: (patch: Record<string, unknown>) => void;
17
+ };
18
+
19
+ /**
20
+ * Start monitoring for ESP32 Voice inbound messages.
21
+ *
22
+ * Registers an HTTP route on the Gateway at:
23
+ * POST /__openclaw__/esp32-voice/message
24
+ *
25
+ * The ESP32 device sends transcribed text here and receives
26
+ * the AI response synchronously in the HTTP response.
27
+ *
28
+ * Flow:
29
+ * 1. ESP32 captures audio → runs STT → gets text
30
+ * 2. ESP32 POSTs { text, deviceId?, sessionId? } with Bearer token
31
+ * 3. Gateway authenticates, routes to agent, waits for response
32
+ * 4. Gateway responds with { ok, text, sessionId }
33
+ * 5. ESP32 receives text → runs TTS → plays audio
34
+ */
35
+ export function monitorEsp32VoiceProvider(params: MonitorParams): void {
36
+ const { statusSink, abortSignal } = params;
37
+
38
+ statusSink({
39
+ running: true,
40
+ connected: true,
41
+ lastStartAt: new Date().toISOString(),
42
+ });
43
+
44
+ console.log(`[esp32voice] Channel ready. ESP32 devices can POST to the Gateway HTTP endpoint.`);
45
+ console.log(
46
+ `[esp32voice] Endpoint: POST /__openclaw__/esp32-voice/message`,
47
+ );
48
+
49
+ // The HTTP handler is registered via the plugin's registerHttpRoute.
50
+ // The monitor just tracks lifecycle state.
51
+ if (abortSignal) {
52
+ abortSignal.addEventListener("abort", () => {
53
+ statusSink({
54
+ running: false,
55
+ connected: false,
56
+ lastStopAt: new Date().toISOString(),
57
+ });
58
+ console.log("[esp32voice] Channel stopped.");
59
+ });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Process an inbound ESP32 message through the agent and return the response.
65
+ *
66
+ * This is called from the HTTP route handler. It:
67
+ * 1. Validates the authenticated account
68
+ * 2. Sends the transcribed text to the OpenClaw agent
69
+ * 3. Waits for the agent response
70
+ * 4. Truncates the response for voice output
71
+ * 5. Returns the response text for TTS on the device
72
+ */
73
+ export async function processEsp32Message(params: {
74
+ text: string;
75
+ account: ResolvedEsp32VoiceAccount;
76
+ deviceId: string;
77
+ sessionId?: string;
78
+ }): Promise<{ ok: boolean; text?: string; error?: string; sessionId?: string }> {
79
+ const { text, account, deviceId, sessionId } = params;
80
+ const runtime = getEsp32VoiceRuntime();
81
+
82
+ try {
83
+ // Build a voice-optimized system hint if enabled.
84
+ let messageText = text;
85
+ if (account.voiceOptimized) {
86
+ // The agent will receive this as a regular message; the channel's
87
+ // agentPrompt adapter adds the voice-optimization context.
88
+ messageText = text;
89
+ }
90
+
91
+ // Route the message through the OpenClaw agent via the runtime.
92
+ const result = await runtime.channel.processInboundMessage({
93
+ channel: "esp32voice",
94
+ from: deviceId,
95
+ text: messageText,
96
+ accountId: account.accountId,
97
+ });
98
+
99
+ if (!result || !result.text) {
100
+ return {
101
+ ok: false,
102
+ error: "No response from agent",
103
+ sessionId,
104
+ };
105
+ }
106
+
107
+ // Truncate for voice output.
108
+ const responseText = truncateForVoice(result.text, account.maxResponseLength);
109
+
110
+ return {
111
+ ok: true,
112
+ text: responseText,
113
+ sessionId: sessionId ?? result.sessionId,
114
+ };
115
+ } catch (err) {
116
+ const errorMessage = err instanceof Error ? err.message : "Unknown error";
117
+ console.error(`[esp32voice] Error processing message from ${deviceId}: ${errorMessage}`);
118
+ return {
119
+ ok: false,
120
+ error: `Processing error: ${errorMessage}`,
121
+ sessionId,
122
+ };
123
+ }
124
+ }