yoto-nodejs-client 0.0.7 → 0.0.9
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 +1 -0
- package/lib/pkg.d.cts +3 -1
- package/lib/yoto-device.d.ts +91 -15
- package/lib/yoto-device.d.ts.map +1 -1
- package/lib/yoto-device.js +241 -12
- package/lib/yoto-device.test.js +218 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -958,6 +958,7 @@ Create a stateful device client that manages device state primarily from MQTT wi
|
|
|
958
958
|
- `await deviceClient.refreshConfig()` - Refresh config from HTTP API
|
|
959
959
|
- `await deviceClient.updateConfig(configUpdate)` - Update device configuration
|
|
960
960
|
- `await deviceClient.sendCommand(command)` - Send device command via HTTP
|
|
961
|
+
- `await deviceClient.startCard({ cardId, [chapterKey], [trackKey] })` - Start playing a card
|
|
961
962
|
|
|
962
963
|
**Events:**
|
|
963
964
|
- `started(metadata)` - Device client started, passes metadata object with device, config, shortcuts, status, playback, initialized, running
|
package/lib/pkg.d.cts
CHANGED
|
@@ -7,13 +7,15 @@ export const pkg: {
|
|
|
7
7
|
url: string;
|
|
8
8
|
};
|
|
9
9
|
dependencies: {
|
|
10
|
+
fastq: string;
|
|
10
11
|
"jwt-decode": string;
|
|
11
12
|
mqtt: string;
|
|
13
|
+
"quick-lru": string;
|
|
12
14
|
undici: string;
|
|
13
15
|
};
|
|
14
16
|
devDependencies: {
|
|
15
|
-
"@unblessed/node": string;
|
|
16
17
|
"@types/node": string;
|
|
18
|
+
"@unblessed/node": string;
|
|
17
19
|
"@voxpelli/tsconfig": string;
|
|
18
20
|
argsclopts: string;
|
|
19
21
|
"auto-changelog": string;
|
package/lib/yoto-device.d.ts
CHANGED
|
@@ -116,6 +116,34 @@ export const YotoDeviceModelConfigType: {};
|
|
|
116
116
|
* @property {string} updatedAt - ISO 8601 timestamp of last update
|
|
117
117
|
*/
|
|
118
118
|
export const YotoPlaybackStateType: {};
|
|
119
|
+
/**
|
|
120
|
+
* Cached chapter info for playback enrichment.
|
|
121
|
+
* @typedef {Object} YotoCardCacheChapterInfo
|
|
122
|
+
* @property {string} key
|
|
123
|
+
* @property {string} title
|
|
124
|
+
*/
|
|
125
|
+
/**
|
|
126
|
+
* Cached track info for playback enrichment.
|
|
127
|
+
* @typedef {Object} YotoCardCacheTrackInfo
|
|
128
|
+
* @property {string} key
|
|
129
|
+
* @property {string} title
|
|
130
|
+
* @property {number} duration
|
|
131
|
+
* @property {string} chapterKey
|
|
132
|
+
* @property {string} chapterTitle
|
|
133
|
+
*/
|
|
134
|
+
/**
|
|
135
|
+
* Cached card metadata for playback enrichment.
|
|
136
|
+
* @typedef {Object} YotoCardCacheEntry
|
|
137
|
+
* @property {string} cardId
|
|
138
|
+
* @property {string} title
|
|
139
|
+
* @property {Map<string, YotoCardCacheChapterInfo>} chaptersByKey
|
|
140
|
+
* @property {Map<string, YotoCardCacheTrackInfo>} tracksByKey
|
|
141
|
+
*/
|
|
142
|
+
/**
|
|
143
|
+
* Cached card lookup task.
|
|
144
|
+
* @typedef {Object} YotoCardCacheTask
|
|
145
|
+
* @property {string} cardId
|
|
146
|
+
*/
|
|
119
147
|
/**
|
|
120
148
|
* Complete device client state
|
|
121
149
|
* @typedef {Object} YotoDeviceClientState
|
|
@@ -389,23 +417,10 @@ export class YotoDeviceModel extends EventEmitter<YotoDeviceModelEventMap> {
|
|
|
389
417
|
reboot(): Promise<void>;
|
|
390
418
|
/**
|
|
391
419
|
* Start card playback over MQTT
|
|
392
|
-
* @param {
|
|
393
|
-
* @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
|
|
394
|
-
* @param {string} [options.chapterKey] - Chapter to start from
|
|
395
|
-
* @param {string} [options.trackKey] - Track to start from
|
|
396
|
-
* @param {number} [options.secondsIn] - Playback start offset in seconds
|
|
397
|
-
* @param {number} [options.cutOff] - Playback stop offset in seconds
|
|
398
|
-
* @param {boolean} [options.anyButtonStop] - Whether button press stops playback
|
|
420
|
+
* @param {YotoCardStartOptions} options - Card start options
|
|
399
421
|
* @returns {Promise<void>}
|
|
400
422
|
*/
|
|
401
|
-
startCard(options:
|
|
402
|
-
uri: string;
|
|
403
|
-
chapterKey?: string | undefined;
|
|
404
|
-
trackKey?: string | undefined;
|
|
405
|
-
secondsIn?: number | undefined;
|
|
406
|
-
cutOff?: number | undefined;
|
|
407
|
-
anyButtonStop?: boolean | undefined;
|
|
408
|
-
}): Promise<void>;
|
|
423
|
+
startCard(options: YotoCardStartOptions): Promise<void>;
|
|
409
424
|
/**
|
|
410
425
|
* Stop card playback over MQTT
|
|
411
426
|
* @returns {Promise<void>}
|
|
@@ -522,6 +537,35 @@ export type DayMode = "unknown" | "night" | "day";
|
|
|
522
537
|
* Power source state
|
|
523
538
|
*/
|
|
524
539
|
export type PowerSource = "battery" | "dock" | "usb-c" | "wireless";
|
|
540
|
+
/**
|
|
541
|
+
* Card start options for Yoto playback.
|
|
542
|
+
*/
|
|
543
|
+
export type YotoCardStartOptions = {
|
|
544
|
+
/**
|
|
545
|
+
* - Card ID (used when uri is not provided)
|
|
546
|
+
*/
|
|
547
|
+
cardId: string;
|
|
548
|
+
/**
|
|
549
|
+
* - Chapter to start from
|
|
550
|
+
*/
|
|
551
|
+
chapterKey?: string;
|
|
552
|
+
/**
|
|
553
|
+
* - Track to start from
|
|
554
|
+
*/
|
|
555
|
+
trackKey?: string;
|
|
556
|
+
/**
|
|
557
|
+
* - Playback start offset in seconds
|
|
558
|
+
*/
|
|
559
|
+
secondsIn?: number;
|
|
560
|
+
/**
|
|
561
|
+
* - Playback stop offset in seconds
|
|
562
|
+
*/
|
|
563
|
+
cutOff?: number;
|
|
564
|
+
/**
|
|
565
|
+
* - Whether button press stops playback
|
|
566
|
+
*/
|
|
567
|
+
anyButtonStop?: boolean;
|
|
568
|
+
};
|
|
525
569
|
/**
|
|
526
570
|
* Canonical device status - normalized format for both HTTP and MQTT sources
|
|
527
571
|
*
|
|
@@ -829,6 +873,38 @@ export type YotoPlaybackState = {
|
|
|
829
873
|
*/
|
|
830
874
|
updatedAt: string;
|
|
831
875
|
};
|
|
876
|
+
/**
|
|
877
|
+
* Cached chapter info for playback enrichment.
|
|
878
|
+
*/
|
|
879
|
+
export type YotoCardCacheChapterInfo = {
|
|
880
|
+
key: string;
|
|
881
|
+
title: string;
|
|
882
|
+
};
|
|
883
|
+
/**
|
|
884
|
+
* Cached track info for playback enrichment.
|
|
885
|
+
*/
|
|
886
|
+
export type YotoCardCacheTrackInfo = {
|
|
887
|
+
key: string;
|
|
888
|
+
title: string;
|
|
889
|
+
duration: number;
|
|
890
|
+
chapterKey: string;
|
|
891
|
+
chapterTitle: string;
|
|
892
|
+
};
|
|
893
|
+
/**
|
|
894
|
+
* Cached card metadata for playback enrichment.
|
|
895
|
+
*/
|
|
896
|
+
export type YotoCardCacheEntry = {
|
|
897
|
+
cardId: string;
|
|
898
|
+
title: string;
|
|
899
|
+
chaptersByKey: Map<string, YotoCardCacheChapterInfo>;
|
|
900
|
+
tracksByKey: Map<string, YotoCardCacheTrackInfo>;
|
|
901
|
+
};
|
|
902
|
+
/**
|
|
903
|
+
* Cached card lookup task.
|
|
904
|
+
*/
|
|
905
|
+
export type YotoCardCacheTask = {
|
|
906
|
+
cardId: string;
|
|
907
|
+
};
|
|
832
908
|
/**
|
|
833
909
|
* Complete device client state
|
|
834
910
|
*/
|
package/lib/yoto-device.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"yoto-device.d.ts","sourceRoot":"","sources":["yoto-device.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"yoto-device.d.ts","sourceRoot":"","sources":["yoto-device.js"],"names":[],"mappings":"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"}
|
package/lib/yoto-device.js
CHANGED
|
@@ -8,17 +8,26 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @import { IConnackPacket } from 'mqtt'
|
|
11
|
+
* @import { queueAsPromised } from 'fastq'
|
|
11
12
|
* @import { YotoClient } from './api-client.js'
|
|
13
|
+
* @import { YotoCard } from './api-endpoints/content.js'
|
|
12
14
|
* @import { YotoDevice, YotoDeviceConfig, YotoDeviceShortcuts, YotoDeviceFullStatus, YotoDeviceStatusResponse, YotoDeviceCommand, YotoDeviceCommandResponse } from './api-endpoints/devices.js'
|
|
13
15
|
* @import { YotoMqttClient, YotoMqttStatus, YotoEventsMessage, YotoLegacyStatus, YotoStatusMessage, YotoStatusLegacyMessage, YotoResponseMessage, YotoMqttClientDisconnectMetadata, YotoMqttClientCloseMetadata, PlaybackStatus } from './mqtt/client.js'
|
|
14
16
|
* @import { YotoMqttOptions } from './mqtt/factory.js'
|
|
17
|
+
* @import { YotoCardStartCommand } from './mqtt/commands.js'
|
|
15
18
|
*/
|
|
16
19
|
|
|
17
20
|
import { EventEmitter } from 'events'
|
|
21
|
+
import fastq from 'fastq'
|
|
22
|
+
import QuickLRU from 'quick-lru'
|
|
18
23
|
import { parseTemperature } from './helpers/temperature.js'
|
|
19
24
|
import { detectPowerState } from './helpers/power-state.js'
|
|
20
25
|
import { typedKeys } from './helpers/typed-keys.js'
|
|
21
26
|
|
|
27
|
+
const CARD_CACHE_MAX_SIZE = 2000
|
|
28
|
+
const CARD_CACHE_QUEUE_CONCURRENCY = 1
|
|
29
|
+
const CARD_ID_NONE = 'none'
|
|
30
|
+
|
|
22
31
|
// ============================================================================
|
|
23
32
|
// Type Definitions
|
|
24
33
|
// ============================================================================
|
|
@@ -81,6 +90,17 @@ function convertPowerSource (numericSource) {
|
|
|
81
90
|
* @typedef {'battery' | 'dock' | 'usb-c' | 'wireless'} PowerSource
|
|
82
91
|
*/
|
|
83
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Card start options for Yoto playback.
|
|
95
|
+
* @typedef {Object} YotoCardStartOptions
|
|
96
|
+
* @property {string} cardId - Card ID (used when uri is not provided)
|
|
97
|
+
* @property {string} [chapterKey] - Chapter to start from
|
|
98
|
+
* @property {string} [trackKey] - Track to start from
|
|
99
|
+
* @property {number} [secondsIn] - Playback start offset in seconds
|
|
100
|
+
* @property {number} [cutOff] - Playback stop offset in seconds
|
|
101
|
+
* @property {boolean} [anyButtonStop] - Whether button press stops playback
|
|
102
|
+
*/
|
|
103
|
+
|
|
84
104
|
/**
|
|
85
105
|
* Normalize "0"/"1" booleans from config to true/false.
|
|
86
106
|
* @param {string | boolean} value
|
|
@@ -398,6 +418,38 @@ export const YotoPlaybackStateType = {}
|
|
|
398
418
|
// Internal Types
|
|
399
419
|
// ============================================================================
|
|
400
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Cached chapter info for playback enrichment.
|
|
423
|
+
* @typedef {Object} YotoCardCacheChapterInfo
|
|
424
|
+
* @property {string} key
|
|
425
|
+
* @property {string} title
|
|
426
|
+
*/
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Cached track info for playback enrichment.
|
|
430
|
+
* @typedef {Object} YotoCardCacheTrackInfo
|
|
431
|
+
* @property {string} key
|
|
432
|
+
* @property {string} title
|
|
433
|
+
* @property {number} duration
|
|
434
|
+
* @property {string} chapterKey
|
|
435
|
+
* @property {string} chapterTitle
|
|
436
|
+
*/
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Cached card metadata for playback enrichment.
|
|
440
|
+
* @typedef {Object} YotoCardCacheEntry
|
|
441
|
+
* @property {string} cardId
|
|
442
|
+
* @property {string} title
|
|
443
|
+
* @property {Map<string, YotoCardCacheChapterInfo>} chaptersByKey
|
|
444
|
+
* @property {Map<string, YotoCardCacheTrackInfo>} tracksByKey
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Cached card lookup task.
|
|
449
|
+
* @typedef {Object} YotoCardCacheTask
|
|
450
|
+
* @property {string} cardId
|
|
451
|
+
*/
|
|
452
|
+
|
|
401
453
|
/**
|
|
402
454
|
* Complete device client state
|
|
403
455
|
* @typedef {Object} YotoDeviceClientState
|
|
@@ -555,6 +607,9 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
555
607
|
/** @type {NodeJS.Timeout | null} */ #eventsRequestTimer = null
|
|
556
608
|
/** @type {NodeJS.Timeout | null} */ #backgroundPollTimer = null
|
|
557
609
|
/** @type {number | null} */ #shutdownDetectedAt = null
|
|
610
|
+
/** @type {QuickLRU<string, YotoCardCacheEntry>} */ #cardCache = new QuickLRU({ maxSize: CARD_CACHE_MAX_SIZE })
|
|
611
|
+
/** @type {queueAsPromised<YotoCardCacheTask, YotoCardCacheEntry | null>} */ #cardCacheQueue = fastq.promise(this, this.#fetchCardCacheTask, CARD_CACHE_QUEUE_CONCURRENCY)
|
|
612
|
+
/** @type {Set<string>} */ #cardCacheQueueTasks = new Set()
|
|
558
613
|
|
|
559
614
|
/**
|
|
560
615
|
* Create a Yoto device client
|
|
@@ -941,17 +996,26 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
941
996
|
|
|
942
997
|
/**
|
|
943
998
|
* Start card playback over MQTT
|
|
944
|
-
* @param {
|
|
945
|
-
* @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
|
|
946
|
-
* @param {string} [options.chapterKey] - Chapter to start from
|
|
947
|
-
* @param {string} [options.trackKey] - Track to start from
|
|
948
|
-
* @param {number} [options.secondsIn] - Playback start offset in seconds
|
|
949
|
-
* @param {number} [options.cutOff] - Playback stop offset in seconds
|
|
950
|
-
* @param {boolean} [options.anyButtonStop] - Whether button press stops playback
|
|
999
|
+
* @param {YotoCardStartOptions} options - Card start options
|
|
951
1000
|
* @returns {Promise<void>}
|
|
952
1001
|
*/
|
|
953
1002
|
async startCard (options) {
|
|
954
|
-
|
|
1003
|
+
const uri = `https://yoto.io/${options.cardId}`
|
|
1004
|
+
|
|
1005
|
+
if (!uri) {
|
|
1006
|
+
throw new Error('Card URI or cardId is required')
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** @type {YotoCardStartCommand} */
|
|
1010
|
+
const payload = { uri }
|
|
1011
|
+
|
|
1012
|
+
if (typeof options.chapterKey === 'string') payload.chapterKey = options.chapterKey
|
|
1013
|
+
if (typeof options.trackKey === 'string') payload.trackKey = options.trackKey
|
|
1014
|
+
if (typeof options.secondsIn === 'number') payload.secondsIn = options.secondsIn
|
|
1015
|
+
if (typeof options.cutOff === 'number') payload.cutOff = options.cutOff
|
|
1016
|
+
if (typeof options.anyButtonStop === 'boolean') payload.anyButtonStop = options.anyButtonStop
|
|
1017
|
+
|
|
1018
|
+
return await this.#mqttClient?.startCard(payload)
|
|
955
1019
|
}
|
|
956
1020
|
|
|
957
1021
|
/**
|
|
@@ -3081,6 +3145,69 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
3081
3145
|
}
|
|
3082
3146
|
}
|
|
3083
3147
|
|
|
3148
|
+
/**
|
|
3149
|
+
* Apply cached card metadata to playback state (if available).
|
|
3150
|
+
* @param {string | null} cardId
|
|
3151
|
+
* @param {YotoPlaybackState} playback
|
|
3152
|
+
* @param {Set<keyof YotoPlaybackState>} playbackChangedFields
|
|
3153
|
+
* @returns {boolean}
|
|
3154
|
+
*/
|
|
3155
|
+
#applyCardCacheToPlayback (cardId, playback, playbackChangedFields) {
|
|
3156
|
+
if (!isCacheableCardId(cardId)) {
|
|
3157
|
+
return false
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
const cachedEntry = this.#cardCache.get(cardId)
|
|
3161
|
+
if (cachedEntry) {
|
|
3162
|
+
return applyCardCacheToPlayback(cachedEntry, playback, playbackChangedFields)
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
this.#queueCardCacheFetch(cardId)
|
|
3166
|
+
return false
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
/**
|
|
3170
|
+
* @param {YotoCardCacheTask} task
|
|
3171
|
+
* @returns {Promise<YotoCardCacheEntry | null>}
|
|
3172
|
+
*/
|
|
3173
|
+
async #fetchCardCacheTask (task) {
|
|
3174
|
+
try {
|
|
3175
|
+
const response = await this.#client.getContent({ cardId: task.cardId })
|
|
3176
|
+
const entry = buildCardCacheEntry(response.card)
|
|
3177
|
+
this.#cardCache.set(task.cardId, entry)
|
|
3178
|
+
|
|
3179
|
+
if (this.#state.playback.cardId === task.cardId) {
|
|
3180
|
+
/** @type {Set<keyof YotoPlaybackState>} */
|
|
3181
|
+
const playbackChangedFields = new Set()
|
|
3182
|
+
if (applyCardCacheToPlayback(entry, this.#state.playback, playbackChangedFields)) {
|
|
3183
|
+
this.#state.playback.updatedAt = new Date().toISOString()
|
|
3184
|
+
this.#state.lastUpdate.playback = Date.now()
|
|
3185
|
+
this.emit('playbackUpdate', this.playback, playbackChangedFields)
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
return entry
|
|
3190
|
+
} catch {
|
|
3191
|
+
return null
|
|
3192
|
+
} finally {
|
|
3193
|
+
this.#cardCacheQueueTasks.delete(task.cardId)
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* Fetch card content for playback enrichment and cache the result.
|
|
3199
|
+
* @param {string} cardId
|
|
3200
|
+
* @returns {void}
|
|
3201
|
+
*/
|
|
3202
|
+
#queueCardCacheFetch (cardId) {
|
|
3203
|
+
if (this.#cardCache.has(cardId) || this.#cardCacheQueueTasks.has(cardId)) {
|
|
3204
|
+
return
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
this.#cardCacheQueueTasks.add(cardId)
|
|
3208
|
+
this.#cardCacheQueue.push({ cardId })
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3084
3211
|
/**
|
|
3085
3212
|
* Handle MQTT event message - updates status, config, and playback
|
|
3086
3213
|
* Events are partial updates - only changed fields are included
|
|
@@ -3102,6 +3229,7 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
3102
3229
|
let playbackChanged = false
|
|
3103
3230
|
|
|
3104
3231
|
const { status, config, playback } = this.#state
|
|
3232
|
+
const previousCardId = playback.cardId
|
|
3105
3233
|
/** @type {Set<keyof YotoDeviceStatus>} */
|
|
3106
3234
|
const statusChangedFields = new Set()
|
|
3107
3235
|
/** @type {Set<keyof YotoDeviceModelConfig>} */
|
|
@@ -3183,10 +3311,13 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
3183
3311
|
break
|
|
3184
3312
|
}
|
|
3185
3313
|
case 'cardId': {
|
|
3186
|
-
if (eventsMessage.cardId !== undefined
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3314
|
+
if (eventsMessage.cardId !== undefined) {
|
|
3315
|
+
const normalizedCardId = eventsMessage.cardId === CARD_ID_NONE ? null : eventsMessage.cardId
|
|
3316
|
+
if (playback.cardId !== normalizedCardId) {
|
|
3317
|
+
playback.cardId = normalizedCardId
|
|
3318
|
+
playbackChangedFields.add('cardId')
|
|
3319
|
+
playbackChanged = true
|
|
3320
|
+
}
|
|
3190
3321
|
}
|
|
3191
3322
|
break
|
|
3192
3323
|
}
|
|
@@ -3265,6 +3396,12 @@ export class YotoDeviceModel extends EventEmitter {
|
|
|
3265
3396
|
handleField(key, eventsMessage)
|
|
3266
3397
|
}
|
|
3267
3398
|
|
|
3399
|
+
if (previousCardId !== playback.cardId) {
|
|
3400
|
+
if (this.#applyCardCacheToPlayback(playback.cardId, playback, playbackChangedFields)) {
|
|
3401
|
+
playbackChanged = true
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3268
3405
|
// Update timestamps and emit events for changed categories
|
|
3269
3406
|
if (statusChanged) {
|
|
3270
3407
|
this.#state.lastUpdate.status = Date.now()
|
|
@@ -3311,6 +3448,98 @@ function createEmptyPlaybackState () {
|
|
|
3311
3448
|
}
|
|
3312
3449
|
}
|
|
3313
3450
|
|
|
3451
|
+
/**
|
|
3452
|
+
* @param {string | null} cardId
|
|
3453
|
+
* @returns {cardId is string}
|
|
3454
|
+
*/
|
|
3455
|
+
function isCacheableCardId (cardId) {
|
|
3456
|
+
return typeof cardId === 'string' && cardId.length > 0 && cardId !== CARD_ID_NONE
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
/**
|
|
3460
|
+
* @param {YotoCard} card
|
|
3461
|
+
* @returns {YotoCardCacheEntry}
|
|
3462
|
+
*/
|
|
3463
|
+
function buildCardCacheEntry (card) {
|
|
3464
|
+
const chaptersByKey = new Map()
|
|
3465
|
+
const tracksByKey = new Map()
|
|
3466
|
+
|
|
3467
|
+
for (const chapter of card.content.chapters) {
|
|
3468
|
+
chaptersByKey.set(chapter.key, {
|
|
3469
|
+
key: chapter.key,
|
|
3470
|
+
title: chapter.title
|
|
3471
|
+
})
|
|
3472
|
+
|
|
3473
|
+
for (const track of chapter.tracks) {
|
|
3474
|
+
tracksByKey.set(track.key, {
|
|
3475
|
+
key: track.key,
|
|
3476
|
+
title: track.title,
|
|
3477
|
+
duration: track.duration,
|
|
3478
|
+
chapterKey: chapter.key,
|
|
3479
|
+
chapterTitle: chapter.title
|
|
3480
|
+
})
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
return {
|
|
3485
|
+
cardId: card.cardId,
|
|
3486
|
+
title: card.title,
|
|
3487
|
+
chaptersByKey,
|
|
3488
|
+
tracksByKey
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
/**
|
|
3493
|
+
* @param {YotoCardCacheEntry} entry
|
|
3494
|
+
* @param {YotoPlaybackState} playback
|
|
3495
|
+
* @param {Set<keyof YotoPlaybackState>} changedFields
|
|
3496
|
+
* @returns {boolean}
|
|
3497
|
+
*/
|
|
3498
|
+
function applyCardCacheToPlayback (entry, playback, changedFields) {
|
|
3499
|
+
let changed = false
|
|
3500
|
+
const { trackKey } = playback
|
|
3501
|
+
|
|
3502
|
+
if (trackKey) {
|
|
3503
|
+
const trackInfo = entry.tracksByKey.get(trackKey)
|
|
3504
|
+
if (trackInfo) {
|
|
3505
|
+
if (playback.trackTitle !== trackInfo.title) {
|
|
3506
|
+
playback.trackTitle = trackInfo.title
|
|
3507
|
+
changedFields.add('trackTitle')
|
|
3508
|
+
changed = true
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
if (playback.trackLength !== trackInfo.duration) {
|
|
3512
|
+
playback.trackLength = trackInfo.duration
|
|
3513
|
+
changedFields.add('trackLength')
|
|
3514
|
+
changed = true
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
if (playback.chapterKey !== trackInfo.chapterKey) {
|
|
3518
|
+
playback.chapterKey = trackInfo.chapterKey
|
|
3519
|
+
changedFields.add('chapterKey')
|
|
3520
|
+
changed = true
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
if (playback.chapterKey === trackInfo.chapterKey && playback.chapterTitle !== trackInfo.chapterTitle) {
|
|
3524
|
+
playback.chapterTitle = trackInfo.chapterTitle
|
|
3525
|
+
changedFields.add('chapterTitle')
|
|
3526
|
+
changed = true
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
if (playback.chapterKey) {
|
|
3532
|
+
const chapterInfo = entry.chaptersByKey.get(playback.chapterKey)
|
|
3533
|
+
if (chapterInfo && playback.chapterTitle !== chapterInfo.title) {
|
|
3534
|
+
playback.chapterTitle = chapterInfo.title
|
|
3535
|
+
changedFields.add('chapterTitle')
|
|
3536
|
+
changed = true
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
return changed
|
|
3541
|
+
}
|
|
3542
|
+
|
|
3314
3543
|
/**
|
|
3315
3544
|
* Create an empty device config object
|
|
3316
3545
|
* @returns {YotoDeviceModelConfig}
|
package/lib/yoto-device.test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** @import { YotoDevice } from './api-endpoints/devices.js' */
|
|
2
|
+
/** @import { YotoPlaybackState } from './yoto-device.js' */
|
|
2
3
|
|
|
3
4
|
import test from 'node:test'
|
|
4
5
|
import assert from 'node:assert/strict'
|
|
@@ -15,6 +16,122 @@ import {
|
|
|
15
16
|
} from './test-helpers/device-model-test-helpers.js'
|
|
16
17
|
|
|
17
18
|
const envPath = join(import.meta.dirname, '..', '.env')
|
|
19
|
+
const PLAYBACK_SAMPLE_SCAN_LIMIT = 5
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} PlaybackSample
|
|
23
|
+
* @property {string} cardId
|
|
24
|
+
* @property {Map<string, { title: string, duration: number, chapterKey: string, chapterTitle: string }>} tracksByKey
|
|
25
|
+
* @property {string} chapterKey
|
|
26
|
+
* @property {string} chapterTitle
|
|
27
|
+
* @property {string} trackKey
|
|
28
|
+
* @property {string} trackTitle
|
|
29
|
+
* @property {number} trackLength
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {YotoDeviceModel} model
|
|
34
|
+
* @param {(playback: YotoPlaybackState, changedFields: Set<keyof YotoPlaybackState>) => boolean} predicate
|
|
35
|
+
* @param {number} timeoutMs
|
|
36
|
+
* @param {string} label
|
|
37
|
+
* @returns {Promise<[YotoPlaybackState, Set<keyof YotoPlaybackState>]>}
|
|
38
|
+
*/
|
|
39
|
+
function waitForPlaybackUpdateMatching (model, predicate, timeoutMs, label) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const timeout = setTimeout(() => {
|
|
42
|
+
model.off('playbackUpdate', handler)
|
|
43
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`))
|
|
44
|
+
}, timeoutMs)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {YotoPlaybackState} playback
|
|
48
|
+
* @param {Set<keyof YotoPlaybackState>} changedFields
|
|
49
|
+
*/
|
|
50
|
+
const handler = (playback, changedFields) => {
|
|
51
|
+
let matches = false
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
matches = predicate(playback, changedFields)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
clearTimeout(timeout)
|
|
57
|
+
model.off('playbackUpdate', handler)
|
|
58
|
+
reject(err)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!matches) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clearTimeout(timeout)
|
|
67
|
+
model.off('playbackUpdate', handler)
|
|
68
|
+
resolve([playback, changedFields])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
model.on('playbackUpdate', handler)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {YotoClient} client
|
|
77
|
+
* @returns {Promise<PlaybackSample | null>}
|
|
78
|
+
*/
|
|
79
|
+
async function findPlaybackSample (client) {
|
|
80
|
+
const response = await client.getUserMyoContent({ showDeleted: false })
|
|
81
|
+
|
|
82
|
+
for (const card of response.cards.slice(0, PLAYBACK_SAMPLE_SCAN_LIMIT)) {
|
|
83
|
+
if (!card.cardId || card.deleted) {
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const contentResponse = await client.getContent({ cardId: card.cardId })
|
|
89
|
+
const chapters = contentResponse.card.content.chapters
|
|
90
|
+
const firstChapter = chapters.find(candidate => candidate.tracks.length > 0)
|
|
91
|
+
|
|
92
|
+
if (!firstChapter) {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const firstTrack = firstChapter.tracks[0]
|
|
97
|
+
|
|
98
|
+
if (!firstTrack || typeof firstTrack.title !== 'string' || !Number.isFinite(firstTrack.duration)) {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const tracksByKey = new Map()
|
|
103
|
+
|
|
104
|
+
for (const chapter of chapters) {
|
|
105
|
+
for (const track of chapter.tracks) {
|
|
106
|
+
if (!track || typeof track.title !== 'string' || !Number.isFinite(track.duration)) {
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
tracksByKey.set(track.key, {
|
|
111
|
+
title: track.title,
|
|
112
|
+
duration: track.duration,
|
|
113
|
+
chapterKey: chapter.key,
|
|
114
|
+
chapterTitle: chapter.title
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
cardId: contentResponse.card.cardId,
|
|
121
|
+
tracksByKey,
|
|
122
|
+
chapterKey: firstChapter.key,
|
|
123
|
+
chapterTitle: firstChapter.title,
|
|
124
|
+
trackKey: firstTrack.key,
|
|
125
|
+
trackTitle: firstTrack.title,
|
|
126
|
+
trackLength: firstTrack.duration
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
18
135
|
|
|
19
136
|
/**
|
|
20
137
|
* @returns {YotoClient}
|
|
@@ -86,3 +203,104 @@ test('YotoDeviceModel - online devices', async (t) => {
|
|
|
86
203
|
await assertDeviceModel(client, onlineV3)
|
|
87
204
|
})
|
|
88
205
|
})
|
|
206
|
+
|
|
207
|
+
test('YotoDeviceModel - playback normalization', async (t) => {
|
|
208
|
+
loadTestTokens()
|
|
209
|
+
const client = createTestClient()
|
|
210
|
+
const response = await client.getDevices()
|
|
211
|
+
|
|
212
|
+
const onlineDevice = response.devices.find(device => device.online)
|
|
213
|
+
if (!onlineDevice) {
|
|
214
|
+
t.skip('No online device found')
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sample = await findPlaybackSample(client)
|
|
219
|
+
if (!sample) {
|
|
220
|
+
t.skip('No MYO content with tracks available')
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const model = new YotoDeviceModel(client, onlineDevice)
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await waitForModelReady(model)
|
|
228
|
+
assertPlaybackShape(model.playback)
|
|
229
|
+
|
|
230
|
+
const mqttClient = model.mqttClient
|
|
231
|
+
assert.ok(mqttClient, 'MQTT client should be initialized')
|
|
232
|
+
|
|
233
|
+
await t.test('card cache enrichment', async () => {
|
|
234
|
+
if (model.playback.cardId === sample.cardId) {
|
|
235
|
+
const resetPromise = waitForPlaybackUpdateMatching(
|
|
236
|
+
model,
|
|
237
|
+
(playback, changedFields) => playback.cardId === null && changedFields.has('cardId'),
|
|
238
|
+
5000,
|
|
239
|
+
'playback card reset'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
mqttClient.emit('events', 'test', { cardId: 'none' })
|
|
243
|
+
await resetPromise
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const cardUpdatePromise = waitForPlaybackUpdateMatching(
|
|
247
|
+
model,
|
|
248
|
+
(playback, changedFields) => playback.cardId === sample.cardId && changedFields.has('cardId'),
|
|
249
|
+
5000,
|
|
250
|
+
'playback card update'
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
const cacheUpdatePromise = waitForPlaybackUpdateMatching(
|
|
254
|
+
model,
|
|
255
|
+
(playback, changedFields) => {
|
|
256
|
+
if (playback.cardId !== sample.cardId) {
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
changedFields.has('trackTitle') ||
|
|
262
|
+
changedFields.has('trackLength') ||
|
|
263
|
+
changedFields.has('chapterKey') ||
|
|
264
|
+
changedFields.has('chapterTitle')
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
15000,
|
|
268
|
+
'playback cache update'
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
mqttClient.emit('events', 'test', {
|
|
272
|
+
cardId: sample.cardId,
|
|
273
|
+
trackKey: sample.trackKey
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
const [cardPlayback] = await cardUpdatePromise
|
|
277
|
+
assert.equal(cardPlayback.cardId, sample.cardId)
|
|
278
|
+
|
|
279
|
+
const [cachePlayback] = await cacheUpdatePromise
|
|
280
|
+
assert.ok(cachePlayback.trackKey, 'trackKey should be set after cache update')
|
|
281
|
+
const trackInfo = sample.tracksByKey.get(cachePlayback.trackKey)
|
|
282
|
+
assert.ok(trackInfo, 'trackKey should exist in card content')
|
|
283
|
+
assert.equal(cachePlayback.trackTitle, trackInfo.title)
|
|
284
|
+
assert.equal(cachePlayback.trackLength, trackInfo.duration)
|
|
285
|
+
assert.equal(cachePlayback.chapterKey, trackInfo.chapterKey)
|
|
286
|
+
assert.equal(cachePlayback.chapterTitle, trackInfo.chapterTitle)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
await t.test('cardId none normalization', async () => {
|
|
290
|
+
const noneUpdatePromise = waitForPlaybackUpdateMatching(
|
|
291
|
+
model,
|
|
292
|
+
(playback, changedFields) => playback.cardId === null && changedFields.has('cardId'),
|
|
293
|
+
5000,
|
|
294
|
+
'playback cardId none normalization'
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
mqttClient.emit('events', 'test', { cardId: 'none' })
|
|
298
|
+
|
|
299
|
+
const [nonePlayback, changedFields] = await noneUpdatePromise
|
|
300
|
+
assert.equal(nonePlayback.cardId, null)
|
|
301
|
+
assert.ok(changedFields.has('cardId'), 'cardId should be in changed fields')
|
|
302
|
+
})
|
|
303
|
+
} finally {
|
|
304
|
+
await model.stop()
|
|
305
|
+
}
|
|
306
|
+
})
|
package/package.json
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yoto-nodejs-client",
|
|
3
3
|
"description": "(Unofficial) Node.js client for the Yoto API with automatic token refresh, MQTT device communication, and TypeScript support",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.9",
|
|
5
5
|
"author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/bcomnes/yoto-nodejs-client/issues"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
+
"fastq": "^1.20.1",
|
|
10
11
|
"jwt-decode": "^4.0.0",
|
|
11
12
|
"mqtt": "^5.14.1",
|
|
13
|
+
"quick-lru": "^7.3.0",
|
|
12
14
|
"undici": "^7.16.0"
|
|
13
15
|
},
|
|
14
16
|
"devDependencies": {
|
|
15
|
-
"@unblessed/node": "1.0.0-alpha.23",
|
|
16
17
|
"@types/node": "^25.0.0",
|
|
18
|
+
"@unblessed/node": "1.0.0-alpha.23",
|
|
17
19
|
"@voxpelli/tsconfig": "^16.1.0",
|
|
18
20
|
"argsclopts": "^1.0.5",
|
|
19
21
|
"auto-changelog": "^2.0.0",
|