feat(robot): connect provisioning hotspots in app
This commit is contained in:
@@ -6,6 +6,7 @@ import { sendJson } from '../route-utils';
|
||||
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
||||
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
import type { RobotHotspotModule } from '../../robot-hotspot';
|
||||
|
||||
const MAX_REQUEST_BYTES = 64 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 256 * 1024;
|
||||
@@ -16,6 +17,7 @@ const REVISION_ETAG = /^(?:W\/)?"(0|[1-9]\d*)"$/;
|
||||
const LOCAL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,35}$/;
|
||||
const CATALOG_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,254}$/;
|
||||
const OPERATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const HOTSPOT_CANDIDATE_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const MAX_RETRY_AFTER_SECONDS = 2;
|
||||
const PROVISIONING_PORTAL_URL = 'http://192.168.4.1/';
|
||||
const GUIDED_HOTSPOT_BINDING_ENABLED = process.env.NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING !== '0';
|
||||
@@ -30,6 +32,7 @@ export type AiHardwareRouteDependencies = {
|
||||
timeoutMs?: number;
|
||||
guidedHotspotBinding?: boolean;
|
||||
openExternal?: (url: string) => Promise<void>;
|
||||
robotHotspot?: RobotHotspotModule;
|
||||
};
|
||||
|
||||
class SafeRouteError extends Error {
|
||||
@@ -291,6 +294,52 @@ function requireRevision(body: Record<string, unknown>): number {
|
||||
return revision;
|
||||
}
|
||||
|
||||
function requireHotspotCandidateId(value: unknown): string {
|
||||
if (typeof value !== 'string' || !HOTSPOT_CANDIDATE_ID.test(value)) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const HOTSPOT_ERRORS: Record<string, { status: number; code: string; error: string }> = {
|
||||
unsupported: {
|
||||
status: 501,
|
||||
code: 'AI_HARDWARE_HOTSPOT_UNSUPPORTED',
|
||||
error: 'Robot hotspot connection is not supported on this platform',
|
||||
},
|
||||
permission_denied: {
|
||||
status: 403,
|
||||
code: 'AI_HARDWARE_HOTSPOT_PERMISSION_DENIED',
|
||||
error: 'Permission to access nearby Robot hotspots was denied',
|
||||
},
|
||||
busy: {
|
||||
status: 409,
|
||||
code: 'AI_HARDWARE_HOTSPOT_BUSY',
|
||||
error: 'Another Robot hotspot operation is in progress',
|
||||
},
|
||||
candidate_expired: {
|
||||
status: 409,
|
||||
code: 'AI_HARDWARE_HOTSPOT_CANDIDATE_EXPIRED',
|
||||
error: 'The Robot hotspot selection expired; scan again',
|
||||
},
|
||||
scan_failed: {
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_HOTSPOT_SCAN_FAILED',
|
||||
error: 'Robot hotspots could not be scanned',
|
||||
},
|
||||
connect_failed: {
|
||||
status: 502,
|
||||
code: 'AI_HARDWARE_HOTSPOT_CONNECT_FAILED',
|
||||
error: 'The Robot hotspot could not be connected',
|
||||
},
|
||||
};
|
||||
|
||||
function asHotspotRouteError(error: unknown, fallback: 'scan_failed' | 'connect_failed'): SafeRouteError {
|
||||
const nativeCode = isRecord(error) && typeof error.code === 'string' ? error.code : fallback;
|
||||
const safe = HOTSPOT_ERRORS[nativeCode] ?? HOTSPOT_ERRORS[fallback];
|
||||
return new SafeRouteError(safe.status, safe.code, safe.error);
|
||||
}
|
||||
|
||||
function takeOperationId(body: Record<string, unknown>, createUuid: () => string): string {
|
||||
const candidate = body.client_operation_id ?? createUuid();
|
||||
if (typeof candidate !== 'string' || !OPERATION_ID.test(candidate)) {
|
||||
@@ -442,6 +491,9 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
||||
const timeoutMs = dependencies.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const guidedHotspotBinding = dependencies.guidedHotspotBinding ?? GUIDED_HOTSPOT_BINDING_ENABLED;
|
||||
const openExternal = dependencies.openExternal ?? ((url: string) => shell.openExternal(url));
|
||||
const getRobotHotspot = dependencies.robotHotspot
|
||||
? async () => dependencies.robotHotspot as RobotHotspotModule
|
||||
: async () => (await import('../../robot-hotspot')).robotHotspotModule;
|
||||
|
||||
return async function handleAiHardwareRoutes(
|
||||
req: IncomingMessage,
|
||||
@@ -493,6 +545,67 @@ export function createAiHardwareRouteHandler(dependencies: AiHardwareRouteDepend
|
||||
sendJson(res, 200, { success: true, data: { opened: true } });
|
||||
return true;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/scan`) {
|
||||
if (url.search) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set());
|
||||
if (!guidedHotspotBinding) {
|
||||
throw new SafeRouteError(
|
||||
403,
|
||||
'AI_HARDWARE_PROVISIONING_DISABLED',
|
||||
'AI hardware guided provisioning is not enabled',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await (await getRobotHotspot()).scan();
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
platform: result.platform,
|
||||
hotspots: result.hotspots.map((hotspot) => ({
|
||||
candidate_id: hotspot.candidateId,
|
||||
ssid: hotspot.ssid,
|
||||
signal_percent: hotspot.signalPercent,
|
||||
connected: hotspot.connected,
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw asHotspotRouteError(error, 'scan_failed');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (method === 'POST' && url.pathname === `${LOCAL_ROOT}/provisioning-hotspots/connect`) {
|
||||
if (url.search) {
|
||||
throw new SafeRouteError(400, 'AI_HARDWARE_INVALID_REQUEST', 'Invalid AI hardware request');
|
||||
}
|
||||
const input = await readBoundedJson(req);
|
||||
ensureExactKeys(input, new Set(['candidate_id']));
|
||||
const candidateId = requireHotspotCandidateId(input.candidate_id);
|
||||
if (!guidedHotspotBinding) {
|
||||
throw new SafeRouteError(
|
||||
403,
|
||||
'AI_HARDWARE_PROVISIONING_DISABLED',
|
||||
'AI hardware guided provisioning is not enabled',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await (await getRobotHotspot()).connect(candidateId);
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
data: {
|
||||
connected: result.connected,
|
||||
candidate_id: result.candidateId,
|
||||
ssid: result.ssid,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw asHotspotRouteError(error, 'connect_failed');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let upstreamPath: string;
|
||||
let body: Record<string, unknown> | undefined;
|
||||
|
||||
Reference in New Issue
Block a user