infinicode 2.8.108 → 2.8.114
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/README.md +20 -2
- package/dist/cli.js +23 -0
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +15 -4
- package/dist/commands/mesh-install.d.ts +1 -0
- package/dist/commands/mesh-install.js +28 -11
- package/dist/commands/mesh-link.d.ts +10 -0
- package/dist/commands/mesh-link.js +44 -0
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +404 -155
- package/dist/kernel/federation/index.d.ts +2 -0
- package/dist/kernel/federation/index.js +1 -0
- package/dist/kernel/federation/mesh-link.d.ts +16 -0
- package/dist/kernel/federation/mesh-link.js +52 -0
- package/dist/kernel/federation/transport-http.js +24 -0
- package/dist/kernel/mcp/mcp-server.js +61 -0
- package/dist/robopark/python-env.d.ts +1 -0
- package/dist/robopark/python-env.js +3 -0
- package/dist/robopark/robot-runtime.d.ts +1 -0
- package/dist/robopark/robot-runtime.js +52 -10
- package/dist/robopark/setup.d.ts +1 -0
- package/dist/robopark/setup.js +2 -0
- package/dist/robopark-cli.js +4 -0
- package/docs/ROBOPARK_ROBOVOICE_SIMPLIFICATION_PLAN.html +139 -0
- package/package.json +5 -2
- package/packages/robopark/pi-client/motor_bridge.py +10 -7
- package/packages/robopark/scheduler/main.py +144 -19
- package/packages/robopark/scheduler/robot_supervisor.py +46 -15
- package/packages/robopark/vision/motor_server.py +44 -18
- package/scripts/integrate-robovoice-control-center.mjs +423 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import process from 'node:process';
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
9
|
+
const targetRoot = path.resolve(process.argv[2] || path.join(repoRoot, '..', 'ROBOVOICE-main'));
|
|
10
|
+
const frontendRoot = path.join(targetRoot, 'frontend');
|
|
11
|
+
|
|
12
|
+
if (!existsSync(path.join(frontendRoot, 'package.json'))) {
|
|
13
|
+
throw new Error(`RoboVoice frontend not found at ${frontendRoot}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function write(file, contents) {
|
|
17
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
18
|
+
await writeFile(file, contents, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function copy(source, destination) {
|
|
22
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
23
|
+
await copyFile(source, destination);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extractDashboard(source) {
|
|
27
|
+
const prefix = 'export const DASHBOARD_HTML = `';
|
|
28
|
+
const start = source.indexOf(prefix);
|
|
29
|
+
const end = source.lastIndexOf('`;');
|
|
30
|
+
if (start < 0 || end < start) {
|
|
31
|
+
throw new Error('Could not extract DASHBOARD_HTML from the canonical dashboard source');
|
|
32
|
+
}
|
|
33
|
+
return source.slice(start + prefix.length, end);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function installNativeVoicePage(html) {
|
|
37
|
+
const voiceStart = '<div class="rp-sub" id="rp-sub-voice">';
|
|
38
|
+
const historyStart = '<div class="rp-sub" id="rp-sub-history">';
|
|
39
|
+
const start = html.indexOf(voiceStart);
|
|
40
|
+
const end = html.indexOf(historyStart, start);
|
|
41
|
+
if (start < 0 || end < 0) {
|
|
42
|
+
throw new Error('Canonical dashboard voice panel markers were not found');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const insertAt = start + voiceStart.length;
|
|
46
|
+
const nativeVoice = `
|
|
47
|
+
<div class="rv-native-shell">
|
|
48
|
+
<div class="rv-native-head">
|
|
49
|
+
<div><div class="eyebrow">Native RoboVoice runtime</div><div class="title">Voice calls & settings</div></div>
|
|
50
|
+
<span class="chip ok"><b>DIRECT</b> no scheduler in media path</span>
|
|
51
|
+
</div>
|
|
52
|
+
<iframe class="rv-native-frame" src="/voice" title="RoboVoice calls and settings" allow="camera; microphone; display-capture; autoplay; clipboard-write"></iframe>
|
|
53
|
+
</div>
|
|
54
|
+
<div hidden aria-hidden="true">`;
|
|
55
|
+
|
|
56
|
+
html = html.slice(0, insertAt) + nativeVoice + html.slice(insertAt, end) + '</div>\n' + html.slice(end);
|
|
57
|
+
html = html.replace('>Voice Call</button>', '>Voice & Settings</button>');
|
|
58
|
+
html = html.replace(
|
|
59
|
+
'</style>',
|
|
60
|
+
`.rv-native-shell{min-height:calc(100vh - 190px);display:flex;flex-direction:column;gap:.65rem}
|
|
61
|
+
.rv-native-head{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.8rem 1rem;border:1px solid var(--glass-border);border-radius:13px;background:var(--panel)}
|
|
62
|
+
.rv-native-head .title{font-family:var(--display);font-size:1.35rem;color:var(--gold-pale)}
|
|
63
|
+
.rv-native-frame{display:block;width:100%;min-height:760px;flex:1;border:1px solid var(--glass-border);border-radius:16px;background:#050504;box-shadow:var(--shadow)}
|
|
64
|
+
@media(max-width:760px){.rv-native-frame{min-height:820px}.rv-native-head{align-items:flex-start;flex-direction:column}}
|
|
65
|
+
</style>`
|
|
66
|
+
);
|
|
67
|
+
return html.replace(
|
|
68
|
+
'<meta name="robopark-ui-build"',
|
|
69
|
+
'<meta name="robovoice-control-center" content="native-v1">\n<meta name="robopark-ui-build"'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const sourceDashboard = path.join(repoRoot, 'src', 'kernel', 'federation', 'dashboard-html.ts');
|
|
74
|
+
const builtDashboard = path.join(repoRoot, 'dist', 'kernel', 'federation', 'dashboard-html.js');
|
|
75
|
+
const dashboardPath = existsSync(sourceDashboard) ? sourceDashboard : builtDashboard;
|
|
76
|
+
if (!existsSync(dashboardPath)) {
|
|
77
|
+
throw new Error('Canonical dashboard is missing. Run the Infinicode build before integrating RoboVoice.');
|
|
78
|
+
}
|
|
79
|
+
const dashboardSource = await readFile(dashboardPath, 'utf8');
|
|
80
|
+
const dashboard = installNativeVoicePage(extractDashboard(dashboardSource));
|
|
81
|
+
|
|
82
|
+
const currentRootPage = path.join(frontendRoot, 'app', '(app)', 'page.tsx');
|
|
83
|
+
const nativeVoicePage = path.join(frontendRoot, 'app', '(app)', 'voice', 'page.tsx');
|
|
84
|
+
if (!existsSync(nativeVoicePage)) {
|
|
85
|
+
await copy(currentRootPage, nativeVoicePage);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
await write(
|
|
89
|
+
currentRootPage,
|
|
90
|
+
`import { redirect } from 'next/navigation';
|
|
91
|
+
|
|
92
|
+
export default function Page() {
|
|
93
|
+
redirect('/robopark-control.html');
|
|
94
|
+
}
|
|
95
|
+
`
|
|
96
|
+
);
|
|
97
|
+
await write(path.join(frontendRoot, 'public', 'robopark-control.html'), dashboard);
|
|
98
|
+
|
|
99
|
+
const robotRegistrySource = `export type RoboVoiceRobot = {
|
|
100
|
+
id: string;
|
|
101
|
+
name: string;
|
|
102
|
+
deviceId: string;
|
|
103
|
+
host: string;
|
|
104
|
+
visionPort: number;
|
|
105
|
+
audioPort: number;
|
|
106
|
+
aliases: string[];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DEFAULT_ROBOTS: RoboVoiceRobot[] = [
|
|
110
|
+
{
|
|
111
|
+
id: 'bmw',
|
|
112
|
+
name: 'BMW',
|
|
113
|
+
deviceId: 'dev_0422e545',
|
|
114
|
+
host: process.env.ROBOVOICE_BMW_HOST || '192.168.0.178',
|
|
115
|
+
visionPort: 5000,
|
|
116
|
+
audioPort: 8000,
|
|
117
|
+
aliases: ['bmw', 'dev_0422e545'],
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
function configuredRobots(): RoboVoiceRobot[] {
|
|
122
|
+
const raw = process.env.ROBOVOICE_ROBOTS_JSON;
|
|
123
|
+
if (!raw) return DEFAULT_ROBOTS;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
if (!Array.isArray(parsed)) throw new Error('expected a JSON array');
|
|
127
|
+
return parsed.map((robot) => ({
|
|
128
|
+
id: String(robot.id || robot.name).toLowerCase(),
|
|
129
|
+
name: String(robot.name || robot.id),
|
|
130
|
+
deviceId: String(robot.deviceId || robot.device_id || robot.id),
|
|
131
|
+
host: String(robot.host || robot.ip),
|
|
132
|
+
visionPort: Number(robot.visionPort || robot.vision_port || 5000),
|
|
133
|
+
audioPort: Number(robot.audioPort || robot.audio_port || 8000),
|
|
134
|
+
aliases: Array.from(new Set([robot.id, robot.name, robot.deviceId, robot.device_id, ...(robot.aliases || [])].filter(Boolean).map(String))),
|
|
135
|
+
}));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error('Invalid ROBOVOICE_ROBOTS_JSON:', error);
|
|
138
|
+
return DEFAULT_ROBOTS;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function listRoboVoiceRobots() {
|
|
143
|
+
return configuredRobots();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function resolveRoboVoiceRobot(reference: string) {
|
|
147
|
+
const wanted = decodeURIComponent(reference).trim().toLowerCase();
|
|
148
|
+
return configuredRobots().find((robot) =>
|
|
149
|
+
[robot.id, robot.name, robot.deviceId, ...robot.aliases]
|
|
150
|
+
.some((value) => String(value).toLowerCase() === wanted)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
`;
|
|
154
|
+
await write(path.join(frontendRoot, 'lib', 'robovoice-robot-registry.ts'), robotRegistrySource);
|
|
155
|
+
|
|
156
|
+
const proxySource = `import type { NextRequest } from 'next/server';
|
|
157
|
+
|
|
158
|
+
const CONTROL_URL = process.env.ROBOPARK_CONTROL_URL || 'http://host.docker.internal:47913';
|
|
159
|
+
const CONTROL_TOKEN = process.env.ROBOPARK_CONTROL_TOKEN || '';
|
|
160
|
+
const HOP_BY_HOP = new Set(['connection','keep-alive','proxy-authenticate','proxy-authorization','te','trailers','transfer-encoding','upgrade','content-length']);
|
|
161
|
+
|
|
162
|
+
export async function proxyRoboParkControl(request: NextRequest, upstreamPath: string) {
|
|
163
|
+
const incoming = new URL(request.url);
|
|
164
|
+
incoming.searchParams.delete('token');
|
|
165
|
+
const target = new URL(upstreamPath + incoming.search, CONTROL_URL);
|
|
166
|
+
const headers = new Headers(request.headers);
|
|
167
|
+
headers.delete('host');
|
|
168
|
+
headers.delete('cookie');
|
|
169
|
+
headers.delete('content-length');
|
|
170
|
+
if (CONTROL_TOKEN) headers.set('authorization', 'Bearer ' + CONTROL_TOKEN);
|
|
171
|
+
const method = request.method.toUpperCase();
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const connectTimer = setTimeout(() => controller.abort(), 7000);
|
|
174
|
+
let upstream: Response;
|
|
175
|
+
try {
|
|
176
|
+
upstream = await fetch(target, {
|
|
177
|
+
method,
|
|
178
|
+
headers,
|
|
179
|
+
body: method === 'GET' || method === 'HEAD' ? undefined : request.body,
|
|
180
|
+
cache: 'no-store',
|
|
181
|
+
redirect: 'manual',
|
|
182
|
+
signal: controller.signal,
|
|
183
|
+
duplex: method === 'GET' || method === 'HEAD' ? undefined : 'half',
|
|
184
|
+
} as RequestInit & { duplex?: 'half' });
|
|
185
|
+
} catch (error) {
|
|
186
|
+
clearTimeout(connectTimer);
|
|
187
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
188
|
+
return Response.json(
|
|
189
|
+
{ ok: false, error: 'RoboPark control upstream unavailable', detail, upstream: target.origin },
|
|
190
|
+
{ status: 502, headers: { 'cache-control': 'no-store' } }
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
// The timer protects connection establishment only. Once headers arrive,
|
|
194
|
+
// MJPEG and SSE bodies must remain open for unattended operation.
|
|
195
|
+
clearTimeout(connectTimer);
|
|
196
|
+
const responseHeaders = new Headers();
|
|
197
|
+
upstream.headers.forEach((value, key) => {
|
|
198
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) responseHeaders.set(key, value);
|
|
199
|
+
});
|
|
200
|
+
responseHeaders.set('cache-control', 'no-store');
|
|
201
|
+
responseHeaders.set('x-robopark-control-source', target.origin);
|
|
202
|
+
return new Response(upstream.body, {
|
|
203
|
+
status: upstream.status,
|
|
204
|
+
statusText: upstream.statusText,
|
|
205
|
+
headers: responseHeaders,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
await write(path.join(frontendRoot, 'lib', 'robopark-control-proxy.ts'), proxySource);
|
|
210
|
+
|
|
211
|
+
const fedRoute = `import type { NextRequest } from 'next/server';
|
|
212
|
+
import { proxyRoboParkControl } from '@/lib/robopark-control-proxy';
|
|
213
|
+
|
|
214
|
+
export const dynamic = 'force-dynamic';
|
|
215
|
+
export const runtime = 'nodejs';
|
|
216
|
+
type RouteContext = { params: Promise<{ path: string[] }> };
|
|
217
|
+
async function handler(request: NextRequest, context: RouteContext) {
|
|
218
|
+
const { path } = await context.params;
|
|
219
|
+
return proxyRoboParkControl(request, '/fed/' + path.map(encodeURIComponent).join('/'));
|
|
220
|
+
}
|
|
221
|
+
export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };
|
|
222
|
+
`;
|
|
223
|
+
await write(path.join(frontendRoot, 'app', 'fed', '[...path]', 'route.ts'), fedRoute);
|
|
224
|
+
|
|
225
|
+
const cameraRoute = `import type { NextRequest } from 'next/server';
|
|
226
|
+
import { proxyRoboParkControl } from '@/lib/robopark-control-proxy';
|
|
227
|
+
import { resolveRoboVoiceRobot } from '@/lib/robovoice-robot-registry';
|
|
228
|
+
|
|
229
|
+
export const dynamic = 'force-dynamic';
|
|
230
|
+
export const runtime = 'nodejs';
|
|
231
|
+
type RouteContext = { params: Promise<{ robotId: string }> };
|
|
232
|
+
|
|
233
|
+
export async function GET(request: NextRequest, context: RouteContext) {
|
|
234
|
+
const { robotId } = await context.params;
|
|
235
|
+
const robot = resolveRoboVoiceRobot(robotId);
|
|
236
|
+
if (!robot) {
|
|
237
|
+
return proxyRoboParkControl(request, '/fed/media/robots/' + encodeURIComponent(robotId) + '/video_feed');
|
|
238
|
+
}
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
const timer = setTimeout(() => controller.abort(), 3500);
|
|
241
|
+
try {
|
|
242
|
+
const target = 'http://' + robot.host + ':' + robot.visionPort + '/video_feed';
|
|
243
|
+
const upstream = await fetch(target, { cache: 'no-store', signal: controller.signal });
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
if (!upstream.ok || !upstream.body) throw new Error('RoboVision returned ' + upstream.status);
|
|
246
|
+
const headers = new Headers();
|
|
247
|
+
headers.set('content-type', upstream.headers.get('content-type') || 'multipart/x-mixed-replace; boundary=frame');
|
|
248
|
+
headers.set('cache-control', 'no-store, no-cache, must-revalidate');
|
|
249
|
+
headers.set('x-robovoice-media-source', 'direct-robovision');
|
|
250
|
+
headers.set('x-robovoice-robot', robot.id);
|
|
251
|
+
return new Response(upstream.body, { status: 200, headers });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
console.warn('Direct RoboVision relay failed for ' + robot.id + ', using control fallback:', error);
|
|
255
|
+
return proxyRoboParkControl(request, '/fed/media/robots/' + encodeURIComponent(robotId) + '/video_feed');
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
`;
|
|
259
|
+
await write(path.join(frontendRoot, 'app', 'fed', 'media', 'robots', '[robotId]', 'video_feed', 'route.ts'), cameraRoute);
|
|
260
|
+
|
|
261
|
+
const nativeStreamRoute = `import { AccessToken, RoomServiceClient, type VideoGrant } from 'livekit-server-sdk';
|
|
262
|
+
import type { NextRequest } from 'next/server';
|
|
263
|
+
import { resolveRoboVoiceRobot } from '@/lib/robovoice-robot-registry';
|
|
264
|
+
|
|
265
|
+
export const dynamic = 'force-dynamic';
|
|
266
|
+
export const runtime = 'nodejs';
|
|
267
|
+
type RouteContext = { params: Promise<{ robotId: string }> };
|
|
268
|
+
|
|
269
|
+
function publicLiveKitUrl(request: NextRequest) {
|
|
270
|
+
const explicit = process.env.LIVEKIT_PUBLIC_URL || process.env.NEXT_PUBLIC_LIVEKIT_URL;
|
|
271
|
+
if (explicit && explicit !== 'auto') return explicit;
|
|
272
|
+
const forwardedProto = request.headers.get('x-forwarded-proto');
|
|
273
|
+
const host = request.headers.get('host') || 'localhost';
|
|
274
|
+
const hostname = host.split(':')[0];
|
|
275
|
+
if (hostname.endsWith('.ts.net')) return 'wss://' + hostname;
|
|
276
|
+
if (forwardedProto === 'https' || request.url.startsWith('https://')) return 'wss://' + host;
|
|
277
|
+
return 'ws://' + hostname + ':7880';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function GET(request: NextRequest, context: RouteContext) {
|
|
281
|
+
const { robotId } = await context.params;
|
|
282
|
+
const robot = resolveRoboVoiceRobot(robotId);
|
|
283
|
+
if (!robot) return Response.json({ active: false, reason: 'robot_not_registered' }, { status: 404 });
|
|
284
|
+
const key = process.env.LIVEKIT_API_KEY;
|
|
285
|
+
const secret = process.env.LIVEKIT_API_SECRET;
|
|
286
|
+
if (!key || !secret) return Response.json({ active: false, reason: 'livekit_credentials_missing' }, { status: 503 });
|
|
287
|
+
const stableRoom = 'robopark-' + robot.deviceId;
|
|
288
|
+
let room = stableRoom;
|
|
289
|
+
let discovered = false;
|
|
290
|
+
try {
|
|
291
|
+
const internal = (process.env.LIVEKIT_URL || 'http://livekit:7880').replace(/^ws:/, 'http:').replace(/^wss:/, 'https:');
|
|
292
|
+
const service = new RoomServiceClient(internal, key, secret, { requestTimeout: 2500 });
|
|
293
|
+
const rooms = await service.listRooms();
|
|
294
|
+
const candidates = rooms.filter((candidate) => candidate.name === stableRoom || candidate.name.startsWith(stableRoom + '-'));
|
|
295
|
+
candidates.sort((a, b) => Number(b.creationTime || 0) - Number(a.creationTime || 0));
|
|
296
|
+
const stable = candidates.find((candidate) => candidate.name === stableRoom && candidate.numParticipants > 0);
|
|
297
|
+
const selected = stable || candidates.find((candidate) => candidate.numParticipants > 0) || candidates[0];
|
|
298
|
+
if (selected) {
|
|
299
|
+
room = selected.name;
|
|
300
|
+
discovered = true;
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.warn('LiveKit room discovery unavailable; using stable robot room:', error);
|
|
304
|
+
}
|
|
305
|
+
const token = new AccessToken(key, secret, {
|
|
306
|
+
identity: 'viewer:' + robot.deviceId + ':' + crypto.randomUUID().slice(0, 8),
|
|
307
|
+
name: 'RoboPark Control monitor',
|
|
308
|
+
ttl: '15m',
|
|
309
|
+
});
|
|
310
|
+
const grant: VideoGrant = { room, roomJoin: true, canSubscribe: true, canPublish: false, canPublishData: false };
|
|
311
|
+
token.addGrant(grant);
|
|
312
|
+
return Response.json({
|
|
313
|
+
active: true,
|
|
314
|
+
url: publicLiveKitUrl(request),
|
|
315
|
+
token: await token.toJwt(),
|
|
316
|
+
room,
|
|
317
|
+
robot_id: robot.deviceId,
|
|
318
|
+
source: 'robovoice-native',
|
|
319
|
+
room_discovered: discovered,
|
|
320
|
+
}, { headers: { 'cache-control': 'no-store' } });
|
|
321
|
+
}
|
|
322
|
+
`;
|
|
323
|
+
await write(path.join(frontendRoot, 'app', 'robopark', 'api', 'robots', '[robotId]', 'stream', 'route.ts'), nativeStreamRoute);
|
|
324
|
+
|
|
325
|
+
const mediaHealthRoute = `import { resolveRoboVoiceRobot } from '@/lib/robovoice-robot-registry';
|
|
326
|
+
|
|
327
|
+
export const dynamic = 'force-dynamic';
|
|
328
|
+
export const runtime = 'nodejs';
|
|
329
|
+
type RouteContext = { params: Promise<{ robotId: string }> };
|
|
330
|
+
|
|
331
|
+
async function probe(url: string) {
|
|
332
|
+
const started = Date.now();
|
|
333
|
+
try {
|
|
334
|
+
const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(2500) });
|
|
335
|
+
return { ok: response.ok, status: response.status, latency_ms: Date.now() - started };
|
|
336
|
+
} catch (error) {
|
|
337
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error), latency_ms: Date.now() - started };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export async function GET(_: Request, context: RouteContext) {
|
|
342
|
+
const { robotId } = await context.params;
|
|
343
|
+
const robot = resolveRoboVoiceRobot(robotId);
|
|
344
|
+
if (!robot) return Response.json({ ok: false, error: 'robot not registered' }, { status: 404 });
|
|
345
|
+
const [vision, audio] = await Promise.all([
|
|
346
|
+
probe('http://' + robot.host + ':' + robot.visionPort + '/api/media/inventory'),
|
|
347
|
+
probe('http://' + robot.host + ':' + robot.audioPort + '/devices'),
|
|
348
|
+
]);
|
|
349
|
+
return Response.json({ ok: vision.ok && audio.ok, robot, vision, audio, room: 'robopark-' + robot.deviceId }, { status: vision.ok && audio.ok ? 200 : 503 });
|
|
350
|
+
}
|
|
351
|
+
`;
|
|
352
|
+
await write(path.join(frontendRoot, 'app', 'api', 'robovoice', 'robots', '[robotId]', 'media-health', 'route.ts'), mediaHealthRoute);
|
|
353
|
+
|
|
354
|
+
const productRoute = `import type { NextRequest } from 'next/server';
|
|
355
|
+
import { proxyRoboParkControl } from '@/lib/robopark-control-proxy';
|
|
356
|
+
|
|
357
|
+
export const dynamic = 'force-dynamic';
|
|
358
|
+
export const runtime = 'nodejs';
|
|
359
|
+
type RouteContext = { params: Promise<{ path: string[] }> };
|
|
360
|
+
async function handler(request: NextRequest, context: RouteContext) {
|
|
361
|
+
const { path } = await context.params;
|
|
362
|
+
return proxyRoboParkControl(request, '/robopark/' + path.map(encodeURIComponent).join('/'));
|
|
363
|
+
}
|
|
364
|
+
export { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE };
|
|
365
|
+
`;
|
|
366
|
+
await write(path.join(frontendRoot, 'app', 'robopark', '[...path]', 'route.ts'), productRoute);
|
|
367
|
+
await write(
|
|
368
|
+
path.join(frontendRoot, 'app', 'robopark', 'page.tsx'),
|
|
369
|
+
`import { redirect } from 'next/navigation';
|
|
370
|
+
|
|
371
|
+
export default function RoboParkPage() {
|
|
372
|
+
redirect('/robopark-control.html#robopark');
|
|
373
|
+
}
|
|
374
|
+
`
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
for (const image of ['bmw.png', 'jaguar.png', 'panda.png', 'tesla.png', 'volt.png']) {
|
|
378
|
+
await copy(path.join(repoRoot, 'static', 'robopark', image), path.join(frontendRoot, 'public', 'static', 'robopark', image));
|
|
379
|
+
}
|
|
380
|
+
await copy(
|
|
381
|
+
path.join(repoRoot, 'node_modules', 'livekit-client', 'dist', 'livekit-client.umd.js'),
|
|
382
|
+
path.join(frontendRoot, 'public', 'vendor', 'livekit-client.umd.js')
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const composePath = path.join(targetRoot, 'docker-compose.yaml');
|
|
386
|
+
let compose = await readFile(composePath, 'utf8');
|
|
387
|
+
if (!compose.includes('ROBOPARK_CONTROL_URL=')) {
|
|
388
|
+
compose = compose.replace(
|
|
389
|
+
' - SCHEDULER_URL=http://host.docker.internal:8080',
|
|
390
|
+
` - SCHEDULER_URL=http://host.docker.internal:8080
|
|
391
|
+
- ROBOPARK_CONTROL_URL=\${ROBOPARK_CONTROL_URL:-http://host.docker.internal:47913}
|
|
392
|
+
- ROBOPARK_CONTROL_TOKEN=\${ROBOPARK_CONTROL_TOKEN:-}`
|
|
393
|
+
);
|
|
394
|
+
await writeFile(composePath, compose, 'utf8');
|
|
395
|
+
}
|
|
396
|
+
if (!compose.includes('ROBOVOICE_ROBOTS_JSON=')) {
|
|
397
|
+
compose = compose.replace(
|
|
398
|
+
' - ROBOPARK_CONTROL_TOKEN=${ROBOPARK_CONTROL_TOKEN:-}',
|
|
399
|
+
` - ROBOPARK_CONTROL_TOKEN=\${ROBOPARK_CONTROL_TOKEN:-}
|
|
400
|
+
- ROBOVOICE_ROBOTS_JSON=\${ROBOVOICE_ROBOTS_JSON:-}
|
|
401
|
+
- ROBOVOICE_BMW_HOST=\${ROBOVOICE_BMW_HOST:-192.168.0.178}`
|
|
402
|
+
);
|
|
403
|
+
await writeFile(composePath, compose, 'utf8');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const envExamplePath = path.join(targetRoot, '.env.example');
|
|
407
|
+
let envExample = await readFile(envExamplePath, 'utf8');
|
|
408
|
+
if (!envExample.includes('ROBOPARK_CONTROL_URL=')) {
|
|
409
|
+
envExample += `
|
|
410
|
+
|
|
411
|
+
# Control upstream. Credentials stay server-side and are never exposed to the browser.
|
|
412
|
+
ROBOPARK_CONTROL_URL=http://host.docker.internal:47913
|
|
413
|
+
ROBOPARK_CONTROL_TOKEN=
|
|
414
|
+
ROBOVOICE_BMW_HOST=192.168.0.178
|
|
415
|
+
# JSON array for all robots. Fields: id, name, deviceId, host, visionPort, audioPort, aliases.
|
|
416
|
+
ROBOVOICE_ROBOTS_JSON=
|
|
417
|
+
`;
|
|
418
|
+
await writeFile(envExamplePath, envExample, 'utf8');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
console.log(`RoboPark Control Center integrated into ${frontendRoot}`);
|
|
422
|
+
console.log('Default UI: /robopark-control.html');
|
|
423
|
+
console.log('Native RoboVoice calls and settings: /voice');
|