iobroker.anthbot 0.0.2

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,885 @@
1
+ /**
2
+ * API client for Anthbot Genie cloud polling
3
+ * NodeJS port of the Python api.py module by @vincentjanv...
4
+ * https://github.com/vincentjanv/anthbot_genie_ha
5
+ * ... with a few addions/changes of course ;)
6
+ */
7
+
8
+ const { Buffer } = require('node:buffer');
9
+ const crypto = require('node:crypto');
10
+ const zlib = require('node:zlib');
11
+
12
+ const tarStream = require('tar-stream');
13
+ const { URLSearchParams } = require('url');
14
+
15
+ // Constants
16
+ const DEFAULT_API_HOST = 'api.anthbot.com';
17
+ const DEFAULT_IOT_REGION = 'us-east-1';
18
+ const DEFAULT_IOT_ENDPOINT = 'a2bhy9nr7jkgaj-ats.iot.us-east-1.amazonaws.com';
19
+ const CN_NORTHWEST_IOT_ENDPOINT = 'a2iw0czxjowiip-ats.iot.cn-northwest-1.amazonaws.com.cn';
20
+
21
+ const AWS_ACCESS_KEY_DEFAULT = 'AKIAV2C4RVIAOLEXB545';
22
+ const AWS_SECRET_KEY_DEFAULT = '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';
23
+
24
+ const AWS_ACCESS_KEY_CN = 'AKIAWJ3KIT7IV6AHMJ5V';
25
+ const AWS_SECRET_KEY_CN = '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';
26
+
27
+ const AWS_ACCESS_KEY_CN_NORTHWEST = 'AKIAYVWVSSRF7W5YWI74';
28
+ const AWS_SECRET_KEY_CN_NORTHWEST = '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';
29
+
30
+ const CLIENT_USER_AGENT = 'LdMower/1581 CFNetwork/3860.400.51 Darwin/25.3.0';
31
+
32
+ const REQUEST_TIMEOUT = 15000; // 15 seconds
33
+
34
+ // Shared methods
35
+ class AnthotCloudApi {
36
+ /**
37
+ * @param {{ verboseLogger?: ((message: string) => void) | null}} options
38
+ */
39
+ constructor({ verboseLogger = null }) {
40
+ this.verboseLogger = verboseLogger;
41
+ }
42
+
43
+ async fetch(url, options = {}) {
44
+ const controller = new AbortController();
45
+ const timeout = REQUEST_TIMEOUT;
46
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
47
+
48
+ if (this.verboseLogger) {
49
+ this.verboseLogger(`[VERBOSE] >>> ${options.method || 'GET'} ${url}`);
50
+ if (options.headers) {
51
+ this.verboseLogger(`[VERBOSE] Headers: ${JSON.stringify(options.headers, null, 2)}`);
52
+ }
53
+ if (options.body) {
54
+ this.verboseLogger(`[VERBOSE] Body: ${options.body}`);
55
+ }
56
+ }
57
+
58
+ try {
59
+ const response = await fetch(url, {
60
+ ...options,
61
+ signal: controller.signal,
62
+ });
63
+
64
+ if (this.verboseLogger) {
65
+ this.verboseLogger(`[VERBOSE] <<< ${response.status} ${response.statusText}`);
66
+ this.verboseLogger(
67
+ `[VERBOSE] Response Headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2)}`,
68
+ );
69
+
70
+ // Clone response to read body without consuming original
71
+ const responseClone = response.clone();
72
+ try {
73
+ const contentType = responseClone.headers.get('content-type') || '';
74
+ if (contentType.includes('application/json') || contentType.startsWith('text/')) {
75
+ const responseBody = await responseClone.text();
76
+ if (responseBody) {
77
+ try {
78
+ const jsonBody = JSON.parse(responseBody);
79
+ this.verboseLogger(`[VERBOSE] Response Body: ${JSON.stringify(jsonBody, null, 2)}`);
80
+ } catch {
81
+ const maxLoggingLength = 512;
82
+ if (responseBody.length > maxLoggingLength) {
83
+ this.verboseLogger(
84
+ `[VERBOSE] Truncated body: ${responseBody.substring(0, maxLoggingLength)}...`,
85
+ );
86
+ } else {
87
+ this.verboseLogger(`[VERBOSE] Response body: ${responseBody}`);
88
+ }
89
+ }
90
+ }
91
+ } else {
92
+ const contentLength = responseClone.headers.get('content-length');
93
+ if (contentLength) {
94
+ this.verboseLogger(`[VERBOSE] Binary response body of ${contentLength} bytes`);
95
+ } else {
96
+ this.verboseLogger(`[VERBOSE] Binary response body`);
97
+ }
98
+ }
99
+ } catch (err) {
100
+ this.verboseLogger(`[VERBOSE] Could not read response body: ${err.message}`);
101
+ }
102
+ }
103
+
104
+ return response;
105
+ } catch (err) {
106
+ if (err.name === 'AbortError') {
107
+ throw new Error('Request timed out');
108
+ }
109
+ throw err;
110
+ } finally {
111
+ clearTimeout(timeoutId);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Client for Anthbot cloud account endpoints
118
+ */
119
+ class AnthbotCloudApiClient {
120
+ /** @param {{ verboseLogger?: ((message: string) => void) | null }} options */
121
+ constructor({ verboseLogger = null }) {
122
+ this.endpointHost = DEFAULT_API_HOST;
123
+ this.authHeaders = {
124
+ Accept: 'application/json, text/plain, */*',
125
+ version: 'v2',
126
+ language: 'en',
127
+ 'User-Agent': CLIENT_USER_AGENT,
128
+ };
129
+ this.bearerToken = null;
130
+ this.verboseLogger = verboseLogger;
131
+ this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
132
+ this.shadowClient = null;
133
+ }
134
+
135
+ /**
136
+ * Login and return bearer token
137
+ */
138
+ async asyncLogin({ username, password, areaCode }) {
139
+ const url = `https://${this.endpointHost}/api/v1/login`;
140
+ const headers = {
141
+ Accept: 'application/json, text/plain, */*',
142
+ 'content-type': 'application/json',
143
+ version: 'v2',
144
+ language: 'en',
145
+ 'User-Agent': CLIENT_USER_AGENT,
146
+ };
147
+ const body = { username, password, areaCode };
148
+
149
+ const response = await this.fetch(url, {
150
+ method: 'POST',
151
+ headers,
152
+ body: JSON.stringify(body),
153
+ });
154
+
155
+ if (response.status !== 200) {
156
+ throw new Error(`Login failed (${response.status})`);
157
+ }
158
+
159
+ let data;
160
+ try {
161
+ data = /** @type {{ code: number; data: { access_token: string } }} */ (await response.json());
162
+ } catch {
163
+ throw new Error('Invalid JSON response from login');
164
+ }
165
+
166
+ if (typeof data !== 'object' || data === null) {
167
+ throw new Error('Invalid login payload type');
168
+ }
169
+ if (data.code !== 0) {
170
+ throw new Error(`Login rejected: code=${JSON.stringify(data.code)}`);
171
+ }
172
+
173
+ const tokenData = data.data;
174
+ if (typeof tokenData !== 'object' || tokenData === null) {
175
+ throw new Error('Login payload missing data object');
176
+ }
177
+
178
+ const accessToken = tokenData.access_token;
179
+ if (typeof accessToken !== 'string' || !accessToken) {
180
+ throw new Error('Login payload missing access_token');
181
+ }
182
+
183
+ const bearerToken = `Bearer ${accessToken}`;
184
+ this.bearerToken = bearerToken;
185
+ this.authHeaders['Authorization'] = bearerToken;
186
+ return bearerToken;
187
+ }
188
+
189
+ checkToken() {
190
+ if (!this.bearerToken) {
191
+ throw new Error('Bearer token not configured');
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Fetch account-bound Anthbot devices
197
+ */
198
+ async asyncGetBoundDevices() {
199
+ this.checkToken();
200
+
201
+ const url = `https://${this.endpointHost}/api/v1/device/bind/list`;
202
+ const response = await this.fetch(url, {
203
+ method: 'GET',
204
+ headers: this.authHeaders,
205
+ });
206
+
207
+ if (response.status !== 200) {
208
+ throw new Error(`Request to ${url} failed, response ${response.status}`);
209
+ }
210
+
211
+ return /** @type {{ data: {alias: string, sn: string}[] }} */ (await response.json()).data;
212
+ }
213
+
214
+ /**
215
+ * Fetch latest messages
216
+ * Returns only last message by default
217
+ */
218
+ async asyncGetCodeList(serialNumber, pageNum = 1, pageSize = 1) {
219
+ this.checkToken();
220
+
221
+ // TODO: allow language other than English?
222
+ const url = `https://${this.endpointHost}/api/v1/device/v2/code/list?sn=${serialNumber}&pagenum=${pageNum}&pagesize=${pageSize}&language=English`;
223
+
224
+ const response = await this.fetch(url, {
225
+ method: 'GET',
226
+ headers: this.authHeaders,
227
+ });
228
+
229
+ if (response.status !== 200) {
230
+ throw new Error(`Request to ${url} failed, response ${response.status}`);
231
+ }
232
+
233
+ return /** @type {{ data: { data: unknown } }} */ (await response.json())?.data?.data;
234
+ }
235
+
236
+ buildVerificationToken(serialNumber) {
237
+ const unixTimestamp = Math.floor(Date.now() / 1000);
238
+ const tokenSuffix = unixTimestamp.toString();
239
+ const tokenPrefix = crypto.createHash('md5').update(`${serialNumber}${tokenSuffix}`, 'utf8').digest('hex');
240
+ return `${tokenPrefix}${tokenSuffix}`;
241
+ }
242
+
243
+ /**
244
+ * Fetch account-bound Anthbot devices
245
+ *
246
+ * @param {string} sn
247
+ * @param {string} filename
248
+ * @param {string} category
249
+ * @param {string} sub_category
250
+ * @returns {Promise<string>} URL for the requested file
251
+ */
252
+ async asyncGetPresignedUrl(sn, filename, category, sub_category) {
253
+ this.checkToken();
254
+
255
+ const params = new URLSearchParams({
256
+ filename,
257
+ sn,
258
+ category,
259
+ sub_category,
260
+ verification_token: this.buildVerificationToken(sn),
261
+ });
262
+ const url = `https://${this.endpointHost}/api/v1/device/v2/presigned_url?${params}`;
263
+ const response = await this.fetch(url, {
264
+ method: 'GET',
265
+ headers: this.authHeaders,
266
+ });
267
+
268
+ if (response.status !== 200) {
269
+ throw new Error(`Request to ${url} failed, response ${response.status}`);
270
+ }
271
+
272
+ return /** @type {{ data: {presigned_url: string } }} */ (await response.json()).data?.presigned_url;
273
+ }
274
+
275
+ /**
276
+ * Decodes a TAR.GZ archive from a Buffer and returns an array of file entries with filename, type and content.
277
+ *
278
+ * @param {Buffer} buffer
279
+ * @returns {Promise<Array<{ filename: string; type: 'json' | 'blob'; content: unknown }>>}
280
+ */
281
+
282
+ decodeTgzBuffer(buffer) {
283
+ return new Promise((resolve, reject) => {
284
+ const results = [];
285
+ const extract = tarStream.extract();
286
+
287
+ extract.on('entry', (header, stream, next) => {
288
+ if (this.verboseLogger) {
289
+ this.verboseLogger(`Processing entry: ${JSON.stringify(header)}`);
290
+ }
291
+
292
+ if (header.type !== 'file') {
293
+ stream.resume();
294
+ return next();
295
+ }
296
+
297
+ const chunks = [];
298
+ stream.on('data', chunk => chunks.push(chunk));
299
+ stream.on('end', () => {
300
+ const fileBuffer = Buffer.concat(chunks);
301
+ const filename = header.name || '';
302
+ let entryType = 'blob';
303
+ let content = fileBuffer;
304
+
305
+ if (filename.toLowerCase().endsWith('.bin')) {
306
+ // Do nothing as entryType and content already set
307
+ } else if (filename.toLowerCase().endsWith('.json')) {
308
+ try {
309
+ content = JSON.parse(fileBuffer.toString());
310
+ entryType = 'json';
311
+ } catch (err) {
312
+ if (this.verboseLogger) {
313
+ this.verboseLogger(
314
+ `Failed to parse JSON for ${filename}: ${err.message}; leaving as binary`,
315
+ );
316
+ }
317
+ }
318
+ } else {
319
+ if (this.verboseLogger) {
320
+ this.verboseLogger(`Unknown archive file type for ${filename}; leaving as binary`);
321
+ }
322
+ }
323
+
324
+ results[filename] = { type: entryType, content };
325
+ next();
326
+ });
327
+ stream.on('error', reject);
328
+ });
329
+
330
+ extract.on('finish', () => resolve(results));
331
+ extract.on('error', reject);
332
+
333
+ const gunzip = zlib.createGunzip();
334
+ gunzip.on('error', reject);
335
+ gunzip.pipe(extract);
336
+ gunzip.end(buffer);
337
+ });
338
+ }
339
+
340
+ async asyncGetDeviceMap(serialNumber) {
341
+ const url = await this.asyncGetPresignedUrl(
342
+ serialNumber,
343
+ `map_manager_${serialNumber}.tar.gz`,
344
+ 'device',
345
+ 'map',
346
+ );
347
+ const response = await this.fetch(url, {
348
+ method: 'GET',
349
+ headers: {
350
+ Accept: 'application/gzip, application/octet-stream, */*',
351
+ },
352
+ });
353
+
354
+ if (response.status !== 200) {
355
+ throw new Error(`Device map download failed (${response.status})`);
356
+ }
357
+
358
+ return await this.decodeTgzBuffer(Buffer.from(await response.arrayBuffer()));
359
+ }
360
+
361
+ /**
362
+ * Fetch device cloud region metadata
363
+ */
364
+ async asyncGetDeviceRegion(serialNumber) {
365
+ if (this.verboseLogger) {
366
+ this.verboseLogger(`Cache miss - fetching device region for ${serialNumber}`);
367
+ }
368
+
369
+ this.checkToken();
370
+
371
+ const url = `https://${this.endpointHost}/api/v1/device/v2/region`;
372
+ const params = new URLSearchParams({ sn: serialNumber });
373
+ const response = await this.fetch(`${url}?${params}`, {
374
+ method: 'GET',
375
+ headers: this.authHeaders,
376
+ });
377
+
378
+ if (response.status !== 200) {
379
+ const body = await response.text();
380
+ throw new Error(`Device region failed (${response.status}): ${body.slice(0, 300)}`);
381
+ }
382
+
383
+ let payload;
384
+ try {
385
+ payload = /** @type {{ code: number; data: {region_name: string; iot_endpoint: string } }} */ (
386
+ await response.json()
387
+ );
388
+ } catch {
389
+ throw new Error('Invalid JSON response from device region');
390
+ }
391
+
392
+ if (typeof payload !== 'object' || payload === null) {
393
+ throw new Error('Invalid device region payload type');
394
+ }
395
+ if (payload.code !== 0) {
396
+ throw new Error(`Device region returned code=${payload.code}`);
397
+ }
398
+
399
+ const data = payload.data;
400
+ if (typeof data !== 'object' || data === null) {
401
+ throw new Error('Device region payload missing data object');
402
+ }
403
+
404
+ const regionName = data.region_name;
405
+ const iotEndpoint = data.iot_endpoint;
406
+ if (typeof regionName !== 'string' || !regionName) {
407
+ throw new Error('Device region missing region_name');
408
+ }
409
+ if (typeof iotEndpoint !== 'string' || !iotEndpoint) {
410
+ throw new Error('Device region missing iot_endpoint');
411
+ }
412
+
413
+ const deviceRegion = { regionName, iotEndpoint };
414
+ if (this.verboseLogger) {
415
+ this.verboseLogger(`deviceRegion for ${serialNumber}: ${JSON.stringify(deviceRegion)}`);
416
+ }
417
+ return deviceRegion;
418
+ }
419
+
420
+ async checkShadowClient(serialNumber) {
421
+ // TODO: Handle multiple devices with different shadow clients instead of caching just one
422
+ if (!this.shadowClient) {
423
+ if (this.verboseLogger) {
424
+ this.verboseLogger(`No shadow client for ${serialNumber}, creating one`);
425
+ }
426
+ const deviceRegion = await this.asyncGetDeviceRegion(serialNumber);
427
+ this.shadowClient = new AnthbotShadowApiClient({
428
+ serialNumber,
429
+ regionName: deviceRegion.regionName,
430
+ iotEndpoint: deviceRegion.iotEndpoint,
431
+ verboseLogger: this.verboseLogger,
432
+ });
433
+ }
434
+ }
435
+
436
+ async asyncGetShadowReportedState(serialNumber) {
437
+ await this.checkShadowClient(serialNumber);
438
+ return await this.shadowClient?.asyncGetShadowReportedState();
439
+ }
440
+
441
+ async asyncSendServiceCommand(serialNumber, cmd, data) {
442
+ await this.checkShadowClient(serialNumber);
443
+ return await this.shadowClient?.asyncPublishServiceCommand({ cmd, data });
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Client for Anthbot AWS IoT shadow endpoint
449
+ */
450
+ class AnthbotShadowApiClient {
451
+ /**
452
+ * @param {{serialNumber: string, regionName?: string | null, iotEndpoint?: string | null, verboseLogger?: ((message: string) => void) | null}} options
453
+ */
454
+ constructor({ serialNumber, regionName = null, iotEndpoint = null, verboseLogger = null }) {
455
+ this._serialNumber = serialNumber;
456
+ this._regionName = typeof regionName === 'string' && regionName ? regionName : null;
457
+ this._iotEndpoint = AnthbotShadowApiClient._normalizeEndpoint(iotEndpoint);
458
+ this.verboseLogger = verboseLogger;
459
+ this.fetch = new AnthotCloudApi({ verboseLogger }).fetch;
460
+
461
+ const endpointRegion = AnthbotShadowApiClient._guessRegionFromEndpoint(this._iotEndpoint);
462
+ if (this._regionName && endpointRegion && this._regionName !== endpointRegion) {
463
+ if (this.verboseLogger) {
464
+ this.verboseLogger(
465
+ `Anthbot region mismatch for ${serialNumber}: api region=${this._regionName} endpoint region=${endpointRegion} endpoint=${this._iotEndpoint}; endpoint region will be used for signing`,
466
+ );
467
+ }
468
+ }
469
+ }
470
+
471
+ static _normalizeEndpoint(iotEndpoint) {
472
+ if (typeof iotEndpoint !== 'string' || !iotEndpoint) {
473
+ return DEFAULT_IOT_ENDPOINT;
474
+ }
475
+ let endpoint = iotEndpoint.trim();
476
+ endpoint = endpoint.replace(/^https?:\/\//i, '');
477
+ endpoint = endpoint.replace(/\/$/, '');
478
+ return endpoint || DEFAULT_IOT_ENDPOINT;
479
+ }
480
+
481
+ static _guessRegionFromEndpoint(iotEndpoint) {
482
+ if (!iotEndpoint.includes('.iot.')) {
483
+ return null;
484
+ }
485
+ const rightSide = iotEndpoint.split('.iot.', 2)[1];
486
+ const region = rightSide.split('.', 1)[0];
487
+ return region || null;
488
+ }
489
+
490
+ static guessRegionFromEndpoint(iotEndpoint) {
491
+ return AnthbotShadowApiClient._guessRegionFromEndpoint(iotEndpoint);
492
+ }
493
+
494
+ get serialNumber() {
495
+ return this._serialNumber;
496
+ }
497
+
498
+ get iotEndpoint() {
499
+ return this._iotEndpoint;
500
+ }
501
+
502
+ get signingRegion() {
503
+ const endpointRegion = AnthbotShadowApiClient._guessRegionFromEndpoint(this._iotEndpoint);
504
+ if (endpointRegion) {
505
+ return endpointRegion;
506
+ }
507
+ return this._regionName || DEFAULT_IOT_REGION;
508
+ }
509
+
510
+ _accessKeyId() {
511
+ if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
512
+ return AWS_ACCESS_KEY_CN_NORTHWEST;
513
+ }
514
+ if (this.signingRegion.startsWith('cn')) {
515
+ return AWS_ACCESS_KEY_CN;
516
+ }
517
+ return AWS_ACCESS_KEY_DEFAULT;
518
+ }
519
+
520
+ _secretAccessKey() {
521
+ if (this._iotEndpoint === CN_NORTHWEST_IOT_ENDPOINT) {
522
+ return AWS_SECRET_KEY_CN_NORTHWEST;
523
+ }
524
+ if (this.signingRegion.startsWith('cn')) {
525
+ return AWS_SECRET_KEY_CN;
526
+ }
527
+ return AWS_SECRET_KEY_DEFAULT;
528
+ }
529
+
530
+ static _sign(key, msg) {
531
+ if (typeof key === 'string') {
532
+ key = Buffer.from(key, 'utf-8');
533
+ }
534
+ return crypto.createHmac('sha256', key).update(msg).digest();
535
+ }
536
+
537
+ _signingKey(dateStamp) {
538
+ const service = 'iotdata';
539
+ const kDate = AnthbotShadowApiClient._sign(`AWS4${this._secretAccessKey()}`, dateStamp);
540
+ const kRegion = AnthbotShadowApiClient._sign(kDate, this.signingRegion);
541
+ const kService = AnthbotShadowApiClient._sign(kRegion, service);
542
+ return AnthbotShadowApiClient._sign(kService, 'aws4_request');
543
+ }
544
+
545
+ _buildAuthorization(amzDate, dateStamp, canonicalRequest) {
546
+ const algorithm = 'AWS4-HMAC-SHA256';
547
+ const signedHeaders = AnthbotShadowApiClient._signedHeadersFromRequest(canonicalRequest);
548
+ const credentialScope = `${dateStamp}/${this.signingRegion}/iotdata/aws4_request`;
549
+ const stringToSign =
550
+ `${algorithm}\n` +
551
+ `${amzDate}\n` +
552
+ `${credentialScope}\n` +
553
+ crypto.createHash('sha256').update(canonicalRequest).digest('hex');
554
+
555
+ const signature = crypto.createHmac('sha256', this._signingKey(dateStamp)).update(stringToSign).digest('hex');
556
+
557
+ return (
558
+ `${algorithm} Credential=${this._accessKeyId()}/${credentialScope}, ` +
559
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`
560
+ );
561
+ }
562
+
563
+ static _normalizeHeaderValue(value) {
564
+ return value.trim().split(/\s+/).join(' ');
565
+ }
566
+
567
+ static _canonicalHeaders(headers) {
568
+ const lowered = {};
569
+ for (const [key, value] of Object.entries(headers)) {
570
+ lowered[key.toLowerCase()] = AnthbotShadowApiClient._normalizeHeaderValue(value);
571
+ }
572
+
573
+ const orderedKeys = Object.keys(lowered).sort();
574
+ let canonical = '';
575
+ for (const key of orderedKeys) {
576
+ canonical += `${key}:${lowered[key]}\n`;
577
+ }
578
+
579
+ const signedHeaders = orderedKeys.join(';');
580
+ return [canonical, signedHeaders];
581
+ }
582
+
583
+ static _signedHeadersFromRequest(canonicalRequest) {
584
+ const parts = canonicalRequest.split('\n');
585
+ if (parts.length < 6) {
586
+ return 'host;x-amz-content-sha256;x-amz-date';
587
+ }
588
+ return parts[parts.length - 2];
589
+ }
590
+
591
+ static _canonicalUriForSigv4(requestUri) {
592
+ /**
593
+ * Build SigV4 canonical URI.
594
+ * AWS canonicalization requires encoding '%' as '%25', so an already
595
+ * encoded request path (for example '/topics/%24aws%2F...') must be
596
+ * double-encoded only for signing.
597
+ */
598
+ const encoded = [];
599
+ const buffer = Buffer.from(requestUri, 'utf-8');
600
+
601
+ for (const byte of buffer) {
602
+ // 0-9: 0x30-0x39, A-Z: 0x41-0x5A, a-z: 0x61-0x7A, - . _ ~ /
603
+ if (
604
+ (byte >= 0x30 && byte <= 0x39) ||
605
+ (byte >= 0x41 && byte <= 0x5a) ||
606
+ (byte >= 0x61 && byte <= 0x7a) ||
607
+ [45, 46, 95, 126, 47].includes(byte) // - . _ ~ /
608
+ ) {
609
+ encoded.push(String.fromCharCode(byte));
610
+ } else {
611
+ encoded.push(`%${byte.toString(16).toUpperCase().padStart(2, '0')}`);
612
+ }
613
+ }
614
+
615
+ return encoded.join('');
616
+ }
617
+
618
+ async asyncGetShadowReportedState() {
619
+ const requestUri = `/things/${this._encodePathComponent(this._serialNumber)}/shadow`;
620
+ const canonicalUri = AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
621
+ const canonicalQuery = `name=${this._encodePathComponent('property')}`;
622
+ const payloadHash = crypto.createHash('sha256').update('').digest('hex');
623
+
624
+ const now = new Date();
625
+ const amzDate = now
626
+ .toISOString()
627
+ .replace(/[:-]/g, '')
628
+ .replace(/\.\d{3}/, '');
629
+ const dateStamp = amzDate.substring(0, 8);
630
+
631
+ const signedHeaderValues = {
632
+ host: this._iotEndpoint,
633
+ 'x-amz-content-sha256': payloadHash,
634
+ 'x-amz-date': amzDate,
635
+ };
636
+
637
+ const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
638
+
639
+ const canonicalRequest =
640
+ `GET\n` +
641
+ `${canonicalUri}\n` +
642
+ `${canonicalQuery}\n` +
643
+ `${canonicalHeaders}\n` +
644
+ `${signedHeaders}\n` +
645
+ `${payloadHash}`;
646
+
647
+ const authorization = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
648
+
649
+ const url = `https://${this._iotEndpoint}${requestUri}?${canonicalQuery}`;
650
+ const headers = {
651
+ Accept: '*/*',
652
+ Host: this._iotEndpoint,
653
+ 'x-amz-date': amzDate,
654
+ 'x-amz-content-sha256': payloadHash,
655
+ Authorization: authorization,
656
+ 'User-Agent': CLIENT_USER_AGENT,
657
+ };
658
+
659
+ const response = await this.fetch(url, {
660
+ method: 'GET',
661
+ headers,
662
+ });
663
+
664
+ if (response.status !== 200) {
665
+ const body = await response.text();
666
+ throw new Error(`Shadow request failed (${response.status}): ${body.slice(0, 300)}`);
667
+ }
668
+
669
+ let payload;
670
+ try {
671
+ payload = /** @type {{ state: { reported: unknown } }} */ (await response.json());
672
+ } catch {
673
+ throw new Error('Invalid JSON response from shadow request');
674
+ }
675
+
676
+ if (typeof payload !== 'object' || payload === null) {
677
+ throw new Error('Invalid response payload type');
678
+ }
679
+
680
+ const state = payload.state;
681
+ const reported = typeof state === 'object' && state !== null ? state.reported : null;
682
+ if (typeof reported !== 'object' || reported === null) {
683
+ throw new Error('Missing state.reported in response');
684
+ }
685
+
686
+ return reported;
687
+ }
688
+
689
+ /**
690
+ * Encode path component for AWS SigV4
691
+ */
692
+ _encodePathComponent(component) {
693
+ return encodeURIComponent(component).replace(/[!'()*]/g, ch => {
694
+ return `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`;
695
+ });
696
+ }
697
+
698
+ async asyncPublishServiceCommand({ cmd, data }) {
699
+ const body = { state: { desired: { cmd, data } } };
700
+ const payloadBytes = Buffer.from(JSON.stringify(body, null, 0).replace(/\s/g, ''), 'utf-8');
701
+
702
+ const topic = `$aws/things/${this._serialNumber}/shadow/name/service/update`;
703
+ const requestUriEncoded = `/topics/${this._encodePathComponent(topic)}`;
704
+ const requestUriRaw = `/topics/${topic}`;
705
+
706
+ // Different AWS clients canonicalize the URI slightly differently.
707
+ // Try the app-observed mode first, then fall back to alternatives.
708
+ /** @type {[string, boolean, string | null, boolean][]} */
709
+ const attempts = [
710
+ // 1) SDK headers + encoded URI + app-style canonical URI (trace match)
711
+ [requestUriEncoded, true, null, true],
712
+ // 2) SDK headers + encoded URI + raw canonical URI
713
+ [requestUriEncoded, true, requestUriEncoded, true],
714
+ // 3) SDK headers + encoded URI + app-style canonical URI, no signed content-length
715
+ [requestUriEncoded, true, null, false],
716
+ // 4) LdMower headers + encoded URI + app-style canonical URI
717
+ [requestUriEncoded, false, null, true],
718
+ // 5) Raw topic path, SDK headers, app-style canonical URI
719
+ [requestUriRaw, true, null, true],
720
+ // 6) Raw topic path, SDK headers, raw canonical URI
721
+ [requestUriRaw, true, requestUriRaw, true],
722
+ // 7) Raw topic path, LdMower headers, app-style canonical URI
723
+ [requestUriRaw, false, null, true],
724
+ ];
725
+
726
+ let lastStatus = 0;
727
+ let lastBody = '';
728
+ let lastHeaders = {};
729
+
730
+ for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
731
+ const [requestUri, includeSdkHeaders, canonicalUriOverride, signContentLength] = attempts[attemptIndex];
732
+
733
+ const [status, bodyText, payload, responseHeaders] = await this._asyncSignedPost({
734
+ requestUri,
735
+ canonicalQuery: '',
736
+ payloadBytes,
737
+ includeSdkHeaders,
738
+ canonicalUriOverride,
739
+ signContentLength,
740
+ });
741
+
742
+ if (status === 200 && typeof payload === 'object' && payload !== null) {
743
+ if (attemptIndex > 0 && this.verboseLogger) {
744
+ this.verboseLogger(`Anthbot command publish recovered after fallback`);
745
+ }
746
+ // Response should contain { message: "OK" }
747
+ if (payload.message !== 'OK') {
748
+ if (this.verboseLogger) {
749
+ this.verboseLogger(`payload.message !== 'OK' even with status === 200`);
750
+ }
751
+ } else {
752
+ // Everything is good
753
+ return;
754
+ }
755
+ }
756
+
757
+ lastStatus = status;
758
+ lastBody = bodyText;
759
+ lastHeaders = responseHeaders;
760
+
761
+ if (status !== 403) {
762
+ break;
763
+ }
764
+
765
+ if (this.verboseLogger) {
766
+ this.verboseLogger(`Anthbot command publish attempt failed (${status}`);
767
+ }
768
+ }
769
+
770
+ throw new Error(
771
+ `Command '${cmd}' failed (${lastStatus}) at endpoint '${this._iotEndpoint}' ` +
772
+ `(region '${this.signingRegion}', errortype '${lastHeaders['x-amzn-errortype']}', ` +
773
+ `requestid '${lastHeaders['x-amzn-requestid'] || lastHeaders['x-amzn-request-id']}'): ` +
774
+ `${lastBody.slice(0, 300)}`,
775
+ );
776
+ }
777
+
778
+ /**
779
+ * @param {{requestUri: string, canonicalQuery: string, payloadBytes: Buffer, includeSdkHeaders: boolean, canonicalUriOverride?: string | null, signContentLength?: boolean}} options
780
+ */
781
+ async _asyncSignedPost({
782
+ requestUri,
783
+ canonicalQuery,
784
+ payloadBytes,
785
+ includeSdkHeaders,
786
+ canonicalUriOverride = null,
787
+ signContentLength = true,
788
+ }) {
789
+ const payloadHash = crypto.createHash('sha256').update(payloadBytes).digest('hex');
790
+
791
+ const now = new Date();
792
+ const amzDate = now
793
+ .toISOString()
794
+ .replace(/[:-]/g, '')
795
+ .replace(/\.\d{3}/, '');
796
+ const dateStamp = amzDate.substring(0, 8);
797
+
798
+ const signedHeaderValues = {
799
+ host: this._iotEndpoint,
800
+ 'content-type': 'application/octet-stream',
801
+ 'x-amz-content-sha256': payloadHash,
802
+ 'x-amz-date': amzDate,
803
+ };
804
+
805
+ const headers = {
806
+ Accept: '*/*',
807
+ Host: this._iotEndpoint,
808
+ 'Content-Type': 'application/octet-stream',
809
+ 'x-amz-content-sha256': payloadHash,
810
+ 'x-amz-date': amzDate,
811
+ };
812
+
813
+ if (signContentLength) {
814
+ signedHeaderValues['content-length'] = String(payloadBytes.length);
815
+ headers['Content-Length'] = String(payloadBytes.length);
816
+ }
817
+
818
+ if (includeSdkHeaders) {
819
+ const invocationId = crypto.randomUUID();
820
+ signedHeaderValues['amz-sdk-invocation-id'] = invocationId;
821
+ signedHeaderValues['amz-sdk-request'] = 'attempt=1; max=3';
822
+ signedHeaderValues['x-amz-user-agent'] = 'aws-sdk-js/3.846.0';
823
+ headers['amz-sdk-invocation-id'] = invocationId;
824
+ headers['amz-sdk-request'] = 'attempt=1; max=3';
825
+ headers['x-amz-user-agent'] = 'aws-sdk-js/3.846.0';
826
+ headers['User-Agent'] =
827
+ 'aws-sdk-js/3.846.0 ua/2.1 os/other lang/js md/rn api/iot-data-plane#3.846.0 m/N,E,e';
828
+ } else {
829
+ headers['User-Agent'] = CLIENT_USER_AGENT;
830
+ }
831
+
832
+ const [canonicalHeaders, signedHeaders] = AnthbotShadowApiClient._canonicalHeaders(signedHeaderValues);
833
+
834
+ const canonicalUri =
835
+ canonicalUriOverride !== null
836
+ ? canonicalUriOverride
837
+ : AnthbotShadowApiClient._canonicalUriForSigv4(requestUri);
838
+
839
+ const canonicalRequest =
840
+ `POST\n` +
841
+ `${canonicalUri}\n` +
842
+ `${canonicalQuery}\n` +
843
+ `${canonicalHeaders}\n` +
844
+ `${signedHeaders}\n` +
845
+ `${payloadHash}`;
846
+
847
+ headers['Authorization'] = this._buildAuthorization(amzDate, dateStamp, canonicalRequest);
848
+
849
+ let url = `https://${this._iotEndpoint}${requestUri}`;
850
+ if (canonicalQuery) {
851
+ url = `${url}?${canonicalQuery}`;
852
+ }
853
+
854
+ const response = await this.fetch(url, {
855
+ method: 'POST',
856
+ headers,
857
+ body: payloadBytes,
858
+ });
859
+
860
+ const bodyText = await response.text();
861
+ let payload = null;
862
+ try {
863
+ const parsed = JSON.parse(bodyText);
864
+ if (typeof parsed === 'object' && parsed !== null) {
865
+ payload = parsed;
866
+ }
867
+ } catch {
868
+ // Invalid JSON, leave payload as null
869
+ }
870
+
871
+ const responseHeaders = {
872
+ 'x-amzn-errortype': response.headers.get('x-amzn-errortype') || '',
873
+ 'x-amzn-requestid': response.headers.get('x-amzn-requestid') || '',
874
+ 'x-amzn-request-id': response.headers.get('x-amzn-request-id') || '',
875
+ date: response.headers.get('date') || '',
876
+ };
877
+
878
+ return [response.status, bodyText, payload, responseHeaders];
879
+ }
880
+ }
881
+
882
+ // Exports
883
+ module.exports = {
884
+ AnthbotCloudApiClient,
885
+ };