yoto-nodejs-client 0.0.5 → 0.0.7

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 (52) hide show
  1. package/README.md +73 -40
  2. package/bin/auth.js +4 -3
  3. package/bin/device-model.js +25 -5
  4. package/bin/device-tui.js +25 -3
  5. package/bin/devices.js +25 -9
  6. package/bin/lib/cli-helpers.d.ts.map +1 -1
  7. package/bin/lib/cli-helpers.js +3 -1
  8. package/bin/lib/token-helpers.d.ts +4 -2
  9. package/bin/lib/token-helpers.d.ts.map +1 -1
  10. package/bin/lib/token-helpers.js +9 -8
  11. package/bin/refresh-token.js +4 -2
  12. package/bin/token-info.js +2 -2
  13. package/lib/api-client.d.ts +11 -10
  14. package/lib/api-client.d.ts.map +1 -1
  15. package/lib/api-client.js +12 -14
  16. package/lib/api-endpoints/auth.test.js +4 -4
  17. package/lib/api-endpoints/content.test.js +32 -32
  18. package/lib/api-endpoints/devices.d.ts +4 -4
  19. package/lib/api-endpoints/devices.js +2 -2
  20. package/lib/api-endpoints/devices.test.js +45 -45
  21. package/lib/api-endpoints/endpoint-test-helpers.d.ts +3 -4
  22. package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -1
  23. package/lib/api-endpoints/endpoint-test-helpers.js +21 -5
  24. package/lib/api-endpoints/family-library-groups.d.ts +3 -3
  25. package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
  26. package/lib/api-endpoints/family-library-groups.js +3 -3
  27. package/lib/api-endpoints/family-library-groups.test.js +29 -29
  28. package/lib/api-endpoints/family.test.js +11 -11
  29. package/lib/api-endpoints/icons.test.js +14 -14
  30. package/lib/mqtt/client.d.ts +123 -48
  31. package/lib/mqtt/client.d.ts.map +1 -1
  32. package/lib/mqtt/client.js +131 -49
  33. package/lib/mqtt/factory.d.ts +12 -5
  34. package/lib/mqtt/factory.d.ts.map +1 -1
  35. package/lib/mqtt/factory.js +39 -11
  36. package/lib/mqtt/index.js +2 -1
  37. package/lib/mqtt/mqtt.test.js +25 -22
  38. package/lib/test-helpers/device-model-test-helpers.d.ts +29 -0
  39. package/lib/test-helpers/device-model-test-helpers.d.ts.map +1 -0
  40. package/lib/test-helpers/device-model-test-helpers.js +116 -0
  41. package/lib/token.d.ts +44 -2
  42. package/lib/token.d.ts.map +1 -1
  43. package/lib/token.js +142 -2
  44. package/lib/yoto-account.d.ts +339 -9
  45. package/lib/yoto-account.d.ts.map +1 -1
  46. package/lib/yoto-account.js +411 -39
  47. package/lib/yoto-account.test.js +139 -0
  48. package/lib/yoto-device.d.ts +418 -30
  49. package/lib/yoto-device.d.ts.map +1 -1
  50. package/lib/yoto-device.js +670 -104
  51. package/lib/yoto-device.test.js +88 -0
  52. package/package.json +1 -1
@@ -7,10 +7,11 @@
7
7
  */
8
8
 
9
9
  /**
10
+ * @import { IConnackPacket } from 'mqtt'
10
11
  * @import { YotoClient } from './api-client.js'
11
12
  * @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'
13
+ * @import { YotoMqttClient, YotoMqttStatus, YotoEventsMessage, YotoLegacyStatus, YotoStatusMessage, YotoStatusLegacyMessage, YotoResponseMessage, YotoMqttClientDisconnectMetadata, YotoMqttClientCloseMetadata, PlaybackStatus } from './mqtt/client.js'
14
+ * @import { YotoMqttOptions } from './mqtt/factory.js'
14
15
  */
15
16
 
16
17
  import { EventEmitter } from 'events'
