yoto-nodejs-client 0.0.5 → 0.0.7

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.
Files changed (52) hide show
  1. package/README.md +73 -40
  2. package/bin/auth.js +4 -3
  3. package/bin/device-model.js +25 -5
  4. package/bin/device-tui.js +25 -3
  5. package/bin/devices.js +25 -9
  6. package/bin/lib/cli-helpers.d.ts.map +1 -1
  7. package/bin/lib/cli-helpers.js +3 -1
  8. package/bin/lib/token-helpers.d.ts +4 -2
  9. package/bin/lib/token-helpers.d.ts.map +1 -1
  10. package/bin/lib/token-helpers.js +9 -8
  11. package/bin/refresh-token.js +4 -2
  12. package/bin/token-info.js +2 -2
  13. package/lib/api-client.d.ts +11 -10
  14. package/lib/api-client.d.ts.map +1 -1
  15. package/lib/api-client.js +12 -14
  16. package/lib/api-endpoints/auth.test.js +4 -4
  17. package/lib/api-endpoints/content.test.js +32 -32
  18. package/lib/api-endpoints/devices.d.ts +4 -4
  19. package/lib/api-endpoints/devices.js +2 -2
  20. package/lib/api-endpoints/devices.test.js +45 -45
  21. package/lib/api-endpoints/endpoint-test-helpers.d.ts +3 -4
  22. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -1
  23. package/lib/api-endpoints/endpoint-test-helpers.js +21 -5
  24. package/lib/api-endpoints/family-library-groups.d.ts +3 -3
  25. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
  26. package/lib/api-endpoints/family-library-groups.js +3 -3
  27. package/lib/api-endpoints/family-library-groups.test.js +29 -29
  28. package/lib/api-endpoints/family.test.js +11 -11
  29. package/lib/api-endpoints/icons.test.js +14 -14
  30. package/lib/mqtt/client.d.ts +123 -48
  31. package/lib/mqtt/client.d.ts.map +1 -1
  32. package/lib/mqtt/client.js +131 -49
  33. package/lib/mqtt/factory.d.ts +12 -5
  34. package/lib/mqtt/factory.d.ts.map +1 -1
  35. package/lib/mqtt/factory.js +39 -11
  36. package/lib/mqtt/index.js +2 -1
  37. package/lib/mqtt/mqtt.test.js +25 -22
  38. package/lib/test-helpers/device-model-test-helpers.d.ts +29 -0
  39. package/lib/test-helpers/device-model-test-helpers.d.ts.map +1 -0
  40. package/lib/test-helpers/device-model-test-helpers.js +116 -0
  41. package/lib/token.d.ts +44 -2
  42. package/lib/token.d.ts.map +1 -1
  43. package/lib/token.js +142 -2
  44. package/lib/yoto-account.d.ts +339 -9
  45. package/lib/yoto-account.d.ts.map +1 -1
  46. package/lib/yoto-account.js +411 -39
  47. package/lib/yoto-account.test.js +139 -0
  48. package/lib/yoto-device.d.ts +418 -30
  49. package/lib/yoto-device.d.ts.map +1 -1
  50. package/lib/yoto-device.js +670 -104
  51. package/lib/yoto-device.test.js +88 -0
  52. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
 
8
8
  /**
9
9
  * @import { IClientOptions } from 'mqtt'
10
+ * @import { RefreshableToken } from '../token.js'
10
11
  */
11
12
 
12
13
  // ============================================================================
@@ -21,12 +22,13 @@
21
22
  /**
22
23
  * @typedef {Object} YotoMqttOptions
23
24
  * @property {string} deviceId - Device ID to connect to
24
- * @property {string} accessToken - JWT access token for authentication
25
+ * @property {RefreshableToken} token - RefreshableToken instance used to supply the current JWT. Enables auto-updating auth on reconnect via transformWsUrl.
26
+ * @property {string} [sessionId=randomUUID()] - Stable unique session ID suffix used to avoid collisions across multiple clients/processes. Used to build the MQTT clientId: DASH${deviceId}${sessionId} (non-alphanumeric characters are stripped). (MQTT calls this clientId, but we call it sessionId to not confuse it with the oauth clientId)
25
27
  * @property {string} [clientIdPrefix='DASH'] - Prefix for MQTT client ID (default: 'DASH')
26
28
  * @property {string} [brokerUrl='wss://aqrphjqbp3u2z-ats.iot.eu-west-2.amazonaws.com'] - MQTT broker URL
27
29
  * @property {number} [port=443] - MQTT broker port
28
30
  * @property {boolean} [autoSubscribe=true] - Auto-subscribe to device topics on connect
29
- * @property {MqttClientOptions} [mqttOptions] - Additional MQTT.js client options (defaults: reconnectPeriod=5000, keepalive=300; cannot override: clientId, username, password, protocol, ALPNProtocols)
31
+ * @property {MqttClientOptions} [mqttOptions] - Additional MQTT.js client options (defaults: reconnectPeriod=5000, reconnectOnConnackError=true, keepalive=300, clean=false; cannot override: clientId, username, password, protocol, ALPNProtocols; transformWsUrl is wrapped to keep auth current)
30
32
  */
