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/AGENTS.md +253 -0
- package/PLAN.md +609 -0
- package/README.md +315 -6
- package/config.schema.json +222 -0
- package/index.js +15 -2
- package/index.test.js +4 -4
- package/lib/auth.js +220 -0
- package/lib/constants.js +157 -0
- package/lib/platform.js +269 -0
- package/lib/playerAccessory.js +1452 -0
- package/lib/types.js +233 -0
- package/lib/yotoApi.js +260 -0
- package/lib/yotoMqtt.js +521 -0
- package/package.json +14 -7
- package/lib/.keep +0 -0
package/lib/auth.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OAuth2 Device Authorization Flow implementation for Yoto API
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { Logger } from 'homebridge' */
|
|
6
|
+
/** @import { YotoApiTokenResponse, YotoApiDeviceCodeResponse } from './types.js' */
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
YOTO_OAUTH_DEVICE_CODE_URL,
|
|
10
|
+
YOTO_OAUTH_TOKEN_URL,
|
|
11
|
+
OAUTH_CLIENT_ID,
|
|
12
|
+
OAUTH_AUDIENCE,
|
|
13
|
+
OAUTH_SCOPE,
|
|
14
|
+
OAUTH_POLLING_INTERVAL,
|
|
15
|
+
OAUTH_DEVICE_CODE_TIMEOUT,
|
|
16
|
+
ERROR_MESSAGES,
|
|
17
|
+
LOG_PREFIX
|
|
18
|
+
} from './constants.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OAuth2 authentication handler for Yoto API
|
|
22
|
+
*/
|
|
23
|
+
export class YotoAuth {
|
|
24
|
+
/**
|
|
25
|
+
* @param {Logger} log - Homebridge logger
|
|
26
|
+
* @param {string} [clientId] - OAuth client ID (uses default if not provided)
|
|
27
|
+
*/
|
|
28
|
+
constructor (log, clientId) {
|
|
29
|
+
this.log = log
|
|
30
|
+
this.clientId = clientId || OAUTH_CLIENT_ID
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Initiate device authorization flow
|
|
35
|
+
* @returns {Promise<YotoApiDeviceCodeResponse>}
|
|
36
|
+
*/
|
|
37
|
+
async initiateDeviceFlow () {
|
|
38
|
+
this.log.info(LOG_PREFIX.AUTH, 'Initiating device authorization flow...')
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(YOTO_OAUTH_DEVICE_CODE_URL, {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'Content-Type': 'application/json'
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
client_id: this.clientId,
|
|
48
|
+
scope: OAUTH_SCOPE,
|
|
49
|
+
audience: OAUTH_AUDIENCE
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const errorText = await response.text()
|
|
55
|
+
throw new Error(`Device code request failed: ${response.status} ${errorText}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const data = /** @type {YotoApiDeviceCodeResponse} */ (await response.json())
|
|
59
|
+
|
|
60
|
+
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
61
|
+
this.log.info(LOG_PREFIX.AUTH, 'YOTO AUTHENTICATION REQUIRED')
|
|
62
|
+
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
63
|
+
this.log.info(LOG_PREFIX.AUTH, '')
|
|
64
|
+
this.log.info(LOG_PREFIX.AUTH, `1. Visit: ${data.verification_uri}`)
|
|
65
|
+
this.log.info(LOG_PREFIX.AUTH, `2. Enter code: ${data.user_code}`)
|
|
66
|
+
this.log.info(LOG_PREFIX.AUTH, '')
|
|
67
|
+
this.log.info(LOG_PREFIX.AUTH, `Or visit: ${data.verification_uri_complete}`)
|
|
68
|
+
this.log.info(LOG_PREFIX.AUTH, '')
|
|
69
|
+
this.log.info(LOG_PREFIX.AUTH, `Code expires in ${Math.floor(data.expires_in / 60)} minutes`)
|
|
70
|
+
this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
|
|
71
|
+
|
|
72
|
+
return data
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.log.error(LOG_PREFIX.AUTH, 'Failed to initiate device flow:', error)
|
|
75
|
+
throw error
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Poll for authorization completion
|
|
81
|
+
* @param {string} deviceCode - Device code from initiation
|
|
82
|
+
* @returns {Promise<YotoApiTokenResponse>}
|
|
83
|
+
*/
|
|
84
|
+
async pollForAuthorization (deviceCode) {
|
|
85
|
+
const startTime = Date.now()
|
|
86
|
+
const timeout = OAUTH_DEVICE_CODE_TIMEOUT
|
|
87
|
+
|
|
88
|
+
this.log.info(LOG_PREFIX.AUTH, 'Waiting for user authorization...')
|
|
89
|
+
|
|
90
|
+
while (Date.now() - startTime < timeout) {
|
|
91
|
+
try {
|
|
92
|
+
const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Content-Type': 'application/json'
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({
|
|
98
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
99
|
+
device_code: deviceCode,
|
|
100
|
+
client_id: this.clientId
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (response.ok) {
|
|
105
|
+
const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
|
|
106
|
+
this.log.info(LOG_PREFIX.AUTH, '✓ Authorization successful!')
|
|
107
|
+
return tokenData
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const errorData = /** @type {any} */ (await response.json().catch(() => ({})))
|
|
111
|
+
|
|
112
|
+
// Handle specific OAuth errors
|
|
113
|
+
if (errorData.error === 'authorization_pending') {
|
|
114
|
+
// Still waiting for user to authorize
|
|
115
|
+
await this.sleep(OAUTH_POLLING_INTERVAL)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (errorData.error === 'slow_down') {
|
|
120
|
+
// Server wants us to slow down polling
|
|
121
|
+
await this.sleep(OAUTH_POLLING_INTERVAL * 2)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (errorData.error === 'expired_token') {
|
|
126
|
+
throw new Error('Device code expired. Please restart the authorization process.')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (errorData.error === 'access_denied') {
|
|
130
|
+
throw new Error('Authorization was denied by the user.')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Unknown error
|
|
134
|
+
throw new Error(`Authorization failed: ${errorData.error || response.statusText}`)
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof Error && error.message.includes('expired')) {
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
this.log.debug(LOG_PREFIX.AUTH, 'Polling error:', error)
|
|
140
|
+
await this.sleep(OAUTH_POLLING_INTERVAL)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
throw new Error('Authorization timed out. Please try again.')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Refresh access token using refresh token
|
|
149
|
+
* @param {string} refreshToken - Refresh token
|
|
150
|
+
* @returns {Promise<YotoApiTokenResponse>}
|
|
151
|
+
*/
|
|
152
|
+
async refreshAccessToken (refreshToken) {
|
|
153
|
+
this.log.debug(LOG_PREFIX.AUTH, 'Refreshing access token...')
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: {
|
|
159
|
+
'Content-Type': 'application/json'
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
grant_type: 'refresh_token',
|
|
163
|
+
refresh_token: refreshToken,
|
|
164
|
+
client_id: this.clientId
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const errorText = await response.text()
|
|
170
|
+
throw new Error(`Token refresh failed: ${response.status} ${errorText}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
|
|
174
|
+
this.log.info(LOG_PREFIX.AUTH, '✓ Token refreshed successfully')
|
|
175
|
+
return tokenData
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.log.error(LOG_PREFIX.AUTH, ERROR_MESSAGES.TOKEN_REFRESH_FAILED, error)
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Complete device authorization flow
|
|
184
|
+
* @returns {Promise<YotoApiTokenResponse>}
|
|
185
|
+
*/
|
|
186
|
+
async authorize () {
|
|
187
|
+
const deviceCodeResponse = await this.initiateDeviceFlow()
|
|
188
|
+
const tokenResponse = await this.pollForAuthorization(deviceCodeResponse.device_code)
|
|
189
|
+
return tokenResponse
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Check if token is expired or expiring soon
|
|
194
|
+
* @param {number} expiresAt - Token expiration timestamp (seconds since epoch)
|
|
195
|
+
* @param {number} bufferSeconds - Seconds before expiry to consider expired
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
isTokenExpired (expiresAt, bufferSeconds = 300) {
|
|
199
|
+
const now = Math.floor(Date.now() / 1000)
|
|
200
|
+
return expiresAt <= now + bufferSeconds
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Calculate token expiration timestamp
|
|
205
|
+
* @param {number} expiresIn - Seconds until token expires
|
|
206
|
+
* @returns {number} - Unix timestamp when token expires
|
|
207
|
+
*/
|
|
208
|
+
calculateExpiresAt (expiresIn) {
|
|
209
|
+
return Math.floor(Date.now() / 1000) + expiresIn
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Sleep for specified milliseconds
|
|
214
|
+
* @param {number} ms - Milliseconds to sleep
|
|
215
|
+
* @returns {Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
sleep (ms) {
|
|
218
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
219
|
+
}
|
|
220
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Constants and default values for the Yoto Homebridge plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plugin identification constants
|
|
7
|
+
*/
|
|
8
|
+
export const PLATFORM_NAME = 'Yoto'
|
|
9
|
+
export const PLUGIN_NAME = 'homebridge-yoto'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Yoto API endpoints
|
|
13
|
+
*/
|
|
14
|
+
export const YOTO_API_BASE_URL = 'https://api.yotoplay.com'
|
|
15
|
+
export const YOTO_OAUTH_AUTHORIZE_URL = `${YOTO_API_BASE_URL}/authorize`
|
|
16
|
+
export const YOTO_OAUTH_TOKEN_URL = `${YOTO_API_BASE_URL}/oauth/token`
|
|
17
|
+
export const YOTO_OAUTH_DEVICE_CODE_URL = `${YOTO_API_BASE_URL}/oauth/device/code`
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MQTT configuration
|
|
21
|
+
*/
|
|
22
|
+
export const YOTO_MQTT_BROKER_URL = 'mqtt://mqtt.yotoplay.com:1883'
|
|
23
|
+
export const MQTT_RECONNECT_PERIOD = 5000 // milliseconds
|
|
24
|
+
export const MQTT_CONNECT_TIMEOUT = 30000 // milliseconds
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* MQTT topic templates
|
|
28
|
+
*/
|
|
29
|
+
export const MQTT_TOPIC_DATA_STATUS = '/device/{deviceId}/data/status'
|
|
30
|
+
export const MQTT_TOPIC_DATA_EVENTS = '/device/{deviceId}/data/events'
|
|
31
|
+
export const MQTT_TOPIC_RESPONSE = '/device/{deviceId}/response'
|
|
32
|
+
export const MQTT_TOPIC_COMMAND_STATUS_REQUEST = '/device/{deviceId}/command/status/request'
|
|
33
|
+
export const MQTT_TOPIC_COMMAND_EVENTS_REQUEST = '/device/{deviceId}/command/events/request'
|
|
34
|
+
export const MQTT_TOPIC_COMMAND_VOLUME_SET = '/device/{deviceId}/command/volume/set'
|
|
35
|
+
export const MQTT_TOPIC_COMMAND_CARD_START = '/device/{deviceId}/command/card/start'
|
|
36
|
+
export const MQTT_TOPIC_COMMAND_CARD_STOP = '/device/{deviceId}/command/card/stop'
|
|
37
|
+
export const MQTT_TOPIC_COMMAND_CARD_PAUSE = '/device/{deviceId}/command/card/pause'
|
|
38
|
+
export const MQTT_TOPIC_COMMAND_CARD_RESUME = '/device/{deviceId}/command/card/resume'
|
|
39
|
+
export const MQTT_TOPIC_COMMAND_SLEEP_TIMER = '/device/{deviceId}/command/sleep-timer/set'
|
|
40
|
+
export const MQTT_TOPIC_COMMAND_AMBIENTS_SET = '/device/{deviceId}/command/ambients/set'
|
|
41
|
+
export const MQTT_TOPIC_COMMAND_REBOOT = '/device/{deviceId}/command/reboot'
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* OAuth configuration
|
|
45
|
+
*/
|
|
46
|
+
export const OAUTH_CLIENT_ID = 'Y4HJ8BFqRQ24GQoLzgOzZ2KSqWmFG8LI'
|
|
47
|
+
export const OAUTH_AUDIENCE = 'https://api.yotoplay.com'
|
|
48
|
+
export const OAUTH_SCOPE = 'profile offline_access openid'
|
|
49
|
+
export const OAUTH_POLLING_INTERVAL = 5000 // milliseconds
|
|
50
|
+
export const OAUTH_DEVICE_CODE_TIMEOUT = 300000 // 5 minutes
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Device polling and timeouts
|
|
54
|
+
*/
|
|
55
|
+
export const DEFAULT_STATUS_TIMEOUT_SECONDS = 120 // Mark device offline after no updates
|
|
56
|
+
export const TOKEN_REFRESH_BUFFER_SECONDS = 300 // Refresh token 5 minutes before expiry
|
|
57
|
+
export const INITIAL_STATUS_REQUEST_DELAY = 2000 // milliseconds after MQTT connect
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* HomeKit service configuration
|
|
61
|
+
*/
|
|
62
|
+
export const DEFAULT_MANUFACTURER = 'Yoto'
|
|
63
|
+
export const DEFAULT_MODEL = 'Yoto Player'
|
|
64
|
+
export const LOW_BATTERY_THRESHOLD = 20 // percentage
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Volume configuration
|
|
68
|
+
*/
|
|
69
|
+
export const MIN_VOLUME = 0
|
|
70
|
+
export const MAX_VOLUME = 100
|
|
71
|
+
export const YOTO_MAX_VOLUME_LIMIT = 16 // Yoto's internal max volume scale
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Playback status mapping
|
|
75
|
+
*/
|
|
76
|
+
export const PLAYBACK_STATUS = {
|
|
77
|
+
PLAYING: 'playing',
|
|
78
|
+
PAUSED: 'paused',
|
|
79
|
+
STOPPED: 'stopped'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Card insertion states
|
|
84
|
+
*/
|
|
85
|
+
export const CARD_INSERTION_STATE = {
|
|
86
|
+
NONE: 0,
|
|
87
|
+
PHYSICAL: 1,
|
|
88
|
+
REMOTE: 2
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Day mode states
|
|
93
|
+
*/
|
|
94
|
+
export const DAY_MODE = {
|
|
95
|
+
UNKNOWN: -1,
|
|
96
|
+
NIGHT: 0,
|
|
97
|
+
DAY: 1
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Power source states
|
|
102
|
+
*/
|
|
103
|
+
export const POWER_SOURCE = {
|
|
104
|
+
BATTERY: 0,
|
|
105
|
+
V2_DOCK: 1,
|
|
106
|
+
USB_C: 2,
|
|
107
|
+
QI_DOCK: 3
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Default configuration values
|
|
112
|
+
*/
|
|
113
|
+
export const DEFAULT_CONFIG = {
|
|
114
|
+
platform: PLATFORM_NAME,
|
|
115
|
+
name: PLATFORM_NAME,
|
|
116
|
+
clientId: OAUTH_CLIENT_ID,
|
|
117
|
+
mqttBroker: YOTO_MQTT_BROKER_URL,
|
|
118
|
+
statusTimeoutSeconds: DEFAULT_STATUS_TIMEOUT_SECONDS,
|
|
119
|
+
exposeTemperature: true,
|
|
120
|
+
exposeBattery: true,
|
|
121
|
+
exposeAdvancedControls: false,
|
|
122
|
+
exposeConnectionStatus: true,
|
|
123
|
+
exposeCardDetection: false,
|
|
124
|
+
exposeDisplayBrightness: true,
|
|
125
|
+
exposeSleepTimer: false,
|
|
126
|
+
exposeVolumeLimits: false,
|
|
127
|
+
exposeAmbientLight: false,
|
|
128
|
+
exposeActiveContent: true,
|
|
129
|
+
updateAccessoryName: false,
|
|
130
|
+
volumeControlType: 'speaker',
|
|
131
|
+
debug: false
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Error messages
|
|
136
|
+
*/
|
|
137
|
+
export const ERROR_MESSAGES = {
|
|
138
|
+
NO_AUTH: 'No authentication credentials found. Please complete OAuth setup.',
|
|
139
|
+
TOKEN_EXPIRED: 'Access token expired. Attempting to refresh...',
|
|
140
|
+
TOKEN_REFRESH_FAILED: 'Failed to refresh access token. Please re-authenticate.',
|
|
141
|
+
MQTT_CONNECTION_FAILED: 'Failed to connect to MQTT broker.',
|
|
142
|
+
MQTT_DISCONNECTED: 'MQTT connection lost. Attempting to reconnect...',
|
|
143
|
+
DEVICE_OFFLINE: 'Device appears to be offline.',
|
|
144
|
+
API_ERROR: 'API request failed',
|
|
145
|
+
INVALID_CONFIG: 'Invalid configuration'
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Log prefixes
|
|
150
|
+
*/
|
|
151
|
+
export const LOG_PREFIX = {
|
|
152
|
+
AUTH: '[Auth]',
|
|
153
|
+
API: '[API]',
|
|
154
|
+
MQTT: '[MQTT]',
|
|
155
|
+
PLATFORM: '[Platform]',
|
|
156
|
+
ACCESSORY: '[Accessory]'
|
|
157
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Main platform implementation for Yoto Homebridge plugin
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge' */
|
|
6
|
+
/** @import { YotoDevice, YotoPlatformConfig, YotoAccessoryContext } from './types.js' */
|
|
7
|
+
|
|
8
|
+
import { YotoAuth } from './auth.js'
|
|
9
|
+
import { YotoApi } from './yotoApi.js'
|
|
10
|
+
import { YotoMqtt } from './yotoMqtt.js'
|
|
11
|
+
import { YotoPlayerAccessory } from './playerAccessory.js'
|
|
12
|
+
import {
|
|
13
|
+
PLATFORM_NAME,
|
|
14
|
+
PLUGIN_NAME,
|
|
15
|
+
DEFAULT_CONFIG,
|
|
16
|
+
ERROR_MESSAGES,
|
|
17
|
+
LOG_PREFIX
|
|
18
|
+
} from './constants.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Yoto Platform implementation
|
|
22
|
+
* @implements {DynamicPlatformPlugin}
|
|
23
|
+
*/
|
|
24
|
+
export class YotoPlatform {
|
|
25
|
+
/**
|
|
26
|
+
* @param {Logger} log - Homebridge logger
|
|
27
|
+
* @param {PlatformConfig & YotoPlatformConfig} config - Platform configuration
|
|
28
|
+
* @param {API} api - Homebridge API
|
|
29
|
+
*/
|
|
30
|
+
constructor (log, config, api) {
|
|
31
|
+
this.log = log
|
|
32
|
+
this.config = /** @type {YotoPlatformConfig} */ ({ ...DEFAULT_CONFIG, ...config })
|
|
33
|
+
this.api = api
|
|
34
|
+
|
|
35
|
+
// Homebridge service and characteristic references
|
|
36
|
+
this.Service = api.hap.Service
|
|
37
|
+
this.Characteristic = api.hap.Characteristic
|
|
38
|
+
|
|
39
|
+
// Track registered accessories
|
|
40
|
+
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */
|
|
41
|
+
this.accessories = new Map()
|
|
42
|
+
|
|
43
|
+
// Track discovered device UUIDs
|
|
44
|
+
/** @type {string[]} */
|
|
45
|
+
this.discoveredUUIDs = []
|
|
46
|
+
|
|
47
|
+
// Initialize API clients
|
|
48
|
+
this.auth = new YotoAuth(log, this.config.clientId)
|
|
49
|
+
this.yotoApi = new YotoApi(log, this.auth)
|
|
50
|
+
this.yotoMqtt = new YotoMqtt(log, {
|
|
51
|
+
brokerUrl: this.config.mqttBroker
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Set up token refresh callback
|
|
55
|
+
this.yotoApi.setTokenRefreshCallback(this.handleTokenRefresh.bind(this))
|
|
56
|
+
|
|
57
|
+
this.log.debug(LOG_PREFIX.PLATFORM, 'Initializing platform:', this.config.name)
|
|
58
|
+
|
|
59
|
+
// Wait for Homebridge to finish launching
|
|
60
|
+
this.api.on('didFinishLaunching', () => {
|
|
61
|
+
this.log.debug(LOG_PREFIX.PLATFORM, 'Executed didFinishLaunching callback')
|
|
62
|
+
this.initialize().catch(error => {
|
|
63
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to initialize:', error)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize the platform - authenticate and discover devices
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
async initialize () {
|
|
73
|
+
try {
|
|
74
|
+
// Check if we have stored credentials
|
|
75
|
+
if (!this.config.accessToken || !this.config.refreshToken) {
|
|
76
|
+
this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
|
|
77
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
|
|
78
|
+
|
|
79
|
+
const tokenResponse = await this.auth.authorize()
|
|
80
|
+
|
|
81
|
+
// Store tokens in config
|
|
82
|
+
this.config.accessToken = tokenResponse.access_token
|
|
83
|
+
this.config.refreshToken = tokenResponse.refresh_token || ''
|
|
84
|
+
this.config.tokenExpiresAt = this.auth.calculateExpiresAt(tokenResponse.expires_in)
|
|
85
|
+
|
|
86
|
+
this.log.info(LOG_PREFIX.PLATFORM, '✓ Authentication successful!')
|
|
87
|
+
this.log.warn(LOG_PREFIX.PLATFORM, 'IMPORTANT: Please update your Homebridge config with the following:')
|
|
88
|
+
this.log.warn(LOG_PREFIX.PLATFORM, JSON.stringify({
|
|
89
|
+
accessToken: this.config.accessToken,
|
|
90
|
+
refreshToken: this.config.refreshToken,
|
|
91
|
+
tokenExpiresAt: this.config.tokenExpiresAt
|
|
92
|
+
}, null, 2))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Set tokens in API client
|
|
96
|
+
this.yotoApi.setTokens(
|
|
97
|
+
this.config.accessToken || '',
|
|
98
|
+
this.config.refreshToken || '',
|
|
99
|
+
this.config.tokenExpiresAt || 0
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// Connect to MQTT
|
|
103
|
+
await this.yotoMqtt.connect(this.config.accessToken)
|
|
104
|
+
|
|
105
|
+
// Discover and register devices
|
|
106
|
+
await this.discoverDevices()
|
|
107
|
+
} catch (error) {
|
|
108
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Initialization failed:', error)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handle token refresh - update config
|
|
114
|
+
* @param {string} accessToken - New access token
|
|
115
|
+
* @param {string} refreshToken - New refresh token
|
|
116
|
+
* @param {number} expiresAt - New expiration timestamp
|
|
117
|
+
*/
|
|
118
|
+
handleTokenRefresh (accessToken, refreshToken, expiresAt) {
|
|
119
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Token refreshed, please update your config')
|
|
120
|
+
this.config.accessToken = accessToken
|
|
121
|
+
this.config.refreshToken = refreshToken
|
|
122
|
+
this.config.tokenExpiresAt = expiresAt
|
|
123
|
+
|
|
124
|
+
// Update MQTT connection with new token
|
|
125
|
+
this.yotoMqtt.disconnect().then(() => {
|
|
126
|
+
return this.yotoMqtt.connect(accessToken)
|
|
127
|
+
}).catch(error => {
|
|
128
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to reconnect MQTT after token refresh:', error)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Restore cached accessories from disk
|
|
134
|
+
* This is called by Homebridge on startup for each cached accessory
|
|
135
|
+
* @param {PlatformAccessory<YotoAccessoryContext>} accessory - Cached accessory
|
|
136
|
+
*/
|
|
137
|
+
configureAccessory (accessory) {
|
|
138
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Loading accessory from cache:', accessory.displayName)
|
|
139
|
+
|
|
140
|
+
// Add to our tracking map
|
|
141
|
+
this.accessories.set(accessory.UUID, accessory)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Discover Yoto devices and register as accessories
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
async discoverDevices () {
|
|
149
|
+
try {
|
|
150
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Discovering Yoto devices...')
|
|
151
|
+
|
|
152
|
+
// Fetch devices from API
|
|
153
|
+
const devices = await this.yotoApi.getDevices()
|
|
154
|
+
|
|
155
|
+
if (devices.length === 0) {
|
|
156
|
+
this.log.warn(LOG_PREFIX.PLATFORM, 'No Yoto devices found in account')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Process each device
|
|
161
|
+
for (const device of devices) {
|
|
162
|
+
await this.registerDevice(device)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Remove accessories that are no longer present
|
|
166
|
+
this.removeStaleAccessories()
|
|
167
|
+
|
|
168
|
+
this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to discover devices:', error)
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Register a device as a platform accessory
|
|
177
|
+
* @param {YotoDevice} device - Device to register
|
|
178
|
+
* @returns {Promise<void>}
|
|
179
|
+
*/
|
|
180
|
+
async registerDevice (device) {
|
|
181
|
+
// Generate UUID for this device
|
|
182
|
+
const uuid = this.api.hap.uuid.generate(device.deviceId)
|
|
183
|
+
this.discoveredUUIDs.push(uuid)
|
|
184
|
+
|
|
185
|
+
// Check if accessory already exists
|
|
186
|
+
const existingAccessory = this.accessories.get(uuid)
|
|
187
|
+
|
|
188
|
+
if (existingAccessory) {
|
|
189
|
+
// Accessory exists - update it
|
|
190
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', device.name)
|
|
191
|
+
|
|
192
|
+
// Update context with fresh device data
|
|
193
|
+
const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (existingAccessory)
|
|
194
|
+
typedAccessory.context.device = device
|
|
195
|
+
typedAccessory.context.lastUpdate = Date.now()
|
|
196
|
+
|
|
197
|
+
// Update accessory information
|
|
198
|
+
this.api.updatePlatformAccessories([existingAccessory])
|
|
199
|
+
|
|
200
|
+
// Create handler for this accessory
|
|
201
|
+
// eslint-disable-next-line no-new
|
|
202
|
+
new YotoPlayerAccessory(this, typedAccessory)
|
|
203
|
+
} else {
|
|
204
|
+
// Create new accessory
|
|
205
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Adding new accessory:', device.name)
|
|
206
|
+
|
|
207
|
+
// Create platform accessory
|
|
208
|
+
// eslint-disable-next-line new-cap
|
|
209
|
+
const accessory = new this.api.platformAccessory(device.name, uuid)
|
|
210
|
+
|
|
211
|
+
// Create typed accessory with context
|
|
212
|
+
const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
|
|
213
|
+
|
|
214
|
+
// Set accessory context
|
|
215
|
+
typedAccessory.context = {
|
|
216
|
+
device,
|
|
217
|
+
lastStatus: null,
|
|
218
|
+
lastEvents: null,
|
|
219
|
+
lastUpdate: Date.now()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Create handler for this accessory
|
|
223
|
+
// eslint-disable-next-line no-new
|
|
224
|
+
new YotoPlayerAccessory(this, typedAccessory)
|
|
225
|
+
|
|
226
|
+
// Register with Homebridge
|
|
227
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
228
|
+
|
|
229
|
+
// Add to our tracking map
|
|
230
|
+
this.accessories.set(uuid, typedAccessory)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Remove accessories that are no longer present in the account
|
|
236
|
+
*/
|
|
237
|
+
removeStaleAccessories () {
|
|
238
|
+
const staleAccessories = []
|
|
239
|
+
|
|
240
|
+
for (const [uuid, accessory] of this.accessories) {
|
|
241
|
+
if (!this.discoveredUUIDs.includes(uuid)) {
|
|
242
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
|
|
243
|
+
staleAccessories.push(accessory)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (staleAccessories.length > 0) {
|
|
248
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, staleAccessories)
|
|
249
|
+
|
|
250
|
+
// Remove from tracking map
|
|
251
|
+
for (const accessory of staleAccessories) {
|
|
252
|
+
this.accessories.delete(accessory.UUID)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Shutdown handler - cleanup connections
|
|
259
|
+
*/
|
|
260
|
+
async shutdown () {
|
|
261
|
+
this.log.info(LOG_PREFIX.PLATFORM, 'Shutting down...')
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await this.yotoMqtt.disconnect()
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.log.error(LOG_PREFIX.PLATFORM, 'Error during shutdown:', error)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|