@fleetless/contracts 1.2.0 → 2.0.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/CHANGELOG.md +26 -1
- package/artifacts/constants.json +30 -4
- package/artifacts/openapi.json +580 -49
- package/artifacts/routes.json +93 -2
- package/artifacts/schema/apply-error.schema.json +2 -1
- package/artifacts/schema/asset-list-response.schema.json +77 -12
- package/artifacts/schema/asset-sync-status.schema.json +35 -8
- package/artifacts/schema/asset.schema.json +2 -3
- package/artifacts/schema/assets-clear-response.schema.json +23 -0
- package/artifacts/schema/bridge-asset-progress.schema.json +14 -8
- package/artifacts/schema/bridge-config-applied.schema.json +2 -1
- package/artifacts/schema/bridge-link-mode.schema.json +36 -0
- package/artifacts/schema/bridge-state.schema.json +6 -1
- package/artifacts/schema/client-robot-list-item.schema.json +6 -1
- package/artifacts/schema/client-robot-list-response.schema.json +6 -1
- package/artifacts/schema/cloud-config.schema.json +90 -5
- package/artifacts/schema/cloud-hello-ok.schema.json +45 -0
- package/artifacts/schema/cloud-ping.schema.json +27 -1
- package/artifacts/schema/config-draft-response.schema.json +90 -5
- package/artifacts/schema/config-state.schema.json +2 -1
- package/artifacts/schema/config-version-response.schema.json +90 -5
- package/artifacts/schema/datapoint-config.schema.json +5 -0
- package/artifacts/schema/datapoint-frame.schema.json +4 -0
- package/artifacts/schema/datapoint-list-response.schema.json +2 -2
- package/artifacts/schema/joint-state-put-request.schema.json +23 -0
- package/artifacts/schema/joint-state-put-response.schema.json +24 -0
- package/artifacts/schema/org-quota-usage-counts.schema.json +0 -5
- package/artifacts/schema/org-quota-usage.schema.json +1 -12
- package/artifacts/schema/org-quotas.schema.json +1 -7
- package/artifacts/schema/robot-config-doc.schema.json +90 -5
- package/artifacts/schema/robot-deletion-summary.schema.json +2 -1
- package/artifacts/schema/robot-detail-response.schema.json +63 -2
- package/artifacts/schema/robot-list-item.schema.json +15 -1
- package/artifacts/schema/robot-list-response.schema.json +15 -1
- package/artifacts/schema/robot-token-rotate-response.schema.json +15 -0
- package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +14 -8
- package/artifacts/schema-outgoing/bridge-config-applied.schema.json +2 -1
- package/artifacts/schema-outgoing/bridge-link-mode.schema.json +37 -0
- package/artifacts/schema-outgoing/datapoint-frame.schema.json +4 -0
- package/dist/assets.d.ts +85 -50
- package/dist/assets.js +152 -62
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +1 -1
- package/dist/client-robots.d.ts +2 -0
- package/dist/common.d.ts +10 -0
- package/dist/common.js +16 -1
- package/dist/config.d.ts +69 -1
- package/dist/config.js +86 -6
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +1 -8
- package/dist/index.d.ts +8 -8
- package/dist/index.js +4 -4
- package/dist/protocol.d.ts +150 -71
- package/dist/protocol.js +144 -87
- package/dist/rest.d.ts +137 -35
- package/dist/rest.js +98 -66
- package/dist/routes.js +57 -8
- package/package.json +1 -1
- package/artifacts/schema/bridge-pressure.schema.json +0 -292
package/dist/protocol.d.ts
CHANGED
|
@@ -1,18 +1,67 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
/**
|
|
4
|
-
* Bridge <-> cloud protocol, version
|
|
4
|
+
* Bridge <-> cloud protocol, version 3.
|
|
5
5
|
*
|
|
6
|
-
* The version is exchanged in the hello handshake
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The version is exchanged in the hello handshake. Since 2026-09 the cloud
|
|
7
|
+
* serves a **window** of versions, not one: every entry of
|
|
8
|
+
* `PROTOCOL_VERSIONS` whose sunset has not passed. A version is deprecated
|
|
9
|
+
* by the cloud release that supersedes it and sunset `PROTOCOL_SUNSET_DAYS`
|
|
10
|
+
* later. Outside the window the cloud refuses with `protocol_mismatch`,
|
|
11
|
+
* which names the window and reaches the robot's detail view as
|
|
12
|
+
* `last_hello_error`.
|
|
13
|
+
*
|
|
14
|
+
* **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
|
|
15
|
+
* sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
|
|
16
|
+
* `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
|
|
17
|
+
* its sunset, and the cloud's protocol-2 adapter owes it two translations on
|
|
18
|
+
* the way in: it drops its pressure datapoints, and it rewrites an
|
|
19
|
+
* `asset_progress` failure of kind `too_large` — a kind protocol 3 no longer
|
|
20
|
+
* has — to `refused` with `details: null`, because a 2.0.0 `bridgeAssetProgress`
|
|
21
|
+
* refuses the frame outright otherwise.
|
|
9
22
|
*
|
|
10
23
|
* **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
|
|
11
|
-
* beside `message`.
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
* beside `message`.
|
|
25
|
+
*/
|
|
26
|
+
export declare const PROTOCOL_VERSION = 3;
|
|
27
|
+
/** Days between a version's deprecation and its sunset. */
|
|
28
|
+
export declare const PROTOCOL_SUNSET_DAYS = 90;
|
|
29
|
+
export interface ProtocolVersionEntry {
|
|
30
|
+
version: number;
|
|
31
|
+
/** The first bridge package version that speaks this protocol. */
|
|
32
|
+
bridge_from: string;
|
|
33
|
+
/** ISO date of the cloud release that superseded it; null while current. */
|
|
34
|
+
deprecated_at: string | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Every protocol version the cloud has served, oldest first. A test keeps
|
|
38
|
+
* exactly one entry current and equal to `PROTOCOL_VERSION`; `test/changelog.test.ts`
|
|
39
|
+
* requires the CHANGELOG's current section to name the newest `bridge_from`
|
|
40
|
+
* and the previous entry's `sunsetOf(...)` date, and `scripts/verify-version-tag.mjs`
|
|
41
|
+
* requires a dated heading for the tag being released.
|
|
14
42
|
*/
|
|
15
|
-
export declare const
|
|
43
|
+
export declare const PROTOCOL_VERSIONS: readonly ProtocolVersionEntry[];
|
|
44
|
+
/** The newest bridge package. The cloud mails organisations still below it. */
|
|
45
|
+
export declare const LATEST_BRIDGE_VERSION = "4.0.0";
|
|
46
|
+
export interface ProtocolStatus {
|
|
47
|
+
status: 'current' | 'deprecated' | 'unsupported';
|
|
48
|
+
/** ISO date, or null for a current or unknown version. */
|
|
49
|
+
sunset_at: string | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function sunsetOf(entry: ProtocolVersionEntry): string | null;
|
|
52
|
+
/**
|
|
53
|
+
* The window rule itself: `protocolStatus` and `minimumProtocolVersion` are
|
|
54
|
+
* this function over `PROTOCOL_VERSIONS`, and the cloud calls it directly.
|
|
55
|
+
*
|
|
56
|
+
* The table is a parameter because callers pass one with a deprecated entry —
|
|
57
|
+
* tests, and any caller reasoning about a sunset. The real table has none
|
|
58
|
+
* until the first bump, so a hard-coded `PROTOCOL_VERSIONS` would leave the
|
|
59
|
+
* deprecated and unsupported branches unreachable.
|
|
60
|
+
*/
|
|
61
|
+
export declare function statusFromTable(table: readonly ProtocolVersionEntry[], version: number, today: Date): ProtocolStatus;
|
|
62
|
+
export declare function protocolStatus(version: number, today?: Date): ProtocolStatus;
|
|
63
|
+
/** The lowest version still inside its window today. */
|
|
64
|
+
export declare function minimumProtocolVersion(today?: Date): number;
|
|
16
65
|
/**
|
|
17
66
|
* The bridge socket close code for "this robot no longer exists".
|
|
18
67
|
*
|
|
@@ -24,6 +73,20 @@ export declare const PROTOCOL_VERSION = 2;
|
|
|
24
73
|
* more informative than "connection closed".
|
|
25
74
|
*/
|
|
26
75
|
export declare const CLOSE_ROBOT_DELETED = 4004;
|
|
76
|
+
/**
|
|
77
|
+
* The bridge socket close code for "the token you connected with is gone".
|
|
78
|
+
*
|
|
79
|
+
* The cloud closes a robot's live socket with this after the robot's token was
|
|
80
|
+
* rotated. A 4.0.0 bridge reads it the way it reads `invalid_token` — stop,
|
|
81
|
+
* exit 2 — because the secret it holds is no longer a secret anyone accepts,
|
|
82
|
+
* and no amount of reconnecting produces the new one. A 3.x bridge does not
|
|
83
|
+
* know the code, reconnects, and is refused at hello; that ends the same way,
|
|
84
|
+
* one round trip later.
|
|
85
|
+
*
|
|
86
|
+
* Its own code rather than `CLOSE_ROBOT_DELETED`, which would tell an operator
|
|
87
|
+
* their robot had been deleted when it very much still exists.
|
|
88
|
+
*/
|
|
89
|
+
export declare const CLOSE_TOKEN_ROTATED = 4005;
|
|
27
90
|
/**
|
|
28
91
|
* How long a command waits for its answer when the caller names no patience
|
|
29
92
|
* of its own.
|
|
@@ -113,10 +176,26 @@ export declare const bridgeHello: z.ZodObject<{
|
|
|
113
176
|
}, z.core.$strip>>>;
|
|
114
177
|
}, z.core.$strip>;
|
|
115
178
|
export type BridgeHello = z.infer<typeof bridgeHello>;
|
|
116
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Cloud accepts the bridge: the robot is online from here on.
|
|
181
|
+
*
|
|
182
|
+
* `protocol` and `bridge` are optional so that a bridge parsing `hello_ok`
|
|
183
|
+
* strictly still parses one from an older cloud. `status` here is never
|
|
184
|
+
* `unsupported`: an unsupported version gets `hello_error`, not this frame.
|
|
185
|
+
*/
|
|
117
186
|
export declare const cloudHelloOk: z.ZodObject<{
|
|
118
187
|
type: z.ZodLiteral<"hello_ok">;
|
|
119
188
|
robot_id: z.ZodUUID;
|
|
189
|
+
protocol: z.ZodOptional<z.ZodObject<{
|
|
190
|
+
status: z.ZodEnum<{
|
|
191
|
+
deprecated: "deprecated";
|
|
192
|
+
current: "current";
|
|
193
|
+
}>;
|
|
194
|
+
sunset_at: z.ZodNullable<z.ZodISODate>;
|
|
195
|
+
}, z.core.$strip>>;
|
|
196
|
+
bridge: z.ZodOptional<z.ZodObject<{
|
|
197
|
+
latest_version: z.ZodString;
|
|
198
|
+
}, z.core.$strip>>;
|
|
120
199
|
}, z.core.$strip>;
|
|
121
200
|
export type CloudHelloOk = z.infer<typeof cloudHelloOk>;
|
|
122
201
|
/** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
|
|
@@ -129,22 +208,40 @@ export type CloudHelloError = z.infer<typeof cloudHelloError>;
|
|
|
129
208
|
/**
|
|
130
209
|
* One datapoint sample. `timestamp_ms` is the capture time at the bridge —
|
|
131
210
|
* never the receive time — so clients compute age themselves.
|
|
211
|
+
*
|
|
212
|
+
* Which is exactly why `backfill` has to be on the frame. A replayed sample
|
|
213
|
+
* carries the capture time it had during the outage, so a cloud that measures
|
|
214
|
+
* lag from every arriving frame reads a two-hour disconnect as two hours of
|
|
215
|
+
* lag the moment the bridge reconnects — and reports a healthy link as the
|
|
216
|
+
* worst one it has ever seen. Only the bridge knows which frames came out of
|
|
217
|
+
* its buffer, so only the bridge can say.
|
|
132
218
|
*/
|
|
133
219
|
export declare const datapointFrame: z.ZodObject<{
|
|
134
220
|
type: z.ZodLiteral<"datapoint">;
|
|
135
221
|
slug: z.ZodString;
|
|
136
222
|
value: z.ZodUnknown;
|
|
137
223
|
timestamp_ms: z.ZodNumber;
|
|
224
|
+
backfill: z.ZodOptional<z.ZodBoolean>;
|
|
138
225
|
}, z.core.$strip>;
|
|
139
226
|
export type DatapointFrame = z.infer<typeof datapointFrame>;
|
|
140
227
|
/**
|
|
141
228
|
* Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
|
|
142
229
|
* the bridge echoes it back untouched and the cloud derives the round-trip
|
|
143
230
|
* latency shown as `bridge_state.latency_ms`.
|
|
231
|
+
*
|
|
232
|
+
* Sent every `pingIntervalMs`; the bridge answers with `pong`. Since protocol
|
|
233
|
+
* 3 it also carries what the cloud measured about this link, so the bridge
|
|
234
|
+
* can decide on its low-bandwidth mode with an end-to-end number: the
|
|
235
|
+
* round trip of the last pong, and the datapoint lag — the median over the
|
|
236
|
+
* last five seconds of (receive time − `timestamp_ms`) minus the minimum of
|
|
237
|
+
* the last ten minutes, which cancels the robot's clock offset. `null` until
|
|
238
|
+
* the cloud has a sample. A protocol-2 bridge reads only `ts_ms`.
|
|
144
239
|
*/
|
|
145
240
|
export declare const cloudPing: z.ZodObject<{
|
|
146
241
|
type: z.ZodLiteral<"ping">;
|
|
147
242
|
ts_ms: z.ZodNumber;
|
|
243
|
+
latency_ms: z.ZodNullable<z.ZodNumber>;
|
|
244
|
+
lag_ms: z.ZodNullable<z.ZodNumber>;
|
|
148
245
|
}, z.core.$strip>;
|
|
149
246
|
export type CloudPing = z.infer<typeof cloudPing>;
|
|
150
247
|
/** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
|
|
@@ -153,6 +250,24 @@ export declare const bridgePong: z.ZodObject<{
|
|
|
153
250
|
ts_ms: z.ZodNumber;
|
|
154
251
|
}, z.core.$strip>;
|
|
155
252
|
export type BridgePong = z.infer<typeof bridgePong>;
|
|
253
|
+
/**
|
|
254
|
+
* The bridge's low-bandwidth mode changed. Sent on every transition and once
|
|
255
|
+
* after `hello_ok`, at tier 0 like the pong: the cloud folds it into
|
|
256
|
+
* `bridge_state.low_bandwidth`, and a frame that waited behind bulk would
|
|
257
|
+
* describe a state that is already over.
|
|
258
|
+
*/
|
|
259
|
+
export declare const bridgeLinkMode: z.ZodObject<{
|
|
260
|
+
type: z.ZodLiteral<"link_mode">;
|
|
261
|
+
low_bandwidth: z.ZodBoolean;
|
|
262
|
+
reason: z.ZodEnum<{
|
|
263
|
+
lag: "lag";
|
|
264
|
+
dwell: "dwell";
|
|
265
|
+
forced: "forced";
|
|
266
|
+
recovered: "recovered";
|
|
267
|
+
}>;
|
|
268
|
+
at_ms: z.ZodNumber;
|
|
269
|
+
}, z.core.$strip>;
|
|
270
|
+
export type BridgeLinkMode = z.infer<typeof bridgeLinkMode>;
|
|
156
271
|
/**
|
|
157
272
|
* The published configuration, cloud → bridge — the bridge applies the
|
|
158
273
|
* published version. Sent right after `hello_ok` and again on every publish,
|
|
@@ -191,6 +306,7 @@ export declare const cloudConfig: z.ZodObject<{
|
|
|
191
306
|
type: z.ZodString;
|
|
192
307
|
field: z.ZodOptional<z.ZodString>;
|
|
193
308
|
rate_throttle_hz: z.ZodOptional<z.ZodNumber>;
|
|
309
|
+
low_bandwidth: z.ZodOptional<z.ZodLiteral<"keep">>;
|
|
194
310
|
description: z.ZodOptional<z.ZodString>;
|
|
195
311
|
numeric: z.ZodOptional<z.ZodObject<{
|
|
196
312
|
scale: z.ZodOptional<z.ZodNumber>;
|
|
@@ -357,6 +473,23 @@ export declare const cloudConfig: z.ZodObject<{
|
|
|
357
473
|
snapshot_interval_seconds: z.ZodNumber;
|
|
358
474
|
description: z.ZodOptional<z.ZodString>;
|
|
359
475
|
}, z.core.$strict>>>;
|
|
476
|
+
low_bandwidth: z.ZodOptional<z.ZodObject<{
|
|
477
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
478
|
+
auto: "auto";
|
|
479
|
+
on: "on";
|
|
480
|
+
off: "off";
|
|
481
|
+
}>>;
|
|
482
|
+
enter_lag_ms: z.ZodOptional<z.ZodNumber>;
|
|
483
|
+
enter_after_s: z.ZodOptional<z.ZodNumber>;
|
|
484
|
+
exit_lag_ms: z.ZodOptional<z.ZodNumber>;
|
|
485
|
+
exit_after_s: z.ZodOptional<z.ZodNumber>;
|
|
486
|
+
datapoint_max_hz: z.ZodOptional<z.ZodNumber>;
|
|
487
|
+
camera: z.ZodOptional<z.ZodEnum<{
|
|
488
|
+
reduce: "reduce";
|
|
489
|
+
stop: "stop";
|
|
490
|
+
}>>;
|
|
491
|
+
camera_bitrate_kbps: z.ZodOptional<z.ZodNumber>;
|
|
492
|
+
}, z.core.$strict>>;
|
|
360
493
|
}, z.core.$strict>;
|
|
361
494
|
}, z.core.$strip>;
|
|
362
495
|
export type CloudConfig = z.infer<typeof cloudConfig>;
|
|
@@ -377,6 +510,7 @@ export declare const bridgeConfigApplied: z.ZodObject<{
|
|
|
377
510
|
service: "service";
|
|
378
511
|
publisher: "publisher";
|
|
379
512
|
camera: "camera";
|
|
513
|
+
low_bandwidth: "low_bandwidth";
|
|
380
514
|
}>;
|
|
381
515
|
code: z.ZodString;
|
|
382
516
|
message: z.ZodString;
|
|
@@ -538,72 +672,17 @@ export type BridgeTypeDefinitions = z.infer<typeof bridgeTypeDefinitions>;
|
|
|
538
672
|
/**
|
|
539
673
|
* The built-in `bridge_state` datapoint every robot has: connection status
|
|
540
674
|
* plus latency, the basis for offline-aware client UIs.
|
|
675
|
+
*
|
|
676
|
+
* `online` and `latency_ms` are cloud-observed (the socket, the pong);
|
|
677
|
+
* `low_bandwidth` is bridge-reported through `link_mode` and `false` for a
|
|
678
|
+
* bridge that never sends one.
|
|
541
679
|
*/
|
|
542
680
|
export declare const bridgeState: z.ZodObject<{
|
|
543
681
|
online: z.ZodBoolean;
|
|
544
682
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
|
683
|
+
low_bandwidth: z.ZodBoolean;
|
|
545
684
|
}, z.core.$strip>;
|
|
546
685
|
export type BridgeState = z.infer<typeof bridgeState>;
|
|
547
|
-
/**
|
|
548
|
-
* The built-in `bridge_pressure` datapoint: the bridge's own
|
|
549
|
-
* bandwidth-shaping state, sent on the same reserved-slug path as
|
|
550
|
-
* `bridge_state` so history, realtime, REST and MCP exposure fall out of the
|
|
551
|
-
* ordinary datapoint machinery for free.
|
|
552
|
-
*/
|
|
553
|
-
export declare const bridgePressure: z.ZodObject<{
|
|
554
|
-
link: z.ZodObject<{
|
|
555
|
-
rate_bps: z.ZodNullable<z.ZodNumber>;
|
|
556
|
-
snapshot_max_bytes: z.ZodNumber;
|
|
557
|
-
}, z.core.$strip>;
|
|
558
|
-
tiers: z.ZodObject<{
|
|
559
|
-
'0': z.ZodOptional<z.ZodObject<{
|
|
560
|
-
sent: z.ZodNumber;
|
|
561
|
-
bytes: z.ZodNumber;
|
|
562
|
-
drops: z.ZodNumber;
|
|
563
|
-
high_water: z.ZodNumber;
|
|
564
|
-
}, z.core.$strip>>;
|
|
565
|
-
'1': z.ZodOptional<z.ZodObject<{
|
|
566
|
-
sent: z.ZodNumber;
|
|
567
|
-
bytes: z.ZodNumber;
|
|
568
|
-
drops: z.ZodNumber;
|
|
569
|
-
high_water: z.ZodNumber;
|
|
570
|
-
}, z.core.$strip>>;
|
|
571
|
-
'2': z.ZodOptional<z.ZodObject<{
|
|
572
|
-
sent: z.ZodNumber;
|
|
573
|
-
bytes: z.ZodNumber;
|
|
574
|
-
drops: z.ZodNumber;
|
|
575
|
-
high_water: z.ZodNumber;
|
|
576
|
-
}, z.core.$strip>>;
|
|
577
|
-
'3': z.ZodOptional<z.ZodObject<{
|
|
578
|
-
sent: z.ZodNumber;
|
|
579
|
-
bytes: z.ZodNumber;
|
|
580
|
-
drops: z.ZodNumber;
|
|
581
|
-
high_water: z.ZodNumber;
|
|
582
|
-
}, z.core.$strip>>;
|
|
583
|
-
'4': z.ZodOptional<z.ZodObject<{
|
|
584
|
-
sent: z.ZodNumber;
|
|
585
|
-
bytes: z.ZodNumber;
|
|
586
|
-
drops: z.ZodNumber;
|
|
587
|
-
high_water: z.ZodNumber;
|
|
588
|
-
}, z.core.$strip>>;
|
|
589
|
-
'5': z.ZodOptional<z.ZodObject<{
|
|
590
|
-
sent: z.ZodNumber;
|
|
591
|
-
bytes: z.ZodNumber;
|
|
592
|
-
drops: z.ZodNumber;
|
|
593
|
-
high_water: z.ZodNumber;
|
|
594
|
-
}, z.core.$strip>>;
|
|
595
|
-
}, z.core.$strict>;
|
|
596
|
-
video: z.ZodObject<{
|
|
597
|
-
active_streams: z.ZodNumber;
|
|
598
|
-
bitrate_sum_kbps: z.ZodNumber;
|
|
599
|
-
uplink_kbps: z.ZodNullable<z.ZodNumber>;
|
|
600
|
-
override_kbps: z.ZodNullable<z.ZodNumber>;
|
|
601
|
-
video_budget_kbps: z.ZodNullable<z.ZodNumber>;
|
|
602
|
-
reserve_kbps: z.ZodNumber;
|
|
603
|
-
}, z.core.$strip>;
|
|
604
|
-
}, z.core.$strip>;
|
|
605
|
-
export type BridgePressure = z.infer<typeof bridgePressure>;
|
|
606
|
-
export declare const PRESSURE_SLUG: "bridge_pressure";
|
|
607
686
|
/**
|
|
608
687
|
* The header of a **binary** snapshot frame, bridge → cloud.
|
|
609
688
|
*
|
|
@@ -724,10 +803,10 @@ export declare const bridgeAssetProgress: z.ZodObject<{
|
|
|
724
803
|
unresolvable: "unresolvable";
|
|
725
804
|
upload_failed: "upload_failed";
|
|
726
805
|
refused: "refused";
|
|
727
|
-
too_large: "too_large";
|
|
728
806
|
}>;
|
|
729
807
|
details: z.ZodOptional<z.ZodNullable<z.ZodObject<{
|
|
730
|
-
|
|
808
|
+
store_bytes: z.ZodNumber;
|
|
809
|
+
used_bytes: z.ZodNumber;
|
|
731
810
|
size_bytes: z.ZodNumber;
|
|
732
811
|
}, z.core.$strip>>>;
|
|
733
812
|
}, z.core.$strip>>;
|
package/dist/protocol.js
CHANGED
|
@@ -7,18 +7,79 @@ import { rosGraph, typeDefinition } from './introspection.js';
|
|
|
7
7
|
import { jobState } from './jobs.js';
|
|
8
8
|
import { rosTypeName } from './common.js';
|
|
9
9
|
/**
|
|
10
|
-
* Bridge <-> cloud protocol, version
|
|
10
|
+
* Bridge <-> cloud protocol, version 3.
|
|
11
11
|
*
|
|
12
|
-
* The version is exchanged in the hello handshake
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* The version is exchanged in the hello handshake. Since 2026-09 the cloud
|
|
13
|
+
* serves a **window** of versions, not one: every entry of
|
|
14
|
+
* `PROTOCOL_VERSIONS` whose sunset has not passed. A version is deprecated
|
|
15
|
+
* by the cloud release that supersedes it and sunset `PROTOCOL_SUNSET_DAYS`
|
|
16
|
+
* later. Outside the window the cloud refuses with `protocol_mismatch`,
|
|
17
|
+
* which names the window and reaches the robot's detail view as
|
|
18
|
+
* `last_hello_error`.
|
|
19
|
+
*
|
|
20
|
+
* **3 (2026-09-22):** the ping carries `latency_ms` and `lag_ms`, the bridge
|
|
21
|
+
* sends `link_mode`, `bridge_state` gains `low_bandwidth`, and the
|
|
22
|
+
* `bridge_pressure` datapoint is gone. A protocol-2 bridge is served until
|
|
23
|
+
* its sunset, and the cloud's protocol-2 adapter owes it two translations on
|
|
24
|
+
* the way in: it drops its pressure datapoints, and it rewrites an
|
|
25
|
+
* `asset_progress` failure of kind `too_large` — a kind protocol 3 no longer
|
|
26
|
+
* has — to `refused` with `details: null`, because a 2.0.0 `bridgeAssetProgress`
|
|
27
|
+
* refuses the frame outright otherwise.
|
|
15
28
|
*
|
|
16
29
|
* **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
|
|
17
|
-
* beside `message`.
|
|
18
|
-
|
|
19
|
-
|
|
30
|
+
* beside `message`.
|
|
31
|
+
*/
|
|
32
|
+
export const PROTOCOL_VERSION = 3;
|
|
33
|
+
/** Days between a version's deprecation and its sunset. */
|
|
34
|
+
export const PROTOCOL_SUNSET_DAYS = 90;
|
|
35
|
+
/**
|
|
36
|
+
* Every protocol version the cloud has served, oldest first. A test keeps
|
|
37
|
+
* exactly one entry current and equal to `PROTOCOL_VERSION`; `test/changelog.test.ts`
|
|
38
|
+
* requires the CHANGELOG's current section to name the newest `bridge_from`
|
|
39
|
+
* and the previous entry's `sunsetOf(...)` date, and `scripts/verify-version-tag.mjs`
|
|
40
|
+
* requires a dated heading for the tag being released.
|
|
41
|
+
*/
|
|
42
|
+
export const PROTOCOL_VERSIONS = [
|
|
43
|
+
{ version: 2, bridge_from: '3.0.0', deprecated_at: '2026-09-22' },
|
|
44
|
+
{ version: 3, bridge_from: '4.0.0', deprecated_at: null },
|
|
45
|
+
];
|
|
46
|
+
/** The newest bridge package. The cloud mails organisations still below it. */
|
|
47
|
+
export const LATEST_BRIDGE_VERSION = '4.0.0';
|
|
48
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
49
|
+
function isoDate(date) {
|
|
50
|
+
return date.toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
export function sunsetOf(entry) {
|
|
53
|
+
if (entry.deprecated_at === null)
|
|
54
|
+
return null;
|
|
55
|
+
return isoDate(new Date(Date.parse(entry.deprecated_at + 'T00:00:00Z') + PROTOCOL_SUNSET_DAYS * DAY_MS));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The window rule itself: `protocolStatus` and `minimumProtocolVersion` are
|
|
59
|
+
* this function over `PROTOCOL_VERSIONS`, and the cloud calls it directly.
|
|
60
|
+
*
|
|
61
|
+
* The table is a parameter because callers pass one with a deprecated entry —
|
|
62
|
+
* tests, and any caller reasoning about a sunset. The real table has none
|
|
63
|
+
* until the first bump, so a hard-coded `PROTOCOL_VERSIONS` would leave the
|
|
64
|
+
* deprecated and unsupported branches unreachable.
|
|
20
65
|
*/
|
|
21
|
-
export
|
|
66
|
+
export function statusFromTable(table, version, today) {
|
|
67
|
+
const entry = table.find((candidate) => candidate.version === version);
|
|
68
|
+
if (!entry)
|
|
69
|
+
return { status: 'unsupported', sunset_at: null };
|
|
70
|
+
const sunset = sunsetOf(entry);
|
|
71
|
+
if (sunset === null)
|
|
72
|
+
return { status: 'current', sunset_at: null };
|
|
73
|
+
return { status: isoDate(today) < sunset ? 'deprecated' : 'unsupported', sunset_at: sunset };
|
|
74
|
+
}
|
|
75
|
+
export function protocolStatus(version, today = new Date()) {
|
|
76
|
+
return statusFromTable(PROTOCOL_VERSIONS, version, today);
|
|
77
|
+
}
|
|
78
|
+
/** The lowest version still inside its window today. */
|
|
79
|
+
export function minimumProtocolVersion(today = new Date()) {
|
|
80
|
+
const alive = PROTOCOL_VERSIONS.filter((entry) => statusFromTable(PROTOCOL_VERSIONS, entry.version, today).status !== 'unsupported');
|
|
81
|
+
return alive[0]?.version ?? PROTOCOL_VERSION;
|
|
82
|
+
}
|
|
22
83
|
/**
|
|
23
84
|
* The bridge socket close code for "this robot no longer exists".
|
|
24
85
|
*
|
|
@@ -30,6 +91,20 @@ export const PROTOCOL_VERSION = 2;
|
|
|
30
91
|
* more informative than "connection closed".
|
|
31
92
|
*/
|
|
32
93
|
export const CLOSE_ROBOT_DELETED = 4004;
|
|
94
|
+
/**
|
|
95
|
+
* The bridge socket close code for "the token you connected with is gone".
|
|
96
|
+
*
|
|
97
|
+
* The cloud closes a robot's live socket with this after the robot's token was
|
|
98
|
+
* rotated. A 4.0.0 bridge reads it the way it reads `invalid_token` — stop,
|
|
99
|
+
* exit 2 — because the secret it holds is no longer a secret anyone accepts,
|
|
100
|
+
* and no amount of reconnecting produces the new one. A 3.x bridge does not
|
|
101
|
+
* know the code, reconnects, and is refused at hello; that ends the same way,
|
|
102
|
+
* one round trip later.
|
|
103
|
+
*
|
|
104
|
+
* Its own code rather than `CLOSE_ROBOT_DELETED`, which would tell an operator
|
|
105
|
+
* their robot had been deleted when it very much still exists.
|
|
106
|
+
*/
|
|
107
|
+
export const CLOSE_TOKEN_ROTATED = 4005;
|
|
33
108
|
/**
|
|
34
109
|
* How long a command waits for its answer when the caller names no patience
|
|
35
110
|
* of its own.
|
|
@@ -120,10 +195,35 @@ export const bridgeHello = z.object({
|
|
|
120
195
|
*/
|
|
121
196
|
active_jobs: z.array(activeJob).max(500).default([]),
|
|
122
197
|
});
|
|
123
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* Cloud accepts the bridge: the robot is online from here on.
|
|
200
|
+
*
|
|
201
|
+
* `protocol` and `bridge` are optional so that a bridge parsing `hello_ok`
|
|
202
|
+
* strictly still parses one from an older cloud. `status` here is never
|
|
203
|
+
* `unsupported`: an unsupported version gets `hello_error`, not this frame.
|
|
204
|
+
*/
|
|
124
205
|
export const cloudHelloOk = z.object({
|
|
125
206
|
type: z.literal('hello_ok'),
|
|
126
207
|
robot_id: z.uuid(),
|
|
208
|
+
protocol: z
|
|
209
|
+
.object({
|
|
210
|
+
status: z.enum(['current', 'deprecated']).meta({
|
|
211
|
+
description: '`current` or `deprecated` — never `unsupported`, which is a `hello_error`.',
|
|
212
|
+
}),
|
|
213
|
+
sunset_at: z.iso.date().nullable().meta({
|
|
214
|
+
description: 'ISO date a deprecated version stops being served; `null` when current.',
|
|
215
|
+
}),
|
|
216
|
+
})
|
|
217
|
+
.optional()
|
|
218
|
+
.meta({ description: "The cloud's verdict on the announced protocol version; absent from an older cloud." }),
|
|
219
|
+
bridge: z
|
|
220
|
+
.object({
|
|
221
|
+
latest_version: z.string().min(1).meta({
|
|
222
|
+
description: "The newest published fleetless-bridge package version, for the bridge's own upgrade hint.",
|
|
223
|
+
}),
|
|
224
|
+
})
|
|
225
|
+
.optional()
|
|
226
|
+
.meta({ description: 'What the cloud knows about bridge packages; absent from an older cloud.' }),
|
|
127
227
|
});
|
|
128
228
|
/** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
|
|
129
229
|
export const cloudHelloError = z.object({
|
|
@@ -134,27 +234,57 @@ export const cloudHelloError = z.object({
|
|
|
134
234
|
/**
|
|
135
235
|
* One datapoint sample. `timestamp_ms` is the capture time at the bridge —
|
|
136
236
|
* never the receive time — so clients compute age themselves.
|
|
237
|
+
*
|
|
238
|
+
* Which is exactly why `backfill` has to be on the frame. A replayed sample
|
|
239
|
+
* carries the capture time it had during the outage, so a cloud that measures
|
|
240
|
+
* lag from every arriving frame reads a two-hour disconnect as two hours of
|
|
241
|
+
* lag the moment the bridge reconnects — and reports a healthy link as the
|
|
242
|
+
* worst one it has ever seen. Only the bridge knows which frames came out of
|
|
243
|
+
* its buffer, so only the bridge can say.
|
|
137
244
|
*/
|
|
138
245
|
export const datapointFrame = z.object({
|
|
139
246
|
type: z.literal('datapoint'),
|
|
140
247
|
slug,
|
|
141
248
|
value: z.unknown(),
|
|
142
249
|
timestamp_ms: z.number().int().nonnegative(),
|
|
250
|
+
backfill: z.boolean().optional().meta({ description: 'true when the sample was captured while the bridge was disconnected and is being replayed after the reconnect. The cloud keeps such a sample out of its lag measure; absent means live.' }),
|
|
143
251
|
});
|
|
144
252
|
/**
|
|
145
253
|
* Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
|
|
146
254
|
* the bridge echoes it back untouched and the cloud derives the round-trip
|
|
147
255
|
* latency shown as `bridge_state.latency_ms`.
|
|
256
|
+
*
|
|
257
|
+
* Sent every `pingIntervalMs`; the bridge answers with `pong`. Since protocol
|
|
258
|
+
* 3 it also carries what the cloud measured about this link, so the bridge
|
|
259
|
+
* can decide on its low-bandwidth mode with an end-to-end number: the
|
|
260
|
+
* round trip of the last pong, and the datapoint lag — the median over the
|
|
261
|
+
* last five seconds of (receive time − `timestamp_ms`) minus the minimum of
|
|
262
|
+
* the last ten minutes, which cancels the robot's clock offset. `null` until
|
|
263
|
+
* the cloud has a sample. A protocol-2 bridge reads only `ts_ms`.
|
|
148
264
|
*/
|
|
149
265
|
export const cloudPing = z.object({
|
|
150
266
|
type: z.literal('ping'),
|
|
151
267
|
ts_ms: z.number().int().nonnegative(),
|
|
268
|
+
latency_ms: z.number().nonnegative().nullable().meta({ description: 'Round trip of the last pong in milliseconds; null before the first.' }),
|
|
269
|
+
lag_ms: z.number().nonnegative().nullable().meta({ description: 'Datapoint lag over the link: median of the last five seconds minus the ten-minute minimum, in milliseconds; null until a sample exists, and null again whenever no live sample arrived in the last five seconds, because a stale median would be a lie.' }),
|
|
152
270
|
});
|
|
153
271
|
/** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
|
|
154
272
|
export const bridgePong = z.object({
|
|
155
273
|
type: z.literal('pong'),
|
|
156
274
|
ts_ms: z.number().int().nonnegative(),
|
|
157
275
|
});
|
|
276
|
+
/**
|
|
277
|
+
* The bridge's low-bandwidth mode changed. Sent on every transition and once
|
|
278
|
+
* after `hello_ok`, at tier 0 like the pong: the cloud folds it into
|
|
279
|
+
* `bridge_state.low_bandwidth`, and a frame that waited behind bulk would
|
|
280
|
+
* describe a state that is already over.
|
|
281
|
+
*/
|
|
282
|
+
export const bridgeLinkMode = z.object({
|
|
283
|
+
type: z.literal('link_mode'),
|
|
284
|
+
low_bandwidth: z.boolean().meta({ description: 'Whether the mode is active after this transition.' }),
|
|
285
|
+
reason: z.enum(['lag', 'dwell', 'forced', 'recovered']).meta({ description: '`lag`: the cloud-measured lag crossed the threshold; `dwell`: the bridge-measured queue dwell did; `forced`: `mode: on` or `off`; `recovered`: both measures stayed at or below the exit threshold.' }),
|
|
286
|
+
at_ms: z.number().int().nonnegative().meta({ description: 'Bridge time of the transition, epoch milliseconds.' }),
|
|
287
|
+
});
|
|
158
288
|
/**
|
|
159
289
|
* The published configuration, cloud → bridge — the bridge applies the
|
|
160
290
|
* published version. Sent right after `hello_ok` and again on every publish,
|
|
@@ -350,89 +480,16 @@ export const bridgeTypeDefinitions = z.object({
|
|
|
350
480
|
/**
|
|
351
481
|
* The built-in `bridge_state` datapoint every robot has: connection status
|
|
352
482
|
* plus latency, the basis for offline-aware client UIs.
|
|
483
|
+
*
|
|
484
|
+
* `online` and `latency_ms` are cloud-observed (the socket, the pong);
|
|
485
|
+
* `low_bandwidth` is bridge-reported through `link_mode` and `false` for a
|
|
486
|
+
* bridge that never sends one.
|
|
353
487
|
*/
|
|
354
488
|
export const bridgeState = z.object({
|
|
355
489
|
online: z.boolean(),
|
|
356
490
|
latency_ms: z.number().nonnegative().nullable(),
|
|
491
|
+
low_bandwidth: z.boolean().meta({ description: 'Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported.' }),
|
|
357
492
|
});
|
|
358
|
-
/** One tier's counters, `tiers` below carries six of these under string keys. */
|
|
359
|
-
const bridgePressureTier = z.object({
|
|
360
|
-
sent: z.number().int().nonnegative(),
|
|
361
|
-
bytes: z.number().int().nonnegative(),
|
|
362
|
-
drops: z.number().int().nonnegative(),
|
|
363
|
-
high_water: z.number().int().nonnegative(),
|
|
364
|
-
});
|
|
365
|
-
/**
|
|
366
|
-
* The built-in `bridge_pressure` datapoint: the bridge's own
|
|
367
|
-
* bandwidth-shaping state, sent on the same reserved-slug path as
|
|
368
|
-
* `bridge_state` so history, realtime, REST and MCP exposure fall out of the
|
|
369
|
-
* ordinary datapoint machinery for free.
|
|
370
|
-
*/
|
|
371
|
-
export const bridgePressure = z.object({
|
|
372
|
-
link: z.object({
|
|
373
|
-
/** bytes/s the socket demonstrably drains, from sends >= 64 KiB
|
|
374
|
-
* only; null until the first large send of the session. */
|
|
375
|
-
rate_bps: z.number().nonnegative().nullable(),
|
|
376
|
-
/**
|
|
377
|
-
* the byte target snapshots are currently encoded to fit.
|
|
378
|
-
*
|
|
379
|
-
* `.nonnegative()`, not `.positive()`: the target is derived from
|
|
380
|
-
* `rate_bps`, and a link measured below 0.5 B/s floors to 0 here. A
|
|
381
|
-
* schema that rejects 0 does not prevent that link — it only makes the
|
|
382
|
-
* frame reporting it unparseable, and a console that cannot parse a
|
|
383
|
-
* pressure frame shows "no feed", i.e. reports a struggling robot as an
|
|
384
|
-
* *old* one. Zero is a legitimate reading and says something true.
|
|
385
|
-
*/
|
|
386
|
-
snapshot_max_bytes: z.number().int().nonnegative(),
|
|
387
|
-
}),
|
|
388
|
-
/**
|
|
389
|
-
* String keys "0".."5" because JSON has no integer keys. Counters are
|
|
390
|
-
* cumulative per session and reset on reconnect; clients window by
|
|
391
|
-
* differencing two samples.
|
|
392
|
-
*
|
|
393
|
-
* **What this schema does not decide:** it does not guarantee all six
|
|
394
|
-
* keys are present (`z.record` over the six literals is exhaustive in
|
|
395
|
-
* zod 4 — tested here, it required every key and rejected none, the
|
|
396
|
-
* opposite of what a partial sample needs — so this is a
|
|
397
|
-
* `.strictObject().partial()` over the same six literal keys instead, a
|
|
398
|
-
* deliberate deviation from the originally sketched `z.record` shape with
|
|
399
|
-
* the same runtime behaviour). A missing tier key reads as zeros; the
|
|
400
|
-
* schema names what it cannot decide rather than implying a completeness
|
|
401
|
-
* it cannot check.
|
|
402
|
-
*/
|
|
403
|
-
tiers: z
|
|
404
|
-
.strictObject({
|
|
405
|
-
'0': bridgePressureTier,
|
|
406
|
-
'1': bridgePressureTier,
|
|
407
|
-
'2': bridgePressureTier,
|
|
408
|
-
'3': bridgePressureTier,
|
|
409
|
-
'4': bridgePressureTier,
|
|
410
|
-
'5': bridgePressureTier,
|
|
411
|
-
})
|
|
412
|
-
.partial(),
|
|
413
|
-
video: z.object({
|
|
414
|
-
active_streams: z.number().int().nonnegative(),
|
|
415
|
-
bitrate_sum_kbps: z.number().int().nonnegative(),
|
|
416
|
-
/**
|
|
417
|
-
* The uplink budget the bridge was configured with
|
|
418
|
-
* (`FLEETLESS_UPLINK_KBPS`), or `null` when none was set.
|
|
419
|
-
*
|
|
420
|
-
* `.nonnegative()`, not `.positive()`: `FLEETLESS_UPLINK_KBPS=0` is a
|
|
421
|
-
* documented setting meaning "no video budget at all", and the bridge
|
|
422
|
-
* emits that 0 verbatim. `.positive()` made every frame from such a
|
|
423
|
-
* robot fail the console's `safeParse`, which renders an unparseable
|
|
424
|
-
* frame as "no pressure feed" — so the one robot that had *deliberately*
|
|
425
|
-
* turned video off was the one diagnosed as running a bridge too old to
|
|
426
|
-
* report pressure. A value the producer legitimately sends must parse;
|
|
427
|
-
* `null` is the only "not set" this field has.
|
|
428
|
-
*/
|
|
429
|
-
uplink_kbps: z.number().int().nonnegative().nullable(),
|
|
430
|
-
override_kbps: z.number().int().nonnegative().nullable(),
|
|
431
|
-
video_budget_kbps: z.number().int().nonnegative().nullable(),
|
|
432
|
-
reserve_kbps: z.number().int().nonnegative(),
|
|
433
|
-
}),
|
|
434
|
-
});
|
|
435
|
-
export const PRESSURE_SLUG = 'bridge_pressure';
|
|
436
493
|
/* ------------------------------------------------------------------------
|
|
437
494
|
* Cameras.
|
|
438
495
|
*/
|