@@ -80,6 +81,194 @@ function convertPowerSource (numericSource) {
80
81
  * @typedef {'battery' | 'dock' | 'usb-c' | 'wireless'} PowerSource
81
82
  */
82
83
 
84
+ /**
85
+ * Normalize "0"/"1" booleans from config to true/false.
86
+ * @param {string | boolean} value
87
+ * @returns {boolean}
88
+ */
89
+ function parseConfigBoolean (value) {
90
+ if (typeof value === 'boolean') return value
91
+ return value === '1'
92
+ }
93
+
94
+ /**
95
+ * Parse a numeric config field into a number.
96
+ * @param {string | number} value
97
+ * @param {number} fallback
98
+ * @returns {number}
99
+ */
100
+ function parseConfigNumber (value, fallback) {
101
+ const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10)
102
+ return Number.isFinite(parsed) ? parsed : fallback
103
+ }
104
+
105
+ /**
106
+ * Parse brightness config values.
107
+ * @param {string} value
108
+ * @returns {{ auto: boolean, value: number | null }}
109
+ */
110
+ function parseConfigBrightness (value) {
111
+ if (value === 'auto') return { auto: true, value: null }
112
+ const parsed = Number.parseInt(value, 10)
113
+ if (Number.isFinite(parsed)) return { auto: false, value: parsed }
114
+ return { auto: false, value: null }
115
+ }
116
+
117
+ /**
118
+ * Parse brightness status values (255 = auto).
119
+ * @param {number} value
120
+ * @returns {{ auto: boolean, value: number | null }}
121
+ */
122
+ function parseStatusBrightness (value) {
123
+ if (value === 255) return { auto: true, value: null }
124
+ return { auto: false, value }
125
+ }
126
+
127
+ /**
128
+ * Parse "off" sentinel for night Yoto Radio.
129
+ * @param {string} value
130
+ * @returns {{ enabled: boolean, value: string | null }}
131
+ */
132
+ function parseRadioSetting (value) {
133
+ if (value === '' || value === '0') return { enabled: false, value: null }
134
+ return { enabled: true, value }
135
+ }
136
+
137
+ /**
138
+ * Build a config update payload in API format.
139
+ * @param {Partial<YotoDeviceModelConfig>} configUpdate
140
+ * @param {YotoDeviceModelConfig} currentConfig
141
+ * @returns {Partial<YotoDeviceConfig>}
142
+ */
143
+ function buildConfigUpdatePayload (configUpdate, currentConfig) {
144
+ /** @type {Partial<YotoDeviceConfig>} */
145
+ const update = {}
146
+ /**
147
+ * @param {keyof YotoDeviceModelConfig} key
148
+ * @returns {boolean}
149
+ */
150
+ const has = (key) => Object.prototype.hasOwnProperty.call(configUpdate, key)
151
+
152
+ if (has('alarms') && configUpdate.alarms !== undefined) update.alarms = configUpdate.alarms
153
+ if (has('ambientColour') && configUpdate.ambientColour !== undefined) update.ambientColour = configUpdate.ambientColour
154
+ if (has('bluetoothEnabled') && configUpdate.bluetoothEnabled !== undefined) {
155
+ update.bluetoothEnabled = configUpdate.bluetoothEnabled ? '1' : '0'
156
+ }
157
+ if (has('btHeadphonesEnabled') && configUpdate.btHeadphonesEnabled !== undefined) {
158
+ update.btHeadphonesEnabled = configUpdate.btHeadphonesEnabled
159
+ }
160
+ if (has('clockFace') && configUpdate.clockFace !== undefined) update.clockFace = configUpdate.clockFace
161
+ if (has('dayTime') && configUpdate.dayTime !== undefined) update.dayTime = configUpdate.dayTime
162
+ if (has('dayYotoDaily') && configUpdate.dayYotoDaily !== undefined) update.dayYotoDaily = configUpdate.dayYotoDaily
163
+ if (has('dayYotoRadio') && configUpdate.dayYotoRadio !== undefined) update.dayYotoRadio = configUpdate.dayYotoRadio
164
+ if (has('daySoundsOff') && configUpdate.daySoundsOff !== undefined) {
165
+ update.daySoundsOff = configUpdate.daySoundsOff ? '1' : '0'
166
+ }
167
+ if (has('displayDimBrightness') && configUpdate.displayDimBrightness !== undefined) {
168
+ update.displayDimBrightness = String(configUpdate.displayDimBrightness)
169
+ }
170
+ if (has('displayDimTimeout') && configUpdate.displayDimTimeout !== undefined) {
171
+ update.displayDimTimeout = String(configUpdate.displayDimTimeout)
172
+ }
173
+ if (has('headphonesVolumeLimited') && configUpdate.headphonesVolumeLimited !== undefined) {
174
+ update.headphonesVolumeLimited = configUpdate.headphonesVolumeLimited
175
+ }
176
+ if (has('hourFormat') && configUpdate.hourFormat !== undefined) {
177
+ update.hourFormat = String(configUpdate.hourFormat)
178
+ }
179
+ if (has('locale') && configUpdate.locale !== undefined) update.locale = configUpdate.locale
180
+ if (has('logLevel') && configUpdate.logLevel !== undefined) update.logLevel = configUpdate.logLevel
181
+ if (has('maxVolumeLimit') && configUpdate.maxVolumeLimit !== undefined) {
182
+ update.maxVolumeLimit = String(configUpdate.maxVolumeLimit)
183
+ }
184
+ if (has('nightAmbientColour') && configUpdate.nightAmbientColour !== undefined) {
185
+ update.nightAmbientColour = configUpdate.nightAmbientColour
186
+ }
187
+ if (has('nightMaxVolumeLimit') && configUpdate.nightMaxVolumeLimit !== undefined) {
188
+ update.nightMaxVolumeLimit = String(configUpdate.nightMaxVolumeLimit)
189
+ }
190
+ if (has('nightTime') && configUpdate.nightTime !== undefined) update.nightTime = configUpdate.nightTime
191
+ if (has('nightYotoDaily') && configUpdate.nightYotoDaily !== undefined) update.nightYotoDaily = configUpdate.nightYotoDaily
192
+ if (has('nightSoundsOff') && configUpdate.nightSoundsOff !== undefined) {
193
+ update.nightSoundsOff = configUpdate.nightSoundsOff ? '1' : '0'
194
+ }
195
+ if (has('pausePowerButton') && configUpdate.pausePowerButton !== undefined) {
196
+ update.pausePowerButton = configUpdate.pausePowerButton
197
+ }
198
+ if (has('pauseVolumeDown') && configUpdate.pauseVolumeDown !== undefined) {
199
+ update.pauseVolumeDown = configUpdate.pauseVolumeDown
200
+ }
201
+ if (has('repeatAll') && configUpdate.repeatAll !== undefined) update.repeatAll = configUpdate.repeatAll
202
+ if (has('showDiagnostics') && configUpdate.showDiagnostics !== undefined) {
203
+ update.showDiagnostics = configUpdate.showDiagnostics
204
+ }
205
+ if (has('shutdownTimeout') && configUpdate.shutdownTimeout !== undefined) {
206
+ update.shutdownTimeout = String(configUpdate.shutdownTimeout)
207
+ }
208
+ if (has('systemVolume') && configUpdate.systemVolume !== undefined) {
209
+ update.systemVolume = String(configUpdate.systemVolume)
210
+ }
211
+ if (has('timezone') && configUpdate.timezone !== undefined) update.timezone = configUpdate.timezone
212
+ if (has('volumeLevel') && configUpdate.volumeLevel !== undefined) update.volumeLevel = configUpdate.volumeLevel
213
+
214
+ const hasDayBrightnessValue = has('dayDisplayBrightness')
215
+ const hasDayBrightnessAuto = has('dayDisplayBrightnessAuto')
216
+ if (hasDayBrightnessValue || hasDayBrightnessAuto) {
217
+ const autoValue = hasDayBrightnessAuto ? configUpdate.dayDisplayBrightnessAuto : undefined
218
+ const auto = autoValue !== undefined
219
+ ? autoValue
220
+ : (hasDayBrightnessValue ? false : currentConfig.dayDisplayBrightnessAuto)
221
+ const value = hasDayBrightnessValue ? configUpdate.dayDisplayBrightness : currentConfig.dayDisplayBrightness
222
+ if (auto) {
223
+ update.dayDisplayBrightness = 'auto'
224
+ } else if (value !== null && value !== undefined) {
225
+ update.dayDisplayBrightness = String(value)
226
+ } else {
227
+ throw new Error('dayDisplayBrightness must be set when dayDisplayBrightnessAuto is false')
228
+ }
229
+ }
230
+
231
+ const hasNightBrightnessValue = has('nightDisplayBrightness')
232
+ const hasNightBrightnessAuto = has('nightDisplayBrightnessAuto')
233
+ if (hasNightBrightnessValue || hasNightBrightnessAuto) {
234
+ const autoValue = hasNightBrightnessAuto ? configUpdate.nightDisplayBrightnessAuto : undefined
235
+ const auto = autoValue !== undefined
236
+ ? autoValue
237
+ : (hasNightBrightnessValue ? false : currentConfig.nightDisplayBrightnessAuto)
238
+ const value = hasNightBrightnessValue ? configUpdate.nightDisplayBrightness : currentConfig.nightDisplayBrightness
239
+ if (auto) {
240
+ update.nightDisplayBrightness = 'auto'
241
+ } else if (value !== null && value !== undefined) {
242
+ update.nightDisplayBrightness = String(value)
243
+ } else {
244
+ throw new Error('nightDisplayBrightness must be set when nightDisplayBrightnessAuto is false')
245
+ }
246
+ }
247
+
248
+ const hasNightRadioValue = has('nightYotoRadio')
249
+ const hasNightRadioEnabled = has('nightYotoRadioEnabled')
250
+ if (hasNightRadioValue || hasNightRadioEnabled) {
251
+ const enabledValue = hasNightRadioEnabled ? configUpdate.nightYotoRadioEnabled : undefined
252
+ const enabled = enabledValue !== undefined
253
+ ? enabledValue
254
+ : (hasNightRadioValue
255
+ ? configUpdate.nightYotoRadio !== null && configUpdate.nightYotoRadio !== undefined
256
+ : currentConfig.nightYotoRadioEnabled)
257
+ const radioValue = hasNightRadioValue ? configUpdate.nightYotoRadio : currentConfig.nightYotoRadio
258
+ if (enabled) {
259
+ if (radioValue && radioValue !== '0') {
260
+ update.nightYotoRadio = radioValue
261
+ } else {
262
+ throw new Error('nightYotoRadio must be set when nightYotoRadioEnabled is true')
263
+ }
264
+ } else {
265
+ update.nightYotoRadio = '0'
266
+ }
267
+ }
268
+
269
+ return update
270
+ }
271
+
83
272
  /**
84
273
  * Official Yoto nightlight colors
85
274
  *
@@ -120,7 +309,7 @@ export function getNightlightColorName (colorValue) {
120
309
  * @property {boolean} isCharging - Whether device is currently charging
121
310
  * @property {boolean} isOnline - Whether device is currently online
122
311
  * @property {number} volume - User volume level (0-16 scale)
123
- * @property {number} maxVolume - Maximum volume limit (0-16 scale)
312
+ * @property {number} maxVolume - Active Maximum volume (depending on day or night) limit (0-16 scale)
124
313
  * @property {CardInsertionState} cardInsertionState - Card insertion state
125
314
  * @property {DayMode} dayMode - Day mode status
126
315
  * @property {PowerSource} powerSource - Power source
@@ -141,12 +330,57 @@ export function getNightlightColorName (colorValue) {
141
330
  */
142
331
  export const YotoDeviceStatusType = {}
143
332
 