31
33
 
32
34
  import mqtt from 'mqtt'
@@ -39,6 +41,7 @@ import {
39
41
  MQTT_KEEPALIVE,
40
42
  MQTT_ALPN_PROTOCOLS
41
43
  } from './topics.js'
44
+ import { randomUUID } from 'node:crypto'
42
45
 
43
46
  /**
44
47
  * Create a configured MQTT client for a Yoto device
@@ -52,7 +55,8 @@ import {
52
55
  *
53
56
  * const client = createYotoMqttClient({
54
57
  * deviceId: 'abc123',
55
- * accessToken: 'eyJhbGc...'
58
+ * token,
59
+ * clientId: 'my-unique-client-id'
56
60
  * })
57
61
  *
58
62
  * client.on('events', (message) => {
@@ -67,7 +71,8 @@ import {
67
71
  * // Disable automatic reconnection
68
72
  * const client = createYotoMqttClient({
69
73
  * deviceId: 'abc123',
70
- * accessToken: 'token',
74
+ * token,
75
+ * clientId: 'my-unique-client-id',
71
76
  * mqttOptions: {
72
77
  * reconnectPeriod: 0, // Disable auto-reconnect (default is 5000ms)
73
78
  * connectTimeout: 30000 // 30 second connection timeout
@@ -81,14 +86,15 @@ export function createYotoMqttClient (options) {
81
86
  throw new Error('deviceId is required')
82
87
  }
83
88
 
84
- if (!options.accessToken) {
85
- throw new Error('accessToken is required')
89
+ if (!options.token) {
90
+ throw new Error('token is required')
86
91
  }
87
92
 
88
93
  // Extract options with defaults
89
94
  const {
90
95
  deviceId,
91
- accessToken,
96
+ token,
97
+ sessionId = randomUUID(),
92
98
  clientIdPrefix = 'DASH',
93
99
  brokerUrl = MQTT_BROKER_URL,
94
100
  port = MQTT_PORT,
@@ -97,26 +103,48 @@ export function createYotoMqttClient (options) {
97
103
  } = options
98
104
 
99
105
  // Build MQTT client ID
100
- const clientId = `${clientIdPrefix}${deviceId}`
106
+ const mqttSessionId = (`${clientIdPrefix}${deviceId}${sessionId}`).replace(/[^a-zA-Z0-9]/g, '')
101
107
 
102
108
  // Build username with authorizer
103
109
  const username = `${deviceId}?x-amz-customauthorizer-name=${MQTT_AUTH_NAME}`
104
110
 
111
+ const userTransformWsUrl = additionalMqttOptions.transformWsUrl
112
+
113
+ /** @type {IClientOptions['transformWsUrl']} */
114
+ const transformWsUrl = (url, mqttOptions, client) => {
115
+ // Ensure the CONNECT auth uses the latest access token for every websocket (re)connect.
116
+ const latestAccessToken = token.accessToken
117
+ client.options.password = latestAccessToken
118
+
119
+ let nextUrl = url
120
+ if (typeof userTransformWsUrl === 'function') {
121
+ nextUrl = userTransformWsUrl(url, mqttOptions, client)
122
+ }
123
+
124
+ // Re-apply in case user hook mutated it.
125
+ client.options.password = latestAccessToken
126
+
127
+ return nextUrl
128
+ }
129
+
105
130
  // Create MQTT connection options
106
131
  // Merge additional options first, then override with required Yoto settings
107
132
  /** @type {IClientOptions} */
