homebridge-yoto 0.0.27 → 0.0.31
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 +42 -157
- package/CHANGELOG.md +13 -5
- package/NOTES.md +87 -0
- package/PLAN.md +320 -504
- package/README.md +18 -314
- package/config.schema.cjs +3 -0
- package/config.schema.json +19 -155
- package/homebridge-ui/server.js +264 -0
- package/index.js +1 -1
- package/index.test.js +1 -1
- package/lib/accessory.js +1870 -0
- package/lib/constants.js +8 -149
- package/lib/platform.js +303 -364
- package/lib/sanitize-name.js +49 -0
- package/lib/settings.js +16 -0
- package/lib/sync-service-names.js +34 -0
- package/logo.png +0 -0
- package/package.json +17 -22
- package/pnpm-workspace.yaml +4 -0
- package/declaration.tsconfig.json +0 -15
- package/lib/auth.js +0 -237
- package/lib/playerAccessory.js +0 -1724
- package/lib/types.js +0 -253
- package/lib/yotoApi.js +0 -270
- package/lib/yotoMqtt.js +0 -570
package/lib/platform.js
CHANGED
|
@@ -2,464 +2,372 @@
|
|
|
2
2
|
* @fileoverview Main platform implementation for Yoto Homebridge plugin
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig } from 'homebridge' */
|
|
6
|
-
/** @import { YotoDevice
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
/** @import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge' */
|
|
6
|
+
/** @import { YotoDevice } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
7
|
+
/** @import { YotoDeviceModel } from 'yoto-nodejs-client' */
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Context stored in PlatformAccessory for Yoto devices
|
|
11
|
+
* @typedef {Object} YotoAccessoryContext
|
|
12
|
+
* @property {YotoDevice} device - Device metadata from Yoto API
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { YotoAccount } from 'yoto-nodejs-client'
|
|
17
|
+
import { randomUUID } from 'node:crypto'
|
|
13
18
|
import {
|
|
14
19
|
PLATFORM_NAME,
|
|
15
20
|
PLUGIN_NAME,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
} from './
|
|
21
|
+
DEFAULT_CLIENT_ID,
|
|
22
|
+
} from './settings.js'
|
|
23
|
+
import { YotoPlayerAccessory } from './accessory.js'
|
|
24
|
+
import { sanitizeName } from './sanitize-name.js'
|
|
20
25
|
|
|
21
26
|
/**
|
|
22
27
|
* Yoto Platform implementation
|
|
28
|
+
* This class is the main constructor for your plugin, this is where you should
|
|
29
|
+
* parse the user config and discover/register accessories with Homebridge.
|
|
23
30
|
* @implements {DynamicPlatformPlugin}
|
|
24
31
|
*/
|
|
25
32
|
export class YotoPlatform {
|
|
33
|
+
/** @type {Logger} */ log
|
|
34
|
+
/** @type {PlatformConfig} */ config
|
|
35
|
+
/** @type {API} */ api
|
|
36
|
+
/** @type {typeof Service} */ Service
|
|
37
|
+
/** @type {typeof Characteristic} */ Characteristic
|
|
38
|
+
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */ accessories = new Map()
|
|
39
|
+
/** @type {Map<string, YotoPlayerAccessory>} */ accessoryHandlers = new Map()
|
|
40
|
+
/** @type {YotoAccount | null} */ yotoAccount = null
|
|
41
|
+
/** @type {string} */ sessionId = randomUUID()
|
|
42
|
+
|
|
26
43
|
/**
|
|
27
44
|
* @param {Logger} log - Homebridge logger
|
|
28
|
-
* @param {PlatformConfig
|
|
45
|
+
* @param {PlatformConfig} config - Platform configuration
|
|
29
46
|
* @param {API} api - Homebridge API
|
|
30
47
|
*/
|
|
31
48
|
constructor (log, config, api) {
|
|
32
49
|
this.log = log
|
|
33
|
-
this.config =
|
|
50
|
+
this.config = config
|
|
34
51
|
this.api = api
|
|
35
|
-
|
|
36
|
-
// Homebridge service and characteristic references
|
|
37
52
|
this.Service = api.hap.Service
|
|
38
53
|
this.Characteristic = api.hap.Characteristic
|
|
39
54
|
|
|
40
|
-
|
|
41
|
-
/** @type {Map<string, PlatformAccessory<YotoAccessoryContext>>} */
|
|
42
|
-
this.accessories = new Map()
|
|
43
|
-
|
|
44
|
-
// Track discovered device UUIDs
|
|
45
|
-
/** @type {string[]} */
|
|
46
|
-
this.discoveredUUIDs = []
|
|
55
|
+
log.debug('Finished initializing platform:', config.name)
|
|
47
56
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
// Extract auth tokens once
|
|
58
|
+
const clientId = config['clientId'] || DEFAULT_CLIENT_ID
|
|
59
|
+
const refreshToken = config['refreshToken']
|
|
60
|
+
const accessToken = config['accessToken']
|
|
51
61
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
// Debug: Log what we found (redacted)
|
|
63
|
+
log.debug('Config check - Has refreshToken:', !!refreshToken)
|
|
64
|
+
log.debug('Config check - Has accessToken:', !!accessToken)
|
|
65
|
+
log.debug('Config check - ClientId:', clientId ? 'present' : 'missing')
|
|
55
66
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
this.ownedCardIds = new Set()
|
|
67
|
+
// Check if we have authentication tokens
|
|
68
|
+
if (!refreshToken || !accessToken) {
|
|
69
|
+
log.warn('No authentication tokens found. Please configure the plugin through the Homebridge UI.')
|
|
70
|
+
return
|
|
71
|
+
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
this.auth = new YotoAuth(log, this.config.clientId)
|
|
65
|
-
this.yotoApi = new YotoApi(log, this.auth)
|
|
73
|
+
log.info('Authentication tokens found, initializing Yoto account...')
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
this.yotoApi.setTokenRefreshCallback(this.handleTokenRefresh.bind(this))
|
|
75
|
+
const { updateHomebridgeConfig, sessionId } = this
|
|
69
76
|
|
|
70
|
-
|
|
77
|
+
// Initialize YotoAccount with client and device options
|
|
78
|
+
this.yotoAccount = new YotoAccount({
|
|
79
|
+
clientOptions: {
|
|
80
|
+
clientId,
|
|
81
|
+
refreshToken,
|
|
82
|
+
accessToken,
|
|
83
|
+
onTokenRefresh: async ({ updatedAccessToken, updatedRefreshToken, updatedExpiresAt, prevAccessToken, prevRefreshToken }) => {
|
|
84
|
+
log.info('Access token refreshed, expires at:', new Date(updatedExpiresAt * 1000).toISOString())
|
|
71
85
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.initialize().catch(error => {
|
|
76
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to initialize:', error)
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
}
|
|
86
|
+
// Update config file with new tokens (similar to homebridge-ring pattern)
|
|
87
|
+
await updateHomebridgeConfig((configContents) => {
|
|
88
|
+
let updatedConfig = configContents
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await this.loadTokens()
|
|
90
|
+
// Replace old tokens with new tokens
|
|
91
|
+
if (prevAccessToken) {
|
|
92
|
+
updatedConfig = updatedConfig.replace(prevAccessToken, updatedAccessToken)
|
|
93
|
+
}
|
|
94
|
+
if (prevRefreshToken && updatedRefreshToken) {
|
|
95
|
+
updatedConfig = updatedConfig.replace(prevRefreshToken, updatedRefreshToken)
|
|
96
|
+
}
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
return updatedConfig
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
deviceOptions: {
|
|
103
|
+
httpPollIntervalMs: config['httpPollIntervalMs'] || 60000,
|
|
104
|
+
yotoDeviceMqttOptions: {
|
|
105
|
+
sessionId
|
|
106
|
+
}
|
|
93
107
|
}
|
|
108
|
+
})
|
|
94
109
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// Discover and register devices
|
|
103
|
-
try {
|
|
104
|
-
await this.discoverDevices()
|
|
105
|
-
} catch (error) {
|
|
106
|
-
// Check if this is an auth error that requires re-authentication
|
|
107
|
-
if (error instanceof Error && error.message.includes('TOKEN_REFRESH_FAILED')) {
|
|
108
|
-
this.log.warn(LOG_PREFIX.PLATFORM, 'Token refresh failed, clearing tokens and restarting auth flow...')
|
|
109
|
-
await this.clearTokensAndReauth()
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
throw error
|
|
110
|
+
// Listen to account-level events
|
|
111
|
+
this.yotoAccount.on('error', ({ error, context }) => {
|
|
112
|
+
if (context.deviceId) {
|
|
113
|
+
log.error(`Device error [${context.deviceId} ${context.operation} ${context.source}]:`, error.message)
|
|
114
|
+
} else {
|
|
115
|
+
log.error('Account error:', error.message)
|
|
113
116
|
}
|
|
117
|
+
})
|
|
114
118
|
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
// When this event is fired it means Homebridge has restored all cached accessories from disk.
|
|
120
|
+
// Dynamic Platform plugins should only register new accessories after this event was fired,
|
|
121
|
+
// in order to ensure they weren't added to homebridge already. This event can also be used
|
|
122
|
+
// to start discovery of new accessories.
|
|
123
|
+
api.on('didFinishLaunching', async () => {
|
|
124
|
+
log.debug('Executed didFinishLaunching callback')
|
|
125
|
+
// Start the YotoAccount which will discover and start all devices
|
|
126
|
+
await this.startAccount()
|
|
127
|
+
})
|
|
117
128
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
this.
|
|
122
|
-
}
|
|
129
|
+
// When homebridge shuts down, cleanup all handlers and MQTT connections
|
|
130
|
+
api.on('shutdown', () => {
|
|
131
|
+
log.debug('Homebridge shutting down, cleaning up accessories...')
|
|
132
|
+
this.shutdown()
|
|
133
|
+
})
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
/**
|
|
126
|
-
*
|
|
137
|
+
* This function is invoked when homebridge restores cached accessories from disk at startup.
|
|
138
|
+
* It should be used to set up event handlers for characteristics and update respective values.
|
|
139
|
+
* In practice, it never is. It simply collects previously created devices into an accessories map
|
|
140
|
+
* that is then used to setup devices after didFinishLaunching fires.
|
|
141
|
+
* @param {PlatformAccessory} accessory - Cached accessory
|
|
127
142
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
await this.checkAllDevicesStatus()
|
|
133
|
-
} catch (error) {
|
|
134
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to check device status:', error)
|
|
135
|
-
}
|
|
136
|
-
}, 60000)
|
|
137
|
-
}
|
|
143
|
+
configureAccessory (accessory) {
|
|
144
|
+
const { log, accessories } = this
|
|
145
|
+
log.info('Loading accessory from cache:', accessory.displayName)
|
|
138
146
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
*/
|
|
142
|
-
stopStatusPolling () {
|
|
143
|
-
if (this.statusPollInterval) {
|
|
144
|
-
clearInterval(this.statusPollInterval)
|
|
145
|
-
this.statusPollInterval = null
|
|
146
|
-
}
|
|
147
|
+
// Add to our tracking map (cast to our typed version)
|
|
148
|
+
accessories.set(accessory.UUID, /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory))
|
|
147
149
|
}
|
|
148
150
|
|
|
149
151
|
/**
|
|
150
|
-
*
|
|
151
|
-
* @returns {Promise<void>}
|
|
152
|
+
* Start YotoAccount - discovers devices and creates device models
|
|
152
153
|
*/
|
|
153
|
-
async
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Update each accessory with fresh device info
|
|
159
|
-
for (const device of devices) {
|
|
160
|
-
const uuid = this.api.hap.uuid.generate(device.deviceId)
|
|
161
|
-
const accessory = this.accessories.get(uuid)
|
|
162
|
-
|
|
163
|
-
if (accessory) {
|
|
164
|
-
const wasOnline = accessory.context.device.online
|
|
165
|
-
const isNowOnline = device.online
|
|
166
|
-
|
|
167
|
-
// Update device info in context
|
|
168
|
-
accessory.context.device = device
|
|
169
|
-
|
|
170
|
-
// Notify accessory handler if status changed
|
|
171
|
-
const handler = this.accessoryHandlers.get(uuid)
|
|
172
|
-
if (handler && wasOnline !== isNowOnline) {
|
|
173
|
-
handler.handleOnlineStatusChange(isNowOnline, wasOnline)
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
} catch (error) {
|
|
178
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Error checking device status:', error)
|
|
154
|
+
async startAccount () {
|
|
155
|
+
if (!this.yotoAccount) {
|
|
156
|
+
this.log.error('Cannot start account - YotoAccount not initialized')
|
|
157
|
+
return
|
|
179
158
|
}
|
|
180
|
-
}
|
|
181
159
|
|
|
182
|
-
/**
|
|
183
|
-
* Fetch user's owned MYO cards and cache their IDs
|
|
184
|
-
* @returns {Promise<void>}
|
|
185
|
-
*/
|
|
186
|
-
async fetchOwnedCards () {
|
|
187
160
|
try {
|
|
188
|
-
this.log.
|
|
189
|
-
const myContent = await this.yotoApi.getMyContent()
|
|
190
|
-
|
|
191
|
-
// Cache card IDs
|
|
192
|
-
if (myContent.cards && Array.isArray(myContent.cards)) {
|
|
193
|
-
this.ownedCardIds.clear()
|
|
194
|
-
for (const card of myContent.cards) {
|
|
195
|
-
if (card.cardId) {
|
|
196
|
-
this.ownedCardIds.add(card.cardId)
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
this.log.info(LOG_PREFIX.PLATFORM, `✓ Cached ${this.ownedCardIds.size} owned card(s)`)
|
|
200
|
-
}
|
|
201
|
-
} catch (error) {
|
|
202
|
-
this.log.warn(LOG_PREFIX.PLATFORM, 'Failed to fetch owned cards, card details may be limited:', error)
|
|
203
|
-
}
|
|
204
|
-
}
|
|
161
|
+
this.log.info('Starting Yoto account...')
|
|
205
162
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Handle token refresh - update config
|
|
217
|
-
* Perform device authorization flow
|
|
218
|
-
* @returns {Promise<void>}
|
|
219
|
-
*/
|
|
220
|
-
async performDeviceFlow () {
|
|
221
|
-
this.log.warn(LOG_PREFIX.PLATFORM, ERROR_MESSAGES.NO_AUTH)
|
|
222
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Starting OAuth flow...')
|
|
163
|
+
// Listen for devices being added
|
|
164
|
+
this.yotoAccount.on('deviceAdded', async ({ deviceId }) => {
|
|
165
|
+
const deviceModel = this.yotoAccount?.getDevice(deviceId)
|
|
166
|
+
if (!deviceModel) {
|
|
167
|
+
this.log.warn(`Device added but no model found for ${deviceId}`)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
223
170
|
|
|
224
|
-
|
|
171
|
+
const device = deviceModel.device
|
|
172
|
+
this.log.info(`Device discovered: ${device.name} (${deviceId})`)
|
|
173
|
+
await this.registerDevice(device, deviceModel)
|
|
174
|
+
})
|
|
225
175
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
176
|
+
this.yotoAccount.on('deviceRemoved', ({ deviceId }) => {
|
|
177
|
+
this.log.info(`Device removed: ${deviceId}`)
|
|
178
|
+
this.removeStaleAccessories()
|
|
179
|
+
})
|
|
230
180
|
|
|
231
|
-
|
|
232
|
-
|
|
181
|
+
this.yotoAccount.on('online', ({ deviceId, metadata }) => {
|
|
182
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
183
|
+
this.log.info(`Device online: ${deviceId}${reason}`)
|
|
184
|
+
})
|
|
233
185
|
|
|
234
|
-
|
|
235
|
-
|
|
186
|
+
this.yotoAccount.on('offline', ({ deviceId, metadata }) => {
|
|
187
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
188
|
+
this.log.info(`Device offline: ${deviceId}${reason}`)
|
|
189
|
+
})
|
|
236
190
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
async clearTokensAndReauth () {
|
|
242
|
-
// Clear tokens from config
|
|
243
|
-
this.config.accessToken = ''
|
|
244
|
-
this.config.refreshToken = ''
|
|
245
|
-
this.config.tokenExpiresAt = 0
|
|
191
|
+
this.yotoAccount.on('statusUpdate', ({ deviceId, source, changedFields }) => {
|
|
192
|
+
const fields = Array.from(changedFields).join(', ')
|
|
193
|
+
this.log.debug(`Status update [${deviceId} ${source}]: ${fields}`)
|
|
194
|
+
})
|
|
246
195
|
|
|
247
|
-
|
|
248
|
-
|
|
196
|
+
this.yotoAccount.on('configUpdate', ({ deviceId, changedFields }) => {
|
|
197
|
+
const fields = Array.from(changedFields).join(', ')
|
|
198
|
+
this.log.debug(`Config update [${deviceId}]: ${fields}`)
|
|
199
|
+
})
|
|
249
200
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
201
|
+
this.yotoAccount.on('playbackUpdate', ({ deviceId, changedFields }) => {
|
|
202
|
+
const fields = Array.from(changedFields).join(', ')
|
|
203
|
+
this.log.debug(`Playback update [${deviceId}]: ${fields}`)
|
|
204
|
+
})
|
|
253
205
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
* @param {string} refreshToken - New refresh token
|
|
258
|
-
* @param {number} expiresAt - New expiration timestamp
|
|
259
|
-
*/
|
|
260
|
-
handleTokenRefresh (accessToken, refreshToken, expiresAt) {
|
|
261
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Token refreshed')
|
|
262
|
-
this.config.accessToken = accessToken
|
|
263
|
-
this.config.refreshToken = refreshToken
|
|
264
|
-
this.config.tokenExpiresAt = expiresAt
|
|
265
|
-
|
|
266
|
-
// Save updated tokens to persistent storage
|
|
267
|
-
this.saveTokens().catch(error => {
|
|
268
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save refreshed tokens:', error)
|
|
269
|
-
})
|
|
206
|
+
this.yotoAccount.on('mqttConnect', ({ deviceId }) => {
|
|
207
|
+
this.log.info(`MQTT connected: ${deviceId}`)
|
|
208
|
+
})
|
|
270
209
|
|
|
271
|
-
|
|
272
|
-
|
|
210
|
+
this.yotoAccount.on('mqttDisconnect', ({ deviceId, metadata }) => {
|
|
211
|
+
const reasonCode = metadata?.packet?.reasonCode
|
|
212
|
+
const reason = typeof reasonCode === 'number' ? ` (code ${reasonCode})` : ''
|
|
213
|
+
this.log.info(`MQTT disconnected: ${deviceId}${reason}`)
|
|
214
|
+
})
|
|
273
215
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
*/
|
|
279
|
-
async loadTokens () {
|
|
280
|
-
// First check if tokens are in config.json
|
|
281
|
-
if (this.config.accessToken && this.config.refreshToken) {
|
|
282
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Using tokens from config.json')
|
|
283
|
-
return
|
|
284
|
-
}
|
|
216
|
+
this.yotoAccount.on('mqttClose', ({ deviceId, metadata }) => {
|
|
217
|
+
const reason = metadata?.reason ? ` (${metadata.reason})` : ''
|
|
218
|
+
this.log.debug(`MQTT closed: ${deviceId}${reason}`)
|
|
219
|
+
})
|
|
285
220
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const tokens = JSON.parse(data)
|
|
290
|
-
|
|
291
|
-
if (tokens.accessToken && tokens.refreshToken) {
|
|
292
|
-
this.config.accessToken = tokens.accessToken
|
|
293
|
-
this.config.refreshToken = tokens.refreshToken
|
|
294
|
-
this.config.tokenExpiresAt = tokens.tokenExpiresAt
|
|
295
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Loaded tokens from persistent storage')
|
|
296
|
-
}
|
|
297
|
-
} catch (error) {
|
|
298
|
-
// File doesn't exist or is invalid - not an error on first run
|
|
299
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'No saved tokens found in storage')
|
|
300
|
-
}
|
|
301
|
-
}
|
|
221
|
+
this.yotoAccount.on('mqttReconnect', ({ deviceId }) => {
|
|
222
|
+
this.log.debug(`MQTT reconnecting: ${deviceId}`)
|
|
223
|
+
})
|
|
302
224
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
*/
|
|
307
|
-
async saveTokens () {
|
|
308
|
-
try {
|
|
309
|
-
// Ensure storage directory exists
|
|
310
|
-
await mkdir(this.storagePath, { recursive: true })
|
|
225
|
+
this.yotoAccount.on('mqttOffline', ({ deviceId }) => {
|
|
226
|
+
this.log.debug(`MQTT offline: ${deviceId}`)
|
|
227
|
+
})
|
|
311
228
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
tokenExpiresAt: this.config.tokenExpiresAt || 0
|
|
316
|
-
}
|
|
229
|
+
this.yotoAccount.on('mqttEnd', ({ deviceId }) => {
|
|
230
|
+
this.log.debug(`MQTT ended: ${deviceId}`)
|
|
231
|
+
})
|
|
317
232
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.log.error(LOG_PREFIX.PLATFORM, 'Failed to save tokens:', error)
|
|
322
|
-
throw error
|
|
323
|
-
}
|
|
324
|
-
}
|
|
233
|
+
this.yotoAccount.on('mqttStatus', ({ deviceId, topic }) => {
|
|
234
|
+
this.log.debug(`MQTT status [${deviceId}]: ${topic}`)
|
|
235
|
+
})
|
|
325
236
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
* @param {PlatformAccessory<YotoAccessoryContext>} accessory - Cached accessory
|
|
330
|
-
*/
|
|
331
|
-
configureAccessory (accessory) {
|
|
332
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Loading accessory from cache:', accessory.displayName)
|
|
237
|
+
this.yotoAccount.on('mqttEvents', ({ deviceId, topic }) => {
|
|
238
|
+
this.log.debug(`MQTT events [${deviceId}]: ${topic}`)
|
|
239
|
+
})
|
|
333
240
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
241
|
+
this.yotoAccount.on('mqttStatusLegacy', ({ deviceId, topic }) => {
|
|
242
|
+
this.log.debug(`MQTT legacy status [${deviceId}]: ${topic}`)
|
|
243
|
+
})
|
|
337
244
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
*/
|
|
342
|
-
async discoverDevices () {
|
|
343
|
-
try {
|
|
344
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Discovering Yoto devices...')
|
|
245
|
+
this.yotoAccount.on('mqttResponse', ({ deviceId, topic }) => {
|
|
246
|
+
this.log.debug(`MQTT response [${deviceId}]: ${topic}`)
|
|
247
|
+
})
|
|
345
248
|
|
|
346
|
-
|
|
347
|
-
|
|
249
|
+
this.yotoAccount.on('mqttUnknown', ({ deviceId, topic }) => {
|
|
250
|
+
this.log.debug(`MQTT unknown [${deviceId}]: ${topic}`)
|
|
251
|
+
})
|
|
348
252
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return
|
|
352
|
-
}
|
|
253
|
+
// Start the account (discovers devices, creates device models, starts MQTT)
|
|
254
|
+
await this.yotoAccount.start()
|
|
353
255
|
|
|
354
|
-
|
|
355
|
-
for (const device of devices) {
|
|
356
|
-
this.log.info(LOG_PREFIX.PLATFORM, 'Device from API:', JSON.stringify(device, null, 2))
|
|
357
|
-
await this.registerDevice(device)
|
|
358
|
-
}
|
|
256
|
+
this.log.info(`✓ Yoto account started with ${this.yotoAccount.devices.size} device(s)`)
|
|
359
257
|
|
|
360
|
-
// Remove accessories
|
|
258
|
+
// Remove stale accessories after all devices are registered
|
|
361
259
|
this.removeStaleAccessories()
|
|
362
|
-
|
|
363
|
-
this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
|
|
364
260
|
} catch (error) {
|
|
365
|
-
this.log.error(
|
|
366
|
-
throw error
|
|
261
|
+
this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
|
|
367
262
|
}
|
|
368
263
|
}
|
|
369
264
|
|
|
370
265
|
/**
|
|
371
266
|
* Register a device as a platform accessory
|
|
372
267
|
* @param {YotoDevice} device - Device to register
|
|
373
|
-
* @
|
|
268
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
269
|
+
* @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
|
|
374
270
|
*/
|
|
375
|
-
async registerDevice (device) {
|
|
271
|
+
async registerDevice (device, deviceModel) {
|
|
376
272
|
// Generate UUID for this device
|
|
377
273
|
const uuid = this.api.hap.uuid.generate(device.deviceId)
|
|
378
|
-
|
|
274
|
+
const sanitizedDeviceName = sanitizeName(device.name)
|
|
275
|
+
const accessoryCategory = this.api.hap.Categories.SPEAKER
|
|
379
276
|
|
|
380
277
|
// Check if accessory already exists
|
|
381
278
|
const existingAccessory = this.accessories.get(uuid)
|
|
382
279
|
|
|
383
280
|
if (existingAccessory) {
|
|
384
281
|
// Accessory exists - update it
|
|
385
|
-
|
|
386
|
-
const displayName = device.description || device.name
|
|
387
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', displayName)
|
|
282
|
+
this.log.info('Restoring existing accessory from cache:', device.name)
|
|
388
283
|
|
|
389
|
-
// Update display name
|
|
390
|
-
if (existingAccessory.displayName !==
|
|
391
|
-
existingAccessory.
|
|
284
|
+
// Update display name if it has changed
|
|
285
|
+
if (existingAccessory.displayName !== sanitizedDeviceName) {
|
|
286
|
+
existingAccessory.updateDisplayName(sanitizedDeviceName)
|
|
392
287
|
const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
|
|
393
288
|
if (infoService) {
|
|
289
|
+
// Only update Name, preserve user's ConfiguredName customization
|
|
290
|
+
// ConfiguredName is intentionally NOT updated here because:
|
|
291
|
+
// - It allows users to rename accessories in the Home app
|
|
292
|
+
// - Their custom names should survive Homebridge restarts and Yoto device name changes
|
|
293
|
+
// - Name stays in sync with Yoto's device name for plugin identification
|
|
394
294
|
infoService
|
|
395
|
-
.setCharacteristic(this.api.hap.Characteristic.Name,
|
|
396
|
-
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName,
|
|
295
|
+
.setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
|
|
296
|
+
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
|
|
397
297
|
}
|
|
398
298
|
}
|
|
399
299
|
|
|
400
300
|
// Update context with fresh device data
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
301
|
+
existingAccessory.context = {
|
|
302
|
+
...existingAccessory.context,
|
|
303
|
+
device,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Ensure category matches our current service model
|
|
307
|
+
if (existingAccessory.category !== accessoryCategory) {
|
|
308
|
+
existingAccessory.category = accessoryCategory
|
|
309
|
+
}
|
|
404
310
|
|
|
405
311
|
// Update accessory information
|
|
406
312
|
this.api.updatePlatformAccessories([existingAccessory])
|
|
407
313
|
|
|
408
|
-
// Create handler for this accessory
|
|
409
|
-
const handler = new YotoPlayerAccessory(
|
|
314
|
+
// Create handler for this accessory with device model
|
|
315
|
+
const handler = new YotoPlayerAccessory({
|
|
316
|
+
platform: this,
|
|
317
|
+
accessory: existingAccessory,
|
|
318
|
+
deviceModel,
|
|
319
|
+
})
|
|
410
320
|
|
|
411
|
-
// Track handler
|
|
321
|
+
// Track handler
|
|
412
322
|
this.accessoryHandlers.set(uuid, handler)
|
|
413
323
|
|
|
414
|
-
// Initialize accessory (
|
|
415
|
-
handler.
|
|
416
|
-
|
|
417
|
-
}
|
|
324
|
+
// Initialize accessory (setup services and event listeners)
|
|
325
|
+
await handler.setup()
|
|
326
|
+
|
|
327
|
+
return { success: true }
|
|
418
328
|
} else {
|
|
419
329
|
// Create new accessory
|
|
420
|
-
|
|
421
|
-
const displayName = device.description || device.name
|
|
422
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Adding new accessory:', displayName)
|
|
330
|
+
this.log.info('Adding new accessory:', device.name)
|
|
423
331
|
|
|
424
332
|
// Create platform accessory
|
|
333
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */
|
|
425
334
|
// eslint-disable-next-line new-cap
|
|
426
|
-
const accessory = new this.api.platformAccessory(
|
|
335
|
+
const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
|
|
427
336
|
|
|
428
337
|
// Set Name and ConfiguredName on AccessoryInformation service
|
|
429
338
|
const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
|
|
430
339
|
if (infoService) {
|
|
431
340
|
infoService
|
|
432
|
-
.setCharacteristic(this.api.hap.Characteristic.Name,
|
|
433
|
-
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName,
|
|
341
|
+
.setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
|
|
342
|
+
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
|
|
434
343
|
}
|
|
435
344
|
|
|
436
|
-
// Create typed accessory with context
|
|
437
|
-
const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
|
|
438
|
-
|
|
439
345
|
// Set accessory context
|
|
440
|
-
|
|
346
|
+
accessory.context = {
|
|
441
347
|
device,
|
|
442
|
-
lastStatus: null,
|
|
443
|
-
lastEvents: null,
|
|
444
|
-
lastUpdate: Date.now()
|
|
445
348
|
}
|
|
446
349
|
|
|
447
|
-
// Create handler for this accessory
|
|
448
|
-
const handler = new YotoPlayerAccessory(
|
|
350
|
+
// Create handler for this accessory with device model
|
|
351
|
+
const handler = new YotoPlayerAccessory({
|
|
352
|
+
platform: this,
|
|
353
|
+
accessory,
|
|
354
|
+
deviceModel,
|
|
355
|
+
})
|
|
449
356
|
|
|
450
|
-
// Track handler
|
|
357
|
+
// Track handler
|
|
451
358
|
this.accessoryHandlers.set(uuid, handler)
|
|
452
359
|
|
|
453
|
-
// Initialize accessory (
|
|
454
|
-
handler.
|
|
455
|
-
this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
|
|
456
|
-
})
|
|
360
|
+
// Initialize accessory (setup services and event listeners)
|
|
361
|
+
await handler.setup()
|
|
457
362
|
|
|
458
|
-
// Register
|
|
363
|
+
// Register as a platform accessory (bridged).
|
|
364
|
+
this.log.info(`Registering new accessory: ${device.name}`)
|
|
459
365
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
460
366
|
|
|
461
|
-
// Add to our tracking map
|
|
462
|
-
this.accessories.set(uuid,
|
|
367
|
+
// Add to our tracking map (cast to typed version)
|
|
368
|
+
this.accessories.set(uuid, accessory)
|
|
369
|
+
|
|
370
|
+
return { success: true }
|
|
463
371
|
}
|
|
464
372
|
}
|
|
465
373
|
|
|
@@ -467,48 +375,79 @@ export class YotoPlatform {
|
|
|
467
375
|
* Remove accessories that are no longer present in the account
|
|
468
376
|
*/
|
|
469
377
|
removeStaleAccessories () {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
for (const [uuid, accessory] of this.accessories) {
|
|
473
|
-
if (!this.discoveredUUIDs.includes(uuid)) {
|
|
474
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
|
|
475
|
-
staleAccessories.push(accessory)
|
|
476
|
-
}
|
|
378
|
+
if (!this.yotoAccount) {
|
|
379
|
+
return
|
|
477
380
|
}
|
|
478
381
|
|
|
479
|
-
|
|
480
|
-
|
|
382
|
+
// Get current device IDs from account
|
|
383
|
+
const currentDeviceIds = this.yotoAccount.getDeviceIds()
|
|
384
|
+
const currentUUIDs = currentDeviceIds.map(id => this.api.hap.uuid.generate(id))
|
|
385
|
+
|
|
386
|
+
for (const [uuid, accessory] of this.accessories) {
|
|
387
|
+
if (!currentUUIDs.includes(uuid)) {
|
|
388
|
+
this.log.info('Removing existing accessory from cache:', accessory.displayName)
|
|
481
389
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const handler = this.accessoryHandlers.get(accessory.UUID)
|
|
390
|
+
// Stop handler if it exists
|
|
391
|
+
const handler = this.accessoryHandlers.get(uuid)
|
|
485
392
|
if (handler) {
|
|
486
|
-
handler.
|
|
487
|
-
this.log.error(
|
|
393
|
+
handler.stop().catch(error => {
|
|
394
|
+
this.log.error(`Failed to stop handler for ${accessory.displayName}:`, error)
|
|
488
395
|
})
|
|
489
|
-
this.accessoryHandlers.delete(
|
|
396
|
+
this.accessoryHandlers.delete(uuid)
|
|
490
397
|
}
|
|
491
|
-
|
|
398
|
+
|
|
399
|
+
// Unregister from Homebridge
|
|
400
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
401
|
+
|
|
402
|
+
// Remove from our tracking map
|
|
403
|
+
this.accessories.delete(uuid)
|
|
492
404
|
}
|
|
493
405
|
}
|
|
494
406
|
}
|
|
495
407
|
|
|
496
408
|
/**
|
|
497
|
-
* Shutdown
|
|
409
|
+
* Shutdown platform - cleanup all handlers and stop account
|
|
498
410
|
*/
|
|
499
411
|
async shutdown () {
|
|
500
|
-
this.log.
|
|
412
|
+
this.log.info('Shutting down Yoto platform...')
|
|
413
|
+
|
|
414
|
+
// Stop all accessory handlers
|
|
415
|
+
const stopPromises = []
|
|
416
|
+
for (const [uuid, handler] of this.accessoryHandlers) {
|
|
417
|
+
stopPromises.push(
|
|
418
|
+
handler.stop().catch(error => {
|
|
419
|
+
this.log.error(`Failed to stop handler for ${uuid}:`, error)
|
|
420
|
+
})
|
|
421
|
+
)
|
|
422
|
+
}
|
|
501
423
|
|
|
502
|
-
//
|
|
503
|
-
|
|
424
|
+
// Wait for all handlers to cleanup
|
|
425
|
+
await Promise.all(stopPromises)
|
|
426
|
+
this.accessoryHandlers.clear()
|
|
504
427
|
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
428
|
+
// Stop the YotoAccount (disconnects all device models and MQTT)
|
|
429
|
+
if (this.yotoAccount) {
|
|
430
|
+
await this.yotoAccount.stop()
|
|
431
|
+
this.yotoAccount = null
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.log.info('✓ Yoto platform shutdown complete')
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Update Homebridge config.json file
|
|
439
|
+
* @param {(configContents: string) => string} updateFn - Function to update config contents
|
|
440
|
+
*/
|
|
441
|
+
async updateHomebridgeConfig (updateFn) {
|
|
442
|
+
const configPath = this.api.user.configPath()
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
const configContents = await readFile(configPath, 'utf8')
|
|
446
|
+
const updatedContents = updateFn(configContents)
|
|
447
|
+
await writeFile(configPath, updatedContents, 'utf8')
|
|
448
|
+
this.log.debug('Updated config.json with new tokens')
|
|
449
|
+
} catch (error) {
|
|
450
|
+
this.log.error('Failed to update config.json:', error)
|
|
512
451
|
}
|
|
513
452
|
}
|
|
514
453
|
}
|