333
+ /**
334
+ * Canonical device config - normalized format for HTTP config payloads
335
+ *
336
+ * String booleans and numeric strings are normalized. Brightness "auto" values
337
+ * are split into value + boolean flag.
338
+ *
339
+ * @typedef {Object} YotoDeviceModelConfig
340
+ * @property {string[]} alarms - Alarm list entries
341
+ * @property {string} ambientColour - Ambient light color (hex code)
342
+ * @property {boolean} bluetoothEnabled - Bluetooth enabled state
343
+ * @property {boolean} btHeadphonesEnabled - Bluetooth headphones enabled
344
+ * @property {string} clockFace - Clock face style
345
+ * @property {number | null} dayDisplayBrightness - Day brightness (0-100), null when auto
346
+ * @property {boolean} dayDisplayBrightnessAuto - Whether day brightness is auto
347
+ * @property {string} dayTime - Day mode start time
348
+ * @property {string} dayYotoDaily - Day mode Yoto Daily card path
349
+ * @property {string} dayYotoRadio - Day mode Yoto Radio card path
350
+ * @property {boolean} daySoundsOff - Day sounds off
351
+ * @property {number} displayDimBrightness - Display dim brightness (0-100)
352
+ * @property {number} displayDimTimeout - Display dim timeout in seconds
353
+ * @property {boolean} headphonesVolumeLimited - Whether headphones volume is limited
354
+ * @property {12 | 24} hourFormat - Hour format
355
+ * @property {string} locale - Device locale
356
+ * @property {string} logLevel - Log level
357
+ * @property {number} maxVolumeLimit - Max volume limit (0-16)
358
+ * @property {string} nightAmbientColour - Night ambient light color (hex code)
359
+ * @property {number | null} nightDisplayBrightness - Night brightness (0-100), null when auto
360
+ * @property {boolean} nightDisplayBrightnessAuto - Whether night brightness is auto
361
+ * @property {number} nightMaxVolumeLimit - Night max volume limit (0-16)
362
+ * @property {string} nightTime - Night mode start time
363
+ * @property {string} nightYotoDaily - Night mode Yoto Daily card path
364
+ * @property {string | null} nightYotoRadio - Night mode Yoto Radio card path
365
+ * @property {boolean} nightYotoRadioEnabled - Whether night Yoto Radio is enabled
366
+ * @property {boolean} nightSoundsOff - Night sounds off
367
+ * @property {boolean} pausePowerButton - Pause on power button press
368
+ * @property {boolean} pauseVolumeDown - Pause on volume down press
369
+ * @property {boolean} repeatAll - Repeat all tracks
370
+ * @property {boolean} showDiagnostics - Show diagnostics
371
+ * @property {number} shutdownTimeout - Shutdown timeout in seconds
372
+ * @property {number} systemVolume - System volume level (0-100 percent)
373
+ * @property {string} timezone - Timezone setting (empty string if not set)
374
+ * @property {string} volumeLevel - Volume level preset
375
+ */
376
+ export const YotoDeviceModelConfigType = {}
377
+
144
378
  /**
145
379
  * Playback state from MQTT events
146
380
  * @typedef {Object} YotoPlaybackState
147
381
  * @property {string | null} cardId - Currently playing card ID TODO: Figure out name of card
148
382
  * @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
383
+ * @property {PlaybackStatus | null} playbackStatus - Playback status
150
384
  * @property {string | null} trackTitle - Current track title
151
385
  * @property {string | null} trackKey - Current track key
152
386
  * @property {string | null} chapterTitle - Current chapter title
@@ -168,7 +402,7 @@ export const YotoPlaybackStateType = {}
168
402
  * Complete device client state
169
403
  * @typedef {Object} YotoDeviceClientState
170
404
  * @property {YotoDevice} device - Basic device information
171
- * @property {YotoDeviceConfig} config - Device configuration (always initialized)
405
+ * @property {YotoDeviceModelConfig} config - Device configuration (always initialized)
172
406
  * @property {YotoDeviceShortcuts} shortcuts - Button shortcuts (always initialized)
173
407
  * @property {YotoDeviceStatus} status - Current device status (always initialized)
174
408
  * @property {YotoPlaybackState} playback - Current playback state (always initialized)
@@ -201,7 +435,7 @@ export const YotoPlaybackStateType = {}
201
435
  /**
202
436
  * Device client initialization options
203
437
  * @typedef {Object} YotoDeviceModelOptions
204
- * @property {MqttClientOptions} [mqttOptions] - MQTT.js client options to pass through to factory
438
+ * @property {Omit<YotoMqttOptions, 'deviceId' | 'token' >} [yotoDeviceMqttOptions] - MQTT.js client options to pass through to factory
205
439
  * @property {number} [httpPollIntervalMs=600000] - Background HTTP polling interval for config+status sync (default: 10 minutes)
206
440
  */
207
441
 
@@ -221,11 +455,26 @@ export const YotoPlaybackStateType = {}
221
455
  * @property {string} [source] - Source of status update (only present for 'http-status' reason)
222
456
  */
223
457
 
458
+ /**
459
+ * MQTT disconnect event metadata (from MQTT disconnect packet)
460
+ * @typedef {YotoMqttClientDisconnectMetadata} YotoMqttDisconnectMetadata
461
+ */
462
+
463
+ /**
464
+ * MQTT connect event metadata (from MQTT CONNACK)
465
+ * @typedef {IConnackPacket} YotoMqttConnectMetadata
466
+ */
467
+
468
+ /**
469
+ * MQTT close event metadata (from connection close)
470
+ * @typedef {YotoMqttClientCloseMetadata} YotoMqttCloseMetadata
471
+ */
472
+
224
473
  /**
225
474
  * Started event metadata
226
475
  * @typedef {Object} YotoDeviceStartedMetadata
227
476
  * @property {YotoDevice} device - Device information
228
- * @property {YotoDeviceConfig} config - Device configuration
477
+ * @property {YotoDeviceModelConfig} config - Device configuration
229
478
  * @property {YotoDeviceShortcuts} shortcuts - Device shortcuts
230
479
  * @property {YotoDeviceStatus} status - Device status
231
480
  * @property {YotoPlaybackState} playback - Playback state
@@ -239,12 +488,21 @@ export const YotoPlaybackStateType = {}
239
488
  * 'started': [YotoDeviceStartedMetadata],
240
489
  * 'stopped': [],
241
490
  * 'statusUpdate': [YotoDeviceStatus, string, Set<keyof YotoDeviceStatus>],
242
- * 'configUpdate': [YotoDeviceConfig, Set<keyof YotoDeviceConfig>],
491
+ * 'configUpdate': [YotoDeviceModelConfig, Set<keyof YotoDeviceModelConfig>],
243
492
  * 'playbackUpdate': [YotoPlaybackState, Set<keyof YotoPlaybackState>],
244
493
  * 'online': [YotoDeviceOnlineMetadata],
245
494
  * 'offline': [YotoDeviceOfflineMetadata],
246
- * 'mqttConnected': [],
247
- * 'mqttDisconnected': [],
495
+ * 'mqttConnect': [YotoMqttConnectMetadata],
496
+ * 'mqttDisconnect': [YotoMqttDisconnectMetadata],
497
+ * 'mqttClose': [YotoMqttCloseMetadata],
498
+ * 'mqttReconnect': [],
499
+ * 'mqttOffline': [],
500
+ * 'mqttEnd': [],
501
+ * 'mqttStatus': [string, YotoStatusMessage],
502
+ * 'mqttEvents': [string, YotoEventsMessage],
503
+ * 'mqttStatusLegacy': [string, YotoStatusLegacyMessage],
504
+ * 'mqttResponse': [string, YotoResponseMessage],
505
+ * 'mqttUnknown': [string, unknown],
248
506
  * 'error': [Error]
249
507
  * }} YotoDeviceModelEventMap
250
508
  */
@@ -271,8 +529,17 @@ export const YotoPlaybackStateType = {}
271
529
  * - 'playbackUpdate' - Emitted when playback state changes, passes (playback, changedFields)
272
530
  * - 'online' - Emitted when device comes online, passes metadata with reason and optional upTime
273
531
  * - '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
