yoto-nodejs-client 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.
Files changed (92) hide show
  1. package/README.md +523 -30
  2. package/bin/auth.js +36 -46
  3. package/bin/content.js +0 -0
  4. package/bin/device-model.d.ts +3 -0
  5. package/bin/device-model.d.ts.map +1 -0
  6. package/bin/device-model.js +360 -0
  7. package/bin/device-tui.TODO.md +125 -0
  8. package/bin/device-tui.d.ts +31 -0
  9. package/bin/device-tui.d.ts.map +1 -0
  10. package/bin/device-tui.js +1123 -0
  11. package/bin/devices.js +166 -28
  12. package/bin/groups.js +0 -0
  13. package/bin/icons.js +0 -0
  14. package/bin/lib/cli-helpers.d.ts +33 -1
  15. package/bin/lib/cli-helpers.d.ts.map +1 -1
  16. package/bin/lib/cli-helpers.js +5 -5
  17. package/bin/lib/token-helpers.d.ts +32 -0
  18. package/bin/lib/token-helpers.d.ts.map +1 -1
  19. package/bin/refresh-token.js +6 -6
  20. package/bin/token-info.js +3 -3
  21. package/index.d.ts +4 -217
  22. package/index.d.ts.map +1 -1
  23. package/index.js +11 -689
  24. package/lib/api-client.d.ts +576 -0
  25. package/lib/api-client.d.ts.map +1 -0
  26. package/lib/api-client.js +681 -0
  27. package/lib/api-endpoints/auth.d.ts +280 -4
  28. package/lib/api-endpoints/auth.d.ts.map +1 -1
  29. package/lib/api-endpoints/auth.js +224 -7
  30. package/lib/api-endpoints/auth.test.js +54 -2
  31. package/lib/api-endpoints/constants.d.ts +30 -2
  32. package/lib/api-endpoints/constants.d.ts.map +1 -1
  33. package/lib/api-endpoints/constants.js +17 -10
  34. package/lib/api-endpoints/content.d.ts +760 -0
  35. package/lib/api-endpoints/content.d.ts.map +1 -1
  36. package/lib/api-endpoints/content.test.js +1 -1
  37. package/lib/api-endpoints/devices.d.ts +917 -48
  38. package/lib/api-endpoints/devices.d.ts.map +1 -1
  39. package/lib/api-endpoints/devices.js +114 -52
  40. package/lib/api-endpoints/devices.test.js +1 -1
  41. package/lib/api-endpoints/endpoint-test-helpers.d.ts +28 -0
  42. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
  43. package/lib/api-endpoints/family-library-groups.d.ts +187 -0
  44. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
  45. package/lib/api-endpoints/family-library-groups.test.js +1 -1
  46. package/lib/api-endpoints/family.d.ts +88 -0
  47. package/lib/api-endpoints/family.d.ts.map +1 -1
  48. package/lib/api-endpoints/family.test.js +1 -1
  49. package/lib/api-endpoints/helpers.d.ts +37 -3
  50. package/lib/api-endpoints/helpers.d.ts.map +1 -1
  51. package/lib/api-endpoints/icons.d.ts +196 -0
  52. package/lib/api-endpoints/icons.d.ts.map +1 -1
  53. package/lib/api-endpoints/icons.test.js +1 -1
  54. package/lib/api-endpoints/media.d.ts +83 -0
  55. package/lib/api-endpoints/media.d.ts.map +1 -1
  56. package/lib/helpers/power-state.d.ts +53 -0
  57. package/lib/helpers/power-state.d.ts.map +1 -0
  58. package/lib/helpers/power-state.js +73 -0
  59. package/lib/helpers/power-state.test.js +100 -0
  60. package/lib/helpers/temperature.d.ts +24 -0
  61. package/lib/helpers/temperature.d.ts.map +1 -0
  62. package/lib/helpers/temperature.js +61 -0
  63. package/lib/helpers/temperature.test.js +58 -0
  64. package/lib/helpers/typed-keys.d.ts +7 -0
  65. package/lib/helpers/typed-keys.d.ts.map +1 -0
  66. package/lib/helpers/typed-keys.js +8 -0
  67. package/lib/mqtt/client.d.ts +610 -7
  68. package/lib/mqtt/client.d.ts.map +1 -1
  69. package/lib/mqtt/client.js +213 -31
  70. package/lib/mqtt/commands.d.ts +195 -0
  71. package/lib/mqtt/commands.d.ts.map +1 -1
  72. package/lib/mqtt/factory.d.ts +62 -1
  73. package/lib/mqtt/factory.d.ts.map +1 -1
  74. package/lib/mqtt/factory.js +27 -5
  75. package/lib/mqtt/mqtt.test.js +85 -28
  76. package/lib/mqtt/topics.d.ts +186 -1
  77. package/lib/mqtt/topics.d.ts.map +1 -1
  78. package/lib/mqtt/topics.js +54 -20
  79. package/lib/pkg.d.cts +9 -0
  80. package/lib/token.d.ts +106 -3
  81. package/lib/token.d.ts.map +1 -1
  82. package/lib/token.js +30 -23
  83. package/lib/yoto-account.d.ts +163 -0
  84. package/lib/yoto-account.d.ts.map +1 -0
  85. package/lib/yoto-account.js +340 -0
  86. package/lib/yoto-device.d.ts +656 -0
  87. package/lib/yoto-device.d.ts.map +1 -0
  88. package/lib/yoto-device.js +2850 -0
  89. package/package.json +22 -15
  90. package/lib/api-endpoints/test-helpers.d.ts +0 -7
  91. package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
  92. /package/lib/api-endpoints/{test-helpers.js → endpoint-test-helpers.js} +0 -0
@@ -0,0 +1,2850 @@
1
+ /**
2
+ * Yoto Device Client - Stateful device manager
3
+ *
4
+ * Manages device state from both HTTP and MQTT sources, providing a unified
5
+ * interface for device status, configuration, and real-time updates.
6
+ *
7
+ */
8
+
9
+ /**
10
+ * @import { YotoClient } from './api-client.js'
11
+ * @import { YotoDevice, YotoDeviceConfig, YotoDeviceShortcuts, YotoDeviceFullStatus, YotoDeviceStatusResponse, YotoDeviceCommand, YotoDeviceCommandResponse } from './api-endpoints/devices.js'
12
+ * @import { YotoMqttClient, YotoMqttStatus, YotoEventsMessage, YotoLegacyStatus } from './mqtt/client.js'
13
+ * @import { MqttClientOptions } from './mqtt/factory.js'
14
+ */
15
+
16
+ import { EventEmitter } from 'events'
17
+ import { parseTemperature } from './helpers/temperature.js'
18
+ import { detectPowerState } from './helpers/power-state.js'
19
+ import { typedKeys } from './helpers/typed-keys.js'
20
+
21
+ // ============================================================================
22
+ // Type Definitions
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Card insertion state values
27
+ * @typedef {'none' | 'physical' | 'remote'} CardInsertionState
28
+ */
29
+
30
+ /**
31
+ * Converts numeric card insertion state to string union
32
+ * @param {0 | 1 | 2} numericState - Numeric state from API
33
+ * @returns {CardInsertionState} String union value
34
+ */
35
+ function convertCardInsertionState (numericState) {
36
+ switch (numericState) {
37
+ case 0: return 'none'
38
+ case 1: return 'physical'
39
+ case 2: return 'remote'
40
+ default: return 'none'
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Convert numeric day mode to string union
46
+ * @param {number} numericMode - Numeric day mode value
47
+ * @returns {DayMode} String representation of day mode
48
+ */
49
+ function convertDayMode (numericMode) {
50
+ switch (numericMode) {
51
+ case -1: return 'unknown'
52
+ case 0: return 'night'
53
+ case 1: return 'day'
54
+ default: return 'unknown'
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Day mode state
60
+ * @typedef {'unknown' | 'night' | 'day'} DayMode
61
+ */
62
+
63
+ /**
64
+ * Convert numeric power source to string union
65
+ * @param {number} numericSource - Numeric power source value
66
+ * @returns {PowerSource} String representation of power source
67
+ */
68
+ function convertPowerSource (numericSource) {
69
+ switch (numericSource) {
70
+ case 0: return 'battery'
71
+ case 1: return 'dock'
72
+ case 2: return 'usb-c'
73
+ case 3: return 'wireless'
74
+ default: return 'battery'
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Power source state
80
+ * @typedef {'battery' | 'dock' | 'usb-c' | 'wireless'} PowerSource
81
+ */
82
+
83
+ /**
84
+ * Official Yoto nightlight colors
85
+ *
86
+ * Maps raw nightlight values to official color names.
87
+ */
88
+ export const NIGHTLIGHT_COLORS = {
89
+ '0x643600': 'Orange Peel',
90
+ '0x640000': 'Tambourine Red',
91
+ '0x602d3c': 'Lilac',
92
+ '0x5a6400': 'Apple Green',
93
+ '0x644800': 'Bumblebee Yellow',
94
+ '0x194a55': 'Sky Blue',
95
+ '0x646464': 'White',
96
+ '0x000000': 'No Light (screen down)',
97
+ off: 'Off (screen up)'
98
+ }
99
+
100
+ /**
101
+ * Get the official name for a nightlight color
102
+ * @param {string} colorValue - Nightlight color value (hex code or 'off')
103
+ * @returns {string} Official color name or the original value if not found
104
+ */
105
+ export function getNightlightColorName (colorValue) {
106
+ return NIGHTLIGHT_COLORS[/** @type {keyof typeof NIGHTLIGHT_COLORS} */ (colorValue)] || colorValue
107
+ }
108
+
109
+ /**
110
+ * Canonical device status - normalized format for both HTTP and MQTT sources
111
+ *
112
+ * This unified format resolves differences between HTTP and MQTT:
113
+ * - HTTP uses string booleans ('0'/'1'), MQTT uses actual booleans
114
+ * - HTTP has nullable fields, MQTT fields are non-nullable
115
+ * - Field names match YotoDeviceStatusResponse with fallback to YotoDeviceFullStatus
116
+ *
117
+ * @typedef {Object} YotoDeviceStatus
118
+ * @property {string | null} activeCardId - Active card ID or null when no card is active
119
+ * @property {number} batteryLevelPercentage - Battery level percentage (integer 0-100)
120
+ * @property {boolean} isCharging - Whether device is currently charging
121
+ * @property {boolean} isOnline - Whether device is currently online
122
+ * @property {number} volume - User volume level (0-16 scale)
123
+ * @property {number} maxVolume - Maximum volume limit (0-16 scale)
124
+ * @property {CardInsertionState} cardInsertionState - Card insertion state
125
+ * @property {DayMode} dayMode - Day mode status
126
+ * @property {PowerSource} powerSource - Power source
127
+ * @property {string} firmwareVersion - Firmware version
128
+ * @property {number} wifiStrength - WiFi signal strength in dBm
129
+ * @property {number} freeDiskSpaceBytes - Free disk space in bytes
130
+ * @property {number} totalDiskSpaceBytes - Total disk space in bytes
131
+ * @property {boolean} isAudioDeviceConnected - Whether headphones are connected
132
+ * @property {boolean} isBluetoothAudioConnected - Whether Bluetooth headphones are enabled
133
+ * @property {string} nightlightMode - Current nightlight color (6-digit hex color like '0xff5733' or 'off'). Most accurate value comes from MQTT status. HTTP endpoint returns either 'off' or '0x000000'. Note: This is the live status; configured colors are in config.ambientColour (day) and config.nightAmbientColour (night). Only available on devices with colored nightlight (v3).
134
+ * @property {string | number | null} temperatureCelsius - Temperature in Celsius (null if not supported, can be number, string "0", or "notSupported") TODO: Number or null only
135
+ * @property {number} ambientLightSensorReading - Ambient light sensor reading TODO: Figure out units
136
+ * @property {number | null} displayBrightness - Current display brightness (null when device is off) - from YotoDeviceFullStatus (dnowBrightness) TODO: Figure out units
137
+ * @property {'12' | '24' | null} timeFormat - Time format preference - from YotoDeviceFullStatus
138
+ * @property {number} uptime - Device uptime in seconds
139
+ * @property {string} updatedAt - ISO 8601 timestamp of last update
140
+ * @property {string} source - Data source ('http' or 'mqtt') - metadata field added by stateful client
141
+ */
142
+ export const YotoDeviceStatusType = {}
143
+
144
+ /**
145
+ * Playback state from MQTT events
146
+ * @typedef {Object} YotoPlaybackState
147
+ * @property {string | null} cardId - Currently playing card ID TODO: Figure out name of card
148
+ * @property {string | null} source - Playback source (e.g., 'card', 'remote', 'MQTT') TODO: Figure out what 'mqtt' source means. Card means card, remote means remotly played card.
149
+ * @property {'playing' | 'paused' | 'stopped' | 'loading' | string | null} playbackStatus - Playback status
150
+ * @property {string | null} trackTitle - Current track title
151
+ * @property {string | null} trackKey - Current track key
152
+ * @property {string | null} chapterTitle - Current chapter title
153
+ * @property {string | null} chapterKey - Current chapter key
154
+ * @property {number | null} position - Current position in seconds
155
+ * @property {number | null} trackLength - Track duration in seconds
156
+ * @property {boolean | null} streaming - Whether streaming
157
+ * @property {boolean | null} sleepTimerActive - Sleep timer active
158
+ * @property {number | null} sleepTimerSeconds - Seconds remaining on sleep timer
159
+ * @property {string} updatedAt - ISO 8601 timestamp of last update
160
+ */
161
+ export const YotoPlaybackStateType = {}
162
+
163
+ // ============================================================================
164
+ // Internal Types
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Complete device client state
169
+ * @typedef {Object} YotoDeviceClientState
170
+ * @property {YotoDevice} device - Basic device information
171
+ * @property {YotoDeviceConfig} config - Device configuration (always initialized)
172
+ * @property {YotoDeviceShortcuts} shortcuts - Button shortcuts (always initialized)
173
+ * @property {YotoDeviceStatus} status - Current device status (always initialized)
174
+ * @property {YotoPlaybackState} playback - Current playback state (always initialized)
175
+ * @property {boolean} initialized - Whether device has been initialized
176
+ * @property {boolean} running - Whether device client is currently running (started)
177
+ * @property {Object} lastUpdate - Timestamps of last updates
178
+ * @property {number | null} lastUpdate.config - Last config update timestamp
179
+ * @property {number | null} lastUpdate.status - Last status update timestamp
180
+ * @property {number | null} lastUpdate.playback - Last playback update timestamp
181
+ * @property {string | null} lastUpdate.source - Source of last status update ('http' or 'mqtt')
182
+ */
183
+
184
+ /**
185
+ * Device hardware capabilities based on deviceType
186
+ * @typedef {Object} YotoDeviceCapabilities
187
+ * @property {boolean} hasTemperatureSensor - Whether device has a temperature sensor
188
+ * @property {boolean} hasAmbientLightSensor - Whether device has an ambient light sensor
189
+ * @property {boolean} hasColoredNightlight - Whether device has a colored nightlight
190
+ * @property {boolean} supported - Whether this device type is supported
191
+ */
192
+
193
+ /**
194
+ * Nightlight information
195
+ * @typedef {Object} YotoDeviceNightlightInfo
196
+ * @property {string} value - Raw nightlight value (hex color like '0x643600' or 'off')
197
+ * @property {string} name - Official color name (e.g., 'Orange Peel', 'Off (screen up)')
198
+ * @property {boolean} supported - Whether this device supports colored nightlight
199
+ */
200
+
201
+ /**
202
+ * Device client initialization options
203
+ * @typedef {Object} YotoDeviceModelOptions
204
+ * @property {MqttClientOptions} [mqttOptions] - MQTT.js client options to pass through to factory
205
+ * @property {number} [httpPollIntervalMs=600000] - Background HTTP polling interval for config+status sync (default: 10 minutes)
206
+ */
207
+
208
+ /**
209
+ * Online event metadata
210
+ * @typedef {Object} YotoDeviceOnlineMetadata
211
+ * @property {'startup' | 'activity'} reason - Reason device came online
212
+ * @property {number | null} [upTime] - Device uptime in seconds (only present for 'startup' reason)
213
+ */
214
+
215
+ /**
216
+ * Offline event metadata
217
+ * @typedef {Object} YotoDeviceOfflineMetadata
218
+ * @property {'shutdown' | 'timeout' | 'http-status'} reason - Reason device went offline
219
+ * @property {string | null} [shutDownReason] - Shutdown reason from device (only present for 'shutdown' reason)
220
+ * @property {number | null} [timeSinceLastSeen] - Time since last seen in ms (only present for 'timeout' reason)
221
+ * @property {string} [source] - Source of status update (only present for 'http-status' reason)
222
+ */
223
+
224
+ /**
225
+ * Started event metadata
226
+ * @typedef {Object} YotoDeviceStartedMetadata
227
+ * @property {YotoDevice} device - Device information
228
+ * @property {YotoDeviceConfig} config - Device configuration
229
+ * @property {YotoDeviceShortcuts} shortcuts - Device shortcuts
230
+ * @property {YotoDeviceStatus} status - Device status
231
+ * @property {YotoPlaybackState} playback - Playback state
232
+ * @property {boolean} initialized - Whether initialized
233
+ * @property {boolean} running - Whether running
234
+ */
235
+
236
+ /**
237
+ * Event map for YotoDeviceModel
238
+ * @typedef {{
239
+ * 'started': [YotoDeviceStartedMetadata],
240
+ * 'stopped': [],
241
+ * 'statusUpdate': [YotoDeviceStatus, string, Set<keyof YotoDeviceStatus>],
242
+ * 'configUpdate': [YotoDeviceConfig, Set<keyof YotoDeviceConfig>],
243
+ * 'playbackUpdate': [YotoPlaybackState, Set<keyof YotoPlaybackState>],
244
+ * 'online': [YotoDeviceOnlineMetadata],
245
+ * 'offline': [YotoDeviceOfflineMetadata],
246
+ * 'mqttConnected': [],
247
+ * 'mqttDisconnected': [],
248
+ * 'error': [Error]
249
+ * }} YotoDeviceModelEventMap
250
+ */
251
+
252
+ // ============================================================================
253
+ // YotoDeviceModel Class
254
+ // ============================================================================
255
+
256
+ /**
257
+ * Stateful device client that manages device state primarily from MQTT with HTTP background sync
258
+ *
259
+ * Philosophy:
260
+ * - MQTT is the primary source for all real-time status updates
261
+ * - MQTT connection is always maintained and handles its own reconnection
262
+ * - Device online/offline state is tracked by MQTT activity (sets online) and explicit shutdown messages
263
+ * - HTTP background polling runs every 10 minutes to sync config+status regardless of online state
264
+ * - HTTP status updates emit offline events if device state changes to offline
265
+ *
266
+ * Events:
267
+ * - 'started' - Emitted when device client is started, passes metadata object
268
+ * - 'stopped' - Emitted when device client is stopped
269
+ * - 'statusUpdate' - Emitted when status changes, passes (status, source, changedFields)
270
+ * - 'configUpdate' - Emitted when config changes, passes (config, changedFields)
271
+ * - 'playbackUpdate' - Emitted when playback state changes, passes (playback, changedFields)
272
+ * - 'online' - Emitted when device comes online, passes metadata with reason and optional upTime
273
+ * - 'offline' - Emitted when device goes offline, passes metadata with reason and optional shutDownReason or timeSinceLastSeen
274
+ * - 'mqttConnected' - Emitted when MQTT client connects
275
+ * - 'mqttDisconnected' - Emitted when MQTT client disconnects
276
+ * - 'error' - Emitted when an error occurs, passes error
277
+ *
278
+ * @extends {EventEmitter<YotoDeviceModelEventMap>}
279
+ */
280
+ export class YotoDeviceModel extends EventEmitter {
281
+ /** @type {YotoClient} */ #client
282
+ /** @type {YotoMqttClient | null} */ #mqttClient = null
283
+ /** @type {YotoDeviceClientState} */ #state
284
+ /** @type {YotoDeviceModelOptions} */ #options
285
+ /** @type {number} */ #mqttRequestStatusDelayMs = 1000
286
+ /** @type {number} */ #mqttRequestEventsDelayMs = 3000
287
+ /** @type {NodeJS.Timeout | null} */ #statusRequestTimer = null
288
+ /** @type {NodeJS.Timeout | null} */ #eventsRequestTimer = null
289
+ /** @type {NodeJS.Timeout | null} */ #backgroundPollTimer = null
290
+ /** @type {number | null} */ #shutdownDetectedAt = null
291
+
292
+ /**
293
+ * Create a Yoto device client
294
+ * @param {YotoClient} client - Authenticated YotoClient instance
295
+ * @param {YotoDevice} device - Device object from getDevices()
296
+ * @param {YotoDeviceModelOptions} [options] - Client options
297
+ */
298
+ constructor (client, device, options = {}) {
299
+ super()
300
+
301
+ this.#client = client
302
+ this.#options = {
303
+ httpPollIntervalMs: options.httpPollIntervalMs ?? 600000 // 10 minutes
304
+ }
305
+
306
+ // Only set mqttOptions if provided (exactOptionalPropertyTypes compatibility)
307
+ if (options.mqttOptions !== undefined) {
308
+ this.#options.mqttOptions = options.mqttOptions
309
+ }
310
+
311
+ // Initialize state
312
+ this.#state = {
313
+ device,
314
+ config: createEmptyDeviceConfig(),
315
+ shortcuts: createEmptyDeviceShortcuts(),
316
+ status: createEmptyDeviceStatus(device),
317
+ playback: createEmptyPlaybackState(),
318
+ initialized: false,
319
+ running: false,
320
+ lastUpdate: {
321
+ config: null,
322
+ status: null,
323
+ playback: null,
324
+ source: null
325
+ }
326
+ }
327
+
328
+ this.#shutdownDetectedAt = null
329
+ }
330
+
331
+ // ==========================================================================
332
+ // Internal Getters/Setters
333
+ // ==========================================================================
334
+
335
+ /**
336
+ * Get device online status
337
+ * @returns {boolean}
338
+ */
339
+ get #deviceOnline () {
340
+ return this.#state.status.isOnline
341
+ }
342
+
343
+ /**
344
+ * Set device online status
345
+ * @param {boolean} value
346
+ */
347
+ set #deviceOnline (value) {
348
+ this.#state.status.isOnline = value
349
+ }
350
+
351
+ /**
352
+ * Get MQTT connection status
353
+ * @returns {boolean}
354
+ */
355
+ get #mqttConnected () {
356
+ return this.#mqttClient?.connected ?? false
357
+ }
358
+
359
+ // ==========================================================================
360
+ // Public API - State Accessors
361
+ /**
362
+ * Get device information
363
+ * @returns {YotoDevice}
364
+ */
365
+ get device () { return { ...this.#state.device } }
366
+
367
+ /**
368
+ * Get current device status
369
+ * @returns { YotoDeviceStatus }
370
+ */
371
+ get status () { return this.#state.status }
372
+
373
+ /**
374
+ * Get device configuration
375
+ * @returns {YotoDeviceConfig}
376
+ */
377
+ get config () { return this.#state.config }
378
+
379
+ /**
380
+ * Get device shortcuts
381
+ * @returns {YotoDeviceShortcuts }
382
+ */
383
+ get shortcuts () { return this.#state.shortcuts }
384
+
385
+ /**
386
+ * Get current playback state
387
+ * @returns {YotoPlaybackState}
388
+ */
389
+ get playback () { return this.#state.playback }
390
+
391
+ /**
392
+ * Check if device has been initialized
393
+ * @returns {boolean}
394
+ */
395
+ get initialized () { return this.#state.initialized }
396
+
397
+ /**
398
+ * Check if device client is running (started)
399
+ * @returns {boolean}
400
+ */
401
+ get running () { return this.#state.running }
402
+
403
+ /**
404
+ * Check if MQTT client is connected
405
+ * @returns {boolean}
406
+ */
407
+ get mqttConnected () { return this.#mqttConnected }
408
+
409
+ /**
410
+ * Check if device is currently online (based on MQTT activity)
411
+ * @returns {boolean}
412
+ */
413
+ get deviceOnline () { return this.#deviceOnline }
414
+
415
+ /**
416
+ * Get device hardware capabilities based on deviceType
417
+ * @returns {YotoDeviceCapabilities}
418
+ */
419
+ get capabilities () {
420
+ const deviceType = this.#state.device.deviceType
421
+
422
+ // v3 has full sensor suite and colored nightlight
423
+ if (deviceType === 'v3') {
424
+ return {
425
+ hasTemperatureSensor: true,
426
+ hasAmbientLightSensor: true,
427
+ hasColoredNightlight: true,
428
+ supported: true
429
+ }
430
+ }
431
+
432
+ // mini has no sensors or colored nightlight
433
+ if (deviceType === 'mini') {
434
+ return {
435
+ hasTemperatureSensor: false,
436
+ hasAmbientLightSensor: false,
437
+ hasColoredNightlight: false,
438
+ supported: true
439
+ }
440
+ }
441
+
442
+ // Unknown/unsupported device type
443
+ return {
444
+ hasTemperatureSensor: false,
445
+ hasAmbientLightSensor: false,
446
+ hasColoredNightlight: false,
447
+ supported: false
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Get nightlight information including color name
453
+ * @returns {YotoDeviceNightlightInfo}
454
+ */
455
+ get nightlight () {
456
+ const value = this.#state.status.nightlightMode
457
+ const supported = this.capabilities.hasColoredNightlight
458
+ const name = getNightlightColorName(value)
459
+
460
+ return {
461
+ value,
462
+ name,
463
+ supported
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Static reference to nightlight colors map
469
+ *
470
+ * @type {Record<string, string>}
471
+ */
472
+ static NIGHTLIGHT_COLORS = NIGHTLIGHT_COLORS
473
+
474
+ /**
475
+ * Static method to get nightlight color name
476
+ *
477
+ * @param {string} colorValue - Nightlight color value (hex code or 'off')
478
+ * @returns {string} Official color name or the original value if not found
479
+ */
480
+ static getNightlightColorName = getNightlightColorName
481
+
482
+ // ==========================================================================
483
+ // Public API - Lifecycle Management
484
+ // ==========================================================================
485
+
486
+ /**
487
+ * Start the device client - fetches config, connects to MQTT, begins monitoring
488
+ * @returns {Promise<void>}
489
+ * @throws {Error} If start fails
490
+ */
491
+ async start () {
492
+ if (this.#state.running) {
493
+ return // Already running
494
+ }
495
+
496
+ try {
497
+ // Fetch device config (includes status and shortcuts)
498
+ const configResponse = await this.#client.getDeviceConfig({
499
+ deviceId: this.#state.device.deviceId
500
+ })
501
+
502
+ // Update state with config data
503
+ this.#state.config = configResponse.device.config
504
+ this.#state.shortcuts = configResponse.device.shortcuts
505
+
506
+ // Update device info with additional details from config
507
+ this.#state.device = {
508
+ ...this.#state.device,
509
+ name: configResponse.device.name,
510
+ online: configResponse.device.online
511
+ }
512
+
513
+ // Fetch device status from status endpoint
514
+ const statusResponse = await this.#client.getDeviceStatus({
515
+ deviceId: this.#state.device.deviceId
516
+ })
517
+
518
+ // Update status from dedicated status endpoint
519
+ this.#updateStatusFromStatusResponse(statusResponse)
520
+
521
+ // Also update from full status if available in config response
522
+ if (configResponse.device.status) {
523
+ this.#updateStatusFromFullStatus(configResponse.device.status)
524
+ }
525
+
526
+ // Mark config as updated
527
+ this.#state.lastUpdate.config = Date.now()
528
+
529
+ // Always initialize MQTT - it's the primary status source
530
+ await this.#initializeMqtt()
531
+
532
+ // Start background HTTP polling - syncs config+status every 10 minutes
533
+ this.#startBackgroundPolling()
534
+
535
+ // Mark as initialized and running
536
+ this.#state.initialized = true
537
+ this.#state.running = true
538
+
539
+ this.emit('started', {
540
+ device: this.device,
541
+ config: this.config,
542
+ shortcuts: this.shortcuts,
543
+ status: this.status,
544
+ playback: this.playback,
545
+ initialized: this.initialized,
546
+ running: this.running
547
+ })
548
+ } catch (err) {
549
+ const error = /** @type {Error} */ (err)
550
+ this.emit('error', error)
551
+ throw error
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Stop the device client - disconnects MQTT, stops background polling
557
+ * @returns {Promise<void>}
558
+ */
559
+ async stop () {
560
+ if (!this.#state.running) {
561
+ return // Not running
562
+ }
563
+
564
+ try {
565
+ // Clear all timers
566
+ this.#clearAllTimers()
567
+
568
+ // Disconnect MQTT
569
+ if (this.#mqttClient) {
570
+ await this.#mqttClient.disconnect()
571
+ this.#mqttClient = null
572
+ }
573
+
574
+ // Mark as stopped
575
+ this.#state.running = false
576
+
577
+ this.emit('stopped')
578
+ } catch (err) {
579
+ const error = /** @type {Error} */ (err)
580
+ this.emit('error', error)
581
+ throw error
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Restart the device client - stops and starts again
587
+ * @returns {Promise<void>}
588
+ */
589
+ async restart () {
590
+ await this.stop()
591
+ await sleep(5000)
592
+ await this.start()
593
+ }
594
+
595
+ /**
596
+ * Get MQTT client instance
597
+ * @returns {YotoMqttClient | null}
598
+ */
599
+ get mqttClient () {
600
+ return this.#mqttClient
601
+ }
602
+
603
+ // ==========================================================================
604
+ // Public API - Device Control
605
+ // ==========================================================================
606
+
607
+ /**
608
+ * Refresh device status from HTTP API
609
+ * This is primarily used as a fallback when device is offline
610
+ * @returns {Promise<YotoDeviceStatus>}
611
+ */
612
+ /**
613
+ * Refresh device config from HTTP API
614
+ * @returns {Promise<YotoDeviceConfig>}
615
+ */
616
+ async refreshConfig () {
617
+ const configResponse = await this.#client.getDeviceConfig({
618
+ deviceId: this.#state.device.deviceId
619
+ })
620
+
621
+ this.#updateConfigFromHttp(
622
+ configResponse.device.config,
623
+ configResponse.device.shortcuts ?? createEmptyDeviceShortcuts()
624
+ )
625
+
626
+ // Also fetch and update status from status endpoint
627
+ const statusResponse = await this.#client.getDeviceStatus({
628
+ deviceId: this.#state.device.deviceId
629
+ })
630
+ this.#updateStatusFromStatusResponse(statusResponse)
631
+
632
+ const config = this.config
633
+ if (!config) throw new Error('Config not available after refresh')
634
+ return config
635
+ }
636
+
637
+ /**
638
+ * Update device configuration
639
+ * @param {Partial<YotoDeviceConfig>} configUpdate - Configuration changes
640
+ * @returns {Promise<void>}
641
+ */
642
+ async updateConfig (configUpdate) {
643
+ await this.#client.updateDeviceConfig({
644
+ deviceId: this.#state.device.deviceId,
645
+ configUpdate: {
646
+ config: configUpdate
647
+ }
648
+ })
649
+
650
+ // Refresh config to get updated values
651
+ await this.refreshConfig()
652
+ }
653
+
654
+ /**
655
+ * Send a device command via HTTP API
656
+ * @param {YotoDeviceCommand} command - Command to send
657
+ * @returns {Promise<YotoDeviceCommandResponse>}
658
+ */
659
+ async sendCommand (command) {
660
+ return await this.#client.sendDeviceCommand({
661
+ deviceId: this.#state.device.deviceId,
662
+ command
663
+ })
664
+ }
665
+
666
+ // ==========================================================================
667
+ // Private - MQTT Initialization
668
+ // ==========================================================================
669
+
670
+ /**
671
+ * Initialize MQTT client and set up event handlers
672
+ */
673
+ async #initializeMqtt () {
674
+ try {
675
+ // Create MQTT client
676
+ this.#mqttClient = await this.#client.createMqttClient({
677
+ deviceId: this.#state.device.deviceId,
678
+ mqttOptions: this.#options.mqttOptions
679
+ })
680
+
681
+ // Set up MQTT event handlers
682
+ this.#setupMqttHandlers()
683
+
684
+ // Connect to MQTT
685
+ await this.#mqttClient.connect()
686
+ } catch (err) {
687
+ const error = /** @type {Error} */ (err)
688
+ this.emit('error', error)
689
+ throw error
690
+ }
691
+ }
692
+
693
+ /**
694
+ * Set up MQTT event handlers
695
+ */
696
+ #setupMqttHandlers () {
697
+ if (!this.#mqttClient) return
698
+
699
+ // Connection events
700
+ this.#mqttClient.on('connected', () => {
701
+ this.emit('mqttConnected')
702
+
703
+ // Request status and events after settling period
704
+ this.#scheduleMqttRequests()
705
+ })
706
+
707
+ this.#mqttClient.on('disconnected', () => {
708
+ this.#clearAllTimers()
709
+ this.emit('mqttDisconnected')
710
+
711
+ // Don't immediately mark as offline - MQTT may reconnect
712
+ // Offline detection is based on lack of status updates, not connection state
713
+ })
714
+
715
+ this.#mqttClient.on('reconnecting', () => {
716
+ // MQTT client is attempting to reconnect
717
+ })
718
+
719
+ this.#mqttClient.on('error', (error) => {
720
+ this.emit('error', error)
721
+ })
722
+
723
+ // Status updates - PRIMARY source for status after initialization
724
+ this.#mqttClient.on('status', (_topic, message) => {
725
+ this.#recordDeviceActivity()
726
+ this.#updateStatusFromDocumentedMqtt(message.status)
727
+ })
728
+
729
+ // Legacy status updates - contains lifecycle events (shutdown/startup) and full hardware diagnostics
730
+ // This does NOT respond to requestStatus() - only emits on real-time events or 5-minute periodic updates
731
+ // NOTE: Don't call #recordDeviceActivity() here - #handleLegacyStatus handles online/offline transitions
732
+ // based on actual power state (shutdown/startup) which is more reliable than just "activity"
733
+ this.#mqttClient.on('status-legacy', (_topic, message) => {
734
+ this.#handleLegacyStatus(message.status)
735
+ })
736
+
737
+ // Events updates (playback, volume, etc.)
738
+ this.#mqttClient.on('events', (_topic, message) => {
739
+ this.#recordDeviceActivity()
740
+ this.#handleEventMessage(message)
741
+ })
742
+
743
+ // Response messages (for debugging/logging)
744
+ this.#mqttClient.on('response', (_topic, _message) => {
745
+ // Could emit these for command confirmation
746
+ // For now just log internally
747
+ })
748
+ }
749
+
750
+ /**
751
+ * Schedule MQTT status and events requests after settling period
752
+ */
753
+ #scheduleMqttRequests () {
754
+ // Clear any existing timers
755
+ this.#clearAllTimers()
756
+
757
+ // Request status after settling delay
758
+ this.#statusRequestTimer = setTimeout(async () => {
759
+ if (this.#mqttClient && this.#mqttClient.connected) {
760
+ try {
761
+ await this.#mqttClient.requestStatus()
762
+ } catch (err) {
763
+ const error = /** @type {Error} */ (err)
764
+ this.emit('error', error)
765
+ }
766
+ }
767
+ }, this.#mqttRequestStatusDelayMs)
768
+
769
+ // Request events after longer delay
770
+ this.#eventsRequestTimer = setTimeout(async () => {
771
+ if (this.#mqttClient && this.#mqttClient.connected) {
772
+ try {
773
+ await this.#mqttClient.requestEvents()
774
+ } catch (err) {
775
+ const error = /** @type {Error} */ (err)
776
+ this.emit('error', error)
777
+ }
778
+ }
779
+ }, this.#mqttRequestEventsDelayMs)
780
+ }
781
+
782
+ /**
783
+ * Clear all timers
784
+ */
785
+ #clearAllTimers () {
786
+ if (this.#statusRequestTimer) {
787
+ clearTimeout(this.#statusRequestTimer)
788
+ this.#statusRequestTimer = null
789
+ }
790
+
791
+ if (this.#eventsRequestTimer) {
792
+ clearTimeout(this.#eventsRequestTimer)
793
+ this.#eventsRequestTimer = null
794
+ }
795
+
796
+ if (this.#backgroundPollTimer) {
797
+ clearInterval(this.#backgroundPollTimer)
798
+ this.#backgroundPollTimer = null
799
+ }
800
+ }
801
+
802
+ // ==========================================================================
803
+ // Private - Online/Offline Tracking
804
+ // ==========================================================================
805
+
806
+ /**
807
+ * Record device activity (MQTT message received)
808
+ * Marks device as online when MQTT messages are received
809
+ * Does NOT mark online if device recently shut down (grace period of 10 seconds)
810
+ */
811
+ #recordDeviceActivity () {
812
+ // If device was offline, check if we should mark it as online
813
+ if (!this.#deviceOnline) {
814
+ // Don't mark online from activity if shutdown was recently detected
815
+ // MQTT messages can arrive after shutdown (queued messages, final status, etc.)
816
+ // Only startup detection from legacy status should mark online after shutdown
817
+ if (this.#shutdownDetectedAt !== null) {
818
+ const timeSinceShutdown = Date.now() - this.#shutdownDetectedAt
819
+ const gracePeriodMs = 10000 // 10 seconds
820
+ if (timeSinceShutdown < gracePeriodMs) {
821
+ // Ignore activity during grace period after shutdown
822
+ return
823
+ }
824
+ // Grace period passed, clear shutdown timestamp
825
+ this.#shutdownDetectedAt = null
826
+ }
827
+
828
+ this.#deviceOnline = true
829
+ this.emit('online', { reason: 'activity' })
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Start background HTTP polling for config+status sync
835
+ * Runs every 10 minutes to keep device state synchronized via HTTP API
836
+ */
837
+ #startBackgroundPolling () {
838
+ // Don't start if already running
839
+ if (this.#backgroundPollTimer) {
840
+ return
841
+ }
842
+
843
+ // Start polling at intervals (not immediately - we just fetched during start)
844
+ this.#backgroundPollTimer = setInterval(() => {
845
+ this.#backgroundPoll()
846
+ }, this.#options.httpPollIntervalMs)
847
+ }
848
+
849
+ /**
850
+ * Background HTTP poll - syncs device config, shortcuts, and status
851
+ * Runs every 10 minutes to update state via HTTP API
852
+ * Emits configUpdate and statusUpdate events, and offline event if device goes offline
853
+ */
854
+ async #backgroundPoll () {
855
+ try {
856
+ const configResponse = await this.#client.getDeviceConfig({
857
+ deviceId: this.#state.device.deviceId
858
+ })
859
+
860
+ // Update config and shortcuts
861
+ this.#updateConfigFromHttp(
862
+ configResponse.device.config,
863
+ configResponse.device.shortcuts
864
+ )
865
+
866
+ // Fetch and update status from status endpoint
867
+ const statusResponse = await this.#client.getDeviceStatus({
868
+ deviceId: this.#state.device.deviceId
869
+ })
870
+ this.#updateStatusFromStatusResponse(statusResponse)
871
+
872
+ // Also update from full status if available in config response
873
+ if (configResponse.device.status) {
874
+ this.#updateStatusFromFullStatus(configResponse.device.status)
875
+ }
876
+ } catch (err) {
877
+ // Log error but don't stop the timer - will retry on next interval
878
+ const error = /** @type {Error} */ (err)
879
+ this.emit('error', error)
880
+ }
881
+ }
882
+
883
+ // ==========================================================================
884
+ // Private - Status Normalization
885
+ // ==========================================================================
886
+
887
+ /**
888
+ * Update internal status from HTTP full status (from config endpoint)
889
+ * Uses exhaustive switch statement pattern to ensure all YotoDeviceFullStatus fields are handled.
890
+ * @param {YotoDeviceFullStatus} fullStatus - Full status object from config endpoint
891
+ */
892
+ #updateStatusFromFullStatus (fullStatus) {
893
+ let statusChanged = false
894
+ let configChanged = false
895
+ const wasOnline = this.#deviceOnline
896
+ const { status, config } = this.#state
897
+ /** @type {Set<keyof YotoDeviceStatus>} */
898
+ const changedFields = new Set()
899
+ /** @type {Set<keyof YotoDeviceConfig>} */
900
+ const configChangedFields = new Set()
901
+
902
+ /**
903
+ * Handler function for each status field
904
+ * @param {keyof YotoDeviceFullStatus} key
905
+ * @param {YotoDeviceFullStatus} fullStatus
906
+ */
907
+ const handleField = (key, fullStatus) => {
908
+ switch (key) {
909
+ case 'activeCard': {
910
+ const normalizedCard = fullStatus.activeCard === 'none' ? null : fullStatus.activeCard
911
+ if (status.activeCardId !== normalizedCard) {
912
+ status.activeCardId = normalizedCard
913
+ changedFields.add('activeCardId')
914
+ statusChanged = true
915
+ }
916
+ break
917
+ }
918
+ case 'aliveTime': {
919
+ // Hardware diagnostic - not stored in status
920
+ break
921
+ }
922
+ case 'als': {
923
+ if (status.ambientLightSensorReading !== fullStatus.als) {
924
+ status.ambientLightSensorReading = fullStatus.als
925
+ changedFields.add('ambientLightSensorReading')
926
+ statusChanged = true
927
+ }
928
+ break
929
+ }
930
+ case 'battery': {
931
+ // Raw battery voltage - not stored in status (we use batteryLevel)
932
+ break
933
+ }
934
+ case 'batteryLevel': {
935
+ if (status.batteryLevelPercentage !== fullStatus.batteryLevel) {
936
+ status.batteryLevelPercentage = fullStatus.batteryLevel
937
+ changedFields.add('batteryLevelPercentage')
938
+ statusChanged = true
939
+ }
940
+ break
941
+ }
942
+ case 'batteryLevelRaw': {
943
+ // Hardware diagnostic - not stored in status
944
+ break
945
+ }
946
+ case 'batteryRemaining': {
947
+ // Hardware diagnostic - not stored in status
948
+ break
949
+ }
950
+ case 'bgDownload': {
951
+ // Hardware diagnostic - not stored in status
952
+ break
953
+ }
954
+ case 'bluetoothHp': {
955
+ const isConnected = Boolean(fullStatus.bluetoothHp)
956
+ if (status.isBluetoothAudioConnected !== isConnected) {
957
+ status.isBluetoothAudioConnected = isConnected
958
+ changedFields.add('isBluetoothAudioConnected')
959
+ statusChanged = true
960
+ }
961
+ break
962
+ }
963
+ case 'buzzErrors': {
964
+ // Hardware diagnostic - not stored in status
965
+ break
966
+ }
967
+ case 'bytesPS': {
968
+ // Hardware diagnostic - not stored in status
969
+ break
970
+ }
971
+ case 'cardInserted': {
972
+ const newState = convertCardInsertionState(fullStatus.cardInserted)
973
+ if (status.cardInsertionState !== newState) {
974
+ status.cardInsertionState = newState
975
+ changedFields.add('cardInsertionState')
976
+ statusChanged = true
977
+ }
978
+ break
979
+ }
980
+ case 'chgStatLevel': {
981
+ // Hardware diagnostic - not stored in status
982
+ break
983
+ }
984
+ case 'charging': {
985
+ const isCharging = Boolean(fullStatus.charging)
986
+ if (status.isCharging !== isCharging) {
987
+ status.isCharging = isCharging
988
+ changedFields.add('isCharging')
989
+ statusChanged = true
990
+ }
991
+ break
992
+ }
993
+ case 'day': {
994
+ const newDayMode = convertDayMode(fullStatus.day)
995
+ if (status.dayMode !== newDayMode) {
996
+ status.dayMode = newDayMode
997
+ changedFields.add('dayMode')
998
+ statusChanged = true
999
+ }
1000
+ break
1001
+ }
1002
+ case 'dayBright': {
1003
+ if (fullStatus.dayBright !== null) {
1004
+ const brightnessValue = String(fullStatus.dayBright)
1005
+ if (config.dayDisplayBrightness !== brightnessValue) {
1006
+ config.dayDisplayBrightness = brightnessValue
1007
+ configChangedFields.add('dayDisplayBrightness')
1008
+ configChanged = true
1009
+ }
1010
+ }
1011
+ break
1012
+ }
1013
+ case 'dbatTimeout': {
1014
+ // Hardware diagnostic - not stored in status
1015
+ break
1016
+ }
1017
+ case 'deviceId': {
1018
+ // Metadata field - not stored in status (stored in device)
1019
+ break
1020
+ }
1021
+ case 'dnowBrightness': {
1022
+ if (fullStatus.dnowBrightness !== null && status.displayBrightness !== fullStatus.dnowBrightness) {
1023
+ status.displayBrightness = fullStatus.dnowBrightness
1024
+ changedFields.add('displayBrightness')
1025
+ statusChanged = true
1026
+ }
1027
+ break
1028
+ }
1029
+ case 'errorsLogged': {
1030
+ // Hardware diagnostic - not stored in status
1031
+ break
1032
+ }
1033
+ case 'failData': {
1034
+ // Hardware diagnostic - not stored in status
1035
+ break
1036
+ }
1037
+ case 'failReason': {
1038
+ // Hardware diagnostic - not stored in status
1039
+ break
1040
+ }
1041
+ case 'free': {
1042
+ // Hardware diagnostic - not stored in status
1043
+ break
1044
+ }
1045
+ case 'free32': {
1046
+ // Hardware diagnostic - not stored in status
1047
+ break
1048
+ }
1049
+ case 'freeDisk': {
1050
+ if (status.freeDiskSpaceBytes !== fullStatus.freeDisk) {
1051
+ status.freeDiskSpaceBytes = fullStatus.freeDisk
1052
+ changedFields.add('freeDiskSpaceBytes')
1053
+ statusChanged = true
1054
+ }
1055
+ break
1056
+ }
1057
+ case 'freeDMA': {
1058
+ // Hardware diagnostic - not stored in status
1059
+ break
1060
+ }
1061
+ case 'fwVersion': {
1062
+ if (status.firmwareVersion !== fullStatus.fwVersion) {
1063
+ status.firmwareVersion = fullStatus.fwVersion
1064
+ changedFields.add('firmwareVersion')
1065
+ statusChanged = true
1066
+ }
1067
+ break
1068
+ }
1069
+ case 'headphones': {
1070
+ const isConnected = Boolean(fullStatus.headphones)
1071
+ if (status.isAudioDeviceConnected !== isConnected) {
1072
+ status.isAudioDeviceConnected = isConnected
1073
+ changedFields.add('isAudioDeviceConnected')
1074
+ statusChanged = true
1075
+ }
1076
+ break
1077
+ }
1078
+ case 'lastSeenAt': {
1079
+ // Metadata field - not stored in status
1080
+ break
1081
+ }
1082
+ case 'missedLogs': {
1083
+ // Hardware diagnostic - not stored in status
1084
+ break
1085
+ }
1086
+ case 'nfcErrs': {
1087
+ // Hardware diagnostic - not stored in status
1088
+ break
1089
+ }
1090
+ case 'nfcLock': {
1091
+ // Hardware diagnostic - not stored in status
1092
+ break
1093
+ }
1094
+ case 'nightBright': {
1095
+ if (fullStatus.nightBright !== null) {
1096
+ const brightnessValue = String(fullStatus.nightBright)
1097
+ if (config.nightDisplayBrightness !== brightnessValue) {
1098
+ config.nightDisplayBrightness = brightnessValue
1099
+ configChangedFields.add('nightDisplayBrightness')
1100
+ configChanged = true
1101
+ }
1102
+ }
1103
+ break
1104
+ }
1105
+ case 'nightlightMode': {
1106
+ // Skip updating from HTTP if value is '0x000000' or device is online
1107
+ // (HTTP returns inaccurate values; MQTT provides actual color)
1108
+ const shouldSkip = fullStatus.nightlightMode === '0x000000' || this.#deviceOnline
1109
+ if (!shouldSkip && status.nightlightMode !== fullStatus.nightlightMode) {
1110
+ status.nightlightMode = fullStatus.nightlightMode
1111
+ changedFields.add('nightlightMode')
1112
+ statusChanged = true
1113
+ }
1114
+ break
1115
+ }
1116
+ case 'playingStatus': {
1117
+ // Playback field - not stored in status
1118
+ break
1119
+ }
1120
+ case 'powerCaps': {
1121
+ // Hardware diagnostic - not stored in status
1122
+ break
1123
+ }
1124
+ case 'powerSrc': {
1125
+ const newPowerSource = convertPowerSource(fullStatus.powerSrc)
1126
+ if (status.powerSource !== newPowerSource) {
1127
+ status.powerSource = newPowerSource
1128
+ changedFields.add('powerSource')
1129
+ statusChanged = true
1130
+ }
1131
+ break
1132
+ }
1133
+ case 'qiOtp': {
1134
+ // Hardware diagnostic - not stored in status
1135
+ break
1136
+ }
1137
+ case 'sd_info': {
1138
+ // Hardware diagnostic - not stored in status
1139
+ break
1140
+ }
1141
+ case 'shutDown': {
1142
+ // Lifecycle field - not stored in status (handled by #handleLegacyStatus)
1143
+ break
1144
+ }
1145
+ case 'shutdownTimeout': {
1146
+ // Config field - not stored in status
1147
+ break
1148
+ }
1149
+ case 'ssid': {
1150
+ // Network field - not stored in status
1151
+ break
1152
+ }
1153
+ case 'statusVersion': {
1154
+ // Metadata field - not stored in status
1155
+ break
1156
+ }
1157
+ case 'temp': {
1158
+ const temperature = parseTemperature(fullStatus.temp)
1159
+ if (status.temperatureCelsius !== temperature) {
1160
+ status.temperatureCelsius = temperature
1161
+ changedFields.add('temperatureCelsius')
1162
+ statusChanged = true
1163
+ }
1164
+ break
1165
+ }
1166
+ case 'timeFormat': {
1167
+ if (fullStatus.timeFormat !== null && status.timeFormat !== fullStatus.timeFormat) {
1168
+ status.timeFormat = fullStatus.timeFormat
1169
+ changedFields.add('timeFormat')
1170
+ statusChanged = true
1171
+ }
1172
+ break
1173
+ }
1174
+ case 'totalDisk': {
1175
+ if (status.totalDiskSpaceBytes !== fullStatus.totalDisk) {
1176
+ status.totalDiskSpaceBytes = fullStatus.totalDisk
1177
+ changedFields.add('totalDiskSpaceBytes')
1178
+ statusChanged = true
1179
+ }
1180
+ break
1181
+ }
1182
+ case 'twdt': {
1183
+ // Hardware diagnostic - not stored in status
1184
+ break
1185
+ }
1186
+ case 'updatedAt': {
1187
+ // Metadata field - handled separately below
1188
+ break
1189
+ }
1190
+ case 'upTime': {
1191
+ if (status.uptime !== fullStatus.upTime) {
1192
+ status.uptime = fullStatus.upTime
1193
+ changedFields.add('uptime')
1194
+ statusChanged = true
1195
+ }
1196
+ break
1197
+ }
1198
+ case 'userVolume': {
1199
+ // Convert from 0-100 percentage to 0-16 scale
1200
+ const volume = Math.round((fullStatus.userVolume / 100) * 16)
1201
+ if (status.volume !== volume) {
1202
+ status.volume = volume
1203
+ changedFields.add('volume')
1204
+ statusChanged = true
1205
+ }
1206
+ break
1207
+ }
1208
+ case 'utcOffset': {
1209
+ // Hardware diagnostic - not stored in status
1210
+ break
1211
+ }
1212
+ case 'utcTime': {
1213
+ // Hardware diagnostic - not stored in status
1214
+ break
1215
+ }
1216
+ case 'volume': {
1217
+ // Convert from 0-100 percentage to 0-16 scale
1218
+ const maxVolume = Math.round((fullStatus.volume / 100) * 16)
1219
+ if (status.maxVolume !== maxVolume) {
1220
+ status.maxVolume = maxVolume
1221
+ changedFields.add('maxVolume')
1222
+ statusChanged = true
1223
+ }
1224
+ break
1225
+ }
1226
+ case 'wifiRestarts': {
1227
+ // Hardware diagnostic - not stored in status
1228
+ break
1229
+ }
1230
+ case 'wifiStrength': {
1231
+ if (status.wifiStrength !== fullStatus.wifiStrength) {
1232
+ status.wifiStrength = fullStatus.wifiStrength
1233
+ changedFields.add('wifiStrength')
1234
+ statusChanged = true
1235
+ }
1236
+ break
1237
+ }
1238
+ default: {
1239
+ // This will cause a type error if a new key is added to YotoDeviceFullStatus
1240
+ // and not handled in the switch statement above
1241
+ /** @type {never} */
1242
+ // eslint-disable-next-line no-unused-expressions
1243
+ (key)
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ // Process all keys from fullStatus
1249
+ for (const key of typedKeys(fullStatus)) {
1250
+ handleField(key, fullStatus)
1251
+ }
1252
+
1253
+ // Only emit if something actually changed
1254
+ if (statusChanged) {
1255
+ // Update metadata
1256
+ status.updatedAt = fullStatus.updatedAt ?? new Date().toISOString()
1257
+ status.source = 'http'
1258
+
1259
+ this.#state.lastUpdate.status = Date.now()
1260
+ this.#state.lastUpdate.source = 'http'
1261
+
1262
+ this.emit('statusUpdate', this.status, 'http', changedFields)
1263
+ }
1264
+
1265
+ // Emit config update if config changed
1266
+ if (configChanged) {
1267
+ this.#state.lastUpdate.config = Date.now()
1268
+
1269
+ this.emit('configUpdate', this.config, configChangedFields)
1270
+ }
1271
+
1272
+ // Check if device went offline (transition from online to offline)
1273
+ if (wasOnline && !this.#deviceOnline) {
1274
+ this.emit('offline', { reason: 'http-status', source: 'http' })
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * Update internal status from HTTP status response (from status endpoint)
1280
+ * Uses exhaustive switch statement pattern to ensure all YotoDeviceStatusResponse fields are handled.
1281
+ * Called from start(), refreshConfig(), and background polling.
1282
+ * @param {YotoDeviceStatusResponse} statusResponse - Status response from status endpoint
1283
+ */
1284
+ #updateStatusFromStatusResponse (statusResponse) {
1285
+ let statusChanged = false
1286
+ const wasOnline = this.#deviceOnline
1287
+ const { status } = this.#state
1288
+ /** @type {Set<keyof YotoDeviceStatus>} */
1289
+ const changedFields = new Set()
1290
+
1291
+ /**
1292
+ * Handler function for each status field
1293
+ * @param {keyof YotoDeviceStatusResponse} key
1294
+ * @param {YotoDeviceStatusResponse} statusResponse
1295
+ */
1296
+ const handleField = (key, statusResponse) => {
1297
+ switch (key) {
1298
+ case 'deviceId': {
1299
+ // Metadata field - not stored in status (stored in device)
1300
+ break
1301
+ }
1302
+ case 'activeCard': {
1303
+ const normalizedCard = statusResponse.activeCard === 'none' ? null : statusResponse.activeCard
1304
+ if (status.activeCardId !== normalizedCard) {
1305
+ status.activeCardId = normalizedCard
1306
+ changedFields.add('activeCardId')
1307
+ statusChanged = true
1308
+ }
1309
+ break
1310
+ }
1311
+ case 'ambientLightSensorReading': {
1312
+ if (status.ambientLightSensorReading !== statusResponse.ambientLightSensorReading) {
1313
+ status.ambientLightSensorReading = statusResponse.ambientLightSensorReading
1314
+ changedFields.add('ambientLightSensorReading')
1315
+ statusChanged = true
1316
+ }
1317
+ break
1318
+ }
1319
+ case 'averageDownloadSpeedBytesSecond': {
1320
+ // Hardware diagnostic - not stored in status
1321
+ break
1322
+ }
1323
+ case 'batteryLevelPercentage': {
1324
+ if (status.batteryLevelPercentage !== statusResponse.batteryLevelPercentage) {
1325
+ status.batteryLevelPercentage = statusResponse.batteryLevelPercentage
1326
+ changedFields.add('batteryLevelPercentage')
1327
+ statusChanged = true
1328
+ }
1329
+ break
1330
+ }
1331
+ case 'batteryLevelPercentageRaw': {
1332
+ // Hardware diagnostic - not stored in status
1333
+ break
1334
+ }
1335
+ case 'buzzErrors': {
1336
+ // Hardware diagnostic - not stored in status
1337
+ break
1338
+ }
1339
+ case 'cardInsertionState': {
1340
+ const newState = convertCardInsertionState(statusResponse.cardInsertionState)
1341
+ if (status.cardInsertionState !== newState) {
1342
+ status.cardInsertionState = newState
1343
+ changedFields.add('cardInsertionState')
1344
+ statusChanged = true
1345
+ }
1346
+ break
1347
+ }
1348
+ case 'dayMode': {
1349
+ const newDayMode = convertDayMode(statusResponse.dayMode)
1350
+ if (status.dayMode !== newDayMode) {
1351
+ status.dayMode = newDayMode
1352
+ changedFields.add('dayMode')
1353
+ statusChanged = true
1354
+ }
1355
+ break
1356
+ }
1357
+ case 'errorsLogged': {
1358
+ // Hardware diagnostic - not stored in status
1359
+ break
1360
+ }
1361
+ case 'firmwareVersion': {
1362
+ if (status.firmwareVersion !== statusResponse.firmwareVersion) {
1363
+ status.firmwareVersion = statusResponse.firmwareVersion
1364
+ changedFields.add('firmwareVersion')
1365
+ statusChanged = true
1366
+ }
1367
+ break
1368
+ }
1369
+ case 'freeDiskSpaceBytes': {
1370
+ if (status.freeDiskSpaceBytes !== statusResponse.freeDiskSpaceBytes) {
1371
+ status.freeDiskSpaceBytes = statusResponse.freeDiskSpaceBytes
1372
+ changedFields.add('freeDiskSpaceBytes')
1373
+ statusChanged = true
1374
+ }
1375
+ break
1376
+ }
1377
+ case 'isAudioDeviceConnected': {
1378
+ if (status.isAudioDeviceConnected !== statusResponse.isAudioDeviceConnected) {
1379
+ status.isAudioDeviceConnected = statusResponse.isAudioDeviceConnected
1380
+ changedFields.add('isAudioDeviceConnected')
1381
+ statusChanged = true
1382
+ }
1383
+ break
1384
+ }
1385
+ case 'isBackgroundDownloadActive': {
1386
+ // Hardware diagnostic - not stored in status
1387
+ break
1388
+ }
1389
+ case 'isBluetoothAudioConnected': {
1390
+ if (status.isBluetoothAudioConnected !== statusResponse.isBluetoothAudioConnected) {
1391
+ status.isBluetoothAudioConnected = statusResponse.isBluetoothAudioConnected
1392
+ changedFields.add('isBluetoothAudioConnected')
1393
+ statusChanged = true
1394
+ }
1395
+ break
1396
+ }
1397
+ case 'isCharging': {
1398
+ if (status.isCharging !== statusResponse.isCharging) {
1399
+ status.isCharging = statusResponse.isCharging
1400
+ changedFields.add('isCharging')
1401
+ statusChanged = true
1402
+ }
1403
+ break
1404
+ }
1405
+ case 'isNfcLocked': {
1406
+ // Hardware diagnostic - not stored in status
1407
+ break
1408
+ }
1409
+ case 'isOnline': {
1410
+ if (status.isOnline !== statusResponse.isOnline) {
1411
+ status.isOnline = statusResponse.isOnline
1412
+ changedFields.add('isOnline')
1413
+ statusChanged = true
1414
+ }
1415
+ break
1416
+ }
1417
+ case 'networkSsid': {
1418
+ // Network field - not stored in status
1419
+ break
1420
+ }
1421
+ case 'nightlightMode': {
1422
+ // Skip updating from HTTP if value is '0x000000' or device is online
1423
+ // (HTTP returns inaccurate values; MQTT provides actual color)
1424
+ const shouldSkip = statusResponse.nightlightMode === '0x000000' || this.#deviceOnline
1425
+ if (!shouldSkip && status.nightlightMode !== statusResponse.nightlightMode) {
1426
+ status.nightlightMode = statusResponse.nightlightMode
1427
+ changedFields.add('nightlightMode')
1428
+ statusChanged = true
1429
+ }
1430
+ break
1431
+ }
1432
+ case 'playingSource': {
1433
+ // Playback field - not stored in status
1434
+ break
1435
+ }
1436
+ case 'powerCapabilities': {
1437
+ // Hardware diagnostic - not stored in status
1438
+ break
1439
+ }
1440
+ case 'powerSource': {
1441
+ const newPowerSource = convertPowerSource(statusResponse.powerSource)
1442
+ if (status.powerSource !== newPowerSource) {
1443
+ status.powerSource = newPowerSource
1444
+ changedFields.add('powerSource')
1445
+ statusChanged = true
1446
+ }
1447
+ break
1448
+ }
1449
+ case 'systemVolumePercentage': {
1450
+ // Convert from 0-100 percentage to 0-16 scale
1451
+ const maxVolume = Math.round((statusResponse.systemVolumePercentage / 100) * 16)
1452
+ if (status.maxVolume !== maxVolume) {
1453
+ status.maxVolume = maxVolume
1454
+ changedFields.add('maxVolume')
1455
+ statusChanged = true
1456
+ }
1457
+ break
1458
+ }
1459
+ case 'taskWatchdogTimeoutCount': {
1460
+ // Hardware diagnostic - not stored in status
1461
+ break
1462
+ }
1463
+ case 'temperatureCelcius': {
1464
+ // API misspells "Celsius" as "Celcius"
1465
+ const temperature = parseTemperature(statusResponse.temperatureCelcius)
1466
+ if (status.temperatureCelsius !== temperature) {
1467
+ status.temperatureCelsius = temperature
1468
+ changedFields.add('temperatureCelsius')
1469
+ statusChanged = true
1470
+ }
1471
+ break
1472
+ }
1473
+ case 'totalDiskSpaceBytes': {
1474
+ if (status.totalDiskSpaceBytes !== statusResponse.totalDiskSpaceBytes) {
1475
+ status.totalDiskSpaceBytes = statusResponse.totalDiskSpaceBytes
1476
+ changedFields.add('totalDiskSpaceBytes')
1477
+ statusChanged = true
1478
+ }
1479
+ break
1480
+ }
1481
+ case 'updatedAt': {
1482
+ // Metadata field - handled separately below
1483
+ break
1484
+ }
1485
+ case 'uptime': {
1486
+ if (status.uptime !== statusResponse.uptime) {
1487
+ status.uptime = statusResponse.uptime
1488
+ changedFields.add('uptime')
1489
+ statusChanged = true
1490
+ }
1491
+ break
1492
+ }
1493
+ case 'userVolumePercentage': {
1494
+ // Convert from 0-100 percentage to 0-16 scale
1495
+ const volume = Math.round((statusResponse.userVolumePercentage / 100) * 16)
1496
+ if (status.volume !== volume) {
1497
+ status.volume = volume
1498
+ changedFields.add('volume')
1499
+ statusChanged = true
1500
+ }
1501
+ break
1502
+ }
1503
+ case 'utcOffsetSeconds': {
1504
+ // Hardware diagnostic - not stored in status
1505
+ break
1506
+ }
1507
+ case 'wifiStrength': {
1508
+ if (status.wifiStrength !== statusResponse.wifiStrength) {
1509
+ status.wifiStrength = statusResponse.wifiStrength
1510
+ changedFields.add('wifiStrength')
1511
+ statusChanged = true
1512
+ }
1513
+ break
1514
+ }
1515
+ default: {
1516
+ // This will cause a type error if a new key is added to YotoDeviceStatusResponse
1517
+ // and not handled in the switch statement above
1518
+ /** @type {never} */
1519
+ // eslint-disable-next-line no-unused-expressions
1520
+ (key)
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ // Process all keys from statusResponse
1526
+ for (const key of typedKeys(statusResponse)) {
1527
+ handleField(key, statusResponse)
1528
+ }
1529
+
1530
+ // Only emit if something actually changed
1531
+ if (statusChanged) {
1532
+ // Update metadata
1533
+ status.updatedAt = statusResponse.updatedAt ?? new Date().toISOString()
1534
+ status.source = 'http'
1535
+
1536
+ this.#state.lastUpdate.status = Date.now()
1537
+ this.#state.lastUpdate.source = 'http'
1538
+
1539
+ this.emit('statusUpdate', this.status, 'http', changedFields)
1540
+ }
1541
+
1542
+ // Check if device went offline (transition from online to offline)
1543
+ if (wasOnline && !this.#deviceOnline) {
1544
+ this.emit('offline', { reason: 'http-status', source: 'http' })
1545
+ }
1546
+ }
1547
+
1548
+ /**
1549
+ * Update config and shortcuts from HTTP API response
1550
+ *
1551
+ * @param {Readonly<YotoDeviceConfig>} configData - Config object from API (readonly)
1552
+ * @param {YotoDeviceShortcuts} [shortcutsData] - Shortcuts object from API
1553
+ */
1554
+ #updateConfigFromHttp (configData, shortcutsData) {
1555
+ let configChanged = false
1556
+
1557
+ const { config } = this.#state
1558
+ /** @type {Set<keyof YotoDeviceConfig>} */
1559
+ const changedFields = new Set()
1560
+
1561
+ /**
1562
+ * Handler function for each config field
1563
+ * @param {keyof YotoDeviceConfig} key
1564
+ * @param {YotoDeviceConfig} configData
1565
+ */
1566
+ const handleField = (key, configData) => {
1567
+ switch (key) {
1568
+ case 'alarms': {
1569
+ if (JSON.stringify(config.alarms) !== JSON.stringify(configData.alarms)) {
1570
+ config.alarms = configData.alarms
1571
+ changedFields.add('alarms')
1572
+ configChanged = true
1573
+ }
1574
+ break
1575
+ }
1576
+ case 'ambientColour': {
1577
+ if (config.ambientColour !== configData.ambientColour) {
1578
+ config.ambientColour = configData.ambientColour
1579
+ changedFields.add('ambientColour')
1580
+ configChanged = true
1581
+ }
1582
+ break
1583
+ }
1584
+ case 'bluetoothEnabled': {
1585
+ if (config.bluetoothEnabled !== configData.bluetoothEnabled) {
1586
+ config.bluetoothEnabled = configData.bluetoothEnabled
1587
+ changedFields.add('bluetoothEnabled')
1588
+ configChanged = true
1589
+ }
1590
+ break
1591
+ }
1592
+ case 'btHeadphonesEnabled': {
1593
+ if (config.btHeadphonesEnabled !== configData.btHeadphonesEnabled) {
1594
+ config.btHeadphonesEnabled = configData.btHeadphonesEnabled
1595
+ changedFields.add('btHeadphonesEnabled')
1596
+ configChanged = true
1597
+ }
1598
+ break
1599
+ }
1600
+ case 'clockFace': {
1601
+ if (config.clockFace !== configData.clockFace) {
1602
+ config.clockFace = configData.clockFace
1603
+ changedFields.add('clockFace')
1604
+ configChanged = true
1605
+ }
1606
+ break
1607
+ }
1608
+ case 'dayDisplayBrightness': {
1609
+ if (config.dayDisplayBrightness !== configData.dayDisplayBrightness) {
1610
+ config.dayDisplayBrightness = configData.dayDisplayBrightness
1611
+ changedFields.add('dayDisplayBrightness')
1612
+ configChanged = true
1613
+ }
1614
+ break
1615
+ }
1616
+ case 'dayTime': {
1617
+ if (config.dayTime !== configData.dayTime) {
1618
+ config.dayTime = configData.dayTime
1619
+ changedFields.add('dayTime')
1620
+ configChanged = true
1621
+ }
1622
+ break
1623
+ }
1624
+ case 'dayYotoDaily': {
1625
+ if (config.dayYotoDaily !== configData.dayYotoDaily) {
1626
+ config.dayYotoDaily = configData.dayYotoDaily
1627
+ changedFields.add('dayYotoDaily')
1628
+ configChanged = true
1629
+ }
1630
+ break
1631
+ }
1632
+ case 'dayYotoRadio': {
1633
+ if (config.dayYotoRadio !== configData.dayYotoRadio) {
1634
+ config.dayYotoRadio = configData.dayYotoRadio
1635
+ changedFields.add('dayYotoRadio')
1636
+ configChanged = true
1637
+ }
1638
+ break
1639
+ }
1640
+ case 'daySoundsOff': {
1641
+ if (config.daySoundsOff !== configData.daySoundsOff) {
1642
+ config.daySoundsOff = configData.daySoundsOff
1643
+ changedFields.add('daySoundsOff')
1644
+ configChanged = true
1645
+ }
1646
+ break
1647
+ }
1648
+ case 'displayDimBrightness': {
1649
+ if (config.displayDimBrightness !== configData.displayDimBrightness) {
1650
+ config.displayDimBrightness = configData.displayDimBrightness
1651
+ changedFields.add('displayDimBrightness')
1652
+ configChanged = true
1653
+ }
1654
+ break
1655
+ }
1656
+ case 'displayDimTimeout': {
1657
+ if (config.displayDimTimeout !== configData.displayDimTimeout) {
1658
+ config.displayDimTimeout = configData.displayDimTimeout
1659
+ changedFields.add('displayDimTimeout')
1660
+ configChanged = true
1661
+ }
1662
+ break
1663
+ }
1664
+ case 'headphonesVolumeLimited': {
1665
+ if (config.headphonesVolumeLimited !== configData.headphonesVolumeLimited) {
1666
+ config.headphonesVolumeLimited = configData.headphonesVolumeLimited
1667
+ changedFields.add('headphonesVolumeLimited')
1668
+ configChanged = true
1669
+ }
1670
+ break
1671
+ }
1672
+ case 'hourFormat': {
1673
+ if (config.hourFormat !== configData.hourFormat) {
1674
+ config.hourFormat = configData.hourFormat
1675
+ changedFields.add('hourFormat')
1676
+ configChanged = true
1677
+ }
1678
+ break
1679
+ }
1680
+ case 'locale': {
1681
+ if (config.locale !== configData.locale) {
1682
+ config.locale = configData.locale
1683
+ changedFields.add('locale')
1684
+ configChanged = true
1685
+ }
1686
+ break
1687
+ }
1688
+ case 'logLevel': {
1689
+ if (config.logLevel !== configData.logLevel) {
1690
+ config.logLevel = configData.logLevel
1691
+ changedFields.add('logLevel')
1692
+ configChanged = true
1693
+ }
1694
+ break
1695
+ }
1696
+ case 'maxVolumeLimit': {
1697
+ if (config.maxVolumeLimit !== configData.maxVolumeLimit) {
1698
+ config.maxVolumeLimit = configData.maxVolumeLimit
1699
+ changedFields.add('maxVolumeLimit')
1700
+ configChanged = true
1701
+ }
1702
+ break
1703
+ }
1704
+ case 'nightAmbientColour': {
1705
+ if (config.nightAmbientColour !== configData.nightAmbientColour) {
1706
+ config.nightAmbientColour = configData.nightAmbientColour
1707
+ changedFields.add('nightAmbientColour')
1708
+ configChanged = true
1709
+ }
1710
+ break
1711
+ }
1712
+ case 'nightDisplayBrightness': {
1713
+ if (config.nightDisplayBrightness !== configData.nightDisplayBrightness) {
1714
+ config.nightDisplayBrightness = configData.nightDisplayBrightness
1715
+ changedFields.add('nightDisplayBrightness')
1716
+ configChanged = true
1717
+ }
1718
+ break
1719
+ }
1720
+ case 'nightMaxVolumeLimit': {
1721
+ if (config.nightMaxVolumeLimit !== configData.nightMaxVolumeLimit) {
1722
+ config.nightMaxVolumeLimit = configData.nightMaxVolumeLimit
1723
+ changedFields.add('nightMaxVolumeLimit')
1724
+ configChanged = true
1725
+ }
1726
+ break
1727
+ }
1728
+ case 'nightTime': {
1729
+ if (config.nightTime !== configData.nightTime) {
1730
+ config.nightTime = configData.nightTime
1731
+ changedFields.add('nightTime')
1732
+ configChanged = true
1733
+ }
1734
+ break
1735
+ }
1736
+ case 'nightYotoDaily': {
1737
+ if (config.nightYotoDaily !== configData.nightYotoDaily) {
1738
+ config.nightYotoDaily = configData.nightYotoDaily
1739
+ changedFields.add('nightYotoDaily')
1740
+ configChanged = true
1741
+ }
1742
+ break
1743
+ }
1744
+ case 'nightYotoRadio': {
1745
+ if (config.nightYotoRadio !== configData.nightYotoRadio) {
1746
+ config.nightYotoRadio = configData.nightYotoRadio
1747
+ changedFields.add('nightYotoRadio')
1748
+ configChanged = true
1749
+ }
1750
+ break
1751
+ }
1752
+ case 'nightSoundsOff': {
1753
+ if (config.nightSoundsOff !== configData.nightSoundsOff) {
1754
+ config.nightSoundsOff = configData.nightSoundsOff
1755
+ changedFields.add('nightSoundsOff')
1756
+ configChanged = true
1757
+ }
1758
+ break
1759
+ }
1760
+ case 'pausePowerButton': {
1761
+ if (config.pausePowerButton !== configData.pausePowerButton) {
1762
+ config.pausePowerButton = configData.pausePowerButton
1763
+ changedFields.add('pausePowerButton')
1764
+ configChanged = true
1765
+ }
1766
+ break
1767
+ }
1768
+ case 'pauseVolumeDown': {
1769
+ if (config.pauseVolumeDown !== configData.pauseVolumeDown) {
1770
+ config.pauseVolumeDown = configData.pauseVolumeDown
1771
+ changedFields.add('pauseVolumeDown')
1772
+ configChanged = true
1773
+ }
1774
+ break
1775
+ }
1776
+ case 'repeatAll': {
1777
+ if (config.repeatAll !== configData.repeatAll) {
1778
+ config.repeatAll = configData.repeatAll
1779
+ changedFields.add('repeatAll')
1780
+ configChanged = true
1781
+ }
1782
+ break
1783
+ }
1784
+ case 'showDiagnostics': {
1785
+ if (config.showDiagnostics !== configData.showDiagnostics) {
1786
+ config.showDiagnostics = configData.showDiagnostics
1787
+ changedFields.add('showDiagnostics')
1788
+ configChanged = true
1789
+ }
1790
+ break
1791
+ }
1792
+ case 'shutdownTimeout': {
1793
+ if (config.shutdownTimeout !== configData.shutdownTimeout) {
1794
+ config.shutdownTimeout = configData.shutdownTimeout
1795
+ changedFields.add('shutdownTimeout')
1796
+ configChanged = true
1797
+ }
1798
+ break
1799
+ }
1800
+ case 'systemVolume': {
1801
+ if (config.systemVolume !== configData.systemVolume) {
1802
+ config.systemVolume = configData.systemVolume
1803
+ changedFields.add('systemVolume')
1804
+ configChanged = true
1805
+ }
1806
+ break
1807
+ }
1808
+ case 'timezone': {
1809
+ if (config.timezone !== configData.timezone) {
1810
+ config.timezone = configData.timezone
1811
+ changedFields.add('timezone')
1812
+ configChanged = true
1813
+ }
1814
+ break
1815
+ }
1816
+ case 'volumeLevel': {
1817
+ if (config.volumeLevel !== configData.volumeLevel) {
1818
+ config.volumeLevel = configData.volumeLevel
1819
+ changedFields.add('volumeLevel')
1820
+ configChanged = true
1821
+ }
1822
+ break
1823
+ }
1824
+ // eslint-disable-next-line no-unused-expressions
1825
+ default: { /** @type {never} */ (key) }
1826
+ }
1827
+ }
1828
+
1829
+ // Process all keys from configData
1830
+ for (const key of typedKeys(configData)) {
1831
+ handleField(key, configData)
1832
+ }
1833
+
1834
+ // Update shortcuts if provided
1835
+ if (shortcutsData) {
1836
+ const shortcutsStr = JSON.stringify(shortcutsData)
1837
+ const currentShortcutsStr = JSON.stringify(this.#state.shortcuts)
1838
+ if (shortcutsStr !== currentShortcutsStr) {
1839
+ this.#state.shortcuts = shortcutsData
1840
+ configChanged = true
1841
+ }
1842
+ }
1843
+
1844
+ // Only emit if something actually changed
1845
+ if (configChanged) {
1846
+ this.#state.lastUpdate.config = Date.now()
1847
+ this.emit('configUpdate', this.config, changedFields)
1848
+ }
1849
+ }
1850
+
1851
+ /**
1852
+ * Handle legacy status messages - contains lifecycle events and full hardware diagnostics
1853
+ * @param {YotoLegacyStatus} legacyStatus
1854
+ */
1855
+ #handleLegacyStatus (legacyStatus) {
1856
+ // Detect shutdown/startup lifecycle events using helper
1857
+ const powerState = detectPowerState(legacyStatus.shutDown, legacyStatus.upTime)
1858
+
1859
+ if (powerState.state === 'startup') {
1860
+ // Device just started up
1861
+ if (!this.#deviceOnline) {
1862
+ this.#deviceOnline = true
1863
+ this.emit('online', { reason: 'startup', upTime: powerState.upTime })
1864
+ }
1865
+ } else if (powerState.state === 'shutdown') {
1866
+ // Device is shutting down or has shut down
1867
+ // Record shutdown time to prevent activity-based online marking
1868
+ this.#shutdownDetectedAt = Date.now()
1869
+ if (this.#deviceOnline) {
1870
+ this.#deviceOnline = false
1871
+ this.emit('offline', { reason: 'shutdown', shutDownReason: powerState.shutDownReason })
1872
+ }
1873
+ return // Don't process rest of status if device is shutting down
1874
+ }
1875
+ // else: state === 'running' - normal operation, continue processing
1876
+
1877
+ // Update status with legacy data (which includes fields not in documented status)
1878
+ this.#updateStatusFromLegacyMqtt(legacyStatus)
1879
+ }
1880
+
1881
+ /**
1882
+ * Update internal status from documented MQTT status message (/data/status)
1883
+ * Uses exhaustive switch statement pattern to ensure all YotoMqttStatus fields are handled.
1884
+ * @param {YotoMqttStatus} mqttStatus - MQTT status object from documented topic
1885
+ */
1886
+ #updateStatusFromDocumentedMqtt (mqttStatus) {
1887
+ let statusChanged = false
1888
+ let configChanged = false
1889
+ const { status, config } = this.#state
1890
+ /** @type {Set<keyof YotoDeviceStatus>} */
1891
+ const changedFields = new Set()
1892
+ /** @type {Set<keyof YotoDeviceConfig>} */
1893
+ const configChangedFields = new Set()
1894
+
1895
+ /**
1896
+ * Handler function for each status field
1897
+ * @param {keyof YotoMqttStatus} key
1898
+ * @param {YotoMqttStatus} mqttStatus
1899
+ */
1900
+ const handleField = (key, mqttStatus) => {
1901
+ switch (key) {
1902
+ case 'statusVersion': {
1903
+ // Metadata field - not stored in status
1904
+ break
1905
+ }
1906
+ case 'fwVersion': {
1907
+ if (status.firmwareVersion !== mqttStatus.fwVersion) {
1908
+ status.firmwareVersion = mqttStatus.fwVersion
1909
+ changedFields.add('firmwareVersion')
1910
+ statusChanged = true
1911
+ }
1912
+ break
1913
+ }
1914
+ case 'productType': {
1915
+ // Metadata field - not stored in status
1916
+ break
1917
+ }
1918
+ case 'batteryLevel': {
1919
+ if (status.batteryLevelPercentage !== mqttStatus.batteryLevel) {
1920
+ status.batteryLevelPercentage = mqttStatus.batteryLevel
1921
+ changedFields.add('batteryLevelPercentage')
1922
+ statusChanged = true
1923
+ }
1924
+ break
1925
+ }
1926
+ case 'als': {
1927
+ if (status.ambientLightSensorReading !== mqttStatus.als) {
1928
+ status.ambientLightSensorReading = mqttStatus.als
1929
+ changedFields.add('ambientLightSensorReading')
1930
+ statusChanged = true
1931
+ }
1932
+ break
1933
+ }
1934
+ case 'freeDisk': {
1935
+ if (status.freeDiskSpaceBytes !== mqttStatus.freeDisk) {
1936
+ status.freeDiskSpaceBytes = mqttStatus.freeDisk
1937
+ changedFields.add('freeDiskSpaceBytes')
1938
+ statusChanged = true
1939
+ }
1940
+ break
1941
+ }
1942
+ case 'shutdownTimeout': {
1943
+ // Config field - not stored in status
1944
+ break
1945
+ }
1946
+ case 'dbatTimeout': {
1947
+ // Hardware diagnostic field - not stored in status
1948
+ break
1949
+ }
1950
+ case 'charging': {
1951
+ const isCharging = Boolean(mqttStatus.charging)
1952
+ if (status.isCharging !== isCharging) {
1953
+ status.isCharging = isCharging
1954
+ changedFields.add('isCharging')
1955
+ statusChanged = true
1956
+ }
1957
+ break
1958
+ }
1959
+ case 'activeCard': {
1960
+ const normalizedCard = mqttStatus.activeCard === 'none' ? null : mqttStatus.activeCard
1961
+ if (status.activeCardId !== normalizedCard) {
1962
+ status.activeCardId = normalizedCard
1963
+ changedFields.add('activeCardId')
1964
+ statusChanged = true
1965
+ }
1966
+ break
1967
+ }
1968
+ case 'cardInserted': {
1969
+ const newState = convertCardInsertionState(mqttStatus.cardInserted)
1970
+ if (status.cardInsertionState !== newState) {
1971
+ status.cardInsertionState = newState
1972
+ changedFields.add('cardInsertionState')
1973
+ statusChanged = true
1974
+ }
1975
+ break
1976
+ }
1977
+ case 'playingStatus': {
1978
+ // Playback field - not stored in status
1979
+ break
1980
+ }
1981
+ case 'headphones': {
1982
+ const isConnected = Boolean(mqttStatus.headphones)
1983
+ if (status.isAudioDeviceConnected !== isConnected) {
1984
+ status.isAudioDeviceConnected = isConnected
1985
+ changedFields.add('isAudioDeviceConnected')
1986
+ statusChanged = true
1987
+ }
1988
+ break
1989
+ }
1990
+ case 'dnowBrightness': {
1991
+ if (status.displayBrightness !== mqttStatus.dnowBrightness) {
1992
+ status.displayBrightness = mqttStatus.dnowBrightness
1993
+ changedFields.add('displayBrightness')
1994
+ statusChanged = true
1995
+ }
1996
+ break
1997
+ }
1998
+ case 'dayBright': {
1999
+ const brightnessValue = String(mqttStatus.dayBright)
2000
+ if (config.dayDisplayBrightness !== brightnessValue) {
2001
+ config.dayDisplayBrightness = brightnessValue
2002
+ configChangedFields.add('dayDisplayBrightness')
2003
+ configChanged = true
2004
+ }
2005
+ break
2006
+ }
2007
+ case 'nightBright': {
2008
+ const brightnessValue = String(mqttStatus.nightBright)
2009
+ if (config.nightDisplayBrightness !== brightnessValue) {
2010
+ config.nightDisplayBrightness = brightnessValue
2011
+ configChangedFields.add('nightDisplayBrightness')
2012
+ configChanged = true
2013
+ }
2014
+ break
2015
+ }
2016
+ case 'bluetoothHp': {
2017
+ const isConnected = Boolean(mqttStatus.bluetoothHp)
2018
+ if (status.isBluetoothAudioConnected !== isConnected) {
2019
+ status.isBluetoothAudioConnected = isConnected
2020
+ changedFields.add('isBluetoothAudioConnected')
2021
+ statusChanged = true
2022
+ }
2023
+ break
2024
+ }
2025
+ case 'volume': {
2026
+ // Convert from 0-100 percentage to 0-16 scale
2027
+ const maxVolume = Math.round((mqttStatus.volume / 100) * 16)
2028
+ if (status.maxVolume !== maxVolume) {
2029
+ status.maxVolume = maxVolume
2030
+ changedFields.add('maxVolume')
2031
+ statusChanged = true
2032
+ }
2033
+ break
2034
+ }
2035
+ case 'userVolume': {
2036
+ // Convert from 0-100 percentage to 0-16 scale
2037
+ const volume = Math.round((mqttStatus.userVolume / 100) * 16)
2038
+ if (status.volume !== volume) {
2039
+ status.volume = volume
2040
+ changedFields.add('volume')
2041
+ statusChanged = true
2042
+ }
2043
+ break
2044
+ }
2045
+ case 'timeFormat': {
2046
+ if (status.timeFormat !== mqttStatus.timeFormat) {
2047
+ status.timeFormat = mqttStatus.timeFormat
2048
+ changedFields.add('timeFormat')
2049
+ statusChanged = true
2050
+ }
2051
+ break
2052
+ }
2053
+ case 'nightlightMode': {
2054
+ if (mqttStatus.nightlightMode !== undefined && status.nightlightMode !== mqttStatus.nightlightMode) {
2055
+ status.nightlightMode = mqttStatus.nightlightMode
2056
+ changedFields.add('nightlightMode')
2057
+ statusChanged = true
2058
+ }
2059
+ break
2060
+ }
2061
+ case 'temp': {
2062
+ if (mqttStatus.temp !== undefined) {
2063
+ const temperature = parseTemperature(mqttStatus.temp)
2064
+ if (status.temperatureCelsius !== temperature) {
2065
+ status.temperatureCelsius = temperature
2066
+ changedFields.add('temperatureCelsius')
2067
+ statusChanged = true
2068
+ }
2069
+ }
2070
+ break
2071
+ }
2072
+ case 'day': {
2073
+ const newDayMode = convertDayMode(mqttStatus.day)
2074
+ if (status.dayMode !== newDayMode) {
2075
+ status.dayMode = newDayMode
2076
+ changedFields.add('dayMode')
2077
+ statusChanged = true
2078
+ }
2079
+ break
2080
+ }
2081
+ default: {
2082
+ // This will cause a type error if a new key is added to YotoMqttStatus
2083
+ // and not handled in the switch statement above
2084
+ /** @type {never} */
2085
+ // eslint-disable-next-line no-unused-expressions
2086
+ (key)
2087
+ }
2088
+ }
2089
+ }
2090
+
2091
+ // Process all keys from mqttStatus
2092
+ for (const key of typedKeys(mqttStatus)) {
2093
+ handleField(key, mqttStatus)
2094
+ }
2095
+
2096
+ // Only emit if something actually changed
2097
+ if (statusChanged) {
2098
+ // Update metadata
2099
+ this.#deviceOnline = true // If we're getting MQTT, device is online
2100
+ status.updatedAt = new Date().toISOString()
2101
+ status.source = 'mqtt'
2102
+ this.#state.lastUpdate.status = Date.now()
2103
+ this.#state.lastUpdate.source = 'mqtt'
2104
+
2105
+ this.emit('statusUpdate', this.status, 'mqtt', changedFields)
2106
+ }
2107
+
2108
+ // Emit config update if config changed
2109
+ if (configChanged) {
2110
+ this.#state.lastUpdate.config = Date.now()
2111
+
2112
+ this.emit('configUpdate', this.config, configChangedFields)
2113
+ }
2114
+ }
2115
+
2116
+ /**
2117
+ * Update internal status from legacy MQTT status message (/status)
2118
+ * Uses exhaustive switch statement pattern to ensure all YotoLegacyStatus fields are handled.
2119
+ * Legacy status includes fields not in documented status (wifiStrength, totalDisk, powerSrc, upTime, etc.)
2120
+ * @param {YotoLegacyStatus} legacyStatus - Legacy MQTT status object
2121
+ */
2122
+ #updateStatusFromLegacyMqtt (legacyStatus) {
2123
+ let statusChanged = false
2124
+ let configChanged = false
2125
+ const { status, config } = this.#state
2126
+ /** @type {Set<keyof YotoDeviceStatus>} */
2127
+ const changedFields = new Set()
2128
+ /** @type {Set<keyof YotoDeviceConfig>} */
2129
+ const configChangedFields = new Set()
2130
+
2131
+ /**
2132
+ * Handler function for each legacy status field
2133
+ * @param {keyof YotoLegacyStatus} key
2134
+ * @param {YotoLegacyStatus} legacyStatus
2135
+ */
2136
+ const handleField = (key, legacyStatus) => {
2137
+ switch (key) {
2138
+ case 'statusVersion': {
2139
+ // Metadata field - not stored in status
2140
+ break
2141
+ }
2142
+ case 'fwVersion': {
2143
+ if (status.firmwareVersion !== legacyStatus.fwVersion) {
2144
+ status.firmwareVersion = legacyStatus.fwVersion
2145
+ changedFields.add('firmwareVersion')
2146
+ statusChanged = true
2147
+ }
2148
+ break
2149
+ }
2150
+ case 'shutDown': {
2151
+ // Handled by #handleLegacyStatus for lifecycle events - not stored in status
2152
+ break
2153
+ }
2154
+ case 'totalDisk': {
2155
+ if (status.totalDiskSpaceBytes !== legacyStatus.totalDisk) {
2156
+ status.totalDiskSpaceBytes = legacyStatus.totalDisk
2157
+ changedFields.add('totalDiskSpaceBytes')
2158
+ statusChanged = true
2159
+ }
2160
+ break
2161
+ }
2162
+ case 'productType': {
2163
+ // Metadata field - not stored in status
2164
+ break
2165
+ }
2166
+ case 'wifiStrength': {
2167
+ if (status.wifiStrength !== legacyStatus.wifiStrength) {
2168
+ status.wifiStrength = legacyStatus.wifiStrength
2169
+ changedFields.add('wifiStrength')
2170
+ statusChanged = true
2171
+ }
2172
+ break
2173
+ }
2174
+ case 'ssid': {
2175
+ // WiFi SSID - not currently stored in status
2176
+ break
2177
+ }
2178
+ case 'rtcResetReasonPRO': {
2179
+ // Hardware diagnostic - not stored in status
2180
+ break
2181
+ }
2182
+ case 'rtcResetReasonAPP': {
2183
+ // Hardware diagnostic - not stored in status
2184
+ break
2185
+ }
2186
+ case 'rtcWakeupCause': {
2187
+ // Hardware diagnostic - not stored in status
2188
+ break
2189
+ }
2190
+ case 'espResetReason': {
2191
+ // Hardware diagnostic - not stored in status
2192
+ break
2193
+ }
2194
+ case 'sd_info': {
2195
+ // Hardware diagnostic - not stored in status
2196
+ break
2197
+ }
2198
+ case 'battery': {
2199
+ // Raw battery voltage - not stored in status (we use batteryLevel)
2200
+ break
2201
+ }
2202
+ case 'powerCaps': {
2203
+ // Hardware diagnostic - not stored in status
2204
+ break
2205
+ }
2206
+ case 'batteryLevel': {
2207
+ if (status.batteryLevelPercentage !== legacyStatus.batteryLevel) {
2208
+ status.batteryLevelPercentage = legacyStatus.batteryLevel
2209
+ changedFields.add('batteryLevelPercentage')
2210
+ statusChanged = true
2211
+ }
2212
+ break
2213
+ }
2214
+ case 'batteryTemp': {
2215
+ // Hardware diagnostic - not stored in status
2216
+ break
2217
+ }
2218
+ case 'batteryData': {
2219
+ // Hardware diagnostic - not stored in status
2220
+ break
2221
+ }
2222
+ case 'batteryLevelRaw': {
2223
+ // Hardware diagnostic - not stored in status
2224
+ break
2225
+ }
2226
+ case 'free': {
2227
+ // Hardware diagnostic - not stored in status
2228
+ break
2229
+ }
2230
+ case 'freeDMA': {
2231
+ // Hardware diagnostic - not stored in status
2232
+ break
2233
+ }
2234
+ case 'free32': {
2235
+ // Hardware diagnostic - not stored in status
2236
+ break
2237
+ }
2238
+ case 'upTime': {
2239
+ if (status.uptime !== legacyStatus.upTime) {
2240
+ status.uptime = legacyStatus.upTime
2241
+ changedFields.add('uptime')
2242
+ statusChanged = true
2243
+ }
2244
+ break
2245
+ }
2246
+ case 'utcTime': {
2247
+ // Hardware diagnostic - not stored in status
2248
+ break
2249
+ }
2250
+ case 'aliveTime': {
2251
+ // Hardware diagnostic - not stored in status
2252
+ break
2253
+ }
2254
+ case 'accelTemp': {
2255
+ // Hardware diagnostic - not stored in status
2256
+ break
2257
+ }
2258
+ case 'batteryProfile': {
2259
+ // Hardware diagnostic - not stored in status
2260
+ break
2261
+ }
2262
+ case 'freeDisk': {
2263
+ if (status.freeDiskSpaceBytes !== legacyStatus.freeDisk) {
2264
+ status.freeDiskSpaceBytes = legacyStatus.freeDisk
2265
+ changedFields.add('freeDiskSpaceBytes')
2266
+ statusChanged = true
2267
+ }
2268
+ break
2269
+ }
2270
+ case 'failReason': {
2271
+ // Hardware diagnostic - not stored in status
2272
+ break
2273
+ }
2274
+ case 'failData': {
2275
+ // Hardware diagnostic - not stored in status
2276
+ break
2277
+ }
2278
+ case 'shutdownTimeout': {
2279
+ // Config field - not stored in status
2280
+ break
2281
+ }
2282
+ case 'utcOffset': {
2283
+ // Hardware diagnostic - not stored in status
2284
+ break
2285
+ }
2286
+ case 'nfcErrs': {
2287
+ // Hardware diagnostic - not stored in status
2288
+ break
2289
+ }
2290
+ case 'dbatTimeout': {
2291
+ // Hardware diagnostic - not stored in status
2292
+ break
2293
+ }
2294
+ case 'charging': {
2295
+ const isCharging = Boolean(legacyStatus.charging)
2296
+ if (status.isCharging !== isCharging) {
2297
+ status.isCharging = isCharging
2298
+ changedFields.add('isCharging')
2299
+ statusChanged = true
2300
+ }
2301
+ break
2302
+ }
2303
+ case 'powerSrc': {
2304
+ const newPowerSource = convertPowerSource(legacyStatus.powerSrc)
2305
+ if (status.powerSource !== newPowerSource) {
2306
+ status.powerSource = newPowerSource
2307
+ changedFields.add('powerSource')
2308
+ statusChanged = true
2309
+ }
2310
+ break
2311
+ }
2312
+ case 'activeCard': {
2313
+ const normalizedCard = legacyStatus.activeCard === 'none' ? null : legacyStatus.activeCard
2314
+ if (status.activeCardId !== normalizedCard) {
2315
+ status.activeCardId = normalizedCard
2316
+ changedFields.add('activeCardId')
2317
+ statusChanged = true
2318
+ }
2319
+ break
2320
+ }
2321
+ case 'cardInserted': {
2322
+ const newState = convertCardInsertionState(legacyStatus.cardInserted)
2323
+ if (status.cardInsertionState !== newState) {
2324
+ status.cardInsertionState = newState
2325
+ changedFields.add('cardInsertionState')
2326
+ statusChanged = true
2327
+ }
2328
+ break
2329
+ }
2330
+ case 'playingStatus': {
2331
+ // Playback field - not stored in status
2332
+ break
2333
+ }
2334
+ case 'headphones': {
2335
+ const isConnected = Boolean(legacyStatus.headphones)
2336
+ if (status.isAudioDeviceConnected !== isConnected) {
2337
+ status.isAudioDeviceConnected = isConnected
2338
+ changedFields.add('isAudioDeviceConnected')
2339
+ statusChanged = true
2340
+ }
2341
+ break
2342
+ }
2343
+ case 'wifiRestarts': {
2344
+ // Hardware diagnostic - not stored in status
2345
+ break
2346
+ }
2347
+ case 'qiOtp': {
2348
+ // Hardware diagnostic - not stored in status
2349
+ break
2350
+ }
2351
+ case 'buzzErrors': {
2352
+ // Hardware diagnostic - not stored in status
2353
+ break
2354
+ }
2355
+ case 'dnowBrightness': {
2356
+ if (status.displayBrightness !== legacyStatus.dnowBrightness) {
2357
+ status.displayBrightness = legacyStatus.dnowBrightness
2358
+ changedFields.add('displayBrightness')
2359
+ statusChanged = true
2360
+ }
2361
+ break
2362
+ }
2363
+ case 'dayBright': {
2364
+ const brightnessValue = String(legacyStatus.dayBright)
2365
+ if (config.dayDisplayBrightness !== brightnessValue) {
2366
+ config.dayDisplayBrightness = brightnessValue
2367
+ configChangedFields.add('dayDisplayBrightness')
2368
+ configChanged = true
2369
+ }
2370
+ break
2371
+ }
2372
+ case 'nightBright': {
2373
+ const brightnessValue = String(legacyStatus.nightBright)
2374
+ if (config.nightDisplayBrightness !== brightnessValue) {
2375
+ config.nightDisplayBrightness = brightnessValue
2376
+ configChangedFields.add('nightDisplayBrightness')
2377
+ configChanged = true
2378
+ }
2379
+ break
2380
+ }
2381
+ case 'errorsLogged': {
2382
+ // Hardware diagnostic - not stored in status
2383
+ break
2384
+ }
2385
+ case 'twdt': {
2386
+ // Hardware diagnostic - not stored in status
2387
+ break
2388
+ }
2389
+ case 'bluetoothHp': {
2390
+ const isConnected = Boolean(legacyStatus.bluetoothHp)
2391
+ if (status.isBluetoothAudioConnected !== isConnected) {
2392
+ status.isBluetoothAudioConnected = isConnected
2393
+ changedFields.add('isBluetoothAudioConnected')
2394
+ statusChanged = true
2395
+ }
2396
+ break
2397
+ }
2398
+ case 'nightlightMode': {
2399
+ if (status.nightlightMode !== legacyStatus.nightlightMode) {
2400
+ status.nightlightMode = legacyStatus.nightlightMode
2401
+ changedFields.add('nightlightMode')
2402
+ statusChanged = true
2403
+ }
2404
+ break
2405
+ }
2406
+ case 'bgDownload': {
2407
+ // Hardware diagnostic - not stored in status
2408
+ break
2409
+ }
2410
+ case 'bytesPS': {
2411
+ // Hardware diagnostic - not stored in status
2412
+ break
2413
+ }
2414
+ case 'day': {
2415
+ const newDayMode = convertDayMode(legacyStatus.day)
2416
+ if (status.dayMode !== newDayMode) {
2417
+ status.dayMode = newDayMode
2418
+ changedFields.add('dayMode')
2419
+ statusChanged = true
2420
+ }
2421
+ break
2422
+ }
2423
+ case 'temp': {
2424
+ const temperature = parseTemperature(legacyStatus.temp)
2425
+ if (status.temperatureCelsius !== temperature) {
2426
+ status.temperatureCelsius = temperature
2427
+ changedFields.add('temperatureCelsius')
2428
+ statusChanged = true
2429
+ }
2430
+ break
2431
+ }
2432
+ case 'als': {
2433
+ if (status.ambientLightSensorReading !== legacyStatus.als) {
2434
+ status.ambientLightSensorReading = legacyStatus.als
2435
+ changedFields.add('ambientLightSensorReading')
2436
+ statusChanged = true
2437
+ }
2438
+ break
2439
+ }
2440
+ case 'volume': {
2441
+ // Convert from 0-100 percentage to 0-16 scale
2442
+ const maxVolume = Math.round((legacyStatus.volume / 100) * 16)
2443
+ if (status.maxVolume !== maxVolume) {
2444
+ status.maxVolume = maxVolume
2445
+ changedFields.add('maxVolume')
2446
+ statusChanged = true
2447
+ }
2448
+ break
2449
+ }
2450
+ case 'userVolume': {
2451
+ // Convert from 0-100 percentage to 0-16 scale
2452
+ const volume = Math.round((legacyStatus.userVolume / 100) * 16)
2453
+ if (status.volume !== volume) {
2454
+ status.volume = volume
2455
+ changedFields.add('volume')
2456
+ statusChanged = true
2457
+ }
2458
+ break
2459
+ }
2460
+ case 'timeFormat': {
2461
+ if (status.timeFormat !== legacyStatus.timeFormat) {
2462
+ status.timeFormat = legacyStatus.timeFormat
2463
+ changedFields.add('timeFormat')
2464
+ statusChanged = true
2465
+ }
2466
+ break
2467
+ }
2468
+ case 'chgStatLevel': {
2469
+ // Hardware diagnostic - not stored in status
2470
+ break
2471
+ }
2472
+ case 'missedLogs': {
2473
+ // Hardware diagnostic - not stored in status
2474
+ break
2475
+ }
2476
+ case 'nfcLock': {
2477
+ // Hardware diagnostic - not stored in status
2478
+ break
2479
+ }
2480
+ case 'batteryFullPct': {
2481
+ // Hardware diagnostic - not stored in status
2482
+ break
2483
+ }
2484
+ default: {
2485
+ // This will cause a type error if a new key is added to YotoLegacyStatus
2486
+ // and not handled in the switch statement above
2487
+ /** @type {never} */
2488
+ // eslint-disable-next-line no-unused-expressions
2489
+ (key)
2490
+ }
2491
+ }
2492
+ }
2493
+
2494
+ // Process all keys from legacyStatus
2495
+ for (const key of typedKeys(legacyStatus)) {
2496
+ handleField(key, legacyStatus)
2497
+ }
2498
+
2499
+ // Only emit if something actually changed
2500
+ if (statusChanged) {
2501
+ // Update metadata
2502
+ this.#deviceOnline = true // If we're getting MQTT, device is online
2503
+ status.updatedAt = new Date().toISOString()
2504
+ status.source = 'mqtt'
2505
+
2506
+ this.#state.lastUpdate.status = Date.now()
2507
+ this.#state.lastUpdate.source = 'mqtt'
2508
+
2509
+ this.emit('statusUpdate', this.status, 'mqtt', changedFields)
2510
+ }
2511
+
2512
+ // Emit config update if config changed
2513
+ if (configChanged) {
2514
+ this.#state.lastUpdate.config = Date.now()
2515
+
2516
+ this.emit('configUpdate', this.config, configChangedFields)
2517
+ }
2518
+ }
2519
+
2520
+ /**
2521
+ * Handle MQTT event message - updates status, config, and playback
2522
+ * Events are partial updates - only changed fields are included
2523
+ * Uses exhaustive switch statement pattern to ensure all YotoEventsMessage fields are handled.
2524
+ *
2525
+ * Event fields are categorized as:
2526
+ * - STATUS: volume
2527
+ * - CONFIG: repeatAll, volumeMax
2528
+ * - PLAYBACK: streaming, sleepTimerActive, sleepTimerSeconds, trackLength,
2529
+ * position, cardId, source, playbackStatus, chapterTitle, chapterKey,
2530
+ * trackTitle, trackKey
2531
+ * - METADATA: eventUtc, cardUpdatedAt, playbackWait (not currently stored)
2532
+ *
2533
+ * @param {YotoEventsMessage} eventsMessage - MQTT events message
2534
+ */
2535
+ #handleEventMessage (eventsMessage) {
2536
+ let statusChanged = false
2537
+ let configChanged = false
2538
+ let playbackChanged = false
2539
+
2540
+ const { status, config, playback } = this.#state
2541
+ /** @type {Set<keyof YotoDeviceStatus>} */
2542
+ const statusChangedFields = new Set()
2543
+ /** @type {Set<keyof YotoDeviceConfig>} */
2544
+ const configChangedFields = new Set()
2545
+ /** @type {Set<keyof YotoPlaybackState>} */
2546
+ const playbackChangedFields = new Set()
2547
+
2548
+ /**
2549
+ * Handler function for each event field
2550
+ * @param {keyof YotoEventsMessage} key
2551
+ * @param {YotoEventsMessage} eventsMessage
2552
+ */
2553
+ const handleField = (key, eventsMessage) => {
2554
+ switch (key) {
2555
+ case 'volume': {
2556
+ if (eventsMessage.volume !== undefined && status.volume !== eventsMessage.volume) {
2557
+ status.volume = eventsMessage.volume
2558
+ status.updatedAt = new Date().toISOString()
2559
+ statusChangedFields.add('volume')
2560
+ statusChanged = true
2561
+ }
2562
+ break
2563
+ }
2564
+ case 'repeatAll': {
2565
+ if (config && eventsMessage.repeatAll !== undefined && config.repeatAll !== eventsMessage.repeatAll) {
2566
+ config.repeatAll = eventsMessage.repeatAll
2567
+ configChangedFields.add('repeatAll')
2568
+ configChanged = true
2569
+ }
2570
+ break
2571
+ }
2572
+ case 'volumeMax': {
2573
+ // volumeMax is the current effective max volume limit (config-derived)
2574
+ // Store as string to match config type (maxVolumeLimit is string)
2575
+ if (eventsMessage.volumeMax !== undefined && status.maxVolume !== eventsMessage.volumeMax) {
2576
+ status.maxVolume = eventsMessage.volumeMax
2577
+ statusChangedFields.add('maxVolume')
2578
+ statusChanged = true
2579
+ }
2580
+ break
2581
+ }
2582
+ case 'streaming': {
2583
+ if (eventsMessage.streaming !== undefined && playback.streaming !== eventsMessage.streaming) {
2584
+ playback.streaming = eventsMessage.streaming
2585
+ playbackChangedFields.add('streaming')
2586
+ playbackChanged = true
2587
+ }
2588
+ break
2589
+ }
2590
+ case 'sleepTimerActive': {
2591
+ if (eventsMessage.sleepTimerActive !== undefined && playback.sleepTimerActive !== eventsMessage.sleepTimerActive) {
2592
+ playback.sleepTimerActive = eventsMessage.sleepTimerActive
2593
+ playbackChangedFields.add('sleepTimerActive')
2594
+ playbackChanged = true
2595
+ }
2596
+ break
2597
+ }
2598
+ case 'sleepTimerSeconds': {
2599
+ if (eventsMessage.sleepTimerSeconds !== undefined && playback.sleepTimerSeconds !== eventsMessage.sleepTimerSeconds) {
2600
+ playback.sleepTimerSeconds = eventsMessage.sleepTimerSeconds
2601
+ playbackChangedFields.add('sleepTimerSeconds')
2602
+ playbackChanged = true
2603
+ }
2604
+ break
2605
+ }
2606
+ case 'trackLength': {
2607
+ if (eventsMessage.trackLength !== undefined && playback.trackLength !== eventsMessage.trackLength) {
2608
+ playback.trackLength = eventsMessage.trackLength
2609
+ playbackChangedFields.add('trackLength')
2610
+ playbackChanged = true
2611
+ }
2612
+ break
2613
+ }
2614
+ case 'position': {
2615
+ if (eventsMessage.position !== undefined && playback.position !== eventsMessage.position) {
2616
+ playback.position = eventsMessage.position
2617
+ playbackChangedFields.add('position')
2618
+ playbackChanged = true
2619
+ }
2620
+ break
2621
+ }
2622
+ case 'cardId': {
2623
+ if (eventsMessage.cardId !== undefined && playback.cardId !== eventsMessage.cardId) {
2624
+ playback.cardId = eventsMessage.cardId
2625
+ playbackChangedFields.add('cardId')
2626
+ playbackChanged = true
2627
+ }
2628
+ break
2629
+ }
2630
+ case 'source': {
2631
+ if (eventsMessage.source !== undefined && playback.source !== eventsMessage.source) {
2632
+ playback.source = eventsMessage.source
2633
+ playbackChangedFields.add('source')
2634
+ playbackChanged = true
2635
+ }
2636
+ break
2637
+ }
2638
+ case 'playbackStatus': {
2639
+ if (eventsMessage.playbackStatus !== undefined && playback.playbackStatus !== eventsMessage.playbackStatus) {
2640
+ playback.playbackStatus = eventsMessage.playbackStatus
2641
+ playbackChangedFields.add('playbackStatus')
2642
+ playbackChanged = true
2643
+ }
2644
+ break
2645
+ }
2646
+ case 'chapterTitle': {
2647
+ if (eventsMessage.chapterTitle !== undefined && playback.chapterTitle !== eventsMessage.chapterTitle) {
2648
+ playback.chapterTitle = eventsMessage.chapterTitle
2649
+ playbackChangedFields.add('chapterTitle')
2650
+ playbackChanged = true
2651
+ }
2652
+ break
2653
+ }
2654
+ case 'chapterKey': {
2655
+ if (eventsMessage.chapterKey !== undefined && playback.chapterKey !== eventsMessage.chapterKey) {
2656
+ playback.chapterKey = eventsMessage.chapterKey
2657
+ playbackChangedFields.add('chapterKey')
2658
+ playbackChanged = true
2659
+ }
2660
+ break
2661
+ }
2662
+ case 'trackTitle': {
2663
+ if (eventsMessage.trackTitle !== undefined && playback.trackTitle !== eventsMessage.trackTitle) {
2664
+ playback.trackTitle = eventsMessage.trackTitle
2665
+ playbackChangedFields.add('trackTitle')
2666
+ playbackChanged = true
2667
+ }
2668
+ break
2669
+ }
2670
+ case 'trackKey': {
2671
+ if (eventsMessage.trackKey !== undefined && playback.trackKey !== eventsMessage.trackKey) {
2672
+ playback.trackKey = eventsMessage.trackKey
2673
+ playbackChangedFields.add('trackKey')
2674
+ playbackChanged = true
2675
+ }
2676
+ break
2677
+ }
2678
+ case 'playbackWait': {
2679
+ // Not currently stored - metadata only
2680
+ break
2681
+ }
2682
+ case 'eventUtc': {
2683
+ // Not currently stored - metadata only
2684
+ break
2685
+ }
2686
+ case 'cardUpdatedAt': {
2687
+ // Not currently stored - metadata only
2688
+ break
2689
+ }
2690
+ default: {
2691
+ // This will cause a type error if a new key is added to YotoEventsMessage
2692
+ // and not handled in the switch statement above
2693
+ /** @type {never} */
2694
+ // eslint-disable-next-line no-unused-expressions
2695
+ (key)
2696
+ }
2697
+ }
2698
+ }
2699
+
2700
+ // Process all keys from eventsMessage
2701
+ for (const key of typedKeys(eventsMessage)) {
2702
+ handleField(key, eventsMessage)
2703
+ }
2704
+
2705
+ // Update timestamps and emit events for changed categories
2706
+ if (statusChanged) {
2707
+ this.#state.lastUpdate.status = Date.now()
2708
+ this.#state.lastUpdate.source = 'mqtt-event'
2709
+ this.emit('statusUpdate', this.status, 'mqtt-event', statusChangedFields)
2710
+ }
2711
+
2712
+ if (configChanged) {
2713
+ this.#state.lastUpdate.config = Date.now()
2714
+ this.emit('configUpdate', this.config, configChangedFields)
2715
+ }
2716
+
2717
+ if (playbackChanged) {
2718
+ playback.updatedAt = new Date().toISOString()
2719
+ this.#state.lastUpdate.playback = Date.now()
2720
+ this.emit('playbackUpdate', this.playback, playbackChangedFields)
2721
+ }
2722
+ }
2723
+ }
2724
+
2725
+ // ============================================================================
2726
+ // Helper Functions
2727
+ // ============================================================================
2728
+
2729
+ /**
2730
+ * Create an empty playback state object
2731
+ * @returns {YotoPlaybackState}
2732
+ */
2733
+ function createEmptyPlaybackState () {
2734
+ return {
2735
+ cardId: null,
2736
+ source: null,
2737
+ playbackStatus: null,
2738
+ trackTitle: null,
2739
+ trackKey: null,
2740
+ chapterTitle: null,
2741
+ chapterKey: null,
2742
+ position: null,
2743
+ trackLength: null,
2744
+ streaming: null,
2745
+ sleepTimerActive: null,
2746
+ sleepTimerSeconds: null,
2747
+ updatedAt: new Date().toISOString()
2748
+ }
2749
+ }
2750
+
2751
+ /**
2752
+ * Create an empty device config object
2753
+ * @returns {YotoDeviceConfig}
2754
+ */
2755
+ function createEmptyDeviceConfig () {
2756
+ return {
2757
+ alarms: [],
2758
+ ambientColour: '#000000',
2759
+ bluetoothEnabled: '0',
2760
+ btHeadphonesEnabled: false,
2761
+ clockFace: 'digital-sun',
2762
+ dayDisplayBrightness: 'auto',
2763
+ dayTime: '07:00',
2764
+ dayYotoDaily: '',
2765
+ dayYotoRadio: '',
2766
+ daySoundsOff: '0',
2767
+ displayDimBrightness: '0',
2768
+ displayDimTimeout: '30',
2769
+ headphonesVolumeLimited: false,
2770
+ hourFormat: '12',
2771
+ locale: 'en',
2772
+ logLevel: 'none',
2773
+ maxVolumeLimit: '16',
2774
+ nightAmbientColour: '#000000',
2775
+ nightDisplayBrightness: 'auto',
2776
+ nightMaxVolumeLimit: '10',
2777
+ nightTime: '19:00',
2778
+ nightYotoDaily: '',
2779
+ nightYotoRadio: '',
2780
+ nightSoundsOff: '0',
2781
+ pausePowerButton: false,
2782
+ pauseVolumeDown: false,
2783
+ repeatAll: false,
2784
+ showDiagnostics: false,
2785
+ shutdownTimeout: '900',
2786
+ systemVolume: '100',
2787
+ timezone: '',
2788
+ volumeLevel: 'safe'
2789
+ }
2790
+ }
2791
+
2792
+ /**
2793
+ * Create an empty device shortcuts object
2794
+ * @returns {YotoDeviceShortcuts}
2795
+ */
2796
+ function createEmptyDeviceShortcuts () {
2797
+ return {
2798
+ modes: {
2799
+ day: {
2800
+ content: []
2801
+ },
2802
+ night: {
2803
+ content: []
2804
+ }
2805
+ },
2806
+ versionId: ''
2807
+ }
2808
+ }
2809
+
2810
+ /**
2811
+ * Create an empty device status object
2812
+ * @param {YotoDevice} [device] - Device object to initialize from
2813
+ * @returns {YotoDeviceStatus}
2814
+ */
2815
+ function createEmptyDeviceStatus (device) {
2816
+ return {
2817
+ activeCardId: null,
2818
+ batteryLevelPercentage: 0,
2819
+ isCharging: false,
2820
+ isOnline: device?.online ?? false,
2821
+ volume: 0,
2822
+ maxVolume: 16,
2823
+ cardInsertionState: 'none',
2824
+ dayMode: convertDayMode(-1),
2825
+ powerSource: convertPowerSource(0),
2826
+ firmwareVersion: '',
2827
+ wifiStrength: 0,
2828
+ freeDiskSpaceBytes: 0,
2829
+ totalDiskSpaceBytes: 0,
2830
+ isAudioDeviceConnected: false,
2831
+ isBluetoothAudioConnected: false,
2832
+ nightlightMode: 'off',
2833
+ temperatureCelsius: null,
2834
+ ambientLightSensorReading: 0,
2835
+ displayBrightness: null,
2836
+ timeFormat: null,
2837
+ uptime: 0,
2838
+ updatedAt: new Date().toISOString(),
2839
+ source: 'http'
2840
+ }
2841
+ }
2842
+
2843
+ /**
2844
+ * Sleep for a specified amount of time
2845
+ * @param {number} ms - Milliseconds to sleep
2846
+ * @returns {Promise<void>}
2847
+ */
2848
+ function sleep (ms) {
2849
+ return new Promise(resolve => setTimeout(resolve, ms))
2850
+ }