@omirion/orbit-sdk 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -19
- package/dist/index.cjs +235 -74
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +176 -103
- package/dist/index.d.ts +176 -103
- package/dist/index.js +235 -75
- package/dist/index.js.map +1 -1
- package/examples/device-rpc-flow/README.md +43 -0
- package/examples/device-rpc-flow/index.tsx +502 -0
- package/examples/device-rpc-flow/package.json +23 -0
- package/examples/device-rpc-flow/tsconfig.json +14 -0
- package/examples/webhook-listener/README.md +41 -0
- package/examples/webhook-listener/index.ts +110 -0
- package/examples/webhook-listener/package.json +19 -0
- package/examples/webhook-listener/tsconfig.json +13 -0
- package/package.json +2 -1
|
@@ -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.
|