532
+ * - 'mqttConnect' - Emitted when MQTT client connects, passes CONNACK metadata
533
+ * - 'mqttDisconnect' - Emitted when MQTT disconnect packet received, passes metadata with disconnect packet
534
+ * - 'mqttClose' - Emitted when MQTT connection closes, passes metadata with close reason
535
+ * - 'mqttReconnect' - Emitted when MQTT client is reconnecting
536
+ * - 'mqttOffline' - Emitted when MQTT client goes offline
537
+ * - 'mqttEnd' - Emitted when MQTT client end is called
538
+ * - 'mqttStatus' - Emitted with raw MQTT status messages, passes (topic, message)
539
+ * - 'mqttEvents' - Emitted with raw MQTT events messages, passes (topic, message)
540
+ * - 'mqttStatusLegacy' - Emitted with raw legacy MQTT status messages, passes (topic, message)
541
+ * - 'mqttResponse' - Emitted with raw MQTT response messages, passes (topic, message)
542
+ * - 'mqttUnknown' - Emitted with unknown MQTT messages, passes (topic, message)
276
543
  * - 'error' - Emitted when an error occurs, passes error
277
544
  *
278
545
  * @extends {EventEmitter<YotoDeviceModelEventMap>}
@@ -304,8 +571,8 @@ export class YotoDeviceModel extends EventEmitter {
304
571
  }
305
572
 
306
573
  // Only set mqttOptions if provided (exactOptionalPropertyTypes compatibility)
307
- if (options.mqttOptions !== undefined) {
308
- this.#options.mqttOptions = options.mqttOptions
574
+ if (options.yotoDeviceMqttOptions !== undefined) {
575
+ this.#options.yotoDeviceMqttOptions = options.yotoDeviceMqttOptions
309
576
  }
310
577
 
311
578
  // Initialize state
@@ -372,7 +639,7 @@ export class YotoDeviceModel extends EventEmitter {
372
639
 
373
640
  /**
374
641
  * Get device configuration
375
- * @returns {YotoDeviceConfig}
642
+ * @returns {YotoDeviceModelConfig}
376
643
  */
377
644
  get config () { return this.#state.config }
378
645
 
@@ -500,10 +767,12 @@ export class YotoDeviceModel extends EventEmitter {
500
767
  })
501
768
 
502
769
  // Update state with config data
503
- this.#state.config = configResponse.device.config
504
- this.#state.shortcuts = configResponse.device.shortcuts
770
+ this.#updateConfigFromHttp(
771
+ configResponse.device.config,
772
+ configResponse.device.shortcuts
773
+ )
505
774
 
506
- // Update device info with additional details from config
775
+ // Update device info with additional detai ls from config
507
776
  this.#state.device = {
508
777
  ...this.#state.device,
509
778
  name: configResponse.device.name,
@@ -600,6 +869,197 @@ export class YotoDeviceModel extends EventEmitter {
600
869
  return this.#mqttClient
601
870
  }
602
871
 
872
+ // ==========================================================================
873
+ // Public API - MQTT Commands
874
+ // ==========================================================================
875
+
876
+ /**
877
+ * Request current events from device over MQTT
878
+ * @param {string} [body=''] - Optional request body for tracking/identification
879
+ * @returns {Promise<void>}
880
+ */
881
+ async requestEvents (body = '') {
882
+ return await this.#mqttClient?.requestEvents(body)
883
+ }
884
+
885
+ /**
886
+ * Request current status from device over MQTT
887
+ * @param {string} [body=''] - Optional request body for tracking/identification
888
+ * @returns {Promise<void>}
889
+ */
890
+ async requestStatus (body = '') {
891
+ return await this.#mqttClient?.requestStatus(body)
892
+ }
893
+
894
+ /**
895
+ * Set device volume over MQTT
896
+ * @param {number} volume - Volume level integer [0-16]
897
+ * @returns {Promise<void>}
898
+ */
899
+ async setVolume (volume) {
900
+ const normalizedVolume = Math.min(16, Math.max(0, Math.round(volume)))
901
+ const volumePercentage = Math.round((normalizedVolume / 16) * 100)
902
+ return await this.#mqttClient?.setVolume(volumePercentage)
903
+ }
904
+
905
+ /**
906
+ * Set ambient light color over MQTT
907
+ * @param {number} r - Red intensity [0-255]
908
+ * @param {number} g - Green intensity [0-255]
909
+ * @param {number} b - Blue intensity [0-255]
910
+ * @returns {Promise<void>}
911
+ */
912
+ async setAmbient (r, g, b) {
913
+ return await this.#mqttClient?.setAmbient(r, g, b)
914
+ }
915
+
916
+ /**
917
+ * Set ambient light color from hex over MQTT
918
+ * @param {string} hexColor - Hex color string (e.g., "#FF0000")
919
+ * @returns {Promise<void>}
920
+ */
921
+ async setAmbientHex (hexColor) {
922
+ return await this.#mqttClient?.setAmbientHex(hexColor)
923
+ }
924
+
925
+ /**
926
+ * Set sleep timer over MQTT
927
+ * @param {number} seconds - Timer duration in seconds (0 to disable)
928
+ * @returns {Promise<void>}
929
+ */
930
+ async setSleepTimer (seconds) {
931
+ return await this.#mqttClient?.setSleepTimer(seconds)
932
+ }
933
+
934
+ /**
935
+ * Reboot device over MQTT
936
+ * @returns {Promise<void>}
937
+ */
938
+ async reboot () {
939
+ return await this.#mqttClient?.reboot()
940
+ }
941
+
942
+ /**
943
+ * Start card playback over MQTT
944
+ * @param {Object} options - Card start options
945
+ * @param {string} options.uri - Card URI (e.g., "https://yoto.io/<cardID>")
946
+ * @param {string} [options.chapterKey] - Chapter to start from
947
+ * @param {string} [options.trackKey] - Track to start from
948
+ * @param {number} [options.secondsIn] - Playback start offset in seconds
949
+ * @param {number} [options.cutOff] - Playback stop offset in seconds
950
+ * @param {boolean} [options.anyButtonStop] - Whether button press stops playback
951
+ * @returns {Promise<void>}
952
+ */
953
+ async startCard (options) {
954
+ return await this.#mqttClient?.startCard(options)
955
+ }
956
+
957
+ /**
958
+ * Stop card playback over MQTT
959
+ * @returns {Promise<void>}
960
+ */
961
+ async stopCard () {
962
+ return await this.#mqttClient?.stopCard()
963
+ }
964
+
965
+ /**
966
+ * Pause card playback over MQTT
967
+ * @returns {Promise<void>}
968
+ */
969
+ async pauseCard () {
970
+ return await this.#mqttClient?.pauseCard()
971
+ }
972
+
973
+ /**
974
+ * Resume card playback over MQTT
975
+ * @returns {Promise<void>}
976
+ */
977
+ async resumeCard () {
978
+ return await this.#mqttClient?.resumeCard()
979
+ }
980
+
981
+ /**
982
+ * Turn Bluetooth on over MQTT
983
+ * @param {Object} [options] - Bluetooth options
984
+ * @param {string} [options.action] - Bluetooth action
985
+ * @param {boolean | string} [options.mode] - Bluetooth mode
986
+ * @param {number} [options.rssi] - RSSI threshold
987
+ * @param {string} [options.name] - Target device name
988
+ * @param {string} [options.mac] - Target device MAC
989
+ * @returns {Promise<void>}
990
+ */
991
+ async bluetoothOn (options) {
992
+ return await this.#mqttClient?.bluetoothOn(options)
993
+ }
994
+
995
+ /**
996
+ * Turn Bluetooth off over MQTT
997
+ * @returns {Promise<void>}
998
+ */
999
+ async bluetoothOff () {
1000
+ return await this.#mqttClient?.bluetoothOff()
1001
+ }
1002
+
1003
+ /**
1004
+ * Enable Bluetooth speaker mode over MQTT
1005
+ * @returns {Promise<void>}
1006
+ */
1007
+ async bluetoothSpeakerMode () {
1008
+ return await this.#mqttClient?.bluetoothSpeakerMode()
1009
+ }
1010
+
1011
+ /**
1012
+ * Enable Bluetooth audio source mode over MQTT
1013
+ * @returns {Promise<void>}
1014
+ */
1015
+ async bluetoothAudioSourceMode () {
1016
+ return await this.#mqttClient?.bluetoothAudioSourceMode()
1017
+ }
1018
+
1019
+ /**
1020
+ * Delete all Bluetooth bonds over MQTT
1021
+ * @returns {Promise<void>}
1022
+ */
1023
+ async bluetoothDeleteBonds () {
1024
+ return await this.#mqttClient?.bluetoothDeleteBonds()
1025
+ }
1026
+
1027
+ /**
1028
+ * Connect to Bluetooth device over MQTT
1029
+ * @returns {Promise<void>}
1030
+ */
1031
+ async bluetoothConnect () {
1032
+ return await this.#mqttClient?.bluetoothConnect()
1033
+ }
1034
+
1035
+ /**
1036
+ * Disconnect Bluetooth device over MQTT
1037
+ * @returns {Promise<void>}
1038
+ */
1039
+ async bluetoothDisconnect () {
1040
+ return await this.#mqttClient?.bluetoothDisconnect()
1041
+ }
1042
+
1043
+ /**
1044
+ * Get Bluetooth state over MQTT
1045
+ * @returns {Promise<void>}
1046
+ */
1047
+ async bluetoothGetState () {
1048
+ return await this.#mqttClient?.bluetoothGetState()
1049
+ }
1050
+
1051
+ /**
1052
+ * Preview display icon over MQTT
1053
+ * @param {Object} options - Display preview options
1054
+ * @param {string} options.uri - Icon URI
1055
+ * @param {number} options.timeout - Display duration in seconds
1056
+ * @param {boolean} options.animated - Whether icon is animated
1057
+ * @returns {Promise<void>}
1058
+ */
1059
+ async displayPreview (options) {
1060
+ return await this.#mqttClient?.displayPreview(options)
1061
+ }
1062
+
603
1063
  // ==========================================================================
604
1064
  // Public API - Device Control
605
1065
  // ==========================================================================
@@ -611,7 +1071,7 @@ export class YotoDeviceModel extends EventEmitter {
611
1071
  */
612
1072
  /**
613
1073
  * Refresh device config from HTTP API
614
- * @returns {Promise<YotoDeviceConfig>}
1074
+ * @returns {Promise<YotoDeviceModelConfig>}
615
1075
  */
616
1076
  async refreshConfig () {
617
1077
  const configResponse = await this.#client.getDeviceConfig({
@@ -636,14 +1096,17 @@ export class YotoDeviceModel extends EventEmitter {
636
1096
 
637
1097
  /**
638
1098
  * Update device configuration
639
- * @param {Partial<YotoDeviceConfig>} configUpdate - Configuration changes
1099
+ * @param {Partial<YotoDeviceModelConfig>} configUpdate - Configuration changes
640
1100
  * @returns {Promise<void>}
641
1101
  */
642
1102
  async updateConfig (configUpdate) {
1103
+ const payload = buildConfigUpdatePayload(configUpdate, this.#state.config)
1104
+ if (Object.keys(payload).length === 0) return
1105
+
643
1106
  await this.#client.updateDeviceConfig({
644
1107
  deviceId: this.#state.device.deviceId,
645
1108
  configUpdate: {
646
- config: configUpdate
1109
+ config: payload
647
1110
  }
648
1111
  })
649
1112
 
@@ -675,7 +1138,7 @@ export class YotoDeviceModel extends EventEmitter {
675
1138
  // Create MQTT client
676
1139
  this.#mqttClient = await this.#client.createMqttClient({
677
1140
  deviceId: this.#state.device.deviceId,
678
- mqttOptions: this.#options.mqttOptions
1141
+ ...this.#options.yotoDeviceMqttOptions
679
1142
  })
680
1143
 
681
1144
  // Set up MQTT event handlers
@@ -697,23 +1160,41 @@ export class YotoDeviceModel extends EventEmitter {
697
1160
  if (!this.#mqttClient) return
698
1161
 
699
1162
  // Connection events
700
- this.#mqttClient.on('connected', () => {
701
- this.emit('mqttConnected')
1163
+ this.#mqttClient.on('connect', (metadata) => {
1164
+ this.emit('mqttConnect', metadata)
702
1165
 
703
1166
  // Request status and events after settling period
704
1167
  this.#scheduleMqttRequests()
705
1168
  })
706
1169
 
707
- this.#mqttClient.on('disconnected', () => {
708
- this.#clearAllTimers()
709
- this.emit('mqttDisconnected')
1170
+ this.#mqttClient.on('disconnect', (metadata) => {
1171
+ this.#clearMqttRequestTimers()
1172
+ this.emit('mqttDisconnect', metadata)
710
1173
 
711
1174
  // Don't immediately mark as offline - MQTT may reconnect
712
1175
  // Offline detection is based on lack of status updates, not connection state
713
1176
  })
714
1177
 
715
- this.#mqttClient.on('reconnecting', () => {
716
- // MQTT client is attempting to reconnect
1178
+ this.#mqttClient.on('close', (metadata) => {
1179
+ this.#clearMqttRequestTimers()
1180
+ this.emit('mqttClose', metadata)
1181
+
1182
+ // Don't immediately mark as offline - MQTT may reconnect
1183
+ // Offline detection is based on lack of status updates, not connection state
1184
+ })
1185
+
1186
+ this.#mqttClient.on('reconnect', () => {
1187
+ this.emit('mqttReconnect')
1188
+ })
1189
+
1190
+ this.#mqttClient.on('offline', () => {
1191
+ this.#clearMqttRequestTimers()
1192
+ this.emit('mqttOffline')
1193
+ })
1194
+
1195
+ this.#mqttClient.on('end', () => {
1196
+ this.#clearMqttRequestTimers()
1197
+ this.emit('mqttEnd')
717
1198
  })