108
133
  const mqttOptions = {
109
134
  // Defaults (can be overridden by additionalMqttOptions)
110
135
  reconnectPeriod: 5000, // Default: auto-reconnect after 5 seconds
136
+ reconnectOnConnackError: true, // Default: keep reconnecting after CONNACK auth errors (token refresh may fix it)
111
137
  keepalive: MQTT_KEEPALIVE, // Default: 300 seconds (5 minutes)
138
+ clean: false, // Default: persistent session for stable subscriptions on the broker
112
139
  ...additionalMqttOptions, // Allow overriding defaults
113
140
  // Required Yoto-specific settings (cannot be overridden)
114
- clientId,
141
+ clientId: mqttSessionId,
115
142
  username,
116
- password: accessToken,
143
+ password: token.accessToken,
117
144
  port,
118
145
  protocol: MQTT_PROTOCOL,
119
- ALPNProtocols: MQTT_ALPN_PROTOCOLS
146
+ ALPNProtocols: MQTT_ALPN_PROTOCOLS,
147
+ transformWsUrl
120
148
  }
121
149
 
122
150
  // Create underlying MQTT client (not connected yet)
package/lib/mqtt/index.js CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * const client = createYotoMqttClient({
12
12
  * deviceId: 'abc123',
13
- * accessToken: 'eyJhbGc...'
13
+ * token,
14
+ * clientId: 'my-unique-client-id'
14
15
  * })
15
16
  *
