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.
- package/README.md +73 -40
- package/bin/auth.js +4 -3
- package/bin/device-model.js +25 -5
- package/bin/device-tui.js +25 -3
- package/bin/devices.js +25 -9
- package/bin/lib/cli-helpers.d.ts.map +1 -1
- package/bin/lib/cli-helpers.js +3 -1
- package/bin/lib/token-helpers.d.ts +4 -2
- package/bin/lib/token-helpers.d.ts.map +1 -1
- package/bin/lib/token-helpers.js +9 -8
- package/bin/refresh-token.js +4 -2
- package/bin/token-info.js +2 -2
- package/lib/api-client.d.ts +11 -10
- package/lib/api-client.d.ts.map +1 -1
- package/lib/api-client.js +12 -14
- package/lib/api-endpoints/auth.test.js +4 -4
- package/lib/api-endpoints/content.test.js +32 -32
- package/lib/api-endpoints/devices.d.ts +4 -4
- package/lib/api-endpoints/devices.js +2 -2
- package/lib/api-endpoints/devices.test.js +45 -45
- package/lib/api-endpoints/endpoint-test-helpers.d.ts +3 -4
- package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -1
- package/lib/api-endpoints/endpoint-test-helpers.js +21 -5
- package/lib/api-endpoints/family-library-groups.d.ts +3 -3
- package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
- package/lib/api-endpoints/family-library-groups.js +3 -3
- package/lib/api-endpoints/family-library-groups.test.js +29 -29
- package/lib/api-endpoints/family.test.js +11 -11
- package/lib/api-endpoints/icons.test.js +14 -14
- package/lib/mqtt/client.d.ts +123 -48
- package/lib/mqtt/client.d.ts.map +1 -1
- package/lib/mqtt/client.js +131 -49
- package/lib/mqtt/factory.d.ts +12 -5
- package/lib/mqtt/factory.d.ts.map +1 -1
- package/lib/mqtt/factory.js +39 -11
- package/lib/mqtt/index.js +2 -1
- package/lib/mqtt/mqtt.test.js +25 -22
- package/lib/test-helpers/device-model-test-helpers.d.ts +29 -0
- package/lib/test-helpers/device-model-test-helpers.d.ts.map +1 -0
- package/lib/test-helpers/device-model-test-helpers.js +116 -0
- package/lib/token.d.ts +44 -2
- package/lib/token.d.ts.map +1 -1
- package/lib/token.js +142 -2
- package/lib/yoto-account.d.ts +339 -9
- package/lib/yoto-account.d.ts.map +1 -1
- package/lib/yoto-account.js +411 -39
- package/lib/yoto-account.test.js +139 -0
- package/lib/yoto-device.d.ts +418 -30
- package/lib/yoto-device.d.ts.map +1 -1
- package/lib/yoto-device.js +670 -104
- package/lib/yoto-device.test.js +88 -0
- package/package.json +1 -1
package/lib/mqtt/factory.js
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
85
|
-
throw new Error('
|
|
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
|
-
|
|
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
|
|
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
package/lib/mqtt/mqtt.test.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
50
|
+
assert.equal(typeof payload.playbackStatus, 'string', 'playbackStatus should be string')
|
|
48
51
|
}
|
|
49
52
|
if (payload.cardId !== undefined) {
|
|
50
|
-
assert.
|
|
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.
|
|
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.
|
|
69
|
-
assert.
|
|
70
|
-
assert.
|
|
71
|
-
assert.
|
|
72
|
-
assert.
|
|
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.
|
|
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.
|
|
92
|
-
assert.
|
|
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.
|
|
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.
|
|
104
|
+
assert.equal(typeof payload.status.upTime, 'number', 'upTime should be number')
|
|
102
105
|
}
|
|
103
106
|
if (payload.status.utcTime !== undefined) {
|
|
104
|
-
assert.
|
|
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.
|
|
112
|
+
assert.equal(typeof payload.status.battery, 'number', 'battery should be number')
|
|
110
113
|
}
|
|
111
114
|
if (payload.status.wifiStrength !== undefined) {
|
|
112
|
-
assert.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
package/lib/token.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["token.js"],"names":[],"mappings":"
|
|
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"}
|