homebridge-yoto 0.0.28 → 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 -363
- 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,463 +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
|
-
await this.registerDevice(device)
|
|
357
|
-
}
|
|
256
|
+
this.log.info(`✓ Yoto account started with ${this.yotoAccount.devices.size} device(s)`)
|
|
358
257
|
|
|
359
|
-
// Remove accessories
|
|
258
|
+
// Remove stale accessories after all devices are registered
|
|
360
259
|
this.removeStaleAccessories()
|
|
361
|
-
|
|
362
|
-
this.log.info(LOG_PREFIX.PLATFORM, `✓ Discovered ${devices.length} device(s)`)
|
|
363
260
|
} catch (error) {
|
|
364
|
-
this.log.error(
|
|
365
|
-
throw error
|
|
261
|
+
this.log.error('Failed to start account:', error instanceof Error ? error.message : String(error))
|
|
366
262
|
}
|
|
367
263
|
}
|
|
368
264
|
|
|
369
265
|
/**
|
|
370
266
|
* Register a device as a platform accessory
|
|
371
267
|
* @param {YotoDevice} device - Device to register
|
|
372
|
-
* @
|
|
268
|
+
* @param {YotoDeviceModel} deviceModel - Device model instance
|
|
269
|
+
* @returns {Promise<{ success: boolean }>} Object indicating if registration succeeded
|
|
373
270
|
*/
|
|
374
|
-
async registerDevice (device) {
|
|
271
|
+
async registerDevice (device, deviceModel) {
|
|
375
272
|
// Generate UUID for this device
|
|
376
273
|
const uuid = this.api.hap.uuid.generate(device.deviceId)
|
|
377
|
-
|
|
274
|
+
const sanitizedDeviceName = sanitizeName(device.name)
|
|
275
|
+
const accessoryCategory = this.api.hap.Categories.SPEAKER
|
|
378
276
|
|
|
379
277
|
// Check if accessory already exists
|
|
380
278
|
const existingAccessory = this.accessories.get(uuid)
|
|
381
279
|
|
|
382
280
|
if (existingAccessory) {
|
|
383
281
|
// Accessory exists - update it
|
|
384
|
-
|
|
385
|
-
const displayName = device.name
|
|
386
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Restoring existing accessory:', displayName)
|
|
282
|
+
this.log.info('Restoring existing accessory from cache:', device.name)
|
|
387
283
|
|
|
388
|
-
// Update display name
|
|
389
|
-
if (existingAccessory.displayName !==
|
|
390
|
-
existingAccessory.
|
|
284
|
+
// Update display name if it has changed
|
|
285
|
+
if (existingAccessory.displayName !== sanitizedDeviceName) {
|
|
286
|
+
existingAccessory.updateDisplayName(sanitizedDeviceName)
|
|
391
287
|
const infoService = existingAccessory.getService(this.api.hap.Service.AccessoryInformation)
|
|
392
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
|
|
393
294
|
infoService
|
|
394
|
-
.setCharacteristic(this.api.hap.Characteristic.Name,
|
|
395
|
-
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName,
|
|
295
|
+
.setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
|
|
296
|
+
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
|
|
396
297
|
}
|
|
397
298
|
}
|
|
398
299
|
|
|
399
300
|
// Update context with fresh device data
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
+
}
|
|
403
310
|
|
|
404
311
|
// Update accessory information
|
|
405
312
|
this.api.updatePlatformAccessories([existingAccessory])
|
|
406
313
|
|
|
407
|
-
// Create handler for this accessory
|
|
408
|
-
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
|
+
})
|
|
409
320
|
|
|
410
|
-
// Track handler
|
|
321
|
+
// Track handler
|
|
411
322
|
this.accessoryHandlers.set(uuid, handler)
|
|
412
323
|
|
|
413
|
-
// Initialize accessory (
|
|
414
|
-
handler.
|
|
415
|
-
|
|
416
|
-
}
|
|
324
|
+
// Initialize accessory (setup services and event listeners)
|
|
325
|
+
await handler.setup()
|
|
326
|
+
|
|
327
|
+
return { success: true }
|
|
417
328
|
} else {
|
|
418
329
|
// Create new accessory
|
|
419
|
-
|
|
420
|
-
const displayName = device.name
|
|
421
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Adding new accessory:', displayName)
|
|
330
|
+
this.log.info('Adding new accessory:', device.name)
|
|
422
331
|
|
|
423
332
|
// Create platform accessory
|
|
333
|
+
/** @type {PlatformAccessory<YotoAccessoryContext>} */
|
|
424
334
|
// eslint-disable-next-line new-cap
|
|
425
|
-
const accessory = new this.api.platformAccessory(
|
|
335
|
+
const accessory = new this.api.platformAccessory(sanitizedDeviceName, uuid, accessoryCategory)
|
|
426
336
|
|
|
427
337
|
// Set Name and ConfiguredName on AccessoryInformation service
|
|
428
338
|
const infoService = accessory.getService(this.api.hap.Service.AccessoryInformation)
|
|
429
339
|
if (infoService) {
|
|
430
340
|
infoService
|
|
431
|
-
.setCharacteristic(this.api.hap.Characteristic.Name,
|
|
432
|
-
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName,
|
|
341
|
+
.setCharacteristic(this.api.hap.Characteristic.Name, sanitizedDeviceName)
|
|
342
|
+
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, sanitizedDeviceName)
|
|
433
343
|
}
|
|
434
344
|
|
|
435
|
-
// Create typed accessory with context
|
|
436
|
-
const typedAccessory = /** @type {PlatformAccessory<YotoAccessoryContext>} */ (accessory)
|
|
437
|
-
|
|
438
345
|
// Set accessory context
|
|
439
|
-
|
|
346
|
+
accessory.context = {
|
|
440
347
|
device,
|
|
441
|
-
lastStatus: null,
|
|
442
|
-
lastEvents: null,
|
|
443
|
-
lastUpdate: Date.now()
|
|
444
348
|
}
|
|
445
349
|
|
|
446
|
-
// Create handler for this accessory
|
|
447
|
-
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
|
+
})
|
|
448
356
|
|
|
449
|
-
// Track handler
|
|
357
|
+
// Track handler
|
|
450
358
|
this.accessoryHandlers.set(uuid, handler)
|
|
451
359
|
|
|
452
|
-
// Initialize accessory (
|
|
453
|
-
handler.
|
|
454
|
-
this.log.error(LOG_PREFIX.PLATFORM, `Failed to initialize ${device.name}:`, error)
|
|
455
|
-
})
|
|
360
|
+
// Initialize accessory (setup services and event listeners)
|
|
361
|
+
await handler.setup()
|
|
456
362
|
|
|
457
|
-
// Register
|
|
363
|
+
// Register as a platform accessory (bridged).
|
|
364
|
+
this.log.info(`Registering new accessory: ${device.name}`)
|
|
458
365
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory])
|
|
459
366
|
|
|
460
|
-
// Add to our tracking map
|
|
461
|
-
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 }
|
|
462
371
|
}
|
|
463
372
|
}
|
|
464
373
|
|
|
@@ -466,48 +375,79 @@ export class YotoPlatform {
|
|
|
466
375
|
* Remove accessories that are no longer present in the account
|
|
467
376
|
*/
|
|
468
377
|
removeStaleAccessories () {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
for (const [uuid, accessory] of this.accessories) {
|
|
472
|
-
if (!this.discoveredUUIDs.includes(uuid)) {
|
|
473
|
-
this.log.debug(LOG_PREFIX.PLATFORM, 'Removing stale accessory:', accessory.displayName)
|
|
474
|
-
staleAccessories.push(accessory)
|
|
475
|
-
}
|
|
378
|
+
if (!this.yotoAccount) {
|
|
379
|
+
return
|
|
476
380
|
}
|
|
477
381
|
|
|
478
|
-
|
|
479
|
-
|
|
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)
|
|
480
389
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const handler = this.accessoryHandlers.get(accessory.UUID)
|
|
390
|
+
// Stop handler if it exists
|
|
391
|
+
const handler = this.accessoryHandlers.get(uuid)
|
|
484
392
|
if (handler) {
|
|
485
|
-
handler.
|
|
486
|
-
this.log.error(
|
|
393
|
+
handler.stop().catch(error => {
|
|
394
|
+
this.log.error(`Failed to stop handler for ${accessory.displayName}:`, error)
|
|
487
395
|
})
|
|
488
|
-
this.accessoryHandlers.delete(
|
|
396
|
+
this.accessoryHandlers.delete(uuid)
|
|
489
397
|
}
|
|
490
|
-
|
|
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)
|
|
491
404
|
}
|
|
492
405
|
}
|
|
493
406
|
}
|
|
494
407
|
|
|
495
408
|
/**
|
|
496
|
-
* Shutdown
|
|
409
|
+
* Shutdown platform - cleanup all handlers and stop account
|
|
497
410
|
*/
|
|
498
411
|
async shutdown () {
|
|
499
|
-
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
|
+
}
|
|
500
423
|
|
|
501
|
-
//
|
|
502
|
-
|
|
424
|
+
// Wait for all handlers to cleanup
|
|
425
|
+
await Promise.all(stopPromises)
|
|
426
|
+
this.accessoryHandlers.clear()
|
|
503
427
|
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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)
|
|
511
451
|
}
|
|
512
452
|
}
|
|
513
453
|
}
|