whiskops-sdk 0.1.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 +34 -0
- package/package.json +17 -0
- package/src/index.js +125 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @codemenschen/whiskops-sdk (Node.js)
|
|
2
|
+
|
|
3
|
+
Client SDK for the Whisk device External API. Wraps bearer-token auth,
|
|
4
|
+
idempotency-key generation for `turn`, retry with backoff on 429/5xx, and
|
|
5
|
+
client-side spacing between `turn` calls.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
import { WhiskClient } from "./src/index.js";
|
|
11
|
+
|
|
12
|
+
const client = new WhiskClient({ apiKey: process.env.WHISK_API_KEY });
|
|
13
|
+
|
|
14
|
+
const status = await client.status();
|
|
15
|
+
if (status.online) {
|
|
16
|
+
await client.turn({ speed: 80 });
|
|
17
|
+
await client.stop();
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Notes
|
|
22
|
+
|
|
23
|
+
- `turn()` validates `speed` is an integer 1-500 before making a request.
|
|
24
|
+
- A 409 response (active Time/Turns session) is not retried — it is thrown
|
|
25
|
+
as a `WhiskApiError` since retrying would not resolve the conflict.
|
|
26
|
+
- `baseUrl` defaults to `https://myremotedevice.com/api/v1/external` per the
|
|
27
|
+
published API docs; override it via the constructor if the real production
|
|
28
|
+
host differs.
|
|
29
|
+
|
|
30
|
+
## Tests
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npm test
|
|
34
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whiskops-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Client SDK for the Whisk device External API (status, turn, stop) with built-in idempotency-key handling and retry/backoff.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"files": ["src", "README.md"],
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=18"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["whisk", "iot", "device-control", "sdk"],
|
|
15
|
+
"author": "Codemenschen GmbH",
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const DEFAULT_BASE_URL = "https://myremotedevice.com/api/v1/external";
|
|
2
|
+
const MIN_SPEED = 1;
|
|
3
|
+
const MAX_SPEED = 500;
|
|
4
|
+
const MIN_TURN_INTERVAL_MS = 1000;
|
|
5
|
+
|
|
6
|
+
export class WhiskApiError extends Error {
|
|
7
|
+
constructor(message, { status, body } = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "WhiskApiError";
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.body = body;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function sleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Client for the Whisk device External API.
|
|
21
|
+
*
|
|
22
|
+
* Wraps authentication, idempotency-key generation for `turn`, and retry with
|
|
23
|
+
* exponential backoff on transient failures (429 / 5xx). A 409 from `turn`
|
|
24
|
+
* (an active Time/Turns session) is not retried — it is returned to the caller
|
|
25
|
+
* as an error since retrying blindly would not help.
|
|
26
|
+
*/
|
|
27
|
+
export class WhiskClient {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.apiKey - Bearer token (`twk_...`) for a single device.
|
|
31
|
+
* @param {string} [opts.baseUrl] - Override the External API base URL.
|
|
32
|
+
* @param {number} [opts.maxRetries] - Max retry attempts on 429/5xx (default 3).
|
|
33
|
+
* @param {number} [opts.minTurnIntervalMs] - Client-side spacing enforced between
|
|
34
|
+
* `turn` calls, mirroring the documented "at least 1 second apart" limit.
|
|
35
|
+
*/
|
|
36
|
+
constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, maxRetries = 3, minTurnIntervalMs = MIN_TURN_INTERVAL_MS } = {}) {
|
|
37
|
+
if (!apiKey) throw new Error("WhiskClient requires apiKey");
|
|
38
|
+
this.apiKey = apiKey;
|
|
39
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
40
|
+
this.maxRetries = maxRetries;
|
|
41
|
+
this.minTurnIntervalMs = minTurnIntervalMs;
|
|
42
|
+
this._lastTurnAt = 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async _request(path, { method = "GET", headers = {}, body } = {}) {
|
|
46
|
+
const url = `${this.baseUrl}${path}`;
|
|
47
|
+
const reqHeaders = {
|
|
48
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
49
|
+
...headers,
|
|
50
|
+
};
|
|
51
|
+
if (body !== undefined) reqHeaders["Content-Type"] = "application/json";
|
|
52
|
+
|
|
53
|
+
let attempt = 0;
|
|
54
|
+
for (;;) {
|
|
55
|
+
const res = await fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: reqHeaders,
|
|
58
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (res.status === 429 || res.status >= 500) {
|
|
62
|
+
if (attempt >= this.maxRetries) {
|
|
63
|
+
const text = await res.text().catch(() => "");
|
|
64
|
+
throw new WhiskApiError(`${method} ${path} failed after ${attempt + 1} attempts: ${res.status}`, {
|
|
65
|
+
status: res.status,
|
|
66
|
+
body: text,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
70
|
+
const backoffMs = Number.isFinite(retryAfter) && retryAfter > 0
|
|
71
|
+
? retryAfter * 1000
|
|
72
|
+
: 2 ** attempt * 500;
|
|
73
|
+
await sleep(backoffMs);
|
|
74
|
+
attempt += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!res.ok) {
|
|
79
|
+
const text = await res.text().catch(() => "");
|
|
80
|
+
throw new WhiskApiError(`${method} ${path} failed: ${res.status}`, { status: res.status, body: text });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (res.status === 204) return null;
|
|
84
|
+
return res.json().catch(() => null);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read current device status. Offline devices return `{ online: false }` successfully. */
|
|
89
|
+
status() {
|
|
90
|
+
return this._request("/device/status");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start a single turn.
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {number} opts.speed - Integer 1-500.
|
|
97
|
+
* @param {string} [opts.idempotencyKey] - Supply your own to control retries/dedup
|
|
98
|
+
* explicitly; otherwise one is generated per call.
|
|
99
|
+
*/
|
|
100
|
+
async turn({ speed, idempotencyKey } = {}) {
|
|
101
|
+
if (!Number.isInteger(speed) || speed < MIN_SPEED || speed > MAX_SPEED) {
|
|
102
|
+
throw new RangeError(`speed must be an integer between ${MIN_SPEED} and ${MAX_SPEED}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sinceLast = Date.now() - this._lastTurnAt;
|
|
106
|
+
if (sinceLast < this.minTurnIntervalMs) {
|
|
107
|
+
await sleep(this.minTurnIntervalMs - sinceLast);
|
|
108
|
+
}
|
|
109
|
+
this._lastTurnAt = Date.now();
|
|
110
|
+
|
|
111
|
+
const key = idempotencyKey || crypto.randomUUID();
|
|
112
|
+
return this._request("/device/turn", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Idempotency-Key": key },
|
|
115
|
+
body: { speed },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Stop the device. Also ends any active Time/Turns session. */
|
|
120
|
+
stop() {
|
|
121
|
+
return this._request("/device/stop", { method: "POST" });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default WhiskClient;
|