16
17
  * client.on('events', (message) => {
@@ -1,15 +1,17 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert'
3
+ import { randomUUID } from 'node:crypto'
3
4
  import { getDevices } from '../api-endpoints/devices.js'
4
5
  import { loadTestTokens, logResponse } from '../api-endpoints/endpoint-test-helpers.js'
5
6
  import { createYotoMqttClient } from './index.js'
6
7
 
7
- const { accessToken } = loadTestTokens()
8
+ const { token } = loadTestTokens()
8
9
 
9
10
  test('MQTT client', async (t) => {
10
11
  await t.test('should connect, request status/events, and receive response and events messages (status-legacy is optional)', async () => {
11
12
  // Get an online device
12
- const response = await getDevices({ accessToken })
13
+
14
+ const response = await getDevices({ accessToken: await token.getAccessToken() })
13
15
  assert.ok(response.devices.length > 0, 'Should have at least one device')
14
16
 
15
17
  const onlineDevice = response.devices.find(d => d.online)
@@ -20,7 +22,8 @@ test('MQTT client', async (t) => {
20
22
  // Create MQTT client
21
23
  const mqttClient = createYotoMqttClient({
22
24
  deviceId,
23
- accessToken
25
+ token,
26
+ sessionId: randomUUID()
24
27
  })
25
28
 
26
29
  // Track received messages
@@ -39,15 +42,15 @@ test('MQTT client', async (t) => {
39
42
  try {
40
43
  // Validate events message structure
41
44
  assert.ok(payload, 'Events message should exist')
42
- assert.ok(typeof topic === 'string', 'Topic should be a string')
45
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
43
46
  assert.ok(topic.includes('/data/events'), 'Topic should contain /data/events')
44
47
 
45
48
  // Events messages are partial - not all fields are always present
46
49
  if (payload.playbackStatus !== undefined) {
47
- assert.ok(typeof payload.playbackStatus === 'string', 'playbackStatus should be string')
50
+ assert.equal(typeof payload.playbackStatus, 'string', 'playbackStatus should be string')
48
51
  }
49
52
  if (payload.cardId !== undefined) {
50
- assert.ok(typeof payload.cardId === 'string', 'cardId should be string')
53
+ assert.equal(typeof payload.cardId, 'string', 'cardId should be string')
51
54
  }
52
55
 
53
56
  eventsMessageReceived = true
@@ -62,14 +65,14 @@ test('MQTT client', async (t) => {
62
65
  try {
63
66
  // Validate status message structure
64
67
  assert.ok(payload, 'Status message should exist')
65
- assert.ok(typeof topic === 'string', 'Topic should be a string')
68
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
66
69
  assert.ok(topic.includes('/data/status'), 'Topic should contain /data/status')
67
70
  assert.ok(payload.status, 'Status message should have status object')
68
- assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
69
- assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
70
- assert.ok(typeof payload.status.productType === 'string', 'Should have productType string')
71
- assert.ok(typeof payload.status.batteryLevel === 'number', 'Should have batteryLevel number')
72
- assert.ok(typeof payload.status.volume === 'number', 'Should have volume number')
71
+ assert.equal(typeof payload.status.statusVersion, 'number', 'Should have statusVersion number')
72
+ assert.equal(typeof payload.status.fwVersion, 'string', 'Should have fwVersion string')
73
+ assert.equal(typeof payload.status.productType, 'string', 'Should have productType string')
74
+ assert.equal(typeof payload.status.batteryLevel, 'number', 'Should have batteryLevel number')
75
+ assert.equal(typeof payload.status.volume, 'number', 'Should have volume number')
73
76
 
74
77
  statusMessageReceived = true
75
78
  } catch (err) {
@@ -83,33 +86,33 @@ test('MQTT client', async (t) => {
83
86
  try {
84
87
  // Validate legacy status message structure
85
88
  assert.ok(payload, 'Legacy status message should exist')
86
- assert.ok(typeof topic === 'string', 'Topic should be a string')
89
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
87
90
  assert.ok(!topic.includes('/data/'), 'Legacy topic should NOT contain /data/')
88
91
  assert.ok(payload.status, 'Legacy status message should have status object')
89
92
 
90
93
  // Validate lifecycle fields unique to legacy format
91
- assert.ok(typeof payload.status.statusVersion === 'number', 'Should have statusVersion number')
92
- assert.ok(typeof payload.status.fwVersion === 'string', 'Should have fwVersion string')
94
+ assert.equal(typeof payload.status.statusVersion, 'number', 'Should have statusVersion number')
95
+ assert.equal(typeof payload.status.fwVersion, 'string', 'Should have fwVersion string')
93
96
 
94
97
  // shutDown field is the key field for lifecycle events
95
98
  if (payload.status.shutDown !== undefined) {
96
- assert.ok(typeof payload.status.shutDown === 'string', 'shutDown should be string')
99
+ assert.equal(typeof payload.status.shutDown, 'string', 'shutDown should be string')
97
100
  }
98
101
 
99
102
  // upTime and utcTime are key for startup detection
100
103
  if (payload.status.upTime !== undefined) {
101
- assert.ok(typeof payload.status.upTime === 'number', 'upTime should be number')
104
+ assert.equal(typeof payload.status.upTime, 'number', 'upTime should be number')
102
105
  }
103
106
  if (payload.status.utcTime !== undefined) {
104
- assert.ok(typeof payload.status.utcTime === 'number', 'utcTime should be number')
107
+ assert.equal(typeof payload.status.utcTime, 'number', 'utcTime should be number')
105
108
  }
106
109
 
107
110
  // Hardware diagnostic fields unique to legacy
108
111
  if (payload.status.battery !== undefined) {
109
- assert.ok(typeof payload.status.battery === 'number', 'battery should be number')
112
+ assert.equal(typeof payload.status.battery, 'number', 'battery should be number')
110
113
  }
111
114
  if (payload.status.wifiStrength !== undefined) {
112
- assert.ok(typeof payload.status.wifiStrength === 'number', 'wifiStrength should be number')
115
+ assert.equal(typeof payload.status.wifiStrength, 'number', 'wifiStrength should be number')
113
116
  }
114
117
 
115
118
  statusLegacyMessageReceived = true
@@ -124,10 +127,10 @@ test('MQTT client', async (t) => {
124
127
  try {
125
128
  // Validate response message structure
126
129
  assert.ok(payload, 'Response message should exist')
127
- assert.ok(typeof topic === 'string', 'Topic should be a string')
130
+ assert.equal(typeof topic, 'string', 'Topic should be a string')
128
131
  assert.ok(topic.includes('/response'), 'Topic should contain /response')
129
132
  assert.ok(payload.status, 'Response should have status object')
130
- assert.ok(typeof payload.status.req_body === 'string', 'Response should have req_body string')
133
+ assert.equal(typeof payload.status.req_body, 'string', 'Response should have req_body string')
131
134
 
132
135
  // Check if status request was acknowledged (field name is 'status/request')
133
136
  if (payload.status['status/request']) {
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @param {Promise<unknown>} promise
3
+ * @param {number} timeoutMs
4
+ * @param {string} label
5
+ * @returns {Promise<unknown>}
6
+ */
7
+ export function withTimeout(promise: Promise<unknown>, timeoutMs: number, label: string): Promise<unknown>;
8
+ /**
9
+ * @param {YotoDeviceModel} model
10
+ * @returns {Promise<void>}
11
+ */
12
+ export function waitForModelReady(model: YotoDeviceModel): Promise<void>;
13
+ /**
14
+ * @param {YotoDeviceModelConfig} config
15
+ */
16
+ export function assertConfigShape(config: YotoDeviceModelConfig): void;
17
+ /**
18
+ * @param {YotoPlaybackState} playback
19
+ */
20
+ export function assertPlaybackShape(playback: YotoPlaybackState): void;
21
+ /**
22
+ * @param {string | undefined} value
23
+ * @returns {string}
24
+ */
25
+ export function toLower(value: string | undefined): string;
26
+ import type { YotoDeviceModel } from '../yoto-device.js';
27
+ import type { YotoDeviceModelConfig } from '../yoto-device.js';
28
+ import type { YotoPlaybackState } from '../yoto-device.js';
29
+ //# sourceMappingURL=device-model-test-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"device-model-test-helpers.d.ts","sourceRoot":"","sources":["device-model-test-helpers.js"],"names":[],"mappings":"AAMA;;;;;GAKG;AACH,qCALW,OAAO,CAAC,OAAO,CAAC,aAChB,MAAM,SACN,MAAM,GACJ,OAAO,CAAC,OAAO,CAAC,CAS5B;AAED;;;GAGG;AACH,yCAHW,eAAe,GACb,OAAO,CAAC,IAAI,CAAC,CAUzB;AAED;;GAEG;AACH,0CAFW,qBAAqB,QAkD/B;AAED;;GAEG;AACH,8CAFW,iBAAiB,QAkB3B;AAED;;;GAGG;AACH,+BAHW,MAAM,GAAG,SAAS,GAChB,MAAM,CAIlB;qCAnH8E,mBAAmB;2CAAnB,mBAAmB;uCAAnB,mBAAmB"}
@@ -0,0 +1,116 @@
1
+ /** @import { YotoDeviceModel, YotoDeviceModelConfig, YotoPlaybackState } from '../yoto-device.js' */
2
+
3
+ import assert from 'node:assert/strict'
4
+ import { once } from 'node:events'
5
+ import { setTimeout as sleep } from 'node:timers/promises'
6
+
7
+ /**
8
+ * @param {Promise<unknown>} promise
9
+ * @param {number} timeoutMs
10
+ * @param {string} label
11
+ * @returns {Promise<unknown>}
12
+ */
13
+ export function withTimeout (promise, timeoutMs, label) {
14
+ return Promise.race([
15
+ promise,
16
+ sleep(timeoutMs).then(() => {
17
+ throw new Error(`${label} timed out after ${timeoutMs}ms`)
18
+ })
19
+ ])
20
+ }
21
+
22
+ /**
23
+ * @param {YotoDeviceModel} model
24
+ * @returns {Promise<void>}
25
+ */
26
+ export async function waitForModelReady (model) {
27
+ const started = withTimeout(once(model, 'started'), 15000, 'started')
28
+ const statusUpdated = withTimeout(once(model, 'statusUpdate'), 15000, 'statusUpdate')
29
+ const configUpdated = withTimeout(once(model, 'configUpdate'), 15000, 'configUpdate')
30
+
31
+ await model.start()
32
+ await Promise.all([started, statusUpdated, configUpdated])
33
+ await sleep(1500)
34
+ }
35
+
36
+ /**
37
+ * @param {YotoDeviceModelConfig} config
38
+ */
39
+ export function assertConfigShape (config) {
40
+ assert.ok(Array.isArray(config.alarms), 'alarms should be an array')
41
+ assert.equal(typeof config.ambientColour, 'string', 'ambientColour should be string')
42
+ assert.equal(typeof config.bluetoothEnabled, 'boolean', 'bluetoothEnabled should be boolean')
43
+ assert.equal(typeof config.btHeadphonesEnabled, 'boolean', 'btHeadphonesEnabled should be boolean')
44
+ assert.equal(typeof config.clockFace, 'string', 'clockFace should be string')
45
+ assert.equal(typeof config.dayDisplayBrightnessAuto, 'boolean', 'dayDisplayBrightnessAuto should be boolean')
46
+ if (config.dayDisplayBrightnessAuto) {
47
+ assert.equal(config.dayDisplayBrightness, null, 'dayDisplayBrightness should be null when auto')
48
+ } else {
49
+ assert.equal(typeof config.dayDisplayBrightness, 'number', 'dayDisplayBrightness should be number when not auto')
50
+ }
51
+ assert.equal(typeof config.dayTime, 'string', 'dayTime should be string')
52
+ assert.equal(typeof config.dayYotoDaily, 'string', 'dayYotoDaily should be string')
53
+ assert.equal(typeof config.dayYotoRadio, 'string', 'dayYotoRadio should be string')
54
+ assert.equal(typeof config.daySoundsOff, 'boolean', 'daySoundsOff should be boolean')
55
+ assert.equal(typeof config.displayDimBrightness, 'number', 'displayDimBrightness should be number')
56
+ assert.equal(typeof config.displayDimTimeout, 'number', 'displayDimTimeout should be number')
57
+ assert.equal(typeof config.headphonesVolumeLimited, 'boolean', 'headphonesVolumeLimited should be boolean')
58
+ assert.ok(config.hourFormat === 12 || config.hourFormat === 24, 'hourFormat should be 12 or 24')
59
+ assert.equal(typeof config.locale, 'string', 'locale should be string')
60
+ assert.equal(typeof config.logLevel, 'string', 'logLevel should be string')
61
+ assert.equal(typeof config.maxVolumeLimit, 'number', 'maxVolumeLimit should be number')
62
+ assert.equal(typeof config.nightAmbientColour, 'string', 'nightAmbientColour should be string')
63
+ assert.equal(typeof config.nightDisplayBrightnessAuto, 'boolean', 'nightDisplayBrightnessAuto should be boolean')
64
+ if (config.nightDisplayBrightnessAuto) {
65
+ assert.equal(config.nightDisplayBrightness, null, 'nightDisplayBrightness should be null when auto')
66
+ } else {
67
+ assert.equal(typeof config.nightDisplayBrightness, 'number', 'nightDisplayBrightness should be number when not auto')
68
+ }
69
+ assert.equal(typeof config.nightMaxVolumeLimit, 'number', 'nightMaxVolumeLimit should be number')
70
+ assert.equal(typeof config.nightTime, 'string', 'nightTime should be string')
71
+ assert.equal(typeof config.nightYotoDaily, 'string', 'nightYotoDaily should be string')
72
+ assert.equal(typeof config.nightYotoRadioEnabled, 'boolean', 'nightYotoRadioEnabled should be boolean')
73
+ if (config.nightYotoRadioEnabled) {
74
+ assert.equal(typeof config.nightYotoRadio, 'string', 'nightYotoRadio should be string when enabled')
75
+ } else {
76
+ assert.equal(config.nightYotoRadio, null, 'nightYotoRadio should be null when disabled')
77
+ }
78
+ assert.equal(typeof config.nightSoundsOff, 'boolean', 'nightSoundsOff should be boolean')
79
+ assert.equal(typeof config.pausePowerButton, 'boolean', 'pausePowerButton should be boolean')
80
+ assert.equal(typeof config.pauseVolumeDown, 'boolean', 'pauseVolumeDown should be boolean')
81
+ assert.equal(typeof config.repeatAll, 'boolean', 'repeatAll should be boolean')
82
+ assert.equal(typeof config.showDiagnostics, 'boolean', 'showDiagnostics should be boolean')
83
+ assert.equal(typeof config.shutdownTimeout, 'number', 'shutdownTimeout should be number')
84
+ assert.equal(typeof config.systemVolume, 'number', 'systemVolume should be number')
85
+ assert.equal(typeof config.timezone, 'string', 'timezone should be string')
86
+ assert.equal(typeof config.volumeLevel, 'string', 'volumeLevel should be string')
87
+ }
88
+
89
+ /**
90
+ * @param {YotoPlaybackState} playback
91
+ */
92
+ export function assertPlaybackShape (playback) {
93
+ assert.ok(playback, 'playback should exist')
94
+ assert.equal(typeof playback.updatedAt, 'string', 'playback.updatedAt should be string')
95
+
96
+ assert.ok(playback.cardId === null || typeof playback.cardId === 'string', 'playback.cardId should be string or null')
97
+ assert.ok(playback.source === null || typeof playback.source === 'string', 'playback.source should be string or null')
98
+ assert.ok(playback.playbackStatus === null || typeof playback.playbackStatus === 'string', 'playback.playbackStatus should be string or null')
99
+ assert.ok(playback.trackTitle === null || typeof playback.trackTitle === 'string', 'playback.trackTitle should be string or null')
100
+ assert.ok(playback.trackKey === null || typeof playback.trackKey === 'string', 'playback.trackKey should be string or null')
101
+ assert.ok(playback.chapterTitle === null || typeof playback.chapterTitle === 'string', 'playback.chapterTitle should be string or null')
102
+ assert.ok(playback.chapterKey === null || typeof playback.chapterKey === 'string', 'playback.chapterKey should be string or null')
103
+ assert.ok(playback.position === null || typeof playback.position === 'number', 'playback.position should be number or null')
104
+ assert.ok(playback.trackLength === null || typeof playback.trackLength === 'number', 'playback.trackLength should be number or null')
105
+ assert.ok(playback.streaming === null || typeof playback.streaming === 'boolean', 'playback.streaming should be boolean or null')
106
+ assert.ok(playback.sleepTimerActive === null || typeof playback.sleepTimerActive === 'boolean', 'playback.sleepTimerActive should be boolean or null')
107
+ assert.ok(playback.sleepTimerSeconds === null || typeof playback.sleepTimerSeconds === 'number', 'playback.sleepTimerSeconds should be number or null')
108
+ }
109
+
110
+ /**
111
+ * @param {string | undefined} value
112
+ * @returns {string}
113
+ */
114
+ export function toLower (value) {
115
+ return typeof value === 'string' ? value.toLowerCase() : ''
116
+ }
package/lib/token.d.ts CHANGED
@@ -3,8 +3,16 @@
3
3
  * @property {string} clientId - OAuth client ID
4
4
  * @property {string} refreshToken - OAuth refresh token
5
5
  * @property {string} accessToken - Initial OAuth access token (JWT)
6
+ * @property {OnTokenRefreshHandler} onTokenRefresh - A function that will receive the refreshed token info and perisist it for future use
6
7
  * @property {number} [bufferSeconds=30] - Seconds before expiration to consider token expired
7
8
  */
9
+ /**
10
+ * This is a REQUIRED function. It gets called after the token is refreshed with new tokens.
11
+ * It should save the new tokens somewhere. When starting back up, re-use the newly perissted tokens.
12
+ * If you loose track of these new tokens, you will effectively be logged out.
13
+ *
14
+ * @typedef {(refreshSuccessEvent: RefreshSuccessEvent) => void | Promise<void>} OnTokenRefreshHandler
15
+ */
8
16
  /**
9
17
  * @typedef {Object} RefreshSuccessEvent
10
18
  * @property {string} clientId - The OAuth client ID
@@ -30,7 +38,7 @@
30
38
  *
31
39
  * Events:
32
40
  * - 'refresh:start' - Emitted when token refresh begins
33
- * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, accessToken, refreshToken, expiresAt }
41
+ * - 'refresh:success' - Emitted when token refresh succeeds, passes { clientId, updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken, prevExpiresAt }
34
42
  * - 'refresh:error' - Emitted when token refresh fails (transient errors), passes error
35
43
  * - 'invalid' - Emitted when refresh token is permanently invalid, passes error
36
44
  *
@@ -40,13 +48,37 @@ export class RefreshableToken extends EventEmitter<RefreshableTokenEventMap> {
40
48
  /**
41
49
  * @param {RefreshableTokenOpts} opts
42
50
  */
43
- constructor({ clientId, refreshToken, accessToken, bufferSeconds }: RefreshableTokenOpts);
51
+ constructor({ clientId, refreshToken, accessToken, bufferSeconds, onTokenRefresh }: RefreshableTokenOpts);
44
52
  /**
45
53
  * Get a valid access token, refreshing if necessary.
46
54
  * @returns {Promise<string>} Valid access token
47
55
  * @throws {Error} If token is invalid or refresh fails
48
56
  */
49
57
  getAccessToken(): Promise<string>;
58
+ /**
59
+ * Stop background token refresh scheduling.
60
+ */
61
+ stopAutoRefresh(): void;
62
+ /**
63
+ * Start background token refresh scheduling (enabled by default).
64
+ */
65
+ startAutoRefresh(): void;
66
+ /**
67
+ * Check if background token refresh scheduling is enabled.
68
+ * @returns {boolean}
69
+ */
70
+ isAutoRefreshEnabled(): boolean;
71
+ /**
72
+ * Get the current OAuth client ID (synchronous).
73
+ * @returns {string}
74
+ */
75
+ get clientId(): string;
76
+ /**
77
+ * Get the latest access token value (synchronous).
78
+ * Prefer getAccessToken() if you need a guaranteed-valid token in async contexts.
79
+ * @returns {string}
80
+ */
81
+ get accessToken(): string;
50
82
  /**
51
83
  * Check if the token is currently valid (not expired and not marked invalid).
52
84
  * @returns {boolean} True if token is valid
@@ -84,11 +116,21 @@ export type RefreshableTokenOpts = {
84
116
  * - Initial OAuth access token (JWT)
85
117
  */
86
118
  accessToken: string;
119
+ /**
120
+ * - A function that will receive the refreshed token info and perisist it for future use
121
+ */
122
+ onTokenRefresh: OnTokenRefreshHandler;
87
123
  /**
88
124
  * - Seconds before expiration to consider token expired
89
125
  */
90
126
  bufferSeconds?: number;
91
127
  };
128
+ /**
129
+ * This is a REQUIRED function. It gets called after the token is refreshed with new tokens.
130
+ * It should save the new tokens somewhere. When starting back up, re-use the newly perissted tokens.
131
+ * If you loose track of these new tokens, you will effectively be logged out.
132
+ */
133
+ export type OnTokenRefreshHandler = (refreshSuccessEvent: RefreshSuccessEvent) => void | Promise<void>;
92
134
  export type RefreshSuccessEvent = {
93
135
  /**
94
136
  * - The OAuth client ID
@@ -1 +1 @@
1
- {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"AAIA;;;;;;GAMG;AAEH;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AAEH;;;;;;;;;;;GAWG;AACH;IAgBE;;OAEG;IACH,oEAFW,oBAAoB,EAoB9B;IAED;;;;OAIG;IACH,kBAHa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAkGD;;;OAGG;IACH,WAFa,OAAO,CASnB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;OAGG;IACH,oBAFa,MAAM,CAKlB;IAED;;;;;OAKG;IACH,WAHa,OAAO,CAAC,mBAAmB,CAAC,CAUxC;;CACF;;;;;cAhPa,MAAM;;;;kBACN,MAAM;;;;iBACN,MAAM;;;;oBACN,MAAM;;;;;;cAKN,MAAM;;;;wBACN,MAAM;;;;yBACN,MAAM;;;;sBACN,MAAM;;;;qBACN,MAAM;;;;sBACN,MAAM;;;;mBACN,MAAM;;;;;uCAKP;IACZ,eAAmB,EAAE,EAAE,CAAC;IACxB,iBAAqB,EAAE,CAAC,mBAAmB,CAAC,CAAC;IAC7C,eAAmB,EAAE,CAAC,KAAK,CAAC,CAAC;IAC7B,SAAa,EAAE,CAAC,KAAK,CAAC,CAAA;CACnB;6BA9ByB,aAAa"}
1
+ {"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"AAOA;;;;;;;GAOG;AAEH;;;;;;GAMG;AAEH;;;;;;;;;GASG;AAEH;;;;;;;;GAQG;AAEH;;;;;;;;;;;GAWG;AAEH;IAwBE;;OAEG;IACH,oFAFW,oBAAoB,EAuB9B;IAED;;;;OAIG;IACH,kBAHa,OAAO,CAAC,MAAM,CAAC,CAkB3B;IAyGD;;OAEG;IACH,wBAGC;IAED;;OAEG;IACH,yBAQC;IAED;;;OAGG;IACH,wBAFa,OAAO,CAInB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;;OAIG;IACH,mBAFa,MAAM,CAIlB;IAiED;;;OAGG;IACH,WAFa,OAAO,CASnB;IAED;;;OAGG;IACH,gBAFa,MAAM,CAIlB;IAED;;;OAGG;IACH,oBAFa,MAAM,CAKlB;IAED;;;;;OAKG;IACH,WAHa,OAAO,CAAC,mBAAmB,CAAC,CAUxC;;CACF;;;;;cAzXa,MAAM;;;;kBACN,MAAM;;;;iBACN,MAAM;;;;oBACN,qBAAqB;;;;oBACrB,MAAM;;;;;;;oCAQP,CAAC,mBAAmB,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;;;;;cAKjE,MAAM;;;;wBACN,MAAM;;;;yBACN,MAAM;;;;sBACN,MAAM;;;;qBACN,MAAM;;;;sBACN,MAAM;;;;mBACN,MAAM;;;;;uCAKP;IACZ,eAAmB,EAAE,EAAE,CAAC;IACxB,iBAAqB,EAAE,CAAC,mBAAmB,CAAC,CAAC;IAC7C,eAAmB,EAAE,CAAC,KAAK,CAAC,CAAC;IAC7B,SAAa,EAAE,CAAC,KAAK,CAAC,CAAA;CACnB;6BA1CyB,aAAa"}