@mentra/cloud-protocol 0.1.0-dev.0
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/package.json +28 -0
- package/src/audio.ts +121 -0
- package/src/camera.ts +157 -0
- package/src/control.ts +13 -0
- package/src/envelope.ts +48 -0
- package/src/errors.ts +27 -0
- package/src/handshake.ts +60 -0
- package/src/index.ts +17 -0
- package/src/maps.ts +185 -0
- package/src/messages.ts +74 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mentra/cloud-protocol",
|
|
3
|
+
"version": "0.1.0-dev.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./*": "./src/*.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"!src/**/*.test.ts"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"zod": "^3.24.1"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/Mentra-Community/MentraOS.git",
|
|
26
|
+
"directory": "cloud-v2/packages/protocol"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/audio.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical audio wire types for the Mentra Runtime protocol.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the audio service's subscription model
|
|
5
|
+
* and result types. These are the single source of truth; `src/audio.types.ts`
|
|
6
|
+
* re-exports the subscription types from here and adds `subscriptionKey()`.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors docs/issues/002-cloud-runtime/audio/spec.md
|
|
9
|
+
* ("Subscription model" and "Result types"). Pure + isomorphic: no server
|
|
10
|
+
* imports, safe to bundle into the RN client.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
// --- Subscription model -----------------------------------------------------
|
|
15
|
+
|
|
16
|
+
export const languageSourceSchema = z.discriminatedUnion("mode", [
|
|
17
|
+
z.object({ mode: z.literal("specific"), code: z.string() }),
|
|
18
|
+
z.object({ mode: z.literal("auto"), hints: z.array(z.string()).optional() }),
|
|
19
|
+
]);
|
|
20
|
+
export type LanguageSource = z.infer<typeof languageSourceSchema>;
|
|
21
|
+
|
|
22
|
+
export const transcriptionSubscriptionSchema = z.object({
|
|
23
|
+
kind: z.literal("transcription"),
|
|
24
|
+
language: languageSourceSchema,
|
|
25
|
+
});
|
|
26
|
+
export type TranscriptionSubscription = z.infer<
|
|
27
|
+
typeof transcriptionSubscriptionSchema
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
export const translationSubscriptionSchema = z.object({
|
|
31
|
+
kind: z.literal("translation"),
|
|
32
|
+
source: languageSourceSchema,
|
|
33
|
+
target: z.string(),
|
|
34
|
+
});
|
|
35
|
+
export type TranslationSubscription = z.infer<
|
|
36
|
+
typeof translationSubscriptionSchema
|
|
37
|
+
>;
|
|
38
|
+
|
|
39
|
+
export const audioSubscriptionSchema = z.discriminatedUnion("kind", [
|
|
40
|
+
transcriptionSubscriptionSchema,
|
|
41
|
+
translationSubscriptionSchema,
|
|
42
|
+
]);
|
|
43
|
+
export type AudioSubscription = z.infer<typeof audioSubscriptionSchema>;
|
|
44
|
+
|
|
45
|
+
// --- UDP liveness -----------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export const UDP_LIVENESS_PROBE_PREFIX = "mentra:udp-probe:";
|
|
48
|
+
|
|
49
|
+
export const udpLivenessAckPayloadSchema = z.object({
|
|
50
|
+
sessionId: z.string(),
|
|
51
|
+
sessionTag: z.number().int(),
|
|
52
|
+
probeId: z.string(),
|
|
53
|
+
receivedAt: z.number().int(),
|
|
54
|
+
});
|
|
55
|
+
export type UdpLivenessAckPayload = z.infer<typeof udpLivenessAckPayloadSchema>;
|
|
56
|
+
|
|
57
|
+
// --- Result types -----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
export const transcriptionTokenSchema = z.object({
|
|
60
|
+
text: z.string(),
|
|
61
|
+
startMs: z.number(),
|
|
62
|
+
endMs: z.number(),
|
|
63
|
+
confidence: z.number(),
|
|
64
|
+
isFinal: z.boolean(),
|
|
65
|
+
speaker: z.string().optional(),
|
|
66
|
+
// per-token; mid-sentence language switches are possible
|
|
67
|
+
detectedLanguage: z.string().optional(),
|
|
68
|
+
});
|
|
69
|
+
export type TranscriptionToken = z.infer<typeof transcriptionTokenSchema>;
|
|
70
|
+
|
|
71
|
+
export const transcriptionDataSchema = z.object({
|
|
72
|
+
userId: z.string(),
|
|
73
|
+
subscription: transcriptionSubscriptionSchema,
|
|
74
|
+
|
|
75
|
+
// Aggregated for simple consumers
|
|
76
|
+
text: z.string(),
|
|
77
|
+
isFinal: z.boolean(),
|
|
78
|
+
utteranceId: z.string().optional(),
|
|
79
|
+
speakerId: z.string().optional(),
|
|
80
|
+
startMs: z.number(),
|
|
81
|
+
endMs: z.number(),
|
|
82
|
+
durationMs: z.number().optional(),
|
|
83
|
+
confidence: z.number().optional(),
|
|
84
|
+
|
|
85
|
+
// Language resolution
|
|
86
|
+
resolvedLanguage: z.string(),
|
|
87
|
+
languageDetected: z.boolean(),
|
|
88
|
+
|
|
89
|
+
// Per-token detail for consumers that need it
|
|
90
|
+
tokens: z.array(transcriptionTokenSchema),
|
|
91
|
+
|
|
92
|
+
provider: z.string(),
|
|
93
|
+
timestamp: z.number(),
|
|
94
|
+
});
|
|
95
|
+
export type TranscriptionData = z.infer<typeof transcriptionDataSchema>;
|
|
96
|
+
|
|
97
|
+
export const translationDataSchema = z.object({
|
|
98
|
+
userId: z.string(),
|
|
99
|
+
subscription: translationSubscriptionSchema,
|
|
100
|
+
|
|
101
|
+
text: z.string(), // translated
|
|
102
|
+
originalText: z.string().optional(), // source-language text
|
|
103
|
+
isFinal: z.boolean(),
|
|
104
|
+
utteranceId: z.string().optional(),
|
|
105
|
+
speakerId: z.string().optional(),
|
|
106
|
+
startMs: z.number(),
|
|
107
|
+
endMs: z.number(),
|
|
108
|
+
durationMs: z.number().optional(),
|
|
109
|
+
confidence: z.number().optional(),
|
|
110
|
+
|
|
111
|
+
source: z.object({
|
|
112
|
+
language: z.string(), // resolved (specified or detected)
|
|
113
|
+
detected: z.boolean(),
|
|
114
|
+
confidence: z.number().optional(),
|
|
115
|
+
}),
|
|
116
|
+
target: z.object({ language: z.string() }),
|
|
117
|
+
|
|
118
|
+
provider: z.string(),
|
|
119
|
+
timestamp: z.number(),
|
|
120
|
+
});
|
|
121
|
+
export type TranslationData = z.infer<typeof translationDataSchema>;
|
package/src/camera.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical camera wire types: managed photo + managed stream.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the camera service. The request/result
|
|
5
|
+
* shapes ride REST; `photo.ready` / `photo.error` are WebSocket push events
|
|
6
|
+
* registered into the message union (messages.ts). Pure + isomorphic: no server
|
|
7
|
+
* imports, safe to bundle into the client.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors docs/issues/002-cloud-runtime/camera/spec.md.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
// --- Managed photo ----------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Device-canonical photo size tier (matches miniapp SDK and ASG). */
|
|
16
|
+
export const photoSizeCanonicalSchema = z.enum(["low", "medium", "high", "max"]);
|
|
17
|
+
export type PhotoSizeTier = z.infer<typeof photoSizeCanonicalSchema>;
|
|
18
|
+
|
|
19
|
+
/** Accepted on the wire: canonical names plus legacy cloud aliases. */
|
|
20
|
+
const photoSizeInputSchema = z.enum([
|
|
21
|
+
"low",
|
|
22
|
+
"medium",
|
|
23
|
+
"high",
|
|
24
|
+
"max",
|
|
25
|
+
"small",
|
|
26
|
+
"large",
|
|
27
|
+
"full",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize a photo size string to the device-canonical tier.
|
|
32
|
+
* Legacy aliases: small→low, large→high, full→max.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizePhotoSizeTier(value: string): PhotoSizeTier {
|
|
35
|
+
switch (value) {
|
|
36
|
+
case "small":
|
|
37
|
+
return "low";
|
|
38
|
+
case "large":
|
|
39
|
+
return "high";
|
|
40
|
+
case "full":
|
|
41
|
+
return "max";
|
|
42
|
+
case "low":
|
|
43
|
+
case "medium":
|
|
44
|
+
case "high":
|
|
45
|
+
case "max":
|
|
46
|
+
return value;
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`invalid photo size: ${value}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const photoCompressInputSchema = z.enum(["none", "low", "medium", "high", "heavy"]);
|
|
53
|
+
|
|
54
|
+
/** Normalize compression aliases to the cloud wire enum. */
|
|
55
|
+
export function normalizePhotoCompress(
|
|
56
|
+
value: string,
|
|
57
|
+
): "none" | "medium" | "heavy" {
|
|
58
|
+
switch (value) {
|
|
59
|
+
case "low":
|
|
60
|
+
case "medium":
|
|
61
|
+
return "medium";
|
|
62
|
+
case "high":
|
|
63
|
+
case "heavy":
|
|
64
|
+
return "heavy";
|
|
65
|
+
case "none":
|
|
66
|
+
return "none";
|
|
67
|
+
default:
|
|
68
|
+
throw new Error(`invalid photo compress: ${value}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const photoOptionsSchema = z.object({
|
|
73
|
+
size: photoSizeInputSchema
|
|
74
|
+
.optional()
|
|
75
|
+
.transform((value) => (value === undefined ? undefined : normalizePhotoSizeTier(value))),
|
|
76
|
+
compress: photoCompressInputSchema
|
|
77
|
+
.optional()
|
|
78
|
+
.transform((value) => (value === undefined ? undefined : normalizePhotoCompress(value))),
|
|
79
|
+
saveToGallery: z.boolean().optional(),
|
|
80
|
+
sound: z.boolean().optional(),
|
|
81
|
+
});
|
|
82
|
+
export type PhotoOptions = z.infer<typeof photoOptionsSchema>;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The REST response to a photo request. The capture happens out of band (the
|
|
86
|
+
* glasses upload to `uploadUrl`); `readUrl` is the presigned URL the finished
|
|
87
|
+
* photo will be readable at, returned up front so the client has it before the
|
|
88
|
+
* `photo.ready` push confirms the upload landed.
|
|
89
|
+
*/
|
|
90
|
+
export const photoRequestResultSchema = z.object({
|
|
91
|
+
requestId: z.string(),
|
|
92
|
+
uploadUrl: z.string(),
|
|
93
|
+
readUrl: z.string(),
|
|
94
|
+
});
|
|
95
|
+
export type PhotoRequestResult = z.infer<typeof photoRequestResultSchema>;
|
|
96
|
+
|
|
97
|
+
/** `photo.ready` push payload: the capture+upload completed. */
|
|
98
|
+
export const photoReadyPayloadSchema = z.object({
|
|
99
|
+
requestId: z.string(),
|
|
100
|
+
readUrl: z.string(),
|
|
101
|
+
});
|
|
102
|
+
export type PhotoReady = z.infer<typeof photoReadyPayloadSchema>;
|
|
103
|
+
|
|
104
|
+
/** `photo.error` push payload: the capture+upload failed. */
|
|
105
|
+
export const photoErrorPayloadSchema = z.object({
|
|
106
|
+
requestId: z.string(),
|
|
107
|
+
reason: z.string(),
|
|
108
|
+
});
|
|
109
|
+
export type PhotoError = z.infer<typeof photoErrorPayloadSchema>;
|
|
110
|
+
|
|
111
|
+
// --- Managed stream ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/** A restream destination: re-publish the ingest to an external RTMP target. */
|
|
114
|
+
export const restreamDestinationSchema = z.union([
|
|
115
|
+
z.string(),
|
|
116
|
+
z.object({ url: z.string(), name: z.string().optional() }),
|
|
117
|
+
]);
|
|
118
|
+
export type RestreamDestination = z.infer<typeof restreamDestinationSchema>;
|
|
119
|
+
|
|
120
|
+
export const streamOptionsSchema = z.object({
|
|
121
|
+
/** Region hint so the cloud provisions a nearby ingest endpoint. */
|
|
122
|
+
region: z.string().optional(),
|
|
123
|
+
/** External RTMP targets the provider re-publishes the ingest to. */
|
|
124
|
+
restreamDestinations: z.array(restreamDestinationSchema).optional(),
|
|
125
|
+
});
|
|
126
|
+
export type StreamOptions = z.infer<typeof streamOptionsSchema>;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A provisioned stream. `ingest` is where the device pushes frames; `playback`
|
|
130
|
+
* is where viewers watch. Both are left as open records because the provider
|
|
131
|
+
* (Cloudflare Stream by default) is swappable per region and its exact field
|
|
132
|
+
* shapes are not finalized. The Cloudflare provider populates ingest
|
|
133
|
+
* `{protocol, url, streamKey, rtmpUrl, srtUrl?, webrtcPublishUrl?}` (the last
|
|
134
|
+
* three ready-to-publish with credentials embedded) and playback
|
|
135
|
+
* `{hls, dash, webrtc?}`.
|
|
136
|
+
*/
|
|
137
|
+
export const managedStreamSchema = z.object({
|
|
138
|
+
streamId: z.string(),
|
|
139
|
+
ingest: z.record(z.unknown()),
|
|
140
|
+
playback: z.record(z.unknown()),
|
|
141
|
+
});
|
|
142
|
+
export type ManagedStream = z.infer<typeof managedStreamSchema>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `GET /api/camera/stream/:id` result: the provider's view of the ingest.
|
|
146
|
+
* `isConnected` is the portable signal (is the device's push arriving?);
|
|
147
|
+
* the rest is provider detail for debugging.
|
|
148
|
+
*/
|
|
149
|
+
export const streamStatusResultSchema = z.object({
|
|
150
|
+
streamId: z.string(),
|
|
151
|
+
isConnected: z.boolean(),
|
|
152
|
+
state: z.string().nullable(),
|
|
153
|
+
connectedAt: z.string().optional(),
|
|
154
|
+
lastSeenAt: z.string().optional(),
|
|
155
|
+
reason: z.string().optional(),
|
|
156
|
+
});
|
|
157
|
+
export type StreamStatusResult = z.infer<typeof streamStatusResultSchema>;
|
package/src/control.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Control payloads: control.ping and control.pong.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Control"). Liveness and
|
|
5
|
+
* RTT, separate from the WebSocket ping frame. Pure + isomorphic.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
export const controlPingPayloadSchema = z.object({}).strict();
|
|
10
|
+
export type ControlPing = z.infer<typeof controlPingPayloadSchema>;
|
|
11
|
+
|
|
12
|
+
export const controlPongPayloadSchema = z.object({}).strict();
|
|
13
|
+
export type ControlPong = z.infer<typeof controlPongPayloadSchema>;
|
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The transport envelope shared by every WebSocket message.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Envelope"). Pure +
|
|
5
|
+
* isomorphic: no server imports.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
/** Protocol major. A mismatched major is rejected at the handshake. */
|
|
10
|
+
export const PROTOCOL_MAJOR = 2 as const;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Wraps a payload schema in the protocol envelope with a literal `type`, so a
|
|
14
|
+
* set of message schemas can be combined into a discriminated union on `type`.
|
|
15
|
+
*
|
|
16
|
+
* const connectionAck = envelope("connection.ack", connectionAckPayloadSchema)
|
|
17
|
+
*/
|
|
18
|
+
export function envelope<Type extends string, Payload extends z.ZodTypeAny>(
|
|
19
|
+
type: Type,
|
|
20
|
+
payload: Payload,
|
|
21
|
+
) {
|
|
22
|
+
return z.object({
|
|
23
|
+
v: z.literal(PROTOCOL_MAJOR),
|
|
24
|
+
type: z.literal(type),
|
|
25
|
+
// present only when a message needs request/ack correlation
|
|
26
|
+
id: z.string().optional(),
|
|
27
|
+
// epoch milliseconds (Unix time)
|
|
28
|
+
timestamp: z.number().int(),
|
|
29
|
+
payload,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The structural envelope, payload left open. Useful for a first-pass parse. */
|
|
34
|
+
export const envelopeSchema = z.object({
|
|
35
|
+
v: z.literal(PROTOCOL_MAJOR),
|
|
36
|
+
type: z.string(),
|
|
37
|
+
id: z.string().optional(),
|
|
38
|
+
timestamp: z.number().int(),
|
|
39
|
+
payload: z.unknown(),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface Envelope<T = unknown> {
|
|
43
|
+
v: typeof PROTOCOL_MAJOR;
|
|
44
|
+
type: string;
|
|
45
|
+
id?: string;
|
|
46
|
+
timestamp: number;
|
|
47
|
+
payload: T;
|
|
48
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The error payload and transport-level error codes.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Errors"). Services may
|
|
5
|
+
* define additional codes for their own payloads (documented in the service
|
|
6
|
+
* doc). Pure + isomorphic.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
/** Transport-level error codes shared by every runtime service. */
|
|
11
|
+
export const PROTOCOL_ERROR_CODES = {
|
|
12
|
+
AUTH_FAILED: { fatal: true }, // token missing or invalid
|
|
13
|
+
AUTH_EXPIRED: { fatal: true }, // token expired; client should refresh and reopen
|
|
14
|
+
UNSUPPORTED_VERSION: { fatal: true }, // protocol major mismatch
|
|
15
|
+
BAD_REQUEST: { fatal: false }, // malformed payload for a known type
|
|
16
|
+
UNKNOWN_TYPE: { fatal: false }, // unrecognized `type`
|
|
17
|
+
INTERNAL: { fatal: false }, // server-side failure
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type ProtocolErrorCode = keyof typeof PROTOCOL_ERROR_CODES;
|
|
21
|
+
|
|
22
|
+
export const protocolErrorPayloadSchema = z.object({
|
|
23
|
+
code: z.string(), // a ProtocolErrorCode or a service-defined code
|
|
24
|
+
message: z.string(),
|
|
25
|
+
fatal: z.boolean(), // if true, the server closes the socket after sending
|
|
26
|
+
});
|
|
27
|
+
export type ProtocolError = z.infer<typeof protocolErrorPayloadSchema>;
|
package/src/handshake.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Handshake payloads: connection.init and connection.ack.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors docs/issues/002-cloud-runtime/protocol.md ("Auth and handshake").
|
|
5
|
+
* Service-scoped config rides in scoped blocks; audio is the only one today.
|
|
6
|
+
* Pure + isomorphic: no server imports.
|
|
7
|
+
*/
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { audioSubscriptionSchema } from "./audio";
|
|
10
|
+
|
|
11
|
+
// --- connection.init (client to cloud) --------------------------------------
|
|
12
|
+
|
|
13
|
+
export const connectionInitPayloadSchema = z.object({
|
|
14
|
+
// omitted if provided via the ?token= fallback
|
|
15
|
+
token: z.string().optional(),
|
|
16
|
+
// semver of the client protocol build, e.g. "2.0.0"
|
|
17
|
+
protocolVersion: z.string(),
|
|
18
|
+
client: z
|
|
19
|
+
.object({
|
|
20
|
+
platform: z.enum(["ios", "android"]),
|
|
21
|
+
appVersion: z.string().optional(),
|
|
22
|
+
})
|
|
23
|
+
.optional(),
|
|
24
|
+
// Service-scoped config blocks. Audio is the only one today.
|
|
25
|
+
audio: z
|
|
26
|
+
.object({
|
|
27
|
+
codec: z.enum(["lc3", "pcm"]),
|
|
28
|
+
sampleRate: z.number().int(), // e.g. 16000
|
|
29
|
+
// LC3 bitrate / frame size in bytes. Only meaningful when codec is
|
|
30
|
+
// "lc3"; omitted PCM sessions keep treating payload bytes as PCM.
|
|
31
|
+
frameSizeBytes: z.union([z.literal(20), z.literal(40), z.literal(60)]).optional(),
|
|
32
|
+
// Optional initial subscription set, seeded atomically with the session
|
|
33
|
+
// so audio that starts before the first REST update is not transcribed
|
|
34
|
+
// with an empty set.
|
|
35
|
+
initialSubscriptions: z.array(audioSubscriptionSchema).optional(),
|
|
36
|
+
})
|
|
37
|
+
.optional(),
|
|
38
|
+
});
|
|
39
|
+
export type ConnectionInit = z.infer<typeof connectionInitPayloadSchema>;
|
|
40
|
+
|
|
41
|
+
// --- connection.ack (cloud to client) ---------------------------------------
|
|
42
|
+
|
|
43
|
+
export const connectionAckPayloadSchema = z.object({
|
|
44
|
+
sessionId: z.string(), // control-plane runtime session id
|
|
45
|
+
negotiatedVersion: z.string(),
|
|
46
|
+
// Audio session ingest coordinates (audio service, UDP path).
|
|
47
|
+
audio: z
|
|
48
|
+
.object({
|
|
49
|
+
sessionTag: z.number().int(), // u32 stamped into UDP audio frames
|
|
50
|
+
udp: z.object({ host: z.string(), port: z.number().int() }),
|
|
51
|
+
// Per-session key for encrypting UDP audio. Delivered here because the
|
|
52
|
+
// handshake is over the TLS WebSocket.
|
|
53
|
+
encryption: z.object({
|
|
54
|
+
algorithm: z.literal("xsalsa20-poly1305"), // NaCl secretbox
|
|
55
|
+
key: z.string(), // base64, 32 bytes
|
|
56
|
+
}),
|
|
57
|
+
})
|
|
58
|
+
.optional(),
|
|
59
|
+
});
|
|
60
|
+
export type ConnectionAck = z.infer<typeof connectionAckPayloadSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `@mentra/cloud-runtime/protocol` public entrypoint.
|
|
3
|
+
*
|
|
4
|
+
* Pure, isomorphic transport types + zod validators for the Mentra Runtime
|
|
5
|
+
* protocol. Zero server imports (no `node:*`, no service code) so the client
|
|
6
|
+
* can import the contract without pulling in server code.
|
|
7
|
+
*
|
|
8
|
+
* See docs/issues/002-cloud-runtime/protocol.md.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./envelope";
|
|
11
|
+
export * from "./handshake";
|
|
12
|
+
export * from "./control";
|
|
13
|
+
export * from "./errors";
|
|
14
|
+
export * from "./messages";
|
|
15
|
+
export * from "./audio";
|
|
16
|
+
export * from "./camera";
|
|
17
|
+
export * from "./maps";
|
package/src/maps.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Canonical maps wire types: directions + reverse geocoding.
|
|
3
|
+
*
|
|
4
|
+
* zod schemas + inferred TS types for the maps service. Both flows are plain
|
|
5
|
+
* REST request/result (no WebSocket push, unlike camera), so nothing here is
|
|
6
|
+
* registered into the message union. Pure + isomorphic: no server imports, safe
|
|
7
|
+
* to bundle into the client.
|
|
8
|
+
*
|
|
9
|
+
* The vocabulary (TravelMode, ManeuverKind, avoidances, the route/step shapes)
|
|
10
|
+
* deliberately mirrors the mobile miniapp navigation SDK
|
|
11
|
+
* (mobile/modules/miniapp/src/modules/navigation.ts) so the contract is identical
|
|
12
|
+
* end-to-end: miniapp SDK <-> cloud-client <-> runtime. These are the PROVIDER-
|
|
13
|
+
* NEUTRAL types; each provider (Mapbox, Google) normalizes its own response into
|
|
14
|
+
* these inside its provider file, so this contract never leaks a vendor's shape.
|
|
15
|
+
*
|
|
16
|
+
* See docs/issues/002-cloud-runtime/maps/spec.md.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
|
|
20
|
+
// --- Shared geometry --------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export const latLngSchema = z.object({
|
|
23
|
+
lat: z.number(),
|
|
24
|
+
lng: z.number(),
|
|
25
|
+
});
|
|
26
|
+
export type LatLng = z.infer<typeof latLngSchema>;
|
|
27
|
+
|
|
28
|
+
/** Travel profile. Providers map this to their own profile vocabulary. */
|
|
29
|
+
export const travelModeSchema = z.enum(["walking", "driving", "cycling", "two_wheeler"]);
|
|
30
|
+
export type TravelMode = z.infer<typeof travelModeSchema>;
|
|
31
|
+
|
|
32
|
+
/** Routing preferences. All flags default to false. */
|
|
33
|
+
export const routeAvoidancesSchema = z.object({
|
|
34
|
+
highways: z.boolean().optional(),
|
|
35
|
+
tolls: z.boolean().optional(),
|
|
36
|
+
ferries: z.boolean().optional(),
|
|
37
|
+
});
|
|
38
|
+
export type RouteAvoidances = z.infer<typeof routeAvoidancesSchema>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Neutral maneuver vocabulary. Each provider maps its own maneuver type/modifier
|
|
42
|
+
* vocabulary into exactly these values; consumers never see vendor strings.
|
|
43
|
+
*/
|
|
44
|
+
export const maneuverKindSchema = z.enum([
|
|
45
|
+
"STRAIGHT",
|
|
46
|
+
"CONTINUE",
|
|
47
|
+
"SLIGHT_LEFT",
|
|
48
|
+
"SLIGHT_RIGHT",
|
|
49
|
+
"TURN_LEFT",
|
|
50
|
+
"TURN_RIGHT",
|
|
51
|
+
"SHARP_LEFT",
|
|
52
|
+
"SHARP_RIGHT",
|
|
53
|
+
"U_TURN",
|
|
54
|
+
"NAME_CHANGE",
|
|
55
|
+
"DEPART",
|
|
56
|
+
"ARRIVE",
|
|
57
|
+
"CROSS_STREET",
|
|
58
|
+
]);
|
|
59
|
+
export type ManeuverKind = z.infer<typeof maneuverKindSchema>;
|
|
60
|
+
|
|
61
|
+
// --- Directions -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
export const directionsRequestSchema = z.object({
|
|
64
|
+
origin: latLngSchema,
|
|
65
|
+
/** Ordered stops; last is the final destination. Must have >= 1 entry. */
|
|
66
|
+
stops: z.array(latLngSchema).min(1),
|
|
67
|
+
/** Defaults to "driving" when omitted. */
|
|
68
|
+
mode: travelModeSchema.optional(),
|
|
69
|
+
avoid: routeAvoidancesSchema.optional(),
|
|
70
|
+
/**
|
|
71
|
+
* Total number of routes to return — the primary plus any alternates, provider
|
|
72
|
+
* permitting. 1 (the default) means the primary route only, no alternates; a
|
|
73
|
+
* value > 1 asks the provider for alternates. (Mirrors the prior on-device
|
|
74
|
+
* behavior: Mapbox `alternatives` is requested only when this exceeds 1.)
|
|
75
|
+
*/
|
|
76
|
+
alternatives: z.number().int().positive().optional(),
|
|
77
|
+
});
|
|
78
|
+
export type DirectionsRequest = z.infer<typeof directionsRequestSchema>;
|
|
79
|
+
|
|
80
|
+
/** One step of a computed route. The maneuver ENDS the segment. */
|
|
81
|
+
export const routeStepSchema = z.object({
|
|
82
|
+
lat: z.number(),
|
|
83
|
+
lng: z.number(),
|
|
84
|
+
endLat: z.number(),
|
|
85
|
+
endLng: z.number(),
|
|
86
|
+
distanceMeters: z.number(),
|
|
87
|
+
maneuver: maneuverKindSchema.optional(),
|
|
88
|
+
/** Full instruction from the provider (e.g. "Turn left onto Fell St"). */
|
|
89
|
+
instruction: z.string().optional(),
|
|
90
|
+
/** Resolved road name. Prefer this over parsing `instruction`. */
|
|
91
|
+
road: z.string().nullable().optional(),
|
|
92
|
+
});
|
|
93
|
+
export type RouteStep = z.infer<typeof routeStepSchema>;
|
|
94
|
+
|
|
95
|
+
export const routeSchema = z.object({
|
|
96
|
+
points: z.array(latLngSchema),
|
|
97
|
+
totalDistanceMeters: z.number(),
|
|
98
|
+
totalDurationSeconds: z.number(),
|
|
99
|
+
summary: z.string().optional(),
|
|
100
|
+
steps: z.array(routeStepSchema).optional(),
|
|
101
|
+
});
|
|
102
|
+
export type Route = z.infer<typeof routeSchema>;
|
|
103
|
+
|
|
104
|
+
/** The REST response for `POST /api/maps/directions`. Primary route first. */
|
|
105
|
+
export const directionsResultSchema = z.object({
|
|
106
|
+
routes: z.array(routeSchema),
|
|
107
|
+
});
|
|
108
|
+
export type DirectionsResult = z.infer<typeof directionsResultSchema>;
|
|
109
|
+
|
|
110
|
+
// --- Reverse geocoding ------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
export const reverseGeocodeRequestSchema = latLngSchema;
|
|
113
|
+
export type ReverseGeocodeRequest = z.infer<typeof reverseGeocodeRequestSchema>;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The REST response for `POST /api/maps/reverse-geocode`.
|
|
117
|
+
*
|
|
118
|
+
* - `road` — short street name (e.g. "Hayes Street"). Backs the pivot
|
|
119
|
+
* engine's road-name fallback.
|
|
120
|
+
* - `address` — full formatted street address with house number + locality
|
|
121
|
+
* (e.g. "369 Hayes Street, San Francisco, California 94102"). Backs the
|
|
122
|
+
* navigation miniapp's dropped-pin / POI-tap labels.
|
|
123
|
+
*
|
|
124
|
+
* Either field is null when nothing of that kind was found near the coordinate;
|
|
125
|
+
* that is a successful empty answer, not an error (an actual failure is a
|
|
126
|
+
* non-2xx response).
|
|
127
|
+
*/
|
|
128
|
+
export const reverseGeocodeResultSchema = z.object({
|
|
129
|
+
road: z.string().nullable(),
|
|
130
|
+
address: z.string().nullable(),
|
|
131
|
+
});
|
|
132
|
+
export type ReverseGeocodeResult = z.infer<typeof reverseGeocodeResultSchema>;
|
|
133
|
+
|
|
134
|
+
// --- Place search (autocomplete + details) ----------------------------------
|
|
135
|
+
//
|
|
136
|
+
// A two-step "type-ahead then pick" flow, provider-neutral like the rest of this
|
|
137
|
+
// file. `placeAutocomplete` lists lightweight suggestions as the user types;
|
|
138
|
+
// `placeDetails` resolves the chosen suggestion to coordinates. A `sessionToken`
|
|
139
|
+
// threads BOTH calls so the provider can bill the keystrokes + the final detail
|
|
140
|
+
// fetch as ONE search session (Mapbox Search Box and Google Places both work
|
|
141
|
+
// this way). Callers create one token per "search box opening" and rotate it
|
|
142
|
+
// after a pick.
|
|
143
|
+
|
|
144
|
+
export const placeAutocompleteRequestSchema = z.object({
|
|
145
|
+
/** The user's partial query. Empty/whitespace yields no suggestions. */
|
|
146
|
+
query: z.string(),
|
|
147
|
+
/** Optional location bias — rank results near this coordinate. */
|
|
148
|
+
near: latLngSchema.optional(),
|
|
149
|
+
/** Opaque per-search-session token; the same value should go on `placeDetails`. */
|
|
150
|
+
sessionToken: z.string(),
|
|
151
|
+
});
|
|
152
|
+
export type PlaceAutocompleteRequest = z.infer<typeof placeAutocompleteRequestSchema>;
|
|
153
|
+
|
|
154
|
+
/** One type-ahead suggestion. `placeId` is fed back into `placeDetails`. */
|
|
155
|
+
export const placeSuggestionSchema = z.object({
|
|
156
|
+
placeId: z.string(),
|
|
157
|
+
/** Primary line, e.g. "Blue Bottle Coffee". */
|
|
158
|
+
mainText: z.string(),
|
|
159
|
+
/** Secondary line, e.g. "66 Mint St, San Francisco". Empty when none. */
|
|
160
|
+
secondaryText: z.string(),
|
|
161
|
+
});
|
|
162
|
+
export type PlaceSuggestion = z.infer<typeof placeSuggestionSchema>;
|
|
163
|
+
|
|
164
|
+
export const placeAutocompleteResultSchema = z.object({
|
|
165
|
+
suggestions: z.array(placeSuggestionSchema),
|
|
166
|
+
});
|
|
167
|
+
export type PlaceAutocompleteResult = z.infer<typeof placeAutocompleteResultSchema>;
|
|
168
|
+
|
|
169
|
+
export const placeDetailsRequestSchema = z.object({
|
|
170
|
+
/** A `placeId` returned by a prior `placeAutocomplete` suggestion. */
|
|
171
|
+
placeId: z.string(),
|
|
172
|
+
/** The same session token used for the autocomplete that produced this id. */
|
|
173
|
+
sessionToken: z.string(),
|
|
174
|
+
});
|
|
175
|
+
export type PlaceDetailsRequest = z.infer<typeof placeDetailsRequestSchema>;
|
|
176
|
+
|
|
177
|
+
/** The resolved place: a name, formatted address, and coordinates to route to. */
|
|
178
|
+
export const placeDetailsResultSchema = z.object({
|
|
179
|
+
placeId: z.string(),
|
|
180
|
+
name: z.string(),
|
|
181
|
+
address: z.string(),
|
|
182
|
+
lat: z.number(),
|
|
183
|
+
lng: z.number(),
|
|
184
|
+
});
|
|
185
|
+
export type PlaceDetailsResult = z.infer<typeof placeDetailsResultSchema>;
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The WebSocket message registry.
|
|
3
|
+
*
|
|
4
|
+
* Each transport `type` maps 1:1 to an enveloped zod schema; the schemas are
|
|
5
|
+
* combined into discriminated unions on `type` for parse-time validation on
|
|
6
|
+
* both ends. Transport-level types live in protocol.md; per-service push events
|
|
7
|
+
* (stream.transcript / stream.translation) are registered by the audio service
|
|
8
|
+
* doc but their schemas live here so the one union validates everything.
|
|
9
|
+
*
|
|
10
|
+
* Pure + isomorphic: no server imports.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { envelope } from "./envelope";
|
|
14
|
+
import { connectionInitPayloadSchema, connectionAckPayloadSchema } from "./handshake";
|
|
15
|
+
import { controlPingPayloadSchema, controlPongPayloadSchema } from "./control";
|
|
16
|
+
import { protocolErrorPayloadSchema } from "./errors";
|
|
17
|
+
import { transcriptionDataSchema, translationDataSchema, udpLivenessAckPayloadSchema } from "./audio";
|
|
18
|
+
import { photoReadyPayloadSchema, photoErrorPayloadSchema } from "./camera";
|
|
19
|
+
|
|
20
|
+
// --- Per-type enveloped message schemas -------------------------------------
|
|
21
|
+
|
|
22
|
+
export const connectionInitMessage = envelope("connection.init", connectionInitPayloadSchema);
|
|
23
|
+
export const connectionAckMessage = envelope("connection.ack", connectionAckPayloadSchema);
|
|
24
|
+
export const controlPingMessage = envelope("control.ping", controlPingPayloadSchema);
|
|
25
|
+
export const controlPongMessage = envelope("control.pong", controlPongPayloadSchema);
|
|
26
|
+
export const errorMessage = envelope("error", protocolErrorPayloadSchema);
|
|
27
|
+
|
|
28
|
+
// Audio service push events (registered in audio/protocol.md).
|
|
29
|
+
export const streamTranscriptMessage = envelope("stream.transcript", transcriptionDataSchema);
|
|
30
|
+
export const streamTranslationMessage = envelope("stream.translation", translationDataSchema);
|
|
31
|
+
export const audioUdpLivenessAckMessage = envelope("audio.udp_liveness_ack", udpLivenessAckPayloadSchema);
|
|
32
|
+
|
|
33
|
+
// Camera service push events (registered in camera/spec.md).
|
|
34
|
+
export const photoReadyMessage = envelope("photo.ready", photoReadyPayloadSchema);
|
|
35
|
+
export const photoErrorMessage = envelope("photo.error", photoErrorPayloadSchema);
|
|
36
|
+
|
|
37
|
+
// --- Direction-scoped unions ------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** Messages the client sends to the cloud. */
|
|
40
|
+
export const clientToCloudMessage = z.discriminatedUnion("type", [
|
|
41
|
+
connectionInitMessage,
|
|
42
|
+
controlPingMessage,
|
|
43
|
+
controlPongMessage,
|
|
44
|
+
]);
|
|
45
|
+
export type ClientToCloudMessage = z.infer<typeof clientToCloudMessage>;
|
|
46
|
+
|
|
47
|
+
/** Messages the cloud pushes to the client. */
|
|
48
|
+
export const cloudToClientMessage = z.discriminatedUnion("type", [
|
|
49
|
+
connectionAckMessage,
|
|
50
|
+
controlPingMessage,
|
|
51
|
+
controlPongMessage,
|
|
52
|
+
errorMessage,
|
|
53
|
+
streamTranscriptMessage,
|
|
54
|
+
streamTranslationMessage,
|
|
55
|
+
audioUdpLivenessAckMessage,
|
|
56
|
+
photoReadyMessage,
|
|
57
|
+
photoErrorMessage,
|
|
58
|
+
]);
|
|
59
|
+
export type CloudToClientMessage = z.infer<typeof cloudToClientMessage>;
|
|
60
|
+
|
|
61
|
+
/** Every known message in either direction. */
|
|
62
|
+
export const anyMessage = z.discriminatedUnion("type", [
|
|
63
|
+
connectionInitMessage,
|
|
64
|
+
connectionAckMessage,
|
|
65
|
+
controlPingMessage,
|
|
66
|
+
controlPongMessage,
|
|
67
|
+
errorMessage,
|
|
68
|
+
streamTranscriptMessage,
|
|
69
|
+
streamTranslationMessage,
|
|
70
|
+
audioUdpLivenessAckMessage,
|
|
71
|
+
photoReadyMessage,
|
|
72
|
+
photoErrorMessage,
|
|
73
|
+
]);
|
|
74
|
+
export type AnyMessage = z.infer<typeof anyMessage>;
|