@omirion/orbit-sdk 0.1.1 → 0.2.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.
@@ -0,0 +1,43 @@
1
+ # device-rpc-flow (TypeScript / Ink)
2
+
3
+ A terminal UI demo of `@omirion/orbit-sdk` built with [Ink](https://github.com/vadimdemedes/ink) (React for CLIs). It walks the full read-then-act loop against a real device:
4
+
5
+ 1. **List devices**. `orbit.devices.list()`
6
+ 2. **Select the first one**. `orbit.devices.get(id)` (the full device carries the `deviceTypeSlug` needed to decode telemetry)
7
+ 3. **Show latest telemetry**. `orbit.telemetry.latest(id)` joined to the type manifest via `decodeSample()`
8
+ 4. **Run the RPC command flow**. A sequence of `orbit.commands.exec(id, { name, args })` calls, run in order, stopping at the first failure
9
+
10
+ The command flow drives an attached Android device over adb:
11
+
12
+ ```
13
+ input-text text="0321" → types the text
14
+ keyevent code=66 → KEYCODE_ENTER
15
+ input-text text="14110108" → types the text
16
+ keyevent code=66 → KEYCODE_ENTER
17
+ launch-app package="com.example.app" → launches the app
18
+ ```
19
+
20
+ > Commands are **declared per device type**, a name plus typed args, validated against the type's command manifest before dispatch. `input-text` / `keyevent` / `launch-app` are **exec commands**. The device type's manifest declares a `runtime` (`adb`) and an `argvTemplate`; the server substitutes the validated args into a concrete argv, Ed25519-signs it, and the device runs it with no shell, nothing to quote or escape. Edit the values (and package) for your device.
21
+
22
+ ## Run
23
+
24
+ Copy this folder anywhere, then:
25
+
26
+ ```bash
27
+ pnpm install # or npm install
28
+ ORBIT_TOKEN=oat_your_token pnpm start
29
+ ```
30
+
31
+ Point it at a non-production server with `ORBIT_BASE_URL`:
32
+
33
+ ```bash
34
+ ORBIT_TOKEN=oat_… ORBIT_BASE_URL=http://localhost:1411 pnpm start
35
+ ```
36
+
37
+ The RPC steps run with a fixed pause between them, purely for watchability. It defaults to 750 ms, tune it with `ORBIT_CMD_DELAY_MS`:
38
+
39
+ ```bash
40
+ ORBIT_TOKEN=oat_… ORBIT_CMD_DELAY_MS=1200 pnpm start
41
+ ```
42
+
43
+ The token is a personal access token minted in the dashboard at **Account → API tokens**; it needs the `api:devices:read`, `api:telemetry:read`, and `api:command:exec` abilities for the steps above.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * device-rpc-flow, an Ink (React-for-CLIs) demo of the Omirion Orbit SDK.
3
+ *
4
+ * Flow:
5
+ * 1. List devices
6
+ * 2. Take the first one (full fetch, for its device-type + telemetry slug)
7
+ * 3. Show its latest telemetry, decoded against the type manifest
8
+ * 4. Drive an Android device over adb, type input, send key events, launch
9
+ * an app, via exec commands (server-resolved argv, signed, no shell)
10
+ *
11
+ * Run with: ORBIT_TOKEN=oat_… pnpm start
12
+ */
13
+ import React, { useEffect, useReducer } from "react";
14
+ import { render, Box, Text, useApp } from "ink";
15
+ import Spinner from "ink-spinner";
16
+ import { OrbitClient, decodeSample } from "@omirion/orbit-sdk";
17
+ import type { CommandResult, DecodedMetric, Device } from "@omirion/orbit-sdk";
18
+
19
+ const BASE_URL = process.env.ORBIT_BASE_URL ?? "https://orbit.omirion.com";
20
+
21
+ /** Fixed pause between RPC commands, purely for watchability. */
22
+ const COMMAND_DELAY_MS = Number(process.env.ORBIT_CMD_DELAY_MS ?? 750);
23
+
24
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
25
+
26
+ type CommandArgs = Record<string, string | number | boolean>;
27
+
28
+ /**
29
+ * The RPC flow. Commands are **declared per device type**, a name plus typed
30
+ * args, validated against the type's command manifest before dispatch. Exec
31
+ * commands (like these adb verbs) carry a `runtime` + `argvTemplate` in the
32
+ * manifest; the server substitutes the args into a concrete argv, signs it, and
33
+ * the device runs it with no shell. Edit the values for your own device.
34
+ */
35
+ const COMMANDS: Array<{ label: string; name: string; args?: CommandArgs }> = [
36
+ { label: "Type text", name: "input-text", args: { text: "14110108" } },
37
+ { label: "Press Enter", name: "keyevent", args: { code: 66 } },
38
+ {
39
+ label: "Launch app",
40
+ name: "launch-app",
41
+ args: { package: "com.omirion.clients.gtmsgc" },
42
+ },
43
+ ];
44
+
45
+ const renderCommand = (c: { name: string; args?: CommandArgs }): string =>
46
+ c.args
47
+ ? `${c.name} ${Object.entries(c.args)
48
+ .map(([k, v]) => `${k}=${JSON.stringify(v)}`)
49
+ .join(" ")}`
50
+ : c.name;
51
+
52
+ type Status = "pending" | "active" | "ok" | "fail" | "skip";
53
+
54
+ interface Step {
55
+ id: string;
56
+ label: string;
57
+ status: Status;
58
+ note?: string;
59
+ }
60
+
61
+ interface CmdState {
62
+ label: string;
63
+ name: string;
64
+ args?: CommandArgs;
65
+ status: Status;
66
+ result?: CommandResult;
67
+ error?: string;
68
+ }
69
+
70
+ interface State {
71
+ steps: Step[];
72
+ commands: CmdState[];
73
+ deviceCount?: number;
74
+ device?: Device;
75
+ metrics?: DecodedMetric[];
76
+ telemetryAt?: string;
77
+ telemetryEmpty?: boolean;
78
+ fatal?: string;
79
+ finished: boolean;
80
+ }
81
+
82
+ type Action =
83
+ | { type: "step"; id: string; status: Status; note?: string }
84
+ | {
85
+ type: "cmd";
86
+ i: number;
87
+ status: Status;
88
+ result?: CommandResult;
89
+ error?: string;
90
+ }
91
+ | { type: "set"; patch: Partial<State> };
92
+
93
+ const initialState: State = {
94
+ steps: [
95
+ { id: "list", label: "List devices", status: "pending" },
96
+ { id: "select", label: "Select first device", status: "pending" },
97
+ { id: "telemetry", label: "Read latest telemetry", status: "pending" },
98
+ { id: "commands", label: "Run RPC command flow", status: "pending" },
99
+ ],
100
+ commands: COMMANDS.map((c) => ({ ...c, status: "pending" as Status })),
101
+ finished: false,
102
+ };
103
+
104
+ function reducer(state: State, action: Action): State {
105
+ switch (action.type) {
106
+ case "step":
107
+ return {
108
+ ...state,
109
+ steps: state.steps.map((s) =>
110
+ s.id === action.id
111
+ ? { ...s, status: action.status, note: action.note ?? s.note }
112
+ : s,
113
+ ),
114
+ };
115
+ case "cmd":
116
+ return {
117
+ ...state,
118
+ commands: state.commands.map((c, idx) =>
119
+ idx === action.i
120
+ ? {
121
+ ...c,
122
+ status: action.status,
123
+ result: action.result,
124
+ error: action.error,
125
+ }
126
+ : c,
127
+ ),
128
+ };
129
+ case "set":
130
+ return { ...state, ...action.patch };
131
+ }
132
+ }
133
+
134
+ const GLYPH: Record<Status, string> = {
135
+ pending: "○",
136
+ active: "",
137
+ ok: "✔",
138
+ fail: "✖",
139
+ skip: "⏭",
140
+ };
141
+
142
+ const COLOR: Record<Status, string> = {
143
+ pending: "gray",
144
+ active: "cyan",
145
+ ok: "green",
146
+ fail: "red",
147
+ skip: "gray",
148
+ };
149
+
150
+ function StatusIcon({ status }: { status: Status }) {
151
+ if (status === "active") {
152
+ return (
153
+ <Text color="cyan">
154
+ <Spinner type="dots" />
155
+ </Text>
156
+ );
157
+ }
158
+ return <Text color={COLOR[status]}>{GLYPH[status]}</Text>;
159
+ }
160
+
161
+ function formatValue(m: DecodedMetric): string {
162
+ const v = m.value;
163
+ if (typeof v === "boolean") return v ? "yes" : "no";
164
+ const num =
165
+ typeof v === "number" ? String(Math.round(v * 1000) / 1000) : String(v);
166
+ return m.unit ? `${num} ${m.unit}` : num;
167
+ }
168
+
169
+ function Header() {
170
+ return (
171
+ <Box
172
+ borderStyle="round"
173
+ borderColor="cyan"
174
+ paddingX={1}
175
+ flexDirection="column"
176
+ marginBottom={1}
177
+ >
178
+ <Text color="cyan" bold>
179
+ ◍ Omirion Orbit · Device RPC Flow
180
+ </Text>
181
+ <Text color="gray">{BASE_URL}</Text>
182
+ </Box>
183
+ );
184
+ }
185
+
186
+ function Steps({ steps }: { steps: Step[] }) {
187
+ return (
188
+ <Box flexDirection="column">
189
+ {steps.map((s) => (
190
+ <Box key={s.id}>
191
+ <Box width={3}>
192
+ <StatusIcon status={s.status} />
193
+ </Box>
194
+ <Text color={s.status === "pending" ? "gray" : undefined}>
195
+ {s.label}
196
+ </Text>
197
+ {s.note ? <Text color="gray">. {s.note}</Text> : null}
198
+ </Box>
199
+ ))}
200
+ </Box>
201
+ );
202
+ }
203
+
204
+ function DevicePanel({ device }: { device: Device }) {
205
+ const row = (label: string, value: string) => (
206
+ <Box>
207
+ <Box width={16}>
208
+ <Text color="gray">{label}</Text>
209
+ </Box>
210
+ <Text>{value}</Text>
211
+ </Box>
212
+ );
213
+ return (
214
+ <Box
215
+ borderStyle="round"
216
+ borderColor="magenta"
217
+ paddingX={1}
218
+ flexDirection="column"
219
+ marginY={1}
220
+ >
221
+ <Text color="magenta" bold>
222
+ Selected device
223
+ </Text>
224
+ {row("Name", device.name)}
225
+ {row("ID", device.id)}
226
+ {row("Status", device.isOnline ? "● online" : "○ offline")}
227
+ {row(
228
+ "Type",
229
+ `${device.deviceTypeName ?? "–"} (${device.deviceTypeSlug ?? "–"})`,
230
+ )}
231
+ {row("Firmware", device.firmwareVersion ?? "–")}
232
+ </Box>
233
+ );
234
+ }
235
+
236
+ function TelemetryPanel({
237
+ metrics,
238
+ at,
239
+ empty,
240
+ }: {
241
+ metrics?: DecodedMetric[];
242
+ at?: string;
243
+ empty?: boolean;
244
+ }) {
245
+ return (
246
+ <Box
247
+ borderStyle="round"
248
+ borderColor="blue"
249
+ paddingX={1}
250
+ flexDirection="column"
251
+ marginBottom={1}
252
+ >
253
+ <Text color="blue" bold>
254
+ Latest telemetry {at ? <Text color="gray">· {at}</Text> : null}
255
+ </Text>
256
+ {empty ? (
257
+ <Text color="gray">No samples reported yet.</Text>
258
+ ) : (
259
+ (metrics ?? []).slice(0, 8).map((m) => (
260
+ <Box key={m.key}>
261
+ <Box width={22}>
262
+ <Text color="gray">{m.label}</Text>
263
+ </Box>
264
+ <Text bold>{formatValue(m)}</Text>
265
+ </Box>
266
+ ))
267
+ )}
268
+ </Box>
269
+ );
270
+ }
271
+
272
+ function CommandPanel({ commands }: { commands: CmdState[] }) {
273
+ return (
274
+ <Box
275
+ borderStyle="round"
276
+ borderColor="yellow"
277
+ paddingX={1}
278
+ flexDirection="column"
279
+ >
280
+ <Text color="yellow" bold>
281
+ RPC command flow
282
+ </Text>
283
+ {commands.map((c, i) => (
284
+ <Box key={i} flexDirection="column" marginTop={i === 0 ? 0 : 1}>
285
+ <Box>
286
+ <Box width={3}>
287
+ <StatusIcon status={c.status} />
288
+ </Box>
289
+ <Text color={c.status === "pending" ? "gray" : undefined}>
290
+ {i + 1}. {c.label}
291
+ </Text>
292
+ {c.result ? (
293
+ <Text color={c.result.exitCode === 0 ? "green" : "red"}>
294
+ {" "}
295
+ exit {c.result.exitCode} · {c.result.durationMs}ms
296
+ </Text>
297
+ ) : null}
298
+ </Box>
299
+ <Box paddingLeft={3}>
300
+ <Text color="gray">→ {renderCommand(c)}</Text>
301
+ </Box>
302
+ {c.result && c.result.output.trim() ? (
303
+ <Box paddingLeft={3} flexDirection="column">
304
+ {c.result.output
305
+ .trim()
306
+ .split("\n")
307
+ .slice(0, 5)
308
+ .map((line, k) => (
309
+ <Text key={k} color="greenBright">
310
+ {line}
311
+ </Text>
312
+ ))}
313
+ </Box>
314
+ ) : null}
315
+ {c.result && c.result.error.trim() ? (
316
+ <Box paddingLeft={3} flexDirection="column">
317
+ {c.result.error
318
+ .trim()
319
+ .split("\n")
320
+ .slice(0, 5)
321
+ .map((line, k) => (
322
+ <Text key={k} color="red">
323
+ {line}
324
+ </Text>
325
+ ))}
326
+ </Box>
327
+ ) : null}
328
+ {c.error ? (
329
+ <Box paddingLeft={3}>
330
+ <Text color="red">{c.error}</Text>
331
+ </Box>
332
+ ) : null}
333
+ </Box>
334
+ ))}
335
+ </Box>
336
+ );
337
+ }
338
+
339
+ function App() {
340
+ const { exit } = useApp();
341
+ const [state, dispatch] = useReducer(reducer, initialState);
342
+
343
+ useEffect(() => {
344
+ let cancelled = false;
345
+ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e));
346
+
347
+ (async () => {
348
+ const token = process.env.ORBIT_TOKEN;
349
+ if (!token) {
350
+ dispatch({
351
+ type: "set",
352
+ patch: {
353
+ fatal:
354
+ "Set ORBIT_TOKEN (an oat_… token from Account → API tokens).",
355
+ finished: true,
356
+ },
357
+ });
358
+ return;
359
+ }
360
+
361
+ const orbit = new OrbitClient({ token, baseUrl: BASE_URL });
362
+ let activeStep = "list";
363
+
364
+ try {
365
+ // 1. List devices
366
+ activeStep = "list";
367
+ dispatch({ type: "step", id: "list", status: "active" });
368
+ const page = await orbit.devices.list({ limit: 25 });
369
+ dispatch({ type: "set", patch: { deviceCount: page.data.length } });
370
+ if (page.data.length === 0)
371
+ throw new Error("No devices visible to this token.");
372
+ dispatch({
373
+ type: "step",
374
+ id: "list",
375
+ status: "ok",
376
+ note: `${page.data.length} device(s)`,
377
+ });
378
+
379
+ // 2. Take the first one (full fetch → device-type slug)
380
+ activeStep = "select";
381
+ dispatch({ type: "step", id: "select", status: "active" });
382
+ const device = await orbit.devices.get(page.data[0].id);
383
+ dispatch({ type: "set", patch: { device } });
384
+ dispatch({
385
+ type: "step",
386
+ id: "select",
387
+ status: "ok",
388
+ note: device.name,
389
+ });
390
+
391
+ // 3. Latest telemetry, decoded against the manifest
392
+ activeStep = "telemetry";
393
+ dispatch({ type: "step", id: "telemetry", status: "active" });
394
+ const sample = await orbit.telemetry.latest(device.id);
395
+ if (!sample) {
396
+ dispatch({ type: "set", patch: { telemetryEmpty: true } });
397
+ dispatch({
398
+ type: "step",
399
+ id: "telemetry",
400
+ status: "ok",
401
+ note: "no samples yet",
402
+ });
403
+ } else {
404
+ const manifest = await orbit.telemetry.manifest(device.id);
405
+ const metrics = manifest
406
+ ? decodeSample(sample, manifest)
407
+ : Object.entries(
408
+ sample.metrics as Record<string, number | boolean | string>,
409
+ ).map(
410
+ ([key, value]) => ({ key, label: key, value }) as DecodedMetric,
411
+ );
412
+ dispatch({
413
+ type: "set",
414
+ patch: { metrics, telemetryAt: sample.recordedAt },
415
+ });
416
+ dispatch({
417
+ type: "step",
418
+ id: "telemetry",
419
+ status: "ok",
420
+ note: `${metrics.length} metric(s)`,
421
+ });
422
+ }
423
+
424
+ // 4. RPC command flow (stops on the first non-zero / failed step)
425
+ activeStep = "commands";
426
+ dispatch({ type: "step", id: "commands", status: "active" });
427
+ let stopped = false;
428
+ for (let i = 0; i < COMMANDS.length; i++) {
429
+ if (stopped) {
430
+ dispatch({ type: "cmd", i, status: "skip" });
431
+ continue;
432
+ }
433
+ if (i > 0 && COMMAND_DELAY_MS > 0) await sleep(COMMAND_DELAY_MS);
434
+ dispatch({ type: "cmd", i, status: "active" });
435
+ try {
436
+ const result = await orbit.commands.exec(device.id, {
437
+ name: COMMANDS[i].name,
438
+ args: COMMANDS[i].args,
439
+ timeoutMs: 15000,
440
+ });
441
+ if (result.success) {
442
+ dispatch({ type: "cmd", i, status: "ok", result });
443
+ } else {
444
+ dispatch({ type: "cmd", i, status: "fail", result });
445
+ stopped = true;
446
+ }
447
+ } catch (e) {
448
+ dispatch({ type: "cmd", i, status: "fail", error: errMsg(e) });
449
+ stopped = true;
450
+ }
451
+ }
452
+ dispatch({
453
+ type: "step",
454
+ id: "commands",
455
+ status: stopped ? "fail" : "ok",
456
+ });
457
+ dispatch({ type: "set", patch: { finished: true } });
458
+ } catch (e) {
459
+ if (cancelled) return;
460
+ // Mark the in-flight step failed so the UI shows where it stopped.
461
+ dispatch({ type: "set", patch: { fatal: errMsg(e), finished: true } });
462
+ dispatch({ type: "step", id: activeStep, status: "fail" });
463
+ }
464
+ })();
465
+
466
+ return () => {
467
+ cancelled = true;
468
+ };
469
+ // eslint-disable-next-line react-hooks/exhaustive-deps
470
+ }, []);
471
+
472
+ useEffect(() => {
473
+ if (state.finished) {
474
+ // Let the final frame paint before tearing down the app.
475
+ const t = setTimeout(() => exit(), 50);
476
+ return () => clearTimeout(t);
477
+ }
478
+ }, [state.finished, exit]);
479
+
480
+ return (
481
+ <Box flexDirection="column">
482
+ <Header />
483
+ <Steps steps={state.steps} />
484
+ {state.device ? <DevicePanel device={state.device} /> : null}
485
+ {state.metrics || state.telemetryEmpty ? (
486
+ <TelemetryPanel
487
+ metrics={state.metrics}
488
+ at={state.telemetryAt}
489
+ empty={state.telemetryEmpty}
490
+ />
491
+ ) : null}
492
+ {state.device ? <CommandPanel commands={state.commands} /> : null}
493
+ {state.fatal ? (
494
+ <Box marginTop={1}>
495
+ <Text color="red">✖ {state.fatal}</Text>
496
+ </Box>
497
+ ) : null}
498
+ </Box>
499
+ );
500
+ }
501
+
502
+ render(<App />);
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@omirion/example-device-rpc-flow",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Ink TUI demo of the Omirion Orbit SDK: list \u2192 select \u2192 telemetry \u2192 RPC command flow.",
7
+ "scripts": {
8
+ "start": "tsx index.tsx",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@omirion/orbit-sdk": "^0.1.1",
13
+ "ink": "^5.0.1",
14
+ "ink-spinner": "^5.0.0",
15
+ "react": "^18.3.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^22.10.0",
19
+ "@types/react": "^18.3.12",
20
+ "tsx": "^4.19.2",
21
+ "typescript": "^5.7.2"
22
+ }
23
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["index.tsx"]
14
+ }
@@ -0,0 +1,41 @@
1
+ # webhook-listener (TypeScript)
2
+
3
+ The smallest useful Orbit webhook receiver. It starts an HTTP server, verifies every delivery's HMAC signature with the SDK's `parseWebhookEvent`, ACKs it, and prints **what type of event arrived** with a one-line summary:
4
+
5
+ ```
6
+ 14:31:07 device.online device gateway-lab-01
7
+ 14:31:22 telemetry.metric_changed device a3b8d1f2-… · door_open: false → true
8
+ 14:31:40 command.result device a3b8d1f2-… · lock → ok
9
+ 14:32:05 deployment.status_changed device a3b8d1f2-… · deployment 7f9c… · installing → succeeded
10
+ ```
11
+
12
+ It also demonstrates the two receiver-side rules that matter in production: verify against the **exact raw body bytes**, and dedupe on the event `id` (deliveries are at-least-once).
13
+
14
+ ## Run
15
+
16
+ Copy this folder anywhere, then:
17
+
18
+ ```bash
19
+ pnpm install # or npm install
20
+ ORBIT_WEBHOOK_SECRET=whsec_your_secret pnpm start # listens on :3900 (PORT to change)
21
+ ```
22
+
23
+ ## Point Orbit at it
24
+
25
+ Orbit only delivers to **public HTTPS** endpoints, so for local testing expose the port with a tunnel (any of `cloudflared tunnel --url http://localhost:3900`, `ngrok http 3900`, …), then create the subscription, dashboard (**Project → Webhooks**) or SDK:
26
+
27
+ ```ts
28
+ import { OrbitClient } from '@omirion/orbit-sdk'
29
+
30
+ const orbit = new OrbitClient({ token: process.env.ORBIT_TOKEN! }) // needs api:webhooks:manage
31
+ const sub = await orbit.webhooks.create({
32
+ projectId: '<PROJECT_UUID>',
33
+ url: 'https://your-tunnel-host/orbit/webhooks',
34
+ eventTypes: ['device.online', 'device.offline', 'telemetry.metric_changed', 'command.result'],
35
+ })
36
+ console.log(sub.secret) // whsec_…, shown exactly once, this is your ORBIT_WEBHOOK_SECRET
37
+ ```
38
+
39
+ Then trigger anything on a device in the project, toggle its power, run a command, deploy firmware, and watch the events land.
40
+
41
+ Delivery not arriving? Check the subscription's delivery log: `orbit.webhooks.deliveries(sub.id)` or the dashboard's Deliveries tab.