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.
- package/README.md +523 -30
- package/bin/auth.js +36 -46
- package/bin/content.js +0 -0
- package/bin/device-model.d.ts +3 -0
- package/bin/device-model.d.ts.map +1 -0
- package/bin/device-model.js +360 -0
- package/bin/device-tui.TODO.md +125 -0
- package/bin/device-tui.d.ts +31 -0
- package/bin/device-tui.d.ts.map +1 -0
- package/bin/device-tui.js +1123 -0
- package/bin/devices.js +166 -28
- package/bin/groups.js +0 -0
- package/bin/icons.js +0 -0
- package/bin/lib/cli-helpers.d.ts +33 -1
- package/bin/lib/cli-helpers.d.ts.map +1 -1
- package/bin/lib/cli-helpers.js +5 -5
- package/bin/lib/token-helpers.d.ts +32 -0
- package/bin/lib/token-helpers.d.ts.map +1 -1
- package/bin/refresh-token.js +6 -6
- package/bin/token-info.js +3 -3
- package/index.d.ts +4 -217
- package/index.d.ts.map +1 -1
- package/index.js +11 -689
- package/lib/api-client.d.ts +576 -0
- package/lib/api-client.d.ts.map +1 -0
- package/lib/api-client.js +681 -0
- package/lib/api-endpoints/auth.d.ts +280 -4
- package/lib/api-endpoints/auth.d.ts.map +1 -1
- package/lib/api-endpoints/auth.js +224 -7
- package/lib/api-endpoints/auth.test.js +54 -2
- package/lib/api-endpoints/constants.d.ts +30 -2
- package/lib/api-endpoints/constants.d.ts.map +1 -1
- package/lib/api-endpoints/constants.js +17 -10
- package/lib/api-endpoints/content.d.ts +760 -0
- package/lib/api-endpoints/content.d.ts.map +1 -1
- package/lib/api-endpoints/content.test.js +1 -1
- package/lib/api-endpoints/devices.d.ts +917 -48
- package/lib/api-endpoints/devices.d.ts.map +1 -1
- package/lib/api-endpoints/devices.js +114 -52
- package/lib/api-endpoints/devices.test.js +1 -1
- package/lib/api-endpoints/endpoint-test-helpers.d.ts +28 -0
- package/lib/api-endpoints/endpoint-test-helpers.d.ts.map +1 -0
- package/lib/api-endpoints/family-library-groups.d.ts +187 -0
- package/lib/api-endpoints/family-library-groups.d.ts.map +1 -1
- package/lib/api-endpoints/family-library-groups.test.js +1 -1
- package/lib/api-endpoints/family.d.ts +88 -0
- package/lib/api-endpoints/family.d.ts.map +1 -1
- package/lib/api-endpoints/family.test.js +1 -1
- package/lib/api-endpoints/helpers.d.ts +37 -3
- package/lib/api-endpoints/helpers.d.ts.map +1 -1
- package/lib/api-endpoints/icons.d.ts +196 -0
- package/lib/api-endpoints/icons.d.ts.map +1 -1
- package/lib/api-endpoints/icons.test.js +1 -1
- package/lib/api-endpoints/media.d.ts +83 -0
- package/lib/api-endpoints/media.d.ts.map +1 -1
- package/lib/helpers/power-state.d.ts +53 -0
- package/lib/helpers/power-state.d.ts.map +1 -0
- package/lib/helpers/power-state.js +73 -0
- package/lib/helpers/power-state.test.js +100 -0
- package/lib/helpers/temperature.d.ts +24 -0
- package/lib/helpers/temperature.d.ts.map +1 -0
- package/lib/helpers/temperature.js +61 -0
- package/lib/helpers/temperature.test.js +58 -0
- package/lib/helpers/typed-keys.d.ts +7 -0
- package/lib/helpers/typed-keys.d.ts.map +1 -0
- package/lib/helpers/typed-keys.js +8 -0
- package/lib/mqtt/client.d.ts +610 -7
- package/lib/mqtt/client.d.ts.map +1 -1
- package/lib/mqtt/client.js +213 -31
- package/lib/mqtt/commands.d.ts +195 -0
- package/lib/mqtt/commands.d.ts.map +1 -1
- package/lib/mqtt/factory.d.ts +62 -1
- package/lib/mqtt/factory.d.ts.map +1 -1
- package/lib/mqtt/factory.js +27 -5
- package/lib/mqtt/mqtt.test.js +85 -28
- package/lib/mqtt/topics.d.ts +186 -1
- package/lib/mqtt/topics.d.ts.map +1 -1
- package/lib/mqtt/topics.js +54 -20
- package/lib/pkg.d.cts +9 -0
- package/lib/token.d.ts +106 -3
- package/lib/token.d.ts.map +1 -1
- package/lib/token.js +30 -23
- package/lib/yoto-account.d.ts +163 -0
- package/lib/yoto-account.d.ts.map +1 -0
- package/lib/yoto-account.js +340 -0
- package/lib/yoto-device.d.ts +656 -0
- package/lib/yoto-device.d.ts.map +1 -0
- package/lib/yoto-device.js +2850 -0
- package/package.json +22 -15
- package/lib/api-endpoints/test-helpers.d.ts +0 -7
- package/lib/api-endpoints/test-helpers.d.ts.map +0 -1
- /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
|
+
}
|