homebridge-yoto 0.0.1 → 0.0.3

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/lib/types.js ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ * @fileoverview Type definitions for Yoto API and Homebridge integration
3
+ */
4
+
5
+ /**
6
+ * @typedef {Object} YotoDevice
7
+ * @property {string} deviceId - Unique identifier for the device
8
+ * @property {string} name - Device name
9
+ * @property {string} description - Device description
10
+ * @property {boolean} online - Whether device is currently online
11
+ * @property {string} releaseChannel - Release channel (e.g., "stable", "beta")
12
+ * @property {string} deviceType - Type of device (e.g., "player")
13
+ * @property {string} deviceFamily - Device family (e.g., "yoto")
14
+ * @property {string} deviceGroup - Device group classification
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} YotoDeviceStatus
19
+ * @property {number} statusVersion - Status data version
20
+ * @property {string} fwVersion - Firmware version
21
+ * @property {string} productType - Product type identifier
22
+ * @property {number} batteryLevel - Battery level (0-100)
23
+ * @property {number} als - Ambient light sensor reading
24
+ * @property {number} freeDisk - Free disk space in bytes
25
+ * @property {number} shutdownTimeout - Auto-shutdown timeout in seconds
26
+ * @property {number} dbatTimeout - Display battery timeout
27
+ * @property {number} charging - Charging state (0=not charging, 1=charging)
28
+ * @property {string | null} activeCard - Currently active card ID or null
29
+ * @property {number} cardInserted - Card insertion state (0=none, 1=physical, 2=remote)
30
+ * @property {number} playingStatus - Playing status code
31
+ * @property {boolean} headphones - Whether headphones are connected
32
+ * @property {number} dnowBrightness - Current display brightness
33
+ * @property {number} dayBright - Day mode brightness setting
34
+ * @property {number} nightBright - Night mode brightness setting
35
+ * @property {boolean} bluetoothHp - Bluetooth headphones enabled
36
+ * @property {number} volume - System volume level
37
+ * @property {number} userVolume - User volume level (0-100)
38
+ * @property {'12' | '24'} timeFormat - Time format preference
39
+ * @property {string} nightlightMode - Nightlight mode setting
40
+ * @property {string} temp - Temperature reading
41
+ * @property {number} day - Day mode (0=night, 1=day, -1=unknown)
42
+ */
43
+
44
+ /**
45
+ * @typedef {Object} YotoPlaybackEvents
46
+ * @property {string} repeatAll - Repeat all setting ("true" or "false")
47
+ * @property {string} streaming - Streaming active ("true" or "false")
48
+ * @property {string} volume - Current volume level
49
+ * @property {string} volumeMax - Maximum volume level
50
+ * @property {string} playbackWait - Playback wait state ("true" or "false")
51
+ * @property {string} sleepTimerActive - Sleep timer active ("true" or "false")
52
+ * @property {string} eventUtc - Event timestamp (Unix timestamp)
53
+ * @property {string} trackLength - Track duration in seconds
54
+ * @property {string} position - Current playback position in seconds
55
+ * @property {string} cardId - Active card ID
56
+ * @property {string} source - Playback source (e.g., "card", "remote", "MQTT")
57
+ * @property {string} cardUpdatedAt - Card last updated timestamp (ISO8601)
58
+ * @property {string} chapterTitle - Current chapter title
59
+ * @property {string} chapterKey - Current chapter key
60
+ * @property {string} trackTitle - Current track title
61
+ * @property {string} trackKey - Current track key
62
+ * @property {string} playbackStatus - Playback status ("playing", "paused", "stopped")
63
+ * @property {string} sleepTimerSeconds - Sleep timer remaining seconds
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} YotoDeviceConfigSettings
68
+ * @property {any[]} alarms - Alarm configurations
69
+ * @property {string} ambientColour - Ambient LED color (hex format)
70
+ * @property {string} bluetoothEnabled - Bluetooth enabled state
71
+ * @property {boolean} btHeadphonesEnabled - Bluetooth headphones enabled
72
+ * @property {string} clockFace - Clock face style
73
+ * @property {string} dayDisplayBrightness - Day display brightness
74
+ * @property {string} dayTime - Day mode start time
75
+ * @property {string} dayYotoDaily - Day mode Yoto Daily content
76
+ * @property {string} dayYotoRadio - Day mode Yoto Radio content
77
+ * @property {string} displayDimBrightness - Display dim brightness level
78
+ * @property {string} displayDimTimeout - Display dim timeout in seconds
79
+ * @property {boolean} headphonesVolumeLimited - Headphones volume limited
80
+ * @property {string} hourFormat - Hour format ("12" or "24")
81
+ * @property {string} locale - Locale setting
82
+ * @property {string} maxVolumeLimit - Max volume limit (0-16)
83
+ * @property {string} nightAmbientColour - Night ambient LED color
84
+ * @property {string} nightDisplayBrightness - Night display brightness
85
+ * @property {string} nightMaxVolumeLimit - Night max volume limit (0-16)
86
+ * @property {string} nightTime - Night mode start time
87
+ * @property {string} nightYotoDaily - Night mode Yoto Daily content
88
+ * @property {string} nightYotoRadio - Night mode Yoto Radio content
89
+ * @property {boolean} repeatAll - Repeat all tracks enabled
90
+ * @property {string} shutdownTimeout - Auto-shutdown timeout in seconds
91
+ * @property {string} volumeLevel - Volume level preset
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object} YotoDeviceConfig
96
+ * @property {string} name - Device name
97
+ * @property {YotoDeviceConfigSettings} config - Device configuration settings
98
+ */
99
+
100
+ /**
101
+ * @typedef {Object} YotoCardContent
102
+ * @property {Object} [card] - Card information
103
+ * @property {string} [card.cardId] - Card unique identifier
104
+ * @property {string} [card.title] - Card title
105
+ * @property {string} [card.slug] - Card slug
106
+ * @property {string} [card.userId] - Owner user ID
107
+ * @property {string} [card.createdAt] - Creation timestamp
108
+ * @property {string} [card.updatedAt] - Update timestamp
109
+ * @property {boolean} [card.deleted] - Whether card is deleted
110
+ * @property {Object} [card.metadata] - Additional metadata
111
+ * @property {string} [card.metadata.author] - Card author
112
+ * @property {string} [card.metadata.category] - Content category
113
+ * @property {string} [card.metadata.description] - Card description
114
+ * @property {Object} [card.metadata.cover] - Cover image data
115
+ * @property {string} [card.metadata.cover.imageL] - Large cover image URL
116
+ * @property {Object} [card.metadata.media] - Media information
117
+ * @property {number} [card.metadata.media.duration] - Total duration in seconds
118
+ * @property {number} [card.metadata.media.fileSize] - File size in bytes
119
+ * @property {YotoChapter[]} [card.chapters] - Card chapters
120
+ */
121
+
122
+ /**
123
+ * @typedef {Object} YotoChapter
124
+ * @property {string} key - Chapter key
125
+ * @property {string} title - Chapter title
126
+ * @property {string | null} availableFrom - Availability date (ISO8601)
127
+ * @property {YotoTrack[]} tracks - Chapter tracks
128
+ */
129
+
130
+ /**
131
+ * @typedef {Object} YotoTrack
132
+ * @property {string} key - Track key
133
+ * @property {string} title - Track title
134
+ * @property {number} duration - Track duration in seconds
135
+ */
136
+
137
+ /**
138
+ * @typedef {Object} YotoApiDevicesResponse
139
+ * @property {YotoDevice[]} devices - Array of devices
140
+ */
141
+
142
+ /**
143
+ * @typedef {Object} YotoApiTokenResponse
144
+ * @property {string} access_token - Access token
145
+ * @property {string} token_type - Token type (typically "Bearer")
146
+ * @property {number} expires_in - Token lifetime in seconds
147
+ * @property {string} [refresh_token] - Refresh token
148
+ * @property {string} [scope] - Granted scopes
149
+ * @property {string} [id_token] - ID token JWT
150
+ */
151
+
152
+ /**
153
+ * @typedef {Object} YotoApiDeviceCodeResponse
154
+ * @property {string} device_code - Device verification code
155
+ * @property {string} user_code - User-facing code to enter
156
+ * @property {string} verification_uri - URL for user to visit
157
+ * @property {string} verification_uri_complete - Complete verification URL with code
158
+ * @property {number} expires_in - Code lifetime in seconds
159
+ * @property {number} interval - Minimum polling interval in seconds
160
+ */
161
+
162
+ /**
163
+ * @typedef {Object} YotoAccessoryContext
164
+ * @property {YotoDevice} device - Device information
165
+ * @property {YotoDeviceStatus | null} lastStatus - Last known device status
166
+ * @property {YotoPlaybackEvents | null} lastEvents - Last known playback events
167
+ * @property {number} lastUpdate - Timestamp of last update
168
+ * @property {YotoCardContent | null} [activeContentInfo] - Current active content information
169
+ */
170
+
171
+ /**
172
+ * @typedef {Object} YotoPlatformConfig
173
+ * @property {string} [platform] - Platform name (should be "Yoto")
174
+ * @property {string} [name] - Platform instance name
175
+ * @property {string} [clientId] - OAuth client ID
176
+ * @property {string} [accessToken] - Stored access token
177
+ * @property {string} [refreshToken] - Stored refresh token
178
+ * @property {number} [tokenExpiresAt] - Token expiration timestamp
179
+ * @property {string} [mqttBroker] - MQTT broker URL
180
+ * @property {number} [statusTimeoutSeconds] - Seconds before marking device offline
181
+ * @property {boolean} [exposeTemperature] - Expose temperature sensor
182
+ * @property {boolean} [exposeBattery] - Expose battery service
183
+ * @property {boolean} [exposeAdvancedControls] - Expose advanced control switches
184
+ * @property {boolean} [exposeConnectionStatus] - Expose connection status sensor
185
+ * @property {boolean} [exposeCardDetection] - Expose card detection sensor
186
+ * @property {boolean} [exposeDisplayBrightness] - Expose display brightness control
187
+ * @property {boolean} [exposeSleepTimer] - Expose sleep timer control
188
+ * @property {boolean} [exposeVolumeLimits] - Expose volume limit controls
189
+ * @property {boolean} [exposeAmbientLight] - Expose ambient light color control
190
+ * @property {boolean} [exposeActiveContent] - Track and display active content information
191
+ * @property {boolean} [updateAccessoryName] - Update accessory display name with current content
192
+ * @property {string} [volumeControlType] - Volume control service type
193
+ * @property {boolean} [debug] - Enable debug logging
194
+ */
195
+
196
+ /**
197
+ * @typedef {Object} MqttCommandResponse
198
+ * @property {Object} status - Response status
199
+ * @property {string} req_body - Stringified request body
200
+ */
201
+
202
+ /**
203
+ * MQTT command payloads
204
+ */
205
+
206
+ /**
207
+ * @typedef {Object} MqttVolumeCommand
208
+ * @property {number} volume - Volume level (0-100)
209
+ */
210
+
211
+ /**
212
+ * @typedef {Object} MqttAmbientCommand
213
+ * @property {number} r - Red intensity (0-255)
214
+ * @property {number} g - Green intensity (0-255)
215
+ * @property {number} b - Blue intensity (0-255)
216
+ */
217
+
218
+ /**
219
+ * @typedef {Object} MqttSleepTimerCommand
220
+ * @property {number} seconds - Sleep timer duration (0 to disable)
221
+ */
222
+
223
+ /**
224
+ * @typedef {Object} MqttCardStartCommand
225
+ * @property {string} uri - Card URI (e.g., "https://yoto.io/{cardId}")
226
+ * @property {string} [chapterKey] - Chapter to start from
227
+ * @property {string} [trackKey] - Track to start from
228
+ * @property {number} [secondsIn] - Playback start offset in seconds
229
+ * @property {number} [cutOff] - Playback stop offset in seconds
230
+ * @property {boolean} [anyButtonStop] - Whether any button stops playback
231
+ */
232
+
233
+ export {}
package/lib/yotoApi.js ADDED
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @fileoverview REST API client for Yoto API
3
+ */
4
+
5
+ /** @import { Logger } from 'homebridge' */
6
+ /** @import { YotoApiDevicesResponse, YotoDevice, YotoDeviceConfig, YotoCardContent } from './types.js' */
7
+ /** @import { YotoAuth } from './auth.js' */
8
+
9
+ import {
10
+ YOTO_API_BASE_URL,
11
+ ERROR_MESSAGES,
12
+ LOG_PREFIX
13
+ } from './constants.js'
14
+
15
+ /**
16
+ * Yoto REST API client
17
+ */
18
+ export class YotoApi {
19
+ /**
20
+ * @param {Logger} log - Homebridge logger
21
+ * @param {YotoAuth} auth - Authentication handler
22
+ */
23
+ constructor (log, auth) {
24
+ this.log = log
25
+ this.auth = auth
26
+ this.accessToken = null
27
+ this.refreshToken = null
28
+ this.tokenExpiresAt = 0
29
+ }
30
+
31
+ /**
32
+ * Set authentication tokens
33
+ * @param {string} accessToken - Access token
34
+ * @param {string} refreshToken - Refresh token
35
+ * @param {number} expiresAt - Token expiration timestamp
36
+ */
37
+ setTokens (accessToken, refreshToken, expiresAt) {
38
+ this.accessToken = accessToken
39
+ this.refreshToken = refreshToken
40
+ this.tokenExpiresAt = expiresAt
41
+ }
42
+
43
+ /**
44
+ * Check if we have valid authentication
45
+ * @returns {boolean}
46
+ */
47
+ hasAuth () {
48
+ return !!this.accessToken
49
+ }
50
+
51
+ /**
52
+ * Ensure we have a valid access token, refreshing if necessary
53
+ * @returns {Promise<void>}
54
+ */
55
+ async ensureValidToken () {
56
+ if (!this.accessToken) {
57
+ throw new Error(ERROR_MESSAGES.NO_AUTH)
58
+ }
59
+
60
+ // Check if token needs refresh
61
+ if (this.auth.isTokenExpired(this.tokenExpiresAt)) {
62
+ this.log.info(LOG_PREFIX.API, ERROR_MESSAGES.TOKEN_EXPIRED)
63
+
64
+ if (!this.refreshToken) {
65
+ throw new Error(ERROR_MESSAGES.TOKEN_REFRESH_FAILED)
66
+ }
67
+
68
+ try {
69
+ const tokenResponse = await this.auth.refreshAccessToken(this.refreshToken)
70
+ this.accessToken = tokenResponse.access_token
71
+ this.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
72
+
73
+ // Update refresh token if a new one was provided
74
+ if (tokenResponse.refresh_token) {
75
+ this.refreshToken = tokenResponse.refresh_token
76
+ }
77
+
78
+ // Notify platform to save updated tokens
79
+ if (this.onTokenRefreshCallback) {
80
+ this.onTokenRefreshCallback(this.accessToken, this.refreshToken, this.tokenExpiresAt)
81
+ }
82
+ } catch (error) {
83
+ this.log.error(LOG_PREFIX.API, 'Token refresh failed:', error)
84
+ throw error
85
+ }
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Make an authenticated API request
91
+ * @param {string} endpoint - API endpoint path
92
+ * @param {RequestInit & { _retried?: boolean }} [options] - Fetch options
93
+ * @returns {Promise<any>}
94
+ */
95
+ async request (endpoint, options = {}) {
96
+ await this.ensureValidToken()
97
+
98
+ const url = `${YOTO_API_BASE_URL}${endpoint}`
99
+
100
+ const headers = {
101
+ Authorization: `Bearer ${this.accessToken}`,
102
+ 'Content-Type': 'application/json',
103
+ ...options.headers
104
+ }
105
+
106
+ try {
107
+ this.log.debug(LOG_PREFIX.API, `${options.method || 'GET'} ${endpoint}`)
108
+
109
+ const response = await fetch(url, {
110
+ ...options,
111
+ headers
112
+ })
113
+
114
+ if (!response.ok) {
115
+ const errorText = await response.text()
116
+ this.log.error(LOG_PREFIX.API, `Request failed: ${response.status} ${errorText}`)
117
+
118
+ // Handle 401 by attempting token refresh once
119
+ if (response.status === 401 && !options._retried) {
120
+ this.log.warn(LOG_PREFIX.API, 'Received 401, forcing token refresh...')
121
+ this.tokenExpiresAt = 0 // Force refresh
122
+ await this.ensureValidToken()
123
+ return this.request(endpoint, { ...options, _retried: true })
124
+ }
125
+
126
+ throw new Error(`API request failed: ${response.status} ${errorText}`)
127
+ }
128
+
129
+ // Return empty object for 204 No Content
130
+ if (response.status === 204) {
131
+ return {}
132
+ }
133
+
134
+ return await response.json()
135
+ } catch (error) {
136
+ this.log.error(LOG_PREFIX.API, `${ERROR_MESSAGES.API_ERROR}:`, error)
137
+ throw error
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Get all devices associated with the authenticated user
143
+ * @returns {Promise<YotoDevice[]>}
144
+ */
145
+ async getDevices () {
146
+ this.log.debug(LOG_PREFIX.API, 'Fetching devices...')
147
+
148
+ const response = /** @type {YotoApiDevicesResponse} */ (await this.request('/device-v2/devices/mine'))
149
+
150
+ this.log.info(LOG_PREFIX.API, `Found ${response.devices.length} device(s)`)
151
+ return response.devices
152
+ }
153
+
154
+ /**
155
+ * Get device configuration
156
+ * @param {string} deviceId - Device ID
157
+ * @returns {Promise<YotoDeviceConfig>}
158
+ */
159
+ async getDeviceConfig (deviceId) {
160
+ this.log.debug(LOG_PREFIX.API, `Fetching config for device ${deviceId}`)
161
+
162
+ const config = /** @type {YotoDeviceConfig} */ (await this.request(`/device-v2/${deviceId}/config`))
163
+ return config
164
+ }
165
+
166
+ /**
167
+ * Update device configuration
168
+ * @param {string} deviceId - Device ID
169
+ * @param {YotoDeviceConfig} config - Updated configuration
170
+ * @returns {Promise<YotoDeviceConfig>}
171
+ */
172
+ async updateDeviceConfig (deviceId, config) {
173
+ this.log.debug(LOG_PREFIX.API, `Updating config for device ${deviceId}`)
174
+
175
+ const updatedConfig = /** @type {YotoDeviceConfig} */ (await this.request(`/device-v2/${deviceId}/config`, {
176
+ method: 'PUT',
177
+ body: JSON.stringify(config)
178
+ }))
179
+
180
+ return updatedConfig
181
+ }
182
+
183
+ /**
184
+ * Get content/card details
185
+ * @param {string} cardId - Card ID
186
+ * @param {Object} [options] - Query options
187
+ * @param {string} [options.timezone] - Timezone for chapter availability
188
+ * @param {boolean} [options.playable] - Return playable signed URLs
189
+ * @returns {Promise<YotoCardContent>}
190
+ */
191
+ async getContent (cardId, options = {}) {
192
+ this.log.debug(LOG_PREFIX.API, `Fetching content for card ${cardId}`)
193
+
194
+ const queryParams = new URLSearchParams()
195
+ if (options.timezone) {
196
+ queryParams.append('timezone', options.timezone)
197
+ }
198
+ if (options.playable) {
199
+ queryParams.append('playable', 'true')
200
+ }
201
+
202
+ const queryString = queryParams.toString()
203
+ const endpoint = `/content/${cardId}${queryString ? `?${queryString}` : ''}`
204
+
205
+ const content = /** @type {YotoCardContent} */ (await this.request(endpoint))
206
+ return content
207
+ }
208
+
209
+ /**
210
+ * Get user's MYO (Make Your Own) cards
211
+ * @param {Object} [options] - Query options
212
+ * @param {boolean} [options.showdeleted] - Show deleted cards
213
+ * @returns {Promise<any>}
214
+ */
215
+ async getMyContent (options = {}) {
216
+ this.log.debug(LOG_PREFIX.API, 'Fetching user content...')
217
+
218
+ const queryParams = new URLSearchParams()
219
+ if (options.showdeleted !== undefined) {
220
+ queryParams.append('showdeleted', String(options.showdeleted))
221
+ }
222
+
223
+ const queryString = queryParams.toString()
224
+ const endpoint = `/content/mine${queryString ? `?${queryString}` : ''}`
225
+
226
+ const content = await this.request(endpoint)
227
+ return content
228
+ }
229
+
230
+ /**
231
+ * Get family library groups
232
+ * @returns {Promise<any>}
233
+ */
234
+ async getLibraryGroups () {
235
+ this.log.debug(LOG_PREFIX.API, 'Fetching library groups...')
236
+
237
+ const groups = await this.request('/card/family/library/groups')
238
+ return groups
239
+ }
240
+
241
+ /**
242
+ * Get specific library group
243
+ * @param {string} groupId - Group ID
244
+ * @returns {Promise<any>}
245
+ */
246
+ async getLibraryGroup (groupId) {
247
+ this.log.debug(LOG_PREFIX.API, `Fetching library group ${groupId}`)
248
+
249
+ const group = await this.request(`/card/family/library/groups/${groupId}`)
250
+ return group
251
+ }
252
+
253
+ /**
254
+ * Set callback for token refresh events
255
+ * @param {(accessToken: string, refreshToken: string, expiresAt: number) => void} callback
256
+ */
257
+ setTokenRefreshCallback (callback) {
258
+ this.onTokenRefreshCallback = callback
259
+ }
260
+ }