@fleetless/sdk 1.0.0 → 2.0.1
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 +113 -0
- package/LICENSE +21 -0
- package/README.md +153 -1134
- package/dist/index.cjs +2963 -1108
- package/dist/index.d.cts +421 -146
- package/dist/index.d.ts +421 -146
- package/dist/index.js +2963 -1108
- package/package.json +43 -7
package/README.md
CHANGED
|
@@ -1,68 +1,83 @@
|
|
|
1
1
|
# @fleetless/sdk
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
**
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@fleetless/sdk)
|
|
4
|
+
[](https://www.npmjs.com/package/@fleetless/sdk)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+
|
|
8
|
+
The official TypeScript SDK for apps built on [Fleetless](https://fleetless.dev),
|
|
9
|
+
which exposes a ROS 2 robot as a hosted REST and realtime API — exactly the
|
|
10
|
+
topics, services and actions a developer chose to publish, under stable slugs,
|
|
11
|
+
behind roles. The SDK covers all of it: datapoints (read, subscribe, recorded
|
|
12
|
+
history), actions, services, publishers, cameras, jobs, assets and URDF, the
|
|
13
|
+
hosted login, and an end user's own consent grants. It is framework-agnostic,
|
|
14
|
+
ships ESM and CJS with its own types, and runs wherever a `fetch` and a
|
|
15
|
+
`WebSocket` exist — a browser, a mobile webview, Node 20 or newer, and
|
|
16
|
+
server-side with a server key instead of a user session.
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
- **One connection, however many subscriptions.** Every datapoint and job
|
|
21
|
+
subscription shares a single authenticated WebSocket. It reconnects with
|
|
22
|
+
exponential backoff, re-authenticates, and resends its own subscribe frames,
|
|
23
|
+
so a dropped network shows up as a gap in events rather than as work for you.
|
|
24
|
+
- **Subscriptions are reference-counted per robot and slug.** Two widgets on
|
|
25
|
+
the same battery value, or one component mounted twice under React
|
|
26
|
+
StrictMode, share one wire subscription. Unsubscribing one never cuts off the
|
|
27
|
+
other.
|
|
28
|
+
- **Errors you branch on, not errors you parse.** Every refusal arrives as a
|
|
29
|
+
`FleetlessError` carrying a stable `code` — `busy`, `forbidden`,
|
|
30
|
+
`robot_offline`, `rate_limited` — plus the SDK's own codes for refusals that
|
|
31
|
+
never reached the network. `rate_limited` is surfaced with its
|
|
32
|
+
`retry_after_ms` and never retried behind your back.
|
|
33
|
+
- **Publishers with a failsafe the robot enforces.** `publishers.publish` sends
|
|
34
|
+
one message and nothing else. If your app stops publishing — including
|
|
35
|
+
because it crashed — the bridge on the robot publishes the configured
|
|
36
|
+
failsafe message itself. The safety primitive lives on the robot, not in your
|
|
37
|
+
event loop.
|
|
38
|
+
- **Cameras that cost nothing while nobody watches.** Snapshot bytes come with
|
|
39
|
+
the cloud's own `age_ms`, and keep being served while the robot is offline.
|
|
40
|
+
`cameras.live` takes a refcounted hold: the first viewer starts the robot
|
|
41
|
+
publishing, the last one leaving stops it. You get a LiveKit room URL and
|
|
42
|
+
token to hand to the LiveKit client of your choice.
|
|
43
|
+
- **Hosted login, so your app never handles a password.** OAuth 2.1
|
|
44
|
+
Authorization Code with PKCE (S256 only). `beginHostedLogin` makes no network
|
|
45
|
+
call at all, and `completeHostedLogin` checks `state` before it exchanges
|
|
46
|
+
anything. Federation to a customer's identity provider happens on the hosted
|
|
47
|
+
page, not in your code.
|
|
48
|
+
- **A token store you control.** Implement two methods to keep a session in
|
|
49
|
+
`localStorage`, a cookie or a native keystore; the default keeps it in
|
|
50
|
+
memory. Refresh is silent and single-flight, so ten concurrent calls that
|
|
51
|
+
meet an expired access token share one refresh instead of firing ten.
|
|
52
|
+
|
|
53
|
+
## Getting started
|
|
39
54
|
|
|
40
55
|
```sh
|
|
41
56
|
npm i @fleetless/sdk
|
|
42
57
|
```
|
|
43
58
|
|
|
44
|
-
|
|
59
|
+
You need an app identifier and an end user's credentials from the
|
|
60
|
+
[Fleetless Console](https://console.fleetless.dev), or your own dev stack.
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
identifier and an end user's credentials from the [Fleetless
|
|
48
|
-
Console](https://console.fleetless.dev) (or your own dev stack).
|
|
49
|
-
|
|
50
|
-
```ts
|
|
62
|
+
```ts checked
|
|
51
63
|
import { createClient } from '@fleetless/sdk'
|
|
52
64
|
|
|
53
65
|
const client = createClient({
|
|
54
66
|
apiUrl: 'https://api.fleetless.dev',
|
|
55
|
-
appIdentifier: '
|
|
67
|
+
appIdentifier: 'warehouse_dash', // your app's identifier, shown in the console
|
|
56
68
|
})
|
|
57
69
|
|
|
58
70
|
await client.auth.login('user@example.com', 'correct-horse-battery')
|
|
59
71
|
|
|
72
|
+
// Robot ids are UUIDs; the console shows the one you want.
|
|
73
|
+
const robotId = '4f2c1a90-7b3e-4d51-9c86-0a1b2c3d4e5f'
|
|
74
|
+
|
|
60
75
|
// One-shot read.
|
|
61
|
-
const battery = await client.datapoints.get(
|
|
76
|
+
const battery = await client.datapoints.get(robotId, 'battery_percentage')
|
|
62
77
|
console.log(battery.value, battery.timestamp_ms)
|
|
63
78
|
|
|
64
79
|
// Live updates. The current value arrives immediately, then every change.
|
|
65
|
-
const subscription = client.datapoints.subscribe(
|
|
80
|
+
const subscription = client.datapoints.subscribe(robotId, 'battery_percentage', {
|
|
66
81
|
onEvent(event) {
|
|
67
82
|
console.log(event.value, event.timestamp_ms)
|
|
68
83
|
},
|
|
@@ -74,336 +89,67 @@ const subscription = client.datapoints.subscribe('robot-id', 'battery-percentage
|
|
|
74
89
|
// Later:
|
|
75
90
|
subscription.unsubscribe()
|
|
76
91
|
await client.auth.logout()
|
|
92
|
+
// logout() already closes the realtime channel. Call close() yourself when a
|
|
93
|
+
// process should exit without logging out — an open WebSocket keeps Node alive.
|
|
94
|
+
client.close()
|
|
77
95
|
```
|
|
78
96
|
|
|
79
|
-
Against a local dev stack
|
|
80
|
-
|
|
81
|
-
derived automatically (see [Configuration](#configuration)).
|
|
97
|
+
Against a local dev stack, point `apiUrl` at `http://localhost:8080`; the
|
|
98
|
+
realtime URL (`ws://localhost:8080/realtime`) is derived from it.
|
|
82
99
|
|
|
83
|
-
|
|
100
|
+
Slugs are the names you chose in the console, in `snake_case`
|
|
101
|
+
(`[a-z][a-z0-9]*(_[a-z0-9]+)*`) — never the robot's internal ROS names, which
|
|
102
|
+
is what makes renaming a node on the robot invisible to your app.
|
|
84
103
|
|
|
85
|
-
|
|
104
|
+
### Hosted login
|
|
86
105
|
|
|
87
|
-
|
|
106
|
+
Redirect the end user to the Fleetless-hosted login page instead of collecting
|
|
107
|
+
a password yourself. Register the OAuth client in the console first, under
|
|
108
|
+
**App Settings → OAuth clients**; its `client_id` is not your `appIdentifier`.
|
|
88
109
|
|
|
89
110
|
```ts
|
|
90
|
-
|
|
91
|
-
await client.auth.login(email, password) // -> stores a session
|
|
92
|
-
await client.auth.me() // -> ClientIdentity, without decoding a token yourself
|
|
93
|
-
const { revoked } = await client.auth.logout() // revokes server-side, then clears the local store
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
- **Silent refresh is automatic.** A REST call that meets an expired access
|
|
97
|
-
token refreshes once and retries transparently. Concurrent calls made
|
|
98
|
-
while a refresh is already in flight share that one refresh instead of
|
|
99
|
-
each firing their own.
|
|
100
|
-
- **`logout()` never rejects.** It always clears the local store — a user
|
|
101
|
-
who presses "log out" ends up logged out locally regardless of the
|
|
102
|
-
network — but the server-side revoke can still fail (offline, a dead
|
|
103
|
-
cloud). `revoked: false` means the refresh family may still be alive
|
|
104
|
-
server-side even though this client has forgotten it; a kiosk or shared
|
|
105
|
-
workstation might want to warn the user or retry, everyone else can
|
|
106
|
-
ignore the return value.
|
|
107
|
-
- **What `logout()` does and does not invalidate.** It revokes the whole
|
|
108
|
-
refresh-token family server-side immediately (a stolen refresh token
|
|
109
|
-
stops working) and closes this client's live realtime connection, if it
|
|
110
|
-
has one. It does **not** invalidate the access token already handed out —
|
|
111
|
-
access-token checks are a signed JWT verified without a server-side
|
|
112
|
-
lookup, so logout has nothing on that token to flip. A token stolen
|
|
113
|
-
before logout keeps working on REST, and can still open a *new* realtime
|
|
114
|
-
connection, until it expires on its own — **at most 15 minutes.** That's
|
|
115
|
-
the same stateless-JWT tradeoff that lets a role change or a block take
|
|
116
|
-
effect on the very next request without forcing a fresh token — a
|
|
117
|
-
deliberate boundary, not a bug, but one a kiosk or shared workstation
|
|
118
|
-
needs to plan around.
|
|
119
|
-
- **Token storage is pluggable.** By default, sessions live in memory and
|
|
120
|
-
are lost on reload. Implement `TokenStore` to persist one — localStorage,
|
|
121
|
-
a cookie, a native keystore:
|
|
122
|
-
|
|
123
|
-
```ts
|
|
124
|
-
import type { TokenStore, StoredSession } from '@fleetless/sdk'
|
|
125
|
-
|
|
126
|
-
const localStorageTokenStore: TokenStore = {
|
|
127
|
-
load: () => {
|
|
128
|
-
const raw = localStorage.getItem('fleetless-session')
|
|
129
|
-
return raw ? (JSON.parse(raw) as StoredSession) : null
|
|
130
|
-
},
|
|
131
|
-
save: (session) => {
|
|
132
|
-
if (session) localStorage.setItem('fleetless-session', JSON.stringify(session))
|
|
133
|
-
else localStorage.removeItem('fleetless-session')
|
|
134
|
-
},
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const client = createClient({ apiUrl, appIdentifier, tokenStore: localStorageTokenStore })
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
### Hosted login (W7b)
|
|
141
|
-
|
|
142
|
-
A third way to authenticate an end user, alongside `login()` above: redirect
|
|
143
|
-
them to a Fleetless-hosted login page (spec §3.4, §17) instead of collecting
|
|
144
|
-
a password in your own app. The page can federate to your own IdP if you've
|
|
145
|
-
configured one for this app, and the flow is Authorization Code + PKCE
|
|
146
|
-
(OAuth 2.1 — no `implicit` grant, no `plain` challenge). It's also MCP's own
|
|
147
|
-
front door (§17), but nothing about using it from this SDK depends on that.
|
|
148
|
-
|
|
149
|
-
**Getting a `clientId`.** Register your app's own OAuth client in the
|
|
150
|
-
console, under **App Settings → OAuth clients** — name it and list the
|
|
151
|
-
redirect URIs it's allowed to use; the panel shows you the `client_id` you
|
|
152
|
-
need below. For scripting (a seed, a gate, CI) the same thing is
|
|
153
|
-
`POST /api/apps/:id/oauth-clients` (developer-authenticated:
|
|
154
|
-
`{ client_name, redirect_uris }`, answers with the `client_id`) — what the
|
|
155
|
-
console panel calls underneath. `clientId` is not your `appIdentifier` —
|
|
156
|
-
they're deliberately different identifiers.
|
|
157
|
-
|
|
158
|
-
```ts
|
|
159
|
-
// 1. Send the browser there. beginHostedLogin makes no network call — it
|
|
160
|
-
// just builds the URL and generates the values PKCE and CSRF protection
|
|
161
|
-
// need.
|
|
111
|
+
// 1. Build the URL. No network call happens here.
|
|
162
112
|
const request = await client.auth.beginHostedLogin({
|
|
163
113
|
clientId: 'oauth-client-id-from-the-console',
|
|
164
114
|
redirectUri: 'https://your-app.example.com/callback',
|
|
165
115
|
})
|
|
166
116
|
|
|
167
|
-
//
|
|
168
|
-
// does not preserve any in-memory state, so this SDK hands them back to you
|
|
169
|
-
// instead of holding them itself. sessionStorage survives the round trip
|
|
170
|
-
// and clears itself when the tab closes.
|
|
117
|
+
// A redirect is a fresh page load, so persist these two yourself.
|
|
171
118
|
sessionStorage.setItem('fleetless_state', request.state)
|
|
172
119
|
sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
|
|
173
120
|
window.location.href = request.url
|
|
174
121
|
|
|
175
|
-
// 2. On the
|
|
176
|
-
//
|
|
177
|
-
// of a code (RFC 6749 §5.2), and reading params.get('code')! unconditionally
|
|
178
|
-
// throws on that assertion instead of showing the user why they aren't
|
|
179
|
-
// signed in. Not a bug: an account with no membership in this app, or a
|
|
180
|
-
// federated IdP that's down, both land here as an error, not a crash.
|
|
122
|
+
// 2. On the callback page. The callback can legitimately carry ?error=...
|
|
123
|
+
// instead of a code, so check for that first.
|
|
181
124
|
const params = new URLSearchParams(window.location.search)
|
|
182
|
-
|
|
183
|
-
// Read once, clear once, before anything else — these are single-use
|
|
184
|
-
// values for this one attempt regardless of what happens below, and doing
|
|
185
|
-
// this first (rather than after an await that might throw) means the
|
|
186
|
-
// cleanup can't be skipped.
|
|
187
125
|
const expectedState = sessionStorage.getItem('fleetless_state')
|
|
188
126
|
const codeVerifier = sessionStorage.getItem('fleetless_code_verifier')
|
|
189
|
-
sessionStorage.removeItem('fleetless_state')
|
|
190
|
-
sessionStorage.removeItem('fleetless_code_verifier')
|
|
191
127
|
|
|
192
|
-
if (params.has('error')) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
// session all read back as null here, and none of them is an attack.
|
|
204
|
-
// completeHostedLogin tells this apart from a real mismatch itself
|
|
205
|
-
// (no_hosted_login_attempt vs state_mismatch) — mirrored here because
|
|
206
|
-
// there's no codeVerifier to even attempt the call with.
|
|
207
|
-
handleHostedLoginError('no_hosted_login_attempt', 'This login was not started in this browser tab.', null)
|
|
208
|
-
} else {
|
|
209
|
-
try {
|
|
210
|
-
await client.auth.completeHostedLogin({
|
|
211
|
-
code: params.get('code')!,
|
|
212
|
-
state: params.get('state')!,
|
|
213
|
-
expectedState,
|
|
214
|
-
codeVerifier,
|
|
215
|
-
clientId: 'oauth-client-id-from-the-console',
|
|
216
|
-
redirectUri: 'https://your-app.example.com/callback',
|
|
217
|
-
})
|
|
218
|
-
// From here on client.auth.me() / logout() / silent refresh behave
|
|
219
|
-
// exactly as they do after login() — completeHostedLogin stores the
|
|
220
|
-
// identical session shape, through the same tokenStore.
|
|
221
|
-
} catch (error) {
|
|
222
|
-
// completeHostedLogin's own refusals (state_mismatch; invalid_grant for
|
|
223
|
-
// an expired or already-used code) surface as a thrown FleetlessError
|
|
224
|
-
// here, not a redirect — the one point in this flow where a thrown
|
|
225
|
-
// error, not a query parameter, is how a refusal reaches your app.
|
|
226
|
-
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
|
|
227
|
-
handleHostedLoginError(code, error instanceof Error ? error.message : String(error), null)
|
|
228
|
-
}
|
|
128
|
+
if (!params.has('error') && expectedState && codeVerifier) {
|
|
129
|
+
await client.auth.completeHostedLogin({
|
|
130
|
+
code: params.get('code') ?? '',
|
|
131
|
+
state: params.get('state') ?? '',
|
|
132
|
+
expectedState,
|
|
133
|
+
codeVerifier,
|
|
134
|
+
clientId: 'oauth-client-id-from-the-console',
|
|
135
|
+
redirectUri: 'https://your-app.example.com/callback',
|
|
136
|
+
})
|
|
137
|
+
// From here on me(), logout() and silent refresh behave exactly as they do
|
|
138
|
+
// after login(). Never call completeHostedLogin twice for the same code.
|
|
229
139
|
}
|
|
230
140
|
```
|
|
231
141
|
|
|
232
|
-
|
|
233
|
-
`access_denied` for "the user declined consent") with no Fleetless-specific
|
|
234
|
-
remedy beyond `error_description`. The five `fleetless_code` values,
|
|
235
|
-
what each means and what your app can tell the user, are documented in
|
|
236
|
-
[`docs/recipes/hosted-login/`](../docs/recipes/hosted-login/README.md) —
|
|
237
|
-
not duplicated here to avoid two tables drifting apart.
|
|
238
|
-
|
|
239
|
-
- **`sessionStorage.getItem(...)!` is the same tell as `params.get('code')!`
|
|
240
|
-
above, and this snippet had both, found by Momus-W7b's review the same
|
|
241
|
-
day the first one was fixed.** A non-null assertion is where a writer
|
|
242
|
-
knows a value can be absent and decides not to say so — reach for a
|
|
243
|
-
presence check (`if (!expectedState || !codeVerifier)` above) instead of
|
|
244
|
-
`!` anywhere a value crossed a boundary this code doesn't control
|
|
245
|
-
(a query string, `sessionStorage`, a thrown error's `.message`), not just
|
|
246
|
-
in the one place a reviewer already looked.
|
|
142
|
+
### Running a job
|
|
247
143
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
itself, because the redirect back is a fresh page load — nothing this SDK
|
|
251
|
-
keeps in memory survives it. Persist them however fits your app; a popup
|
|
252
|
-
flow that never truly navigates away can just keep them in a variable
|
|
253
|
-
instead.
|
|
254
|
-
- **`completeHostedLogin` refuses before any network call** (`state_mismatch`)
|
|
255
|
-
if `state` doesn't match what `beginHostedLogin` returned for this
|
|
256
|
-
attempt — it will not complete a login your app did not itself start.
|
|
257
|
-
- **Errors from this exchange use OAuth's own vocabulary, not this SDK's
|
|
258
|
-
usual `code`/`message` pairs.** `completeHostedLogin` can throw a
|
|
259
|
-
`FleetlessError` whose `.code` is e.g. `invalid_grant` (an expired or
|
|
260
|
-
already-used `code`) straight from RFC 6749 §5.2 — the exchange happens
|
|
261
|
-
against `/oauth/token`, a standards-compliant endpoint any OAuth client
|
|
262
|
-
can drive, not this SDK's own API.
|
|
263
|
-
- **Never call `completeHostedLogin` a second time for the same redirect
|
|
264
|
-
while a first call is still in flight** — no timeout, framework retry,
|
|
265
|
-
or double-click guard should ever resubmit it (W7c; `completeHostedLogin`
|
|
266
|
-
itself makes exactly one request and has no retry of its own, so this is
|
|
267
|
-
entirely a call-site discipline). A second presentation of the same
|
|
268
|
-
authorization code revokes the whole refresh-token family server-side,
|
|
269
|
-
on purpose — a plain race looks identical to theft on the wire, and the
|
|
270
|
-
platform treats it as theft. What actually happens: one of the two
|
|
271
|
-
requests gets back `200` with a refresh token that is already dead, the
|
|
272
|
-
access token keeps working for the rest of its short TTL, and the
|
|
273
|
-
failure only surfaces at this session's first silent refresh — as
|
|
274
|
-
`token_revoked`, possibly minutes later, with nothing pointing back at
|
|
275
|
-
the retry that caused it. Guard the call site (an in-flight flag,
|
|
276
|
-
disabling the button) — this method cannot protect you from itself being
|
|
277
|
-
called twice.
|
|
278
|
-
|
|
279
|
-
### Registration and account recovery (W6c)
|
|
144
|
+
An action is a job with a lifecycle. `invoke` resolves as soon as the job
|
|
145
|
+
exists; feedback, progress and the result arrive over `subscribe`.
|
|
280
146
|
|
|
281
147
|
```ts
|
|
282
|
-
|
|
283
|
-
// this address's domain. Sets the password now; does NOT log you in — see
|
|
284
|
-
// below for why. Returns { mail } so you can show "check your email".
|
|
285
|
-
await client.auth.register('user@example.com', 'correct-horse-battery')
|
|
286
|
-
await client.auth.confirmRegistration(tokenFromTheEmailedLink) // -> now logged in
|
|
287
|
-
|
|
288
|
-
// Changing your own password while logged in.
|
|
289
|
-
await client.auth.changePassword('current-password', 'new-correct-horse-battery')
|
|
290
|
-
|
|
291
|
-
// Forgot it entirely: request a link, then use the token it contains.
|
|
292
|
-
await client.auth.requestPasswordReset('user@example.com')
|
|
293
|
-
await client.auth.confirmPasswordReset(tokenFromTheEmailedLink, 'new-correct-horse-battery')
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
- **`register` does not log you in.** It sets the password and mails a
|
|
297
|
-
confirmation link; `confirmRegistration(token)` is the call that proves
|
|
298
|
-
you own the address and returns a session. This is deliberate, not a
|
|
299
|
-
missing convenience: a domain filter gates *which domains* may register,
|
|
300
|
-
never *whether the caller owns the address* — minting a session straight
|
|
301
|
-
out of `register` would let anybody who knows an allowed domain register
|
|
302
|
-
as somebody else at it and receive whatever role the app assigns, which on
|
|
303
|
-
this platform can mean permission to move a robot. **A deployment with no
|
|
304
|
-
SMTP configured cannot complete a self-registration at all** — unlike an
|
|
305
|
-
invitation, where a developer can hand the link over directly, there is no
|
|
306
|
-
other channel for the confirmation link. If you're testing against a
|
|
307
|
-
mail-less dev cloud, that is why nothing arrives.
|
|
308
|
-
- **`register` does not take a role.** The app's own self-registration
|
|
309
|
-
setting decides it (§3.2: a pool member gets exactly one role per app,
|
|
310
|
-
the app owner's to configure, not a self-registering stranger's to pick).
|
|
311
|
-
It fails the same way whether the app has self-registration turned off or
|
|
312
|
-
the address's domain isn't on the allowed list — **those two cases are
|
|
313
|
-
indistinguishable on purpose**, the same reasoning as the reset request
|
|
314
|
-
below. Don't build a UI that tries to tell them apart. And `register`
|
|
315
|
-
resolves identically for an address that is new and one that already has
|
|
316
|
-
an account — same account-enumeration reasoning as `requestPasswordReset`;
|
|
317
|
-
`mail` in the response describes this deployment's mail configuration, not
|
|
318
|
-
whether the address exists, so it's safe to read either way.
|
|
319
|
-
- **`confirmRegistration`'s token is single-use and expires**, and both
|
|
320
|
-
cases answer `token_spent` — same as `confirmPasswordReset` below.
|
|
321
|
-
- **`changePassword` re-issues your session rather than leaving it alone.**
|
|
322
|
-
It returns fresh tokens, which this method stores for you exactly like
|
|
323
|
-
`login` does — you don't need to do anything with the return value. Every
|
|
324
|
-
*other* session of this identity is revoked; only the one that made this
|
|
325
|
-
call keeps working, and it keeps working because it was just handed new
|
|
326
|
-
credentials, not because the old ones were spared. Make sure your UI
|
|
327
|
-
surfaces the "your other sessions are about to end" consequence *before*
|
|
328
|
-
the user confirms, not after they get a "why was I logged out?" support
|
|
329
|
-
ticket.
|
|
330
|
-
- **`requestPasswordReset` answers the same way for a known and an unknown
|
|
331
|
-
address, always.** This is not a bug and not something you can detect or
|
|
332
|
-
work around: it is the one place in this SDK where telling the two apart
|
|
333
|
-
would let a stranger enumerate accounts by their email address, so the
|
|
334
|
-
server (and this method) simply never says. Do not add a "no such
|
|
335
|
-
account" branch to your UI here — there is nothing in the response for
|
|
336
|
-
it to key off, on purpose. Show a generic "check your email" message
|
|
337
|
-
regardless of the outcome.
|
|
338
|
-
- **`confirmPasswordReset`'s token is single-use and expires**, and both
|
|
339
|
-
cases answer `token_spent` — deliberately one code for both, so a caller
|
|
340
|
-
cannot tell "already used" from "too late" and learn anything about the
|
|
341
|
-
token's history. The recovery is the same either way: request a new link.
|
|
342
|
-
Succeeding revokes every other session, exactly like `changePassword`.
|
|
343
|
-
- **`register` and `requestPasswordReset` are both behind the rate limiter**
|
|
344
|
-
(W6c) that guards every unauthenticated route — see
|
|
345
|
-
[Errors](#errors) below for how `rate_limited` surfaces and why this SDK
|
|
346
|
-
never retries it for you.
|
|
347
|
-
- **`register`, `confirmRegistration` and `changePassword` are not available
|
|
348
|
-
on a `serverKey` client** — they throw immediately, same as
|
|
349
|
-
`login`/`logout`. A server key has no "own password" to change; a
|
|
350
|
-
successful `confirmRegistration`/`changePassword` returns tokens with
|
|
351
|
-
nowhere to store them (no token store on a serverKey client); and
|
|
352
|
-
self-registration is specifically the act of a *user* signing themselves
|
|
353
|
-
up — a backend creating pool members programmatically is a different act
|
|
354
|
-
with a different audit trail. `requestPasswordReset`/`confirmPasswordReset`
|
|
355
|
-
work on either kind of client — neither touches a session or a token
|
|
356
|
-
store, so there is nothing structural to forbid.
|
|
357
|
-
- **These are the end-user routes (`/api/client/password/...`).** The
|
|
358
|
-
developer-facing console has its own equivalents under `/api/auth/`; this
|
|
359
|
-
SDK never speaks to that identity space.
|
|
148
|
+
const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 })
|
|
360
149
|
|
|
361
|
-
### Server-side callers — `serverKey`
|
|
362
|
-
|
|
363
|
-
For your own backend, automation, or CI — full app rights, no login step:
|
|
364
|
-
|
|
365
|
-
```ts
|
|
366
|
-
const client = createClient({ apiUrl, appIdentifier, serverKey: 'flk_...' })
|
|
367
|
-
await client.datapoints.get('robot-id', 'battery-percentage') // works immediately
|
|
368
|
-
```
|
|
369
|
-
|
|
370
|
-
A `serverKey` client has no session to manage: `auth.login`/`auth.logout`
|
|
371
|
-
throw if called. A rejected server key does not refresh — there is nothing
|
|
372
|
-
to refresh into — it just fails.
|
|
373
|
-
|
|
374
|
-
`tokenStore` and `serverKey` are mutually exclusive; pick one per client.
|
|
375
|
-
|
|
376
|
-
## Datapoints
|
|
377
|
-
|
|
378
|
-
```ts
|
|
379
|
-
client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
|
|
380
|
-
client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe() }
|
|
381
|
-
```
|
|
382
|
-
|
|
383
|
-
- **Your session survives a laptop lid being closed.** If a client reconnects
|
|
384
|
-
after being offline longer than the access token's lifetime, the
|
|
385
|
-
reconnect's authentication is refused as expired — the SDK refreshes
|
|
386
|
-
silently and retries the connection once before giving up, the same
|
|
387
|
-
courtesy a REST call gets. You do not need to detect this or re-login by
|
|
388
|
-
hand.
|
|
389
|
-
- `subscribe` shares one WebSocket connection across every subscription on a
|
|
390
|
-
client. It reconnects with exponential backoff on a network drop, and
|
|
391
|
-
every active subscription is re-established automatically once the
|
|
392
|
-
connection (and, if the access token has since expired, a silent refresh)
|
|
393
|
-
comes back — you do not need to resubscribe yourself.
|
|
394
|
-
- A subscription refused by the server (role does not grant the slug, an
|
|
395
|
-
unknown slug, ...) calls `onError` once with a `FleetlessError`; it never
|
|
396
|
-
calls `onEvent`. Note that Fleetless deliberately answers `forbidden` both
|
|
397
|
-
for "your role does not grant this" and for "no such slug" — the SDK does
|
|
398
|
-
not invent a distinction the API does not make.
|
|
399
|
-
|
|
400
|
-
## Actions
|
|
401
|
-
|
|
402
|
-
```ts
|
|
403
|
-
const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 }) // -> Job, id is informative
|
|
404
150
|
const sub = client.actions.subscribe(robotId, 'dock', {
|
|
405
151
|
onJob(event) {
|
|
406
|
-
console.log(event.job.state, event.feedback, event.progress)
|
|
152
|
+
console.log(event.job.state, event.feedback, event.progress)
|
|
407
153
|
if (event.job.state === 'succeeded') console.log(event.job.result)
|
|
408
154
|
},
|
|
409
155
|
onError(error) {
|
|
@@ -411,807 +157,80 @@ const sub = client.actions.subscribe(robotId, 'dock', {
|
|
|
411
157
|
},
|
|
412
158
|
})
|
|
413
159
|
|
|
414
|
-
//
|
|
415
|
-
const stopped = await client.actions.cancel(robotId, 'dock')
|
|
416
|
-
|
|
417
|
-
// Cancel the job you invoked, specifically — not whatever else might be
|
|
418
|
-
// running there by the time this arrives:
|
|
160
|
+
// Cancel the job you started, specifically — not whatever runs there by now.
|
|
419
161
|
const cancelled = await client.actions.cancel(robotId, 'dock', job.id)
|
|
420
|
-
// cancelled is the Job that was actually stopped, or null if nothing
|
|
421
|
-
// matched. Naming a job.id that is no longer running rejects `not_found` —
|
|
422
|
-
// it never falls back to stopping whatever *is* running instead, because
|
|
423
|
-
// naming an id already said which one you meant.
|
|
424
|
-
|
|
425
162
|
sub.unsubscribe()
|
|
426
163
|
```
|
|
427
164
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
> `cancel(robotId, slug, { timeoutMs: 5000 })` — that object now lands in
|
|
433
|
-
> the `jobId` position instead. In TypeScript this is a compile error. In
|
|
434
|
-
> plain JavaScript it throws `invalid_option` immediately, before anything
|
|
435
|
-
> is sent, naming the mistake in the message — it does not silently send a
|
|
436
|
-
> malformed request. Update the call to `cancel(robotId, slug, undefined,
|
|
437
|
-
> { timeoutMs: 5000 })`, or to `cancel(robotId, slug, job.id)` if you meant
|
|
438
|
-
> to address a specific job all along.
|
|
439
|
-
|
|
440
|
-
- **`invoke` resolves as soon as the job exists** — the returned job id is
|
|
441
|
-
informative (spec §11.3), not the result. Feedback, progress and the
|
|
442
|
-
eventual result arrive separately, over `subscribe`.
|
|
443
|
-
- **State is observed by slug, not by job id.** `subscribe(robotId, slug,
|
|
444
|
-
...)` shows whatever job is currently running there — including a job
|
|
445
|
-
invoked by a different caller — which is also what makes late delivery
|
|
446
|
-
after a reconnect and two observers watching the same job both work
|
|
447
|
-
without any special-casing on your part. This does **not** change with
|
|
448
|
-
job-id cancel below: a slug is still a place a job may be running, not the
|
|
449
|
-
job itself, and `subscribe` still watches the place.
|
|
450
|
-
- **One job runs per action slug.** A second `invoke` while one is already
|
|
451
|
-
running rejects `busy`, and `error.details.running` is the `Job` that is
|
|
452
|
-
already in flight — enough to show the user what is happening, or to
|
|
453
|
-
`subscribe` and wait for it, without guessing.
|
|
454
|
-
- **`cancel` addresses either a slug or a specific job (W6b).**
|
|
455
|
-
`cancel(robotId, slug)` is the blunt operator form: stop whatever is
|
|
456
|
-
running there, whoever started it. `cancel(robotId, slug, job.id)` is a
|
|
457
|
-
caller cancelling the specific job it invoked — the difference matters
|
|
458
|
-
because a plain slug-cancel that arrives a moment after its own job ended
|
|
459
|
-
will happily stop whoever's job runs next on that slug, and a caller who
|
|
460
|
-
holds a `job.id` usually wants to rule that out. There is no automatic
|
|
461
|
-
fallback from the id form to the slug form on `not_found`: if you want
|
|
462
|
-
"stop whatever's there" behaviour, ask for it explicitly by omitting the id,
|
|
463
|
-
rather than relying on a refused id to degrade into it.
|
|
464
|
-
- `subscribe` is reference-counted per `(robotId, slug)`, exactly like
|
|
465
|
-
`datapoints.subscribe`: two callers on the same key share one wire
|
|
466
|
-
subscription, and unsubscribing one never affects the other.
|
|
467
|
-
- **`params` is flat, keyed by the exact `parameterSpec.name` the developer
|
|
468
|
-
declared** — `'target_pose.position.x'`, not `{ target_pose: { position: {
|
|
469
|
-
x: 1.0 } } }`. Do not nest it yourself: a field a spec did not declare
|
|
470
|
-
cannot be set at all (that is what §4.4 parameter checking *is*), and a
|
|
471
|
-
`parameter_invalid` refusal names the `field` it rejected using this same
|
|
472
|
-
flat key — nesting it client-side would make the refusal point at a path
|
|
473
|
-
you never typed.
|
|
474
|
-
- **`options.patienceMs` bounds goal *acceptance* only (W6b)**, not the whole
|
|
475
|
-
job: `client.actions.invoke(robotId, 'dock', params, { patienceMs: 5_000 })`
|
|
476
|
-
makes the platform give up if no action server accepts the goal within 5s
|
|
477
|
-
(`goal_timeout`), but once accepted the job runs as long as it runs — you
|
|
478
|
-
observe it via `subscribe`, you never await it. Omit it and you get the
|
|
479
|
-
platform default (currently 15s), unchanged from before this option
|
|
480
|
-
existed. Outside the platform's `[1s, 120s]` bounds the call is refused
|
|
481
|
-
with `validation_error` — this SDK does not clamp your number to fit, and
|
|
482
|
-
does not retry with a different one on your behalf. The floor exists
|
|
483
|
-
because a too-short patience does not just fail politely: it made the
|
|
484
|
-
bridge report `goal_timeout` and then issue a corrective cancel against a
|
|
485
|
-
goal an action server accepted a moment later, so an unreachable deadline
|
|
486
|
-
was causing a real cancellation on the robot, repeatably.
|
|
487
|
-
|
|
488
|
-
## Services
|
|
489
|
-
|
|
490
|
-
```ts
|
|
491
|
-
const result = await client.services.call(robotId, 'get-status', { verbose: true })
|
|
492
|
-
|
|
493
|
-
// patienceMs bounds the *whole* wait here — unlike an action, a service has
|
|
494
|
-
// no further state to observe once the platform gives up on it:
|
|
495
|
-
const status = await client.services.call(robotId, 'get-status', {}, { patienceMs: 5_000 })
|
|
496
|
-
```
|
|
497
|
-
|
|
498
|
-
Same flat-key convention for `params` as `actions.invoke`.
|
|
499
|
-
|
|
500
|
-
A service call is a job underneath — the same disconnect survival as an
|
|
501
|
-
action (spec §6.1: "an action goal or a service call") — but that is
|
|
502
|
-
deliberately invisible here: `call` waits for it internally and resolves
|
|
503
|
-
with the result directly, or rejects with the typed error it failed with.
|
|
504
|
-
There is nothing to subscribe to for a service: no feedback, no progress,
|
|
505
|
-
no cancel.
|
|
506
|
-
|
|
507
|
-
`options.patienceMs` (W6b) is the platform's own patience for this call —
|
|
508
|
-
distinct from `options.timeoutMs`, which is this SDK's own local bound on the
|
|
509
|
-
**whole call** (default 30s): the ack that a job was created, plus however
|
|
510
|
-
much of that budget is left for the job to then reach a terminal state — not
|
|
511
|
-
two separate 30-second windows back to back (D10). Setting `timeoutMs: 5000`
|
|
512
|
-
bounds total latency at ~5s, once, not ~5s-then-another-5s if the ack happens
|
|
513
|
-
to arrive late. `patienceMs` and `timeoutMs` still bound different things and
|
|
514
|
-
neither derives from the other when both are set: a short `timeoutMs` with a
|
|
515
|
-
long `patienceMs` gives up locally while the platform keeps trying; a short
|
|
516
|
-
`patienceMs` with a long `timeoutMs` means the platform's own refusal
|
|
517
|
-
(`goal_timeout` or similar) usually arrives well before your local timeout
|
|
518
|
-
would have fired anyway.
|
|
519
|
-
|
|
520
|
-
## Publishers
|
|
521
|
-
|
|
522
|
-
```ts
|
|
523
|
-
await client.publishers.publish(robotId, 'cmd-vel', { 'linear.x': 0.2, 'angular.z': 0 })
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
`message` follows the same flat, `parameterSpec.name`-keyed convention as
|
|
527
|
-
`actions.invoke`'s `params` — **not** a nested ROS message tree. (The
|
|
528
|
-
publisher's *failsafe* message, by contrast, is a whole nested message body —
|
|
529
|
-
but that one is authored once by the developer in the console, never sent by
|
|
530
|
-
a caller.)
|
|
531
|
-
|
|
532
|
-
**Publishing is a plain method call — no more, no less.** There is
|
|
533
|
-
deliberately no deadman switch, rate governor, or "takt" helper in this SDK
|
|
534
|
-
(spec §14.3): the safety pattern for *how often* and *when* to publish is
|
|
535
|
-
your app's responsibility, not something this library does for you. Believing
|
|
536
|
-
the SDK protects you here is more dangerous than knowing it does not.
|
|
165
|
+
`params` is flat, keyed by the parameter names the developer declared. A second
|
|
166
|
+
`invoke` while one is still running is refused `busy`, with the running job on
|
|
167
|
+
`error.details.running`. A service is the same machinery with a plain result:
|
|
168
|
+
`await client.services.call(robotId, 'set_mode', { mode: 'autonomous' })`.
|
|
537
169
|
|
|
538
|
-
|
|
539
|
-
failsafe: the developer configures a `timeout_ms` and a failsafe message when
|
|
540
|
-
exposing the publisher, and if messages stop arriving for any reason —
|
|
541
|
-
including your process crashing — **the bridge itself** publishes that
|
|
542
|
-
failsafe message on the topic. That is the platform's safety primitive, not
|
|
543
|
-
this SDK; build your app as if it is the only thing standing between a
|
|
544
|
-
dropped connection and a robot that keeps moving, because for the interval up
|
|
545
|
-
to `timeout_ms` it is.
|
|
546
|
-
|
|
547
|
-
Publishing is also implicitly exclusive: whoever last published holds the
|
|
548
|
-
publisher until they have been quiet for the configured `quiet_timeout_ms`.
|
|
549
|
-
A `publish` call while someone else holds it rejects `publisher_busy`.
|
|
550
|
-
|
|
551
|
-
## Jobs
|
|
170
|
+
### Cameras
|
|
552
171
|
|
|
553
172
|
```ts
|
|
554
|
-
|
|
555
|
-
```
|
|
173
|
+
import { Room } from 'livekit-client' // your choice of LiveKit client
|
|
556
174
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
register row 2k).
|
|
561
|
-
|
|
562
|
-
`actions`/`services` require knowing the slug first, which is not always
|
|
563
|
-
true: a bridge reconnecting can name a job the cloud only *adopted*, and a
|
|
564
|
-
configuration change can leave a job on a slug the published document no
|
|
565
|
-
longer contains. Both are jobs no slug can name up front — `jobs.list` is how
|
|
566
|
-
you find them anyway, e.g. to reconcile local UI state against "what is this
|
|
567
|
-
robot actually doing" on load, rather than polling every slug you happen to
|
|
568
|
-
know about.
|
|
569
|
-
|
|
570
|
-
Grant-filtered like everything else: an end user or server key sees only
|
|
571
|
-
jobs on slugs their role grants; a developer session sees every job on the
|
|
572
|
-
robot. `[]` means the robot is doing nothing, not "we did not look" — the
|
|
573
|
-
array is never `null`.
|
|
574
|
-
|
|
575
|
-
**Ordered newest first by `started_at`, `job.seq` as the tiebreaker for two
|
|
576
|
-
jobs minted in the same millisecond** — but for an adopted job, `started_at`
|
|
577
|
-
is adoption time, not when it actually started on the robot, so read this as
|
|
578
|
-
newest-*known*-first: a job the robot has been running for an hour can sit
|
|
579
|
-
above one started a minute ago if the hour-long one was only just adopted.
|
|
580
|
-
|
|
581
|
-
## Cameras
|
|
582
|
-
|
|
583
|
-
```ts
|
|
584
|
-
const cameras = await client.cameras.list(robotId) // -> CameraDescriptor[]
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
### Snapshot — always on, independent of live
|
|
588
|
-
|
|
589
|
-
```ts
|
|
590
|
-
const snap = await client.cameras.snapshot(robotId, 'front')
|
|
591
|
-
if (snap.image === null) {
|
|
592
|
-
// Nothing captured yet (e.g. right after a fresh publish) — a state, not
|
|
593
|
-
// an error. Don't treat this as a failure.
|
|
594
|
-
} else {
|
|
595
|
-
console.log(`${snap.mime}, ${snap.age_ms}ms old`) // an <img> src, a file, whatever you need
|
|
596
|
-
}
|
|
597
|
-
```
|
|
598
|
-
|
|
599
|
-
- **`age_ms` is the cloud's own figure — always trust it, never recompute
|
|
600
|
-
it** as `Date.now() - snap.timestamp_ms`. The cloud is the one clock that
|
|
601
|
-
knows how long it has actually held the frame; your own clock skew would
|
|
602
|
-
quietly turn "how old is this?" into a lie.
|
|
603
|
-
- A snapshot keeps updating on the camera's configured interval **whether or
|
|
604
|
-
not anyone is watching live** (spec §10), and keeps being served — with a
|
|
605
|
-
growing `age_ms` — even while the robot's bridge is offline. It never goes
|
|
606
|
-
stale silently: you always know exactly how old what you have is.
|
|
607
|
-
- Polling for "did a newer frame arrive?" without re-downloading the image
|
|
608
|
-
every time: use `client.cameras.snapshotMeta(robotId, slug)`, which returns
|
|
609
|
-
everything `snapshot` does except `image`.
|
|
610
|
-
|
|
611
|
-
### Live — on demand, refcounted, and it costs you a release
|
|
612
|
-
|
|
613
|
-
```ts
|
|
614
|
-
import { Room } from 'livekit-client' // your choice of LiveKit client SDK
|
|
175
|
+
// The snapshot is always there, independent of anyone watching live.
|
|
176
|
+
const snap = await client.cameras.snapshot(robotId, 'camera_front')
|
|
177
|
+
if (snap.image !== null) console.log(snap.mime, `${snap.age_ms}ms old`)
|
|
615
178
|
|
|
179
|
+
// Live is on demand, and it costs you a release.
|
|
616
180
|
const room = new Room()
|
|
617
|
-
const session = await client.cameras.live(robotId, '
|
|
181
|
+
const session = await client.cameras.live(robotId, 'camera_front')
|
|
618
182
|
await room.connect(session.url, session.token)
|
|
619
|
-
// ... attach room's video track to your <video> element, however you render it
|
|
620
183
|
|
|
621
|
-
// When you
|
|
184
|
+
// When you are done watching — both calls, together. Disconnecting the Room is
|
|
185
|
+
// what actually stops the stream; release() is the courteous fast path.
|
|
622
186
|
await room.disconnect()
|
|
623
187
|
await session.release()
|
|
624
188
|
```
|
|
625
189
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
void room.disconnect()
|
|
674
|
-
void session?.release()
|
|
675
|
-
}
|
|
676
|
-
}, [robotId])
|
|
677
|
-
```
|
|
678
|
-
|
|
679
|
-
- **`session.expires_at` is a join deadline, not a session backstop.**
|
|
680
|
-
LiveKit checks a token when a `Room` connects, not while it stays
|
|
681
|
-
connected — so a `Room` that joined before `expires_at` keeps streaming
|
|
682
|
-
right past that timestamp, untouched. It only bounds how long an *unused*
|
|
683
|
-
token sits around; it does **not** bound a crashed process or a `kill -9`
|
|
684
|
-
after joining. What actually ends an already-joined session is the pair
|
|
685
|
-
of calls above, the cloud noticing (via its own reconciliation) that this
|
|
686
|
-
viewer's room participation ended, or revocation kicking the participant.
|
|
687
|
-
Do not design around this field as if it protected you from a forgotten
|
|
688
|
-
cleanup.
|
|
689
|
-
- `release()` is safe to call more than once and never rejects — it is a
|
|
690
|
-
courtesy notification, not the thing that actually stops the stream (see
|
|
691
|
-
above), so there is nothing more useful a caller could do with a failed
|
|
692
|
-
one. That also makes it safe to use directly as a cleanup callback
|
|
693
|
-
without wrapping it in a `try`/`catch`.
|
|
694
|
-
- **No video widget, no teleop-style helper here** (spec §14.3 keeps this
|
|
695
|
-
SDK thin): `live()` hands back exactly what a LiveKit client needs
|
|
696
|
-
(`url`, `token`) and stops — rendering the video, choosing a UI library,
|
|
697
|
-
and reconnect/backoff for the *media* connection are yours, the same way
|
|
698
|
-
`publishers.publish` hands you a plain method call with no rate governor
|
|
699
|
-
built in.
|
|
700
|
-
|
|
701
|
-
## Assets
|
|
702
|
-
|
|
703
|
-
```ts
|
|
704
|
-
const { assets, urdf, urdf_available } = await client.assets.list(robotId)
|
|
705
|
-
```
|
|
706
|
-
|
|
707
|
-
The asset store (spec §4.6): a robot's URDF and the meshes it references,
|
|
708
|
-
each addressed by uuid, unmodifiable, and reachable only with the same
|
|
709
|
-
`Authorization` header as everything else — no signed URL, no token in the
|
|
710
|
-
query string. Syncing a URDF from the connected bridge is a console action,
|
|
711
|
-
Owner-tier, and out of scope for this SDK on purpose (§4.6 says so
|
|
712
|
-
explicitly) — this surface is for *reading* what has already been synced.
|
|
713
|
-
|
|
714
|
-
`urdf.missing` names the `package://` URIs the sync could not resolve. Show
|
|
715
|
-
the URIs, not just the count — "2 Meshes fehlen" sends a developer looking
|
|
716
|
-
through a workspace by hand; the URIs are what they can act on.
|
|
717
|
-
`urdf_available` is `true`/`false` when a bridge is connected and reporting,
|
|
718
|
-
`null` when none is — "no robot is online to ask" and "the robot has no
|
|
719
|
-
URDF" are different facts a caller needs to tell apart.
|
|
720
|
-
|
|
721
|
-
```ts
|
|
722
|
-
const { body, mime } = await client.assets.get(robotId, assetId)
|
|
723
|
-
const xml = await client.assets.urdf(robotId) // -> string, ready for URDFLoader.parse()
|
|
724
|
-
```
|
|
725
|
-
|
|
726
|
-
`get` answers one asset's raw bytes — a mesh, or anything else by id, the
|
|
727
|
-
same way `createMeshLoader` reaches one internally. `urdf` is a special case
|
|
728
|
-
of the same read: XML text with every `package://` mesh URI already
|
|
729
|
-
rewritten to an absolute Fleetless asset URL, decoded because every consumer
|
|
730
|
-
needs it as a string for `URDFLoader.parse(xml)`.
|
|
731
|
-
|
|
732
|
-
Neither absorbs a "nothing yet" state the way `cameras.snapshot` absorbs
|
|
733
|
-
`no_snapshot_yet` — a missing or too-large asset is a real failure here, not
|
|
734
|
-
a state a renderer should quietly treat as empty, so both throw a
|
|
735
|
-
`FleetlessError` (`asset_missing`, `asset_too_large`, `forbidden`) like
|
|
736
|
-
every other route.
|
|
737
|
-
|
|
738
|
-
### Rendering a textured robot — `prepareUrdfScene`
|
|
739
|
-
|
|
740
|
-
`createMeshLoader` (below) only ever solved meshes, because `loadMeshCb` is
|
|
741
|
-
the one override hook `urdf-loader` has — and three.js's `TextureLoader` has
|
|
742
|
-
no equivalent hook at all. So a `<material><texture>` in the URDF, and an
|
|
743
|
-
image a `.dae` references *internally* (`<init_from>textures/skin.png`,
|
|
744
|
-
resolved by the renderer against wherever it loaded the `.dae` from), were
|
|
745
|
-
both unreachable: rewritten to a real asset URL, fetched by three.js with no
|
|
746
|
-
credential, and refused.
|
|
747
|
-
|
|
748
|
-
`prepareUrdfScene` covers all three cases — mesh, URDF-level texture,
|
|
749
|
-
`.dae`-internal texture — with one mechanism: three.js's
|
|
750
|
-
`LoadingManager.setURLModifier`. Every load the manager oversees, including
|
|
751
|
-
the images `ColladaLoader` fetches on its own account, is routed through it
|
|
752
|
-
first.
|
|
753
|
-
|
|
754
|
-
```ts
|
|
755
|
-
import URDFLoader from 'urdf-loader'
|
|
756
|
-
import * as THREE from 'three'
|
|
757
|
-
|
|
758
|
-
const manager = new THREE.LoadingManager()
|
|
759
|
-
const { urdfText, missing, dispose } = await client.assets.prepareUrdfScene(robotId, manager)
|
|
760
|
-
|
|
761
|
-
if (missing.length > 0) {
|
|
762
|
-
// Same strings assets.list()'s urdf.missing reports — decide whether to
|
|
763
|
-
// warn, block, or just render what resolved. Not thrown.
|
|
764
|
-
console.warn(`This robot will render incompletely — unresolved: ${missing.join(', ')}`)
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
const loader = new URDFLoader(manager)
|
|
768
|
-
const robot = loader.parse(urdfText)
|
|
769
|
-
scene.add(robot)
|
|
770
|
-
|
|
771
|
-
// Once the scene has finished loading (or on unmount):
|
|
772
|
-
dispose()
|
|
773
|
-
```
|
|
774
|
-
|
|
775
|
-
What it does: fetches the robot's asset list, fetches every mesh and texture
|
|
776
|
-
asset **with the `Authorization` header** into a `Blob`, maps each asset's
|
|
777
|
-
name to a `blob:` URL, and installs `manager.setURLModifier` to resolve
|
|
778
|
-
against that map.
|
|
779
|
-
|
|
780
|
-
**Only claims what it owns — everything else passes through unchanged.**
|
|
781
|
-
`manager` is frequently your own scene-wide `LoadingManager`, shared for an
|
|
782
|
-
HDRI, an environment map, a font atlas, a ground texture — none of which
|
|
783
|
-
have anything to do with this robot. So the rule is: a `package://`
|
|
784
|
-
reference, or a root-relative path whose leading segment names a ROS package
|
|
785
|
-
this robot's assets (or `missing`) actually mention, is this method's to
|
|
786
|
-
resolve or refuse. Anything else — a relative path, a root-relative path
|
|
787
|
-
under a different namespace, a `data:`/`blob:` URL — is left completely
|
|
788
|
-
alone, exactly as if `prepareUrdfScene` had never been called. **The one
|
|
789
|
-
exception is an absolute `http(s)` URL**, refused regardless of namespace: a
|
|
790
|
-
hostile URDF naming an attacker's host directly (bypassing `package://`
|
|
791
|
-
entirely) must never reach a real network request, so this is the one case
|
|
792
|
-
this method cannot afford to leave ambiguous.
|
|
793
|
-
|
|
794
|
-
Anything this method owns and cannot resolve — a reference the store never
|
|
795
|
-
got, or that attacker-named absolute URL — resolves to a shared, empty,
|
|
796
|
-
page-local `blob:` URL, never the original string. A loader handed empty
|
|
797
|
-
bytes fails to parse them, which is the same loud failure a genuinely
|
|
798
|
-
missing mesh already produces; nothing quietly renders wrong and nothing
|
|
799
|
-
quietly reaches the network. `dispose()` revokes every real asset `blob:`
|
|
800
|
-
URL this call created — not that shared one, which stays valid for the life
|
|
801
|
-
of the page, which is what lets the installed modifier keep refusing owned
|
|
802
|
-
references correctly even after `dispose()`, rather than reverting to raw,
|
|
803
|
-
unrefused passthrough.
|
|
804
|
-
|
|
805
|
-
This closes a real gap, not a hypothetical one: `URDFLoader.resolvePath()`
|
|
806
|
-
passes a non-`package://` filename through unchanged, so a `<mesh
|
|
807
|
-
filename="http://attacker.example/x.stl">` on a robot's ROS graph would
|
|
808
|
-
otherwise reach the browser's own `fetch` verbatim. No credential travels
|
|
809
|
-
with it — three.js does not carry the bearer token — but it is still a
|
|
810
|
-
real, attacker-named network request made by every viewer who renders the
|
|
811
|
-
URDF. A `blob:` URL created in this page cannot resolve to any other origin
|
|
812
|
-
by construction, so refusing this way removes the network request entirely
|
|
813
|
-
rather than merely declining to authenticate it.
|
|
814
|
-
|
|
815
|
-
**`urdf-loader` resolves `package://` itself, before the URL modifier ever
|
|
816
|
-
sees it.** `URDFLoader.parse()`'s own `resolvePath()` rewrites
|
|
817
|
-
`package://pkg/rel` to the root-relative `/pkg/rel` under its default
|
|
818
|
-
`packages: ''`, and *that* rewritten form is what actually reaches the URL
|
|
819
|
-
modifier — not the original string. `prepareUrdfScene` registers both forms
|
|
820
|
-
per asset, so the example above works with a plain `new URDFLoader(manager)`
|
|
821
|
-
and no extra configuration. If you set `loader.packages` to something other
|
|
822
|
-
than the default, results depend on what it produces; setting it to
|
|
823
|
-
`(pkg) => \`package://${pkg}\`` reconstructs the original name and is
|
|
824
|
-
covered too.
|
|
825
|
-
|
|
826
|
-
**A `.dae`'s own internal references get a second-chance, normalized lookup
|
|
827
|
-
too.** three.js builds the request for one by plain string concatenation
|
|
828
|
-
(`this.path + url`, no `..`/`.` collapsing), while `asset.name` carries the
|
|
829
|
-
*normalized* tail — so `../textures/skin.png` or `./textures/skin.png`
|
|
830
|
-
(ordinary exporter output; Blender writes `./` routinely) would otherwise
|
|
831
|
-
miss the direct lookup. Handled the same way for you: nothing to configure.
|
|
832
|
-
|
|
833
|
-
The browser **never** fetches an asset directly, for anything this method
|
|
834
|
-
owns — every read goes through this SDK with the bearer token first, same
|
|
835
|
-
as everything else in this package, and the cloud enforces `assets` exactly
|
|
836
|
-
as it does today. Pre-fetch
|
|
837
|
-
is unavoidable, not a design shortcut: a URL modifier cannot be asynchronous,
|
|
838
|
-
so bytes have to already be in memory before `URDFLoader.parse` runs. Fetches
|
|
839
|
-
are bounded to `options.concurrency` (default 6, must be a positive integer —
|
|
840
|
-
`0` or negative throws `invalid_option` rather than silently fetching nothing
|
|
841
|
-
and returning a scene that renders completely blank with no error to explain
|
|
842
|
-
why) and scoped to the mesh and texture assets in the robot's own list —
|
|
843
|
-
which already *is* what its current URDF references (a re-sync reconciles,
|
|
844
|
-
so a mesh the URDF stops naming stops belonging to the robot).
|
|
845
|
-
|
|
846
|
-
`options.signal` cancels the whole call (register row 244, W7c) — pass an
|
|
847
|
-
`AbortController`'s signal if the caller might navigate away or switch
|
|
848
|
-
robots mid-load. Checked before the first request and forwarded straight to
|
|
849
|
-
every underlying `fetch()`, so an abort tears down connections already in
|
|
850
|
-
flight rather than merely stopping new ones from starting; every `blob:`
|
|
851
|
-
URL already created before the abort is revoked, and the rejection is always
|
|
852
|
-
`FleetlessError('aborted', ...)` regardless of which stage the abort landed
|
|
853
|
-
in or which shape the runtime's own `fetch()` rejects an aborted request
|
|
854
|
-
with.
|
|
855
|
-
|
|
856
|
-
**Do not also install `createMeshLoader` on the same manager.** They read
|
|
857
|
-
two different URDF sources — this method fetches the URDF's *raw* bytes,
|
|
858
|
-
`createMeshLoader` is meant to pair with `urdf()`'s cloud-rewritten text.
|
|
859
|
-
**Not "double-fetches every mesh"** — a first version of this sentence said
|
|
860
|
-
that, and it was traced and found wrong (Momus-W7a review): what actually
|
|
861
|
-
happens is asymmetric breakage, not duplication. Paired with this method's
|
|
862
|
-
raw text, `createMeshLoader` gets urdf-loader's own `resolvePath()` output
|
|
863
|
-
rather than an absolute Fleetless URL and 404s every mesh while textures
|
|
864
|
-
still work; paired with `urdf()`'s rewritten text instead, meshes load and
|
|
865
|
-
every texture 401s, because `createMeshLoader` bypasses this method's URL
|
|
866
|
-
modifier entirely for meshes and there is no equivalent hook for a texture.
|
|
867
|
-
A developer who combined them by accident would have debugged the wrong
|
|
868
|
-
symptom either way — **enforced now, not only documented**: installing both
|
|
869
|
-
on the same manager fails loudly, before either ever touches the network,
|
|
870
|
-
rather than silently producing one of those two broken scenes. Use
|
|
871
|
-
`prepareUrdfScene` for anything rendered with `urdf-loader`/three.js;
|
|
872
|
-
`createMeshLoader` stays useful only for a mesh-only, non-three.js
|
|
873
|
-
`loadMeshCb`-shaped renderer.
|
|
874
|
-
|
|
875
|
-
### The mesh callback — `createMeshLoader`
|
|
876
|
-
|
|
877
|
-
**Superseded by `prepareUrdfScene` above for three.js/`urdf-loader`
|
|
878
|
-
consumers — reach for it there first, especially for anything with
|
|
879
|
-
textures.** Kept because it is renderer-agnostic (any `loadMeshCb`-shaped
|
|
880
|
-
consumer, not only three.js) and mesh-only apps may not need the wider
|
|
881
|
-
mechanism. Do not install both on one load — see the warning above.
|
|
882
|
-
|
|
883
|
-
An `<img>` tag and the default three.js loaders cannot set an
|
|
884
|
-
`Authorization` header, and the platform deliberately has no signed URLs and
|
|
885
|
-
no token in the query string (see the reasoning in `@fleetless/contracts`'
|
|
886
|
-
`assets.ts`) — so every app would otherwise write this glue itself, and each
|
|
887
|
-
one differently. `createMeshLoader` is that glue, shaped to drop straight
|
|
888
|
-
into [`urdf-loader`](https://github.com/gkjohnson/urdf-loaders)'s own
|
|
889
|
-
`loadMeshCb` hook:
|
|
890
|
-
|
|
891
|
-
```ts
|
|
892
|
-
import URDFLoader from 'urdf-loader'
|
|
893
|
-
|
|
894
|
-
const loader = new URDFLoader()
|
|
895
|
-
loader.loadMeshCb = client.assets.createMeshLoader(robotId, loader.defaultMeshLoader.bind(loader))
|
|
896
|
-
|
|
897
|
-
const xml = await client.assets.urdf(robotId)
|
|
898
|
-
const robot = loader.parse(xml)
|
|
899
|
-
scene.add(robot)
|
|
900
|
-
```
|
|
901
|
-
|
|
902
|
-
`createMeshLoader(robotId, delegate, options?)` returns a function with
|
|
903
|
-
`loadMeshCb`'s own four-argument signature
|
|
904
|
-
(`path, manager, material, onComplete`) — assign it directly, do not wrap
|
|
905
|
-
it. `delegate` gets the same four arguments and does the actual
|
|
906
|
-
format-specific parsing (STL/OBJ/DAE/GLTF); in practice it is
|
|
907
|
-
`loader.defaultMeshLoader.bind(loader)`, urdf-loader's own built-in mesh
|
|
908
|
-
parser, not something an app writes from scratch.
|
|
909
|
-
|
|
910
|
-
What this method does that a plain loader can't: it fetches `path` with the
|
|
911
|
-
`Authorization` header attached, wraps the bytes in a `Blob`, and calls
|
|
912
|
-
`delegate` with an **object URL** substituted for `path` — so `delegate`
|
|
913
|
-
never touches the network, and every format-specific parser downstream
|
|
914
|
-
works unmodified against a same-origin `blob:` URL. The object URL is
|
|
915
|
-
revoked the moment `delegate` reports success or failure, or after
|
|
916
|
-
`options.timeoutMs` (default 30s) if it never reports at all — a URDF pulls
|
|
917
|
-
in thirty-odd meshes and this callback can run for days in a long-lived
|
|
918
|
-
dashboard, so a delegate that hangs or never calls back must not leak the
|
|
919
|
-
URL or stall the load forever. A delegate that throws synchronously is
|
|
920
|
-
caught the same way, resolving `onComplete(null, err)` rather than
|
|
921
|
-
propagating into whatever called this callback (typically three.js's own
|
|
922
|
-
URDF traversal).
|
|
923
|
-
|
|
924
|
-
**`path` is only ever fetched if its origin matches this client's own
|
|
925
|
-
`apiUrl`.** A URDF is ROS graph input, not first-party data — anything on a
|
|
926
|
-
robot's ROS graph can publish one — and the cloud only rewrites `package://`
|
|
927
|
-
URIs; a `<mesh filename="https://...">` naming a URL outright is served
|
|
928
|
-
back unchanged. Without this check, a hostile URDF could point a mesh
|
|
929
|
-
anywhere and this callback would fetch it with the caller's own bearer
|
|
930
|
-
token attached. A mismatched origin is refused before any network call —
|
|
931
|
-
`onComplete(null, err)` with `err.code === 'untrusted_absolute_url'` — never
|
|
932
|
-
fetched anonymously either, so a malicious reference fails loudly instead
|
|
933
|
-
of quietly making an unexpected request on the app's behalf.
|
|
934
|
-
|
|
935
|
-
## History
|
|
936
|
-
|
|
937
|
-
Reads recorded data for a datapoint that was published with `retention: true`
|
|
938
|
-
(spec §8) — plain REST, no realtime channel, the same way `cameras.snapshot`
|
|
939
|
-
is. You do not need to learn the underlying query grammar; `history()` takes
|
|
940
|
-
a plain options object and hands back a typed, discriminated result.
|
|
941
|
-
|
|
942
|
-
### Range — relative or absolute, always a string
|
|
943
|
-
|
|
944
|
-
```ts
|
|
945
|
-
// Relative to now:
|
|
946
|
-
await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-30s' })
|
|
947
|
-
await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h', to: 'now-30m' })
|
|
948
|
-
|
|
949
|
-
// Absolute unix milliseconds, for a report over a fixed window:
|
|
950
|
-
await client.datapoints.history(robotId, 'battery-percentage', { from: '1700000000000', to: '1700003600000' })
|
|
951
|
-
```
|
|
952
|
-
|
|
953
|
-
`from` (required) and `to` (optional, defaults to now) each accept `now-30s`
|
|
954
|
-
/ `now-5m` / `now-1h`, or absolute unix milliseconds — **always as a
|
|
955
|
-
string**, whichever form you use. The SDK does not accept a `Date` or a
|
|
956
|
-
`number` and stringify it for you: that would be a decision about which of
|
|
957
|
-
the two forms you meant, made silently on your behalf, and the next person
|
|
958
|
-
reading the actual wire traffic would have no way to tell which of you made
|
|
959
|
-
it. Convert yourself, explicitly, at the call site.
|
|
960
|
-
|
|
961
|
-
### Two result shapes, chosen by whether you asked for aggregation
|
|
962
|
-
|
|
963
|
-
```ts
|
|
964
|
-
// Raw samples: leave `aggregate` out.
|
|
965
|
-
const samples = await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h' })
|
|
966
|
-
console.log(samples.kind) // 'samples'
|
|
967
|
-
for (const s of samples.samples) console.log(s.timestamp_ms, s.value)
|
|
968
|
-
if (samples.truncated) {
|
|
969
|
-
// `limit` was hit — say so, rather than let a short array look like a quiet period.
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// Aggregated buckets: pass `aggregate`. `window` and `agg` always travel
|
|
973
|
-
// together — the API refuses one without the other, so this SDK models them
|
|
974
|
-
// as one object instead of two independent optional fields you could set
|
|
975
|
-
// only one of.
|
|
976
|
-
const buckets = await client.datapoints.history(robotId, 'battery-percentage', {
|
|
977
|
-
from: 'now-1h',
|
|
978
|
-
aggregate: { window: '1m', agg: 'avg' },
|
|
979
|
-
})
|
|
980
|
-
console.log(buckets.kind) // 'buckets'
|
|
981
|
-
for (const b of buckets.buckets) {
|
|
982
|
-
if (b.sample_count === 0) {
|
|
983
|
-
// Genuinely empty — nothing was recorded in this bucket. Draw a break in
|
|
984
|
-
// the line here.
|
|
985
|
-
} else if (b.value === null) {
|
|
986
|
-
// Samples DID land here, none of them numeric — a string, a bool, a whole
|
|
987
|
-
// message. There is data in this interval; it just has no height. Do not
|
|
988
|
-
// draw this as a gap: that is the same lie as reporting an empty bucket,
|
|
989
|
-
// and it is what this branch exists to prevent.
|
|
990
|
-
console.log(b.bucket_start_ms, `${b.sample_count} samples, none numeric`)
|
|
991
|
-
} else {
|
|
992
|
-
console.log(b.bucket_start_ms, b.value, b.sample_count)
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
`history` is overloaded on whether `aggregate` is present: passing it gets
|
|
998
|
-
you back a `HistoryBucketsResponse` (`kind: 'buckets'`) at the type level
|
|
999
|
-
already, no narrowing required, because you already know that's what you
|
|
1000
|
-
asked for; leaving it out gets you `HistorySamplesResponse` (`kind:
|
|
1001
|
-
'samples'`). Both still carry `kind` at runtime, so code that holds the
|
|
1002
|
-
result in a variable typed as the union can branch on it the same way.
|
|
1003
|
-
|
|
1004
|
-
`aggregate.field` names a numeric field inside an object value (`'pose.x'`,
|
|
1005
|
-
the same flat-key convention as `actions.invoke`'s `params`) — only needed
|
|
1006
|
-
when the datapoint's own value isn't itself a number.
|
|
1007
|
-
|
|
1008
|
-
**Buckets align to wall-clock UTC, not to your `from`.** For a clean width
|
|
1009
|
-
like `10s`/`1m`/`1h`, `bucket_start_ms` lands on :00/:10/:20 seconds,
|
|
1010
|
-
top-of-minute, top-of-hour — never at `from`, `from + window`, `from +
|
|
1011
|
-
2*window`, etc. Strictly the boundaries are counted from a fixed origin
|
|
1012
|
-
(2000-01-03) rather than from midnight, which is the same thing for any width
|
|
1013
|
-
that divides evenly into an hour; a width like `45s` is legal and its
|
|
1014
|
-
boundaries will not look like round clock times. This is deliberate: `from` is often relative (`now-30s`), so
|
|
1015
|
-
a chart re-polling the same query on an interval gets a slightly different
|
|
1016
|
-
absolute `from` on every poll — under `from`-aligned buckets that would
|
|
1017
|
-
reshuffle every boundary each time, drawing visibly jittery bars for no
|
|
1018
|
-
reason in the data. Wall-clock alignment stays stable across repeated polls
|
|
1019
|
-
of the same window width.
|
|
1020
|
-
|
|
1021
|
-
One consequence worth knowing: **the first and last bucket in a response can
|
|
1022
|
-
be partial.** `bucket_start_ms` is the wall-clock boundary that contains the
|
|
1023
|
-
earliest/latest in-range sample, and that boundary can fall before `from` or
|
|
1024
|
-
extend past `to` — but `sample_count`/`value` only ever reflect samples that
|
|
1025
|
-
actually matched the queried range. A low count on an edge bucket is that
|
|
1026
|
-
boundary effect, not a gap in what was recorded.
|
|
1027
|
-
|
|
1028
|
-
Two more things that are easy to assume and wrong:
|
|
1029
|
-
|
|
1030
|
-
- **A truncated samples response says *why*.** `truncated_by` is `'limit'`
|
|
1031
|
-
when the row cap cut it and `'bytes'` when the response-size budget did.
|
|
1032
|
-
The remedies differ: raising `limit` fixes the first and does nothing for
|
|
1033
|
-
the second, which needs a narrower range or a numeric `field` so whole
|
|
1034
|
-
messages are not carried. `null` when nothing was cut.
|
|
1035
|
-
- **`value * sample_count` is not a sum.** `value` aggregates only the numeric
|
|
1036
|
-
samples; `sample_count` counts all of them. The two agree only for a
|
|
1037
|
-
datapoint whose values are always numbers.
|
|
1038
|
-
- **The two paths do not currently share a range convention.** Raw samples are
|
|
1039
|
-
inclusive of `to`; buckets are half-open and exclude it. A sample landing
|
|
1040
|
-
exactly on an absolute `to` therefore appears in one and not the other. This
|
|
1041
|
-
is a known inconsistency, not a design: both become half-open, and this
|
|
1042
|
-
paragraph moves with them.
|
|
1043
|
-
|
|
1044
|
-
### Errors specific to history
|
|
1045
|
-
|
|
1046
|
-
```ts
|
|
1047
|
-
try {
|
|
1048
|
-
await client.datapoints.history(robotId, 'robot-details', { from: 'now-1h' })
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
if (error instanceof FleetlessError) {
|
|
1051
|
-
switch (error.code) {
|
|
1052
|
-
case 'not_recorded':
|
|
1053
|
-
// The slug is granted, but was never marked `retention: true`. An
|
|
1054
|
-
// empty samples array would look identical to "recorded, but
|
|
1055
|
-
// nothing happened in this window" — and the two need opposite
|
|
1056
|
-
// fixes, so the API refuses instead of answering with silence.
|
|
1057
|
-
break
|
|
1058
|
-
case 'not_aggregatable':
|
|
1059
|
-
// `aggregate` was requested on a value that isn't a number, and no
|
|
1060
|
-
// numeric `aggregate.field` was given. Refused rather than coerced
|
|
1061
|
-
// — an average of strings or booleans is a number that means
|
|
1062
|
-
// nothing, and it would chart as confidently as a real one.
|
|
1063
|
-
break
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
```
|
|
1068
|
-
|
|
1069
|
-
Neither of these is absorbed into a quiet empty result the way
|
|
1070
|
-
`cameras.snapshot` absorbs `no_snapshot_yet` into `image: null` — that
|
|
1071
|
-
absorption is right for a snapshot, where an empty answer is the truth; here
|
|
1072
|
-
it would erase the distinction between "turn recording on" and "look at a
|
|
1073
|
-
different window," so both reach you as a thrown `FleetlessError` instead.
|
|
1074
|
-
|
|
1075
|
-
## Errors
|
|
1076
|
-
|
|
1077
|
-
Every rejected call throws (or, for a subscription, hands to `onError`) a
|
|
1078
|
-
`FleetlessError`:
|
|
1079
|
-
|
|
1080
|
-
```ts
|
|
1081
|
-
import { FleetlessError } from '@fleetless/sdk'
|
|
1082
|
-
|
|
1083
|
-
try {
|
|
1084
|
-
await client.datapoints.get(robotId, 'robot-details')
|
|
1085
|
-
} catch (error) {
|
|
1086
|
-
if (error instanceof FleetlessError) {
|
|
1087
|
-
switch (error.code) {
|
|
1088
|
-
case 'forbidden':
|
|
1089
|
-
// your role does not grant this slug
|
|
1090
|
-
break
|
|
1091
|
-
case 'token_expired':
|
|
1092
|
-
case 'token_revoked':
|
|
1093
|
-
// the session could not be refreshed — send the user back to login
|
|
1094
|
-
break
|
|
1095
|
-
default:
|
|
1096
|
-
console.error(error.code, error.message, error.details)
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
```
|
|
1101
|
-
|
|
1102
|
-
`code` is the stable, machine-readable value from the spec's error culture
|
|
1103
|
-
(§11.5) — branch on it, never on `message`, which is for logs and humans
|
|
1104
|
-
only. It's typed as `ErrorCode | SdkErrorCode | (string & {})`, so a
|
|
1105
|
-
`switch` gets autocomplete on both sets while still accepting a code newer
|
|
1106
|
-
than your installed version.
|
|
1107
|
-
|
|
1108
|
-
**None of these codes ever come from the server** — `SDK_ERROR_CODES` lists
|
|
1109
|
-
them:
|
|
1110
|
-
|
|
1111
|
-
- `no_session` — a call was made, or a subscription attempted, with nobody
|
|
1112
|
-
logged in. Call `auth.login()` first.
|
|
1113
|
-
- `no_websocket` — no `WebSocket` implementation is available in this
|
|
1114
|
-
environment. Pass one via the `WebSocket` option.
|
|
1115
|
-
|
|
1116
|
-
Both fire *before* a request ever reaches the network, and are named
|
|
1117
|
-
apart from the server's own `unauthorized` so you can always tell "the
|
|
1118
|
-
server refused me" from "the SDK refused before asking."
|
|
1119
|
-
|
|
1120
|
-
- `unparseable_error` — the opposite direction: a response *did* come back,
|
|
1121
|
-
its body just wasn't shaped like §11.5's error format, so there is no
|
|
1122
|
-
server `code` to relay.
|
|
1123
|
-
- `command_timeout` — an `invoke`/`cancel`/`publish`/`services.call` got no
|
|
1124
|
-
reply within its timeout (default 10s, 30s for `services.call`; override
|
|
1125
|
-
with `{ timeoutMs }`). The server may still be working on it — this only
|
|
1126
|
-
means the SDK stopped waiting.
|
|
1127
|
-
- `command_outcome_unknown` — worse than a timeout, and told apart from it
|
|
1128
|
-
on purpose: the realtime connection was replaced by a reconnect before any
|
|
1129
|
-
reply to your command arrived, so a reply can now never come — the
|
|
1130
|
-
server, if it answered at all, answered a socket that no longer exists.
|
|
1131
|
-
**The SDK never retries automatically** (that could invoke an action
|
|
1132
|
-
twice); recover by reading the job — `actions.subscribe(robotId, slug,
|
|
1133
|
-
...)` shows you whatever is actually running, regardless of which
|
|
1134
|
-
connection asked for it.
|
|
1135
|
-
- `unexpected_response` — the server said the command succeeded but left out
|
|
1136
|
-
something it is defined to always return (e.g. no job on a successful
|
|
1137
|
-
`invoke`, or W6c: a REST route documented as always returning a body —
|
|
1138
|
-
`login`, `me`, `register`, all the others except the handful documented as
|
|
1139
|
-
answering `204`/a bodyless `202` — coming back with an empty one). A
|
|
1140
|
-
contract violation the SDK noticed, not a refusal.
|
|
1141
|
-
- `untrusted_absolute_url` (W7) — `assets.createMeshLoader`'s callback
|
|
1142
|
-
received an absolute URL (from a URDF's rewritten mesh URIs) whose origin
|
|
1143
|
-
does not match this client's own `apiUrl`, and refused to fetch it. Fires
|
|
1144
|
-
before the network call, so no `Authorization` header is ever built for
|
|
1145
|
-
it, let alone attached — a URDF is ROS graph input, not first-party data,
|
|
1146
|
-
and this SDK will not send your credential to whatever host it happens to
|
|
1147
|
-
name. Surfaced through `createMeshLoader`'s `onComplete(null, err)`, the
|
|
1148
|
-
same channel as a network failure.
|
|
1149
|
-
- `no_urdf_synced` (W7a) — `assets.prepareUrdfScene` found no `kind: 'urdf'`
|
|
1150
|
-
row in `assets.list()`. Thrown before any asset fetch — the fix is to sync
|
|
1151
|
-
a URDF first (console, Owner-tier), which this error says directly rather
|
|
1152
|
-
than surfacing later as a confusing failure from `URDFLoader.parse`.
|
|
1153
|
-
- `aborted` (W7c) — `assets.prepareUrdfScene`'s `options.signal` fired,
|
|
1154
|
-
either before the call started or mid-flight. One code regardless of when
|
|
1155
|
-
the abort landed, rather than whatever shape the runtime's own `fetch()`
|
|
1156
|
-
happens to reject an aborted request with.
|
|
1157
|
-
|
|
1158
|
-
### `rate_limited` — surfaced, never retried (W6c)
|
|
1159
|
-
|
|
1160
|
-
Unlike the codes above, `rate_limited` **does** come from the server — every
|
|
1161
|
-
unauthenticated route (`login`, `register`, `requestPasswordReset`, and the
|
|
1162
|
-
rest) sits behind a limiter that refuses before it ever checks a password,
|
|
1163
|
-
so a flood of attempts cannot be turned into CPU spent hashing them.
|
|
1164
|
-
|
|
1165
|
-
```ts
|
|
1166
|
-
import type { RateLimitDetails } from '@fleetless/sdk'
|
|
1167
|
-
|
|
1168
|
-
try {
|
|
1169
|
-
await client.auth.login(email, password)
|
|
1170
|
-
} catch (error) {
|
|
1171
|
-
if (error instanceof FleetlessError && error.code === 'rate_limited') {
|
|
1172
|
-
const { retry_after_ms } = error.details as RateLimitDetails
|
|
1173
|
-
// wait retry_after_ms, then let the USER retry — see below
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
```
|
|
1177
|
-
|
|
1178
|
-
- **It carries exactly one number, `retry_after_ms`** — the same way `busy`
|
|
1179
|
-
carries `error.details.running` (see [Actions](#actions)): when to come
|
|
1180
|
-
back, and nothing else. No window, no attempt count, no ceiling — none of
|
|
1181
|
-
that changes what an honest caller does, and all of it would help a
|
|
1182
|
-
dishonest one, so the server never sends it and there is nothing more to
|
|
1183
|
-
read off `error.details`.
|
|
1184
|
-
- **This SDK never retries a `rate_limited` response for you, anywhere, and
|
|
1185
|
-
never will.** A client library that retries a rate limit automatically is
|
|
1186
|
-
exactly the client behaviour the limit exists to stop — building that in
|
|
1187
|
-
would turn every app built on this SDK into the attack. Back off using
|
|
1188
|
-
`retry_after_ms` yourself, and only after the person at the keyboard asks
|
|
1189
|
-
for it again — not on a timer that fires unattended.
|
|
1190
|
-
|
|
1191
|
-
## Configuration
|
|
1192
|
-
|
|
1193
|
-
```ts
|
|
1194
|
-
createClient({
|
|
1195
|
-
apiUrl: string, // e.g. 'https://api.fleetless.dev'
|
|
1196
|
-
appIdentifier: string, // the app's identifier, from the console
|
|
1197
|
-
tokenStore?: TokenStore, // default: in-memory
|
|
1198
|
-
serverKey?: string, // 'flk_...' — alternative to tokenStore
|
|
1199
|
-
realtimeUrl?: string, // default: derived from apiUrl (ws(s) + /realtime)
|
|
1200
|
-
fetch?: typeof fetch, // default: the global fetch
|
|
1201
|
-
WebSocket?: typeof WebSocket, // default: the global WebSocket
|
|
1202
|
-
})
|
|
1203
|
-
```
|
|
1204
|
-
|
|
1205
|
-
`fetch`/`WebSocket` are injectable for testing and for runtimes without a
|
|
1206
|
-
global implementation of one of them — the SDK never assumes a browser.
|
|
1207
|
-
|
|
1208
|
-
## Contributing
|
|
1209
|
-
|
|
1210
|
-
```sh
|
|
1211
|
-
pnpm i
|
|
1212
|
-
pnpm test # vitest, against a fake fetch and a fake WebSocket — no cloud needed
|
|
1213
|
-
pnpm build # tsup -> dist/ (ESM + CJS + .d.ts)
|
|
1214
|
-
```
|
|
1215
|
-
|
|
1216
|
-
`@fleetless/contracts` is the source of truth for every wire type — never
|
|
1217
|
-
redefine a shape the SDK reads from or writes to the API; import it.
|
|
190
|
+
## Migrating from 1.0.0
|
|
191
|
+
|
|
192
|
+
2.0.0 is a breaking release. `CHANGELOG.md` carries the complete inventory —
|
|
193
|
+
established by diffing the published 1.0.0 tarball's types against this one's,
|
|
194
|
+
not read off the commit log. The breaks, one line each:
|
|
195
|
+
|
|
196
|
+
- `auth.register()` and `auth.confirmRegistration()` are gone with no
|
|
197
|
+
successor; a group grows by invitation (see [Account recovery](https://docs.fleetless.dev/reference/sdk/#account-recovery) in the SDK reference).
|
|
198
|
+
- `auth.requestPasswordReset()` and `auth.confirmPasswordReset()` are gone.
|
|
199
|
+
Link the user to `auth.passwordResetUrl()` instead.
|
|
200
|
+
- The types `MailStatus` and `ClientRegisterResponse` went with them.
|
|
201
|
+
- The error code `not_a_member` is gone — a compile break for an exhaustive
|
|
202
|
+
`switch` on `error.code`.
|
|
203
|
+
- `CameraDescriptor.snapshot_interval_ms` is now `snapshot_interval_seconds`.
|
|
204
|
+
**Read that twice:** it is a rename *and* a unit change, so renaming the
|
|
205
|
+
property and nothing else leaves you with a number a thousand times too
|
|
206
|
+
small.
|
|
207
|
+
- `UrdfCompleteness.missing` and `UrdfSceneResources.missing` are no longer
|
|
208
|
+
`string[]` but `{ uri, element }[]`, so a missing texture is no longer
|
|
209
|
+
reported as a missing mesh. For the old shape: `missing.map((m) => m.uri)`.
|
|
210
|
+
|
|
211
|
+
Nothing else was removed or re-typed. `auth.logout()`'s result grew a field and
|
|
212
|
+
gained a name (`LogoutResult`); `.revoked` is unchanged.
|
|
213
|
+
|
|
214
|
+
## Documentation and help
|
|
215
|
+
|
|
216
|
+
- **[SDK reference](https://docs.fleetless.dev/reference/sdk/)** — every method,
|
|
217
|
+
every option, and what each one deliberately does not do.
|
|
218
|
+
- **[Getting started](https://docs.fleetless.dev/getting-started/)** — from a
|
|
219
|
+
robot that has never connected to a value in your app.
|
|
220
|
+
- **[REST and realtime API](https://docs.fleetless.dev/reference/api/)** — the
|
|
221
|
+
wire surface underneath this package.
|
|
222
|
+
- **[Hosted login recipe](https://docs.fleetless.dev/recipes/hosted-login/)** —
|
|
223
|
+
the full walkthrough, including every error the callback can carry.
|
|
224
|
+
- **[CHANGELOG.md](CHANGELOG.md)** — what changed in each version.
|
|
225
|
+
- Questions, bug reports and feature requests: hello@fleetless.dev.
|
|
226
|
+
|
|
227
|
+
Fleetless is in closed beta. The waiting list is at
|
|
228
|
+
<https://fleetless.dev/#waiting-list>.
|
|
229
|
+
|
|
230
|
+
## Maintainers
|
|
231
|
+
|
|
232
|
+
Maintained by [Dehne Robotik GmbH](https://dehne-robotik.de). The source
|
|
233
|
+
repository is private and takes no outside contributions; questions and bug
|
|
234
|
+
reports go to hello@fleetless.dev.
|
|
235
|
+
|
|
236
|
+
MIT licensed — see [LICENSE](LICENSE).
|