718
1199
 
719
1200
  this.#mqttClient.on('error', (error) => {
@@ -721,8 +1202,9 @@ export class YotoDeviceModel extends EventEmitter {
721
1202
  })
722
1203
 
723
1204
  // Status updates - PRIMARY source for status after initialization
724
- this.#mqttClient.on('status', (_topic, message) => {
1205
+ this.#mqttClient.on('status', (topic, message) => {
725
1206
  this.#recordDeviceActivity()
1207
+ this.emit('mqttStatus', topic, message)
726
1208
  this.#updateStatusFromDocumentedMqtt(message.status)
727
1209
  })
728
1210
 
@@ -730,20 +1212,25 @@ export class YotoDeviceModel extends EventEmitter {
730
1212
  // This does NOT respond to requestStatus() - only emits on real-time events or 5-minute periodic updates
731
1213
  // NOTE: Don't call #recordDeviceActivity() here - #handleLegacyStatus handles online/offline transitions
732
1214
  // based on actual power state (shutdown/startup) which is more reliable than just "activity"
733
- this.#mqttClient.on('status-legacy', (_topic, message) => {
1215
+ this.#mqttClient.on('status-legacy', (topic, message) => {
1216
+ this.emit('mqttStatusLegacy', topic, message)
734
1217
  this.#handleLegacyStatus(message.status)
735
1218
  })
736
1219
 
737
1220
  // Events updates (playback, volume, etc.)
738
- this.#mqttClient.on('events', (_topic, message) => {
1221
+ this.#mqttClient.on('events', (topic, message) => {
739
1222
  this.#recordDeviceActivity()
1223
+ this.emit('mqttEvents', topic, message)
740
1224
  this.#handleEventMessage(message)
741
1225
  })
742
1226
 
743
1227
  // 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
1228
+ this.#mqttClient.on('response', (topic, message) => {
1229
+ this.emit('mqttResponse', topic, message)
1230
+ })
1231
+
1232
+ this.#mqttClient.on('unknown', (topic, message) => {
1233
+ this.emit('mqttUnknown', topic, message)
747
1234
  })
748
1235
  }
749
1236
 
@@ -752,7 +1239,7 @@ export class YotoDeviceModel extends EventEmitter {
752
1239
  */
753
1240
  #scheduleMqttRequests () {
754
1241
  // Clear any existing timers
755
- this.#clearAllTimers()
1242
+ this.#clearMqttRequestTimers()
756
1243
 
757
1244
  // Request status after settling delay
758
1245
  this.#statusRequestTimer = setTimeout(async () => {
@@ -780,9 +1267,9 @@ export class YotoDeviceModel extends EventEmitter {
780
1267
  }
781
1268
 
782
1269
  /**
783
- * Clear all timers
1270
+ * Clear MQTT request timers (status/events) after connect
784
1271
  */
785
- #clearAllTimers () {
1272
+ #clearMqttRequestTimers () {
786
1273
  if (this.#statusRequestTimer) {
787
1274
  clearTimeout(this.#statusRequestTimer)
788
1275
  this.#statusRequestTimer = null
@@ -792,13 +1279,26 @@ export class YotoDeviceModel extends EventEmitter {
792
1279
  clearTimeout(this.#eventsRequestTimer)
793
1280
  this.#eventsRequestTimer = null
794
1281
  }
1282
+ }
795
1283
 
1284
+ /**
1285
+ * Stop background HTTP polling
1286
+ */
1287
+ #stopBackgroundPolling () {
796
1288
  if (this.#backgroundPollTimer) {
797
1289
  clearInterval(this.#backgroundPollTimer)
798
1290
  this.#backgroundPollTimer = null
799
1291
  }
800
1292
  }
801
1293
 
1294
+ /**
1295
+ * Clear all timers
1296
+ */
1297
+ #clearAllTimers () {
1298
+ this.#clearMqttRequestTimers()
1299
+ this.#stopBackgroundPolling()
1300
+ }
1301
+
802
1302
  // ==========================================================================
803
1303
  // Private - Online/Offline Tracking
804
1304
  // ==========================================================================
@@ -896,7 +1396,7 @@ export class YotoDeviceModel extends EventEmitter {
896
1396
  const { status, config } = this.#state
897
1397
  /** @type {Set<keyof YotoDeviceStatus>} */
898
1398
  const changedFields = new Set()
899
- /** @type {Set<keyof YotoDeviceConfig>} */
1399
+ /** @type {Set<keyof YotoDeviceModelConfig>} */
900
1400
  const configChangedFields = new Set()
901
1401
 
902
1402
  /**
@@ -1001,9 +1501,15 @@ export class YotoDeviceModel extends EventEmitter {
1001
1501
  }
1002
1502
  case 'dayBright': {
1003
1503
  if (fullStatus.dayBright !== null) {
1004
- const brightnessValue = String(fullStatus.dayBright)
1005
- if (config.dayDisplayBrightness !== brightnessValue) {
1006
- config.dayDisplayBrightness = brightnessValue
1504
+ // HTTP fullStatus represents 'auto' brightness as 255
1505
+ const brightness = parseStatusBrightness(fullStatus.dayBright)
1506
+ if (config.dayDisplayBrightnessAuto !== brightness.auto) {
1507
+ config.dayDisplayBrightnessAuto = brightness.auto
1508
+ configChangedFields.add('dayDisplayBrightnessAuto')
1509
+ configChanged = true
1510
+ }
1511
+ if (config.dayDisplayBrightness !== brightness.value) {
1512
+ config.dayDisplayBrightness = brightness.value
1007
1513
  configChangedFields.add('dayDisplayBrightness')
1008
1514
  configChanged = true
1009
1515
  }
@@ -1093,9 +1599,15 @@ export class YotoDeviceModel extends EventEmitter {
1093
1599
  }
1094
1600
  case 'nightBright': {
1095
1601
  if (fullStatus.nightBright !== null) {
1096
- const brightnessValue = String(fullStatus.nightBright)
1097
- if (config.nightDisplayBrightness !== brightnessValue) {
1098
- config.nightDisplayBrightness = brightnessValue
1602
+ // HTTP fullStatus represents 'auto' brightness as 255
1603
+ const brightness = parseStatusBrightness(fullStatus.nightBright)
1604
+ if (config.nightDisplayBrightnessAuto !== brightness.auto) {
1605
+ config.nightDisplayBrightnessAuto = brightness.auto
1606
+ configChangedFields.add('nightDisplayBrightnessAuto')
1607
+ configChanged = true
1608
+ }
1609
+ if (config.nightDisplayBrightness !== brightness.value) {
1610
+ config.nightDisplayBrightness = brightness.value
1099
1611
  configChangedFields.add('nightDisplayBrightness')
1100
1612
  configChanged = true
1101
1613
  }
@@ -1555,7 +2067,7 @@ export class YotoDeviceModel extends EventEmitter {
1555
2067
  let configChanged = false
1556
2068
 
1557
2069
  const { config } = this.#state
1558
- /** @type {Set<keyof YotoDeviceConfig>} */
2070
+ /** @type {Set<keyof YotoDeviceModelConfig>} */
1559
2071
  const changedFields = new Set()
1560
2072
 
1561
2073
  /**
@@ -1582,8 +2094,9 @@ export class YotoDeviceModel extends EventEmitter {
1582
2094
  break
1583
2095
  }
1584
2096
  case 'bluetoothEnabled': {
1585
- if (config.bluetoothEnabled !== configData.bluetoothEnabled) {
1586
- config.bluetoothEnabled = configData.bluetoothEnabled
2097
+ const bluetoothEnabled = parseConfigBoolean(configData.bluetoothEnabled)
2098
+ if (config.bluetoothEnabled !== bluetoothEnabled) {
2099
+ config.bluetoothEnabled = bluetoothEnabled
1587
2100
  changedFields.add('bluetoothEnabled')
1588
2101
  configChanged = true
1589
2102
  }
@@ -1606,8 +2119,14 @@ export class YotoDeviceModel extends EventEmitter {
1606
2119
  break
1607
2120
  }
1608
2121
  case 'dayDisplayBrightness': {
1609
- if (config.dayDisplayBrightness !== configData.dayDisplayBrightness) {
1610
- config.dayDisplayBrightness = configData.dayDisplayBrightness
2122
+ const brightness = parseConfigBrightness(configData.dayDisplayBrightness)
2123
+ if (config.dayDisplayBrightnessAuto !== brightness.auto) {
2124
+ config.dayDisplayBrightnessAuto = brightness.auto
2125
+ changedFields.add('dayDisplayBrightnessAuto')
2126
+ configChanged = true
2127
+ }
2128
+ if (config.dayDisplayBrightness !== brightness.value) {
2129
+ config.dayDisplayBrightness = brightness.value
1611
2130
  changedFields.add('dayDisplayBrightness')
1612
2131
  configChanged = true
1613
2132
  }
@@ -1638,24 +2157,27 @@ export class YotoDeviceModel extends EventEmitter {
1638
2157
  break
1639
2158
  }
1640
2159
  case 'daySoundsOff': {
1641
- if (config.daySoundsOff !== configData.daySoundsOff) {
1642
- config.daySoundsOff = configData.daySoundsOff
2160
+ const daySoundsOff = parseConfigBoolean(configData.daySoundsOff)
2161
+ if (config.daySoundsOff !== daySoundsOff) {
2162
+ config.daySoundsOff = daySoundsOff
1643
2163
  changedFields.add('daySoundsOff')
1644
2164
  configChanged = true
1645
2165
  }
1646
2166
  break
1647
2167
  }
1648
2168
  case 'displayDimBrightness': {
1649
- if (config.displayDimBrightness !== configData.displayDimBrightness) {
1650
- config.displayDimBrightness = configData.displayDimBrightness
2169
+ const displayDimBrightness = parseConfigNumber(configData.displayDimBrightness, config.displayDimBrightness)
2170
+ if (config.displayDimBrightness !== displayDimBrightness) {
2171
+ config.displayDimBrightness = displayDimBrightness
1651
2172
  changedFields.add('displayDimBrightness')
1652
2173
  configChanged = true
1653
2174
  }
1654
2175
  break
1655
2176
  }
1656
2177
  case 'displayDimTimeout': {
1657
- if (config.displayDimTimeout !== configData.displayDimTimeout) {
1658
- config.displayDimTimeout = configData.displayDimTimeout
2178
+ const displayDimTimeout = parseConfigNumber(configData.displayDimTimeout, config.displayDimTimeout)
2179
+ if (config.displayDimTimeout !== displayDimTimeout) {
2180
+ config.displayDimTimeout = displayDimTimeout
1659
2181
  changedFields.add('displayDimTimeout')
1660
2182
  configChanged = true
1661
2183
  }
@@ -1670,8 +2192,9 @@ export class YotoDeviceModel extends EventEmitter {
1670
2192
  break
1671
2193
  }
1672
2194
  case 'hourFormat': {
1673
- if (config.hourFormat !== configData.hourFormat) {
1674
- config.hourFormat = configData.hourFormat
2195
+ const hourFormat = parseConfigNumber(configData.hourFormat, config.hourFormat)
2196
+ if (config.hourFormat !== hourFormat) {
2197
+ config.hourFormat = /** @type {12 | 24} */ (hourFormat)
1675
2198
  changedFields.add('hourFormat')
1676
2199
  configChanged = true
1677
2200
  }
@@ -1694,8 +2217,9 @@ export class YotoDeviceModel extends EventEmitter {
1694
2217
  break
1695
2218
  }
1696
2219
  case 'maxVolumeLimit': {
1697
- if (config.maxVolumeLimit !== configData.maxVolumeLimit) {
1698
- config.maxVolumeLimit = configData.maxVolumeLimit
2220
+ const maxVolumeLimit = parseConfigNumber(configData.maxVolumeLimit, config.maxVolumeLimit)
2221
+ if (config.maxVolumeLimit !== maxVolumeLimit) {
2222
+ config.maxVolumeLimit = maxVolumeLimit
1699
2223
  changedFields.add('maxVolumeLimit')
1700
2224
  configChanged = true
1701
2225
  }
@@ -1710,16 +2234,23 @@ export class YotoDeviceModel extends EventEmitter {
1710
2234
  break
1711
2235
  }
1712
2236
  case 'nightDisplayBrightness': {
1713
- if (config.nightDisplayBrightness !== configData.nightDisplayBrightness) {
1714
- config.nightDisplayBrightness = configData.nightDisplayBrightness
2237
+ const brightness = parseConfigBrightness(configData.nightDisplayBrightness)
2238
+ if (config.nightDisplayBrightnessAuto !== brightness.auto) {
2239
+ config.nightDisplayBrightnessAuto = brightness.auto
2240
+ changedFields.add('nightDisplayBrightnessAuto')
2241
+ configChanged = true
2242
+ }
2243
+ if (config.nightDisplayBrightness !== brightness.value) {
2244
+ config.nightDisplayBrightness = brightness.value
1715
2245
  changedFields.add('nightDisplayBrightness')
1716
2246
  configChanged = true
1717
2247
  }
1718
2248
  break
1719
2249
  }
1720
2250
  case 'nightMaxVolumeLimit': {
1721
- if (config.nightMaxVolumeLimit !== configData.nightMaxVolumeLimit) {
1722
- config.nightMaxVolumeLimit = configData.nightMaxVolumeLimit
2251
+ const nightMaxVolumeLimit = parseConfigNumber(configData.nightMaxVolumeLimit, config.nightMaxVolumeLimit)
2252
+ if (config.nightMaxVolumeLimit !== nightMaxVolumeLimit) {
2253
+ config.nightMaxVolumeLimit = nightMaxVolumeLimit
1723
2254
  changedFields.add('nightMaxVolumeLimit')
1724
2255
  configChanged = true
1725
2256
  }
@@ -1742,16 +2273,23 @@ export class YotoDeviceModel extends EventEmitter {
1742
2273
  break
1743
2274
  }
1744
2275
  case 'nightYotoRadio': {
1745
- if (config.nightYotoRadio !== configData.nightYotoRadio) {
1746
- config.nightYotoRadio = configData.nightYotoRadio
2276
+ const radio = parseRadioSetting(configData.nightYotoRadio)
2277
+ if (config.nightYotoRadioEnabled !== radio.enabled) {
2278
+ config.nightYotoRadioEnabled = radio.enabled
2279
+ changedFields.add('nightYotoRadioEnabled')
2280
+ configChanged = true
2281
+ }
2282
+ if (config.nightYotoRadio !== radio.value) {
2283
+ config.nightYotoRadio = radio.value
1747
2284
  changedFields.add('nightYotoRadio')
1748
2285
  configChanged = true
1749
2286
  }
1750
2287
  break
1751
2288
  }
1752
2289
  case 'nightSoundsOff': {
1753
- if (config.nightSoundsOff !== configData.nightSoundsOff) {
1754
- config.nightSoundsOff = configData.nightSoundsOff
2290
+ const nightSoundsOff = parseConfigBoolean(configData.nightSoundsOff)
2291
+ if (config.nightSoundsOff !== nightSoundsOff) {
2292
+ config.nightSoundsOff = nightSoundsOff
1755
2293
  changedFields.add('nightSoundsOff')
1756
2294
  configChanged = true
1757
2295
  }
@@ -1790,16 +2328,18 @@ export class YotoDeviceModel extends EventEmitter {
1790
2328
  break
1791
2329
  }
1792
2330
  case 'shutdownTimeout': {
1793
- if (config.shutdownTimeout !== configData.shutdownTimeout) {
1794
- config.shutdownTimeout = configData.shutdownTimeout
2331
+ const shutdownTimeout = parseConfigNumber(configData.shutdownTimeout, config.shutdownTimeout)
2332
+ if (config.shutdownTimeout !== shutdownTimeout) {
2333
+ config.shutdownTimeout = shutdownTimeout
1795
2334
  changedFields.add('shutdownTimeout')
1796
2335
  configChanged = true
1797
2336
  }
1798
2337
  break
1799
2338
  }
1800
2339
  case 'systemVolume': {
1801
- if (config.systemVolume !== configData.systemVolume) {
1802
- config.systemVolume = configData.systemVolume
2340
+ const systemVolume = parseConfigNumber(configData.systemVolume, config.systemVolume)
2341
+ if (config.systemVolume !== systemVolume) {
2342
+ config.systemVolume = systemVolume
1803
2343
  changedFields.add('systemVolume')
1804
2344
  configChanged = true
1805
2345
  }
@@ -1889,7 +2429,7 @@ export class YotoDeviceModel extends EventEmitter {
1889
2429
  const { status, config } = this.#state
1890
2430
  /** @type {Set<keyof YotoDeviceStatus>} */
1891
2431
  const changedFields = new Set()
1892
- /** @type {Set<keyof YotoDeviceConfig>} */
2432
+ /** @type {Set<keyof YotoDeviceModelConfig>} */
1893
2433
  const configChangedFields = new Set()
1894
2434
 
1895
2435
  /**
@@ -1996,18 +2536,30 @@ export class YotoDeviceModel extends EventEmitter {
1996
2536
  break
1997
2537
  }
1998
2538
  case 'dayBright': {
1999
- const brightnessValue = String(mqttStatus.dayBright)
2000
- if (config.dayDisplayBrightness !== brightnessValue) {
2001
- config.dayDisplayBrightness = brightnessValue
2539
+ // MQTT represents 'auto' brightness as 255
2540
+ const brightness = parseStatusBrightness(mqttStatus.dayBright)
2541
+ if (config.dayDisplayBrightnessAuto !== brightness.auto) {
2542
+ config.dayDisplayBrightnessAuto = brightness.auto
2543
+ configChangedFields.add('dayDisplayBrightnessAuto')
2544
+ configChanged = true
2545
+ }
2546
+ if (config.dayDisplayBrightness !== brightness.value) {
2547
+ config.dayDisplayBrightness = brightness.value
2002
2548
  configChangedFields.add('dayDisplayBrightness')
2003
2549
  configChanged = true
2004
2550
  }
2005
2551
  break
2006
2552
  }
2007
2553
  case 'nightBright': {
2008
- const brightnessValue = String(mqttStatus.nightBright)
2009
- if (config.nightDisplayBrightness !== brightnessValue) {
2010
- config.nightDisplayBrightness = brightnessValue
2554
+ // MQTT represents 'auto' brightness as 255
2555
+ const brightness = parseStatusBrightness(mqttStatus.nightBright)
2556
+ if (config.nightDisplayBrightnessAuto !== brightness.auto) {
2557
+ config.nightDisplayBrightnessAuto = brightness.auto
2558
+ configChangedFields.add('nightDisplayBrightnessAuto')
2559
+ configChanged = true
2560
+ }
2561
+ if (config.nightDisplayBrightness !== brightness.value) {
2562
+ config.nightDisplayBrightness = brightness.value
2011
2563
  configChangedFields.add('nightDisplayBrightness')
2012
2564
  configChanged = true
2013
2565
  }
@@ -2125,7 +2677,7 @@ export class YotoDeviceModel extends EventEmitter {
2125
2677
  const { status, config } = this.#state
2126
2678
  /** @type {Set<keyof YotoDeviceStatus>} */
2127
2679
  const changedFields = new Set()
2128
- /** @type {Set<keyof YotoDeviceConfig>} */
2680
+ /** @type {Set<keyof YotoDeviceModelConfig>} */
2129
2681
  const configChangedFields = new Set()
2130
2682
 
2131
2683
  /**
@@ -2361,18 +2913,30 @@ export class YotoDeviceModel extends EventEmitter {
2361
2913
  break
2362
2914
  }
2363
2915
  case 'dayBright': {
2364
- const brightnessValue = String(legacyStatus.dayBright)
2365
- if (config.dayDisplayBrightness !== brightnessValue) {
2366
- config.dayDisplayBrightness = brightnessValue
2916
+ // MQTT represents 'auto' brightness as 255
2917
+ const brightness = parseStatusBrightness(legacyStatus.dayBright)
2918
+ if (config.dayDisplayBrightnessAuto !== brightness.auto) {
2919
+ config.dayDisplayBrightnessAuto = brightness.auto
2920
+ configChangedFields.add('dayDisplayBrightnessAuto')
2921
+ configChanged = true
2922
+ }
2923
+ if (config.dayDisplayBrightness !== brightness.value) {
2924
+ config.dayDisplayBrightness = brightness.value
2367
2925
  configChangedFields.add('dayDisplayBrightness')
2368
2926
  configChanged = true
2369
2927
  }
2370
2928
  break
2371
2929
  }
2372
2930
  case 'nightBright': {
2373
- const brightnessValue = String(legacyStatus.nightBright)
2374
- if (config.nightDisplayBrightness !== brightnessValue) {
2375
- config.nightDisplayBrightness = brightnessValue
2931
+ // MQTT represents 'auto' brightness as 255
2932
+ const brightness = parseStatusBrightness(legacyStatus.nightBright)
2933
+ if (config.nightDisplayBrightnessAuto !== brightness.auto) {
2934
+ config.nightDisplayBrightnessAuto = brightness.auto
2935
+ configChangedFields.add('nightDisplayBrightnessAuto')
2936
+ configChanged = true
2937
+ }
2938
+ if (config.nightDisplayBrightness !== brightness.value) {
2939
+ config.nightDisplayBrightness = brightness.value
2376
2940
  configChangedFields.add('nightDisplayBrightness')
2377
2941
  configChanged = true
2378
2942
  }
@@ -2523,8 +3087,8 @@ export class YotoDeviceModel extends EventEmitter {
2523
3087
  * Uses exhaustive switch statement pattern to ensure all YotoEventsMessage fields are handled.
2524
3088
  *
2525
3089
  * Event fields are categorized as:
2526
- * - STATUS: volume
2527
- * - CONFIG: repeatAll, volumeMax
3090
+ * - STATUS: volume, volumeMax
3091
+ * - CONFIG: repeatAll
2528
3092
  * - PLAYBACK: streaming, sleepTimerActive, sleepTimerSeconds, trackLength,
2529
3093
  * position, cardId, source, playbackStatus, chapterTitle, chapterKey,
2530
3094
  * trackTitle, trackKey
@@ -2540,7 +3104,7 @@ export class YotoDeviceModel extends EventEmitter {
2540
3104
  const { status, config, playback } = this.#state
2541
3105
  /** @type {Set<keyof YotoDeviceStatus>} */
2542
3106
  const statusChangedFields = new Set()
2543
- /** @type {Set<keyof YotoDeviceConfig>} */
3107
+ /** @type {Set<keyof YotoDeviceModelConfig>} */
2544
3108
  const configChangedFields = new Set()
2545
3109
  /** @type {Set<keyof YotoPlaybackState>} */
2546
3110
  const playbackChangedFields = new Set()
@@ -2571,7 +3135,6 @@ export class YotoDeviceModel extends EventEmitter {
2571
3135
  }
2572
3136
  case 'volumeMax': {
2573
3137
  // volumeMax is the current effective max volume limit (config-derived)
2574
- // Store as string to match config type (maxVolumeLimit is string)
2575
3138
  if (eventsMessage.volumeMax !== undefined && status.maxVolume !== eventsMessage.volumeMax) {
2576
3139
  status.maxVolume = eventsMessage.volumeMax
2577
3140
  statusChangedFields.add('maxVolume')
@@ -2750,40 +3313,43 @@ function createEmptyPlaybackState () {
2750
3313
 
2751
3314
  /**
2752
3315
  * Create an empty device config object
2753
- * @returns {YotoDeviceConfig}
3316
+ * @returns {YotoDeviceModelConfig}
2754
3317
  */
2755
3318
  function createEmptyDeviceConfig () {
2756
3319
  return {
2757
3320
  alarms: [],
2758
3321
  ambientColour: '#000000',
2759
- bluetoothEnabled: '0',
3322
+ bluetoothEnabled: false,
2760
3323
  btHeadphonesEnabled: false,
2761
3324
  clockFace: 'digital-sun',
2762
- dayDisplayBrightness: 'auto',
3325
+ dayDisplayBrightness: null,
3326
+ dayDisplayBrightnessAuto: true,
2763
3327
  dayTime: '07:00',
2764
3328
  dayYotoDaily: '',
2765
3329
  dayYotoRadio: '',
2766
- daySoundsOff: '0',
2767
- displayDimBrightness: '0',
2768
- displayDimTimeout: '30',
3330
+ daySoundsOff: false,
3331
+ displayDimBrightness: 0,
3332
+ displayDimTimeout: 30,
2769
3333
  headphonesVolumeLimited: false,
2770
- hourFormat: '12',
3334
+ hourFormat: 12,
2771
3335
  locale: 'en',
2772
3336
  logLevel: 'none',
2773
- maxVolumeLimit: '16',
3337
+ maxVolumeLimit: 16,
2774
3338
  nightAmbientColour: '#000000',
2775
- nightDisplayBrightness: 'auto',
2776
- nightMaxVolumeLimit: '10',
3339
+ nightDisplayBrightness: null,
3340
+ nightDisplayBrightnessAuto: true,
3341
+ nightMaxVolumeLimit: 10,
2777
3342
  nightTime: '19:00',
2778
3343
  nightYotoDaily: '',
2779
- nightYotoRadio: '',
2780
- nightSoundsOff: '0',
3344
+ nightYotoRadio: null,
3345
+ nightYotoRadioEnabled: false,
3346
+ nightSoundsOff: false,
2781
3347
  pausePowerButton: false,
2782
3348
  pauseVolumeDown: false,
2783
3349
  repeatAll: false,
2784
3350
  showDiagnostics: false,
2785
- shutdownTimeout: '900',
2786
- systemVolume: '100',
3351
+ shutdownTimeout: 900,
3352
+ systemVolume: 100,
2787
3353
  timezone: '',
2788
3354
  volumeLevel: 'safe'
2789
3355
  }