@switchbot/homebridge-switchbot 5.0.0-beta.52 → 5.0.0-beta.54

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.
@@ -1,97 +1,167 @@
1
- /* global NodeJS */
1
+ //
2
2
 
3
3
  /**
4
- * Robotic Vacuum Cleaner Accessory Class
4
+ * Robotic Vacuum Cleaner Accessory (SwitchBot-aligned)
5
5
  *
6
- * This is a comprehensive example demonstrating all available features
7
- * of the RoboticVacuumCleaner device type including:
8
- * - Multiple run modes (Idle, Cleaning, Mapping)
9
- * - Multiple clean modes (Vacuum, Mop, Vacuum & Mop, Deep Clean)
10
- * - Operational state management with realistic transitions
11
- * - Service area (room) support
6
+ * This implementation is intentionally limited to SwitchBot OpenAPI-supported
7
+ * behaviors for Robot Vacuum families. We expose only commands and modes that
8
+ * exist in the public API docs:
9
+ * https://github.com/OpenWonderLabs/SwitchBotAPI
12
10
  *
13
- * IMPORTANT: Platform Matter accessories
14
- * =======================================
15
- * This vacuum is registered as a platform accessory using
16
- * api.matter.registerPlatformAccessories(), which works the same as HAP.
17
- * Platform accessories are registered synchronously and are immediately ready for use.
11
+ * Capability matrix (derived from OpenAPI v1.1):
12
+ * - S1 / S1 Plus / K10+ / K10+ Pro
13
+ * Commands: start, stop, dock, PowLevel {0-3}
14
+ * No pause/resume, no mop, no area selection
15
+ * - K10+ Pro Combo
16
+ * • Commands: startClean {action: sweep|mop, param: {fanLevel 1-4}}, pause, dock
17
+ * • changeParam {fanLevel}, setVolume
18
+ * - S10 / S20 / K11+ / K20+ Pro
19
+ * • Commands: startClean {action: sweep|sweep_mop|mop (model-dependent), param: {fanLevel 1-4, waterLevel 1-2?}},
20
+ * pause, dock, changeParam, setVolume, (S10/S20) selfClean/addWaterForHumi
21
+ * • Some models support waterLevel, some do not (e.g. K10+ Pro Combo)
18
22
  *
19
- * Demo behavior:
20
- * - Start/Resume: Sets run mode to "Cleaning", runs for 15/10 seconds, then returns to dock
21
- * - Return to dock: Immediately sets "Idle" mode, then Seeking (5s) → Charging (3s) → Docked
22
- * - Stop: Immediately stops and resets to "Idle" mode
23
- * - Pause: Cancels automatic completion timer, keeps "Cleaning" mode
23
+ * Matter clusters exposed:
24
+ * - rvcRunMode: Idle/Cleaning only (no Mapping mode via OpenAPI)
25
+ * - rvcCleanMode: Interpreted per model:
26
+ * Basic vac (S1/K10 family): suction levels Quiet/Standard/Strong/MAX
27
+ * Mop-capable (K10+ Pro Combo/K11+/K20+ Pro): action Vacuum or Mop
28
+ * • Mop+Vac-capable (S10/S20): action → Vacuum or Vacuum & Mop
29
+ * - rvcOperationalState: start/stop/pause/goHome handlers are attached only when supported
30
+ * - serviceArea: NOT exposed (area/room selection isn’t in public OpenAPI)
24
31
  */
25
32
 
26
33
  import type { API, Logger, MatterRequests } from 'homebridge'
27
34
 
28
35
  import { BaseMatterAccessory } from './BaseMatterAccessory.js'
29
36
 
30
- export class RoboticVacuumAccessory extends BaseMatterAccessory {
31
- private activeTimers: NodeJS.Timeout[] = []
37
+ type SwitchBotVacuumModel
38
+ = 'S1'
39
+ | 'S1 Plus'
40
+ | 'S1 Pro'
41
+ | 'S1 Mini'
42
+ | 'WoSweeper'
43
+ | 'WoSweeperMini'
44
+ | 'K10+'
45
+ | 'K10+ Pro'
46
+ | 'K10+ Pro Combo'
47
+ | 'S10'
48
+ | 'S20'
49
+ | 'K11+'
50
+ | 'K20+ Pro'
51
+
52
+ interface VacuumCapabilities {
53
+ // Whether pause/resume is available
54
+ pause: boolean
55
+ resume: boolean
56
+ // Clean action type exposed via rvcCleanMode
57
+ cleanAction: 'vacuum-only' | 'vacuum-or-mop' | 'vacuum-or-vacmop'
58
+ // Suction mapping: basic uses PowLevel 0-3; advanced uses fanLevel 1-4
59
+ suctionKind: 'powLevel-0-3' | 'fanLevel-1-4' | 'none'
60
+ // Whether waterLevel parameter is supported on start/changeParam
61
+ waterLevel: boolean
62
+ // Advanced commands available (S10/S20 only)
63
+ advancedCommands?: {
64
+ setVolume?: boolean
65
+ selfClean?: boolean
66
+ addWaterForHumi?: boolean
67
+ }
68
+ }
32
69
 
33
- constructor(api: API, log: Logger, opts?: Partial<import('./BaseMatterAccessory').BaseMatterAccessoryConfig & { deviceId?: string }>) {
34
- const serialNumber = opts?.serialNumber ?? 'VACUUM-001'
35
- const clusters = opts?.clusters ?? {
36
- rvcRunMode: {
37
- supportedModes: [
38
- { label: 'Idle', mode: 0, modeTags: [{ value: 16384 }] },
39
- { label: 'Cleaning', mode: 1, modeTags: [{ value: 16385 }] },
40
- { label: 'Mapping', mode: 2, modeTags: [{ value: 16386 }] },
41
- ],
42
- currentMode: 0,
43
- },
44
- rvcCleanMode: {
45
- supportedModes: [
46
- { label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] },
47
- { label: 'Mop', mode: 1, modeTags: [{ value: 16386 }] },
48
- { label: 'Vacuum & Mop', mode: 2, modeTags: [{ value: 16385 }, { value: 16386 }] },
49
- { label: 'Deep Clean', mode: 3, modeTags: [{ value: 16384 }] },
50
- { label: 'Deep Vacuum', mode: 4, modeTags: [{ value: 16384 }, { value: 16385 }] },
51
- { label: 'Deep Mop', mode: 5, modeTags: [{ value: 16384 }, { value: 16386 }] },
52
- { label: 'Quick Clean', mode: 6, modeTags: [{ value: 1 }, { value: 16385 }] },
53
- { label: 'Max Clean', mode: 7, modeTags: [{ value: 7 }, { value: 16385 }] },
54
- { label: 'Min Clean', mode: 8, modeTags: [{ value: 6 }, { value: 16385 }] },
55
- { label: 'Quiet Vacuum', mode: 9, modeTags: [{ value: 2 }, { value: 16385 }] },
56
- { label: 'Quiet Mop', mode: 10, modeTags: [{ value: 2 }, { value: 16386 }] },
57
- { label: 'Night Mode', mode: 11, modeTags: [{ value: 8 }, { value: 16385 }] },
58
- { label: 'Eco Vacuum', mode: 12, modeTags: [{ value: 4 }, { value: 16385 }] },
59
- { label: 'Eco Mop', mode: 13, modeTags: [{ value: 4 }, { value: 16386 }] },
60
- { label: 'Auto', mode: 14, modeTags: [{ value: 0 }, { value: 16385 }] },
61
- ],
62
- currentMode: 0,
63
- },
64
- rvcOperationalState: {
65
- operationalStateList: [
66
- { operationalStateId: 0 },
67
- { operationalStateId: 1 },
68
- { operationalStateId: 2 },
69
- { operationalStateId: 3 },
70
- { operationalStateId: 64 },
71
- { operationalStateId: 65 },
72
- { operationalStateId: 66 },
73
- ],
74
- operationalState: 66,
75
- },
76
- serviceArea: {
77
- supportedMaps: [],
78
- supportedAreas: [
79
- { areaId: 0, mapId: null, areaInfo: { locationInfo: { locationName: 'Living Room', floorNumber: 0, areaType: 7 }, landmarkInfo: null } },
80
- { areaId: 1, mapId: null, areaInfo: { locationInfo: { locationName: 'Kitchen', floorNumber: 0, areaType: 10 }, landmarkInfo: null } },
81
- { areaId: 2, mapId: null, areaInfo: { locationInfo: { locationName: 'Bedroom', floorNumber: 0, areaType: 2 }, landmarkInfo: null } },
82
- { areaId: 3, mapId: null, areaInfo: { locationInfo: { locationName: 'Bathroom', floorNumber: 0, areaType: 6 }, landmarkInfo: null } },
83
- ],
84
- selectedAreas: [0, 1, 2, 3],
70
+ function detectModelFromContext(ctx?: Record<string, unknown>): SwitchBotVacuumModel | undefined {
71
+ const t = (ctx?.deviceType as string | undefined)?.trim()
72
+ switch (t) {
73
+ case 'Robot Vacuum Cleaner S1':
74
+ return 'S1'
75
+ case 'Robot Vacuum Cleaner S1 Plus':
76
+ return 'S1 Plus'
77
+ case 'Robot Vacuum Cleaner S1 Pro':
78
+ return 'S1 Pro'
79
+ case 'Robot Vacuum Cleaner S1 Mini':
80
+ return 'S1 Mini'
81
+ case 'WoSweeper':
82
+ return 'WoSweeper'
83
+ case 'WoSweeperMini':
84
+ return 'WoSweeperMini'
85
+ case 'K10+':
86
+ return 'K10+'
87
+ case 'K10+ Pro':
88
+ return 'K10+ Pro'
89
+ case 'Robot Vacuum Cleaner K10+ Pro Combo':
90
+ case 'K10+ Pro Combo':
91
+ return 'K10+ Pro Combo'
92
+ case 'Robot Vacuum Cleaner S10':
93
+ return 'S10'
94
+ case 'Robot Vacuum Cleaner S20':
95
+ case 'S20':
96
+ return 'S20'
97
+ case 'Robot Vacuum Cleaner K11+':
98
+ case 'K11+':
99
+ return 'K11+'
100
+ case 'Robot Vacuum Cleaner K20 Plus Pro':
101
+ case 'K20+ Pro':
102
+ return 'K20+ Pro'
103
+ default:
104
+ return undefined
105
+ }
106
+ }
107
+
108
+ function capabilitiesFor(model?: SwitchBotVacuumModel): VacuumCapabilities {
109
+ // Defaults: conservative basic vac
110
+ if (!model) {
111
+ return { pause: false, resume: false, cleanAction: 'vacuum-only', suctionKind: 'powLevel-0-3', waterLevel: false }
112
+ }
113
+
114
+ // Basic vacuum models (S1 family, WoSweeper family, K10 family without Combo)
115
+ if (model === 'S1' || model === 'S1 Plus' || model === 'S1 Pro' || model === 'S1 Mini'
116
+ || model === 'WoSweeper' || model === 'WoSweeperMini'
117
+ || model === 'K10+' || model === 'K10+ Pro') {
118
+ return { pause: false, resume: false, cleanAction: 'vacuum-only', suctionKind: 'powLevel-0-3', waterLevel: false }
119
+ }
120
+
121
+ // K10+ Pro Combo: vacuum or mop, fanLevel, no waterLevel
122
+ if (model === 'K10+ Pro Combo') {
123
+ return { pause: true, resume: false, cleanAction: 'vacuum-or-mop', suctionKind: 'fanLevel-1-4', waterLevel: false }
124
+ }
125
+
126
+ // S10/S20: vacuum or vacuum+mop combo, fanLevel, waterLevel, plus advanced commands
127
+ if (model === 'S10' || model === 'S20') {
128
+ return {
129
+ pause: true,
130
+ resume: false,
131
+ cleanAction: 'vacuum-or-vacmop',
132
+ suctionKind: 'fanLevel-1-4',
133
+ waterLevel: true,
134
+ advancedCommands: {
135
+ setVolume: true,
136
+ selfClean: true,
137
+ addWaterForHumi: true,
85
138
  },
86
139
  }
140
+ }
87
141
 
88
- const handlers = opts?.handlers ?? {
89
- rvcRunMode: { changeToMode: async (request: MatterRequests.ChangeToMode) => this.handleChangeRunMode(request) },
90
- rvcCleanMode: { changeToMode: async (request: MatterRequests.ChangeToMode) => this.handleChangeCleanMode(request) },
91
- rvcOperationalState: { pause: async () => this.handlePause(), stop: async () => this.handleStop(), start: async () => this.handleStart(), resume: async () => this.handleResume(), goHome: async () => this.handleGoHome() },
92
- serviceArea: { selectAreas: async (request: MatterRequests.SelectAreas) => this.handleSelectAreas(request), skipArea: async (request: MatterRequests.SkipArea) => this.handleSkipArea(request) },
93
- }
142
+ // K11+/K20+ Pro: vacuum or mop, fanLevel, waterLevel, volume control
143
+ return {
144
+ pause: true,
145
+ resume: false,
146
+ cleanAction: 'vacuum-or-mop',
147
+ suctionKind: 'fanLevel-1-4',
148
+ waterLevel: true,
149
+ advancedCommands: {
150
+ setVolume: true,
151
+ },
152
+ }
153
+ }
94
154
 
155
+ export class RoboticVacuumAccessory extends BaseMatterAccessory {
156
+ // Track current high-level preferences that affect OpenAPI payloads
157
+ private currentCleanAction: 'vacuum' | 'mop' | 'vacuum_mop' = 'vacuum'
158
+ private currentFanLevel: 1 | 2 | 3 | 4 = 2
159
+ private capabilities: VacuumCapabilities
160
+
161
+ constructor(api: API, log: Logger, opts?: Partial<import('./BaseMatterAccessory').BaseMatterAccessoryConfig & { deviceId?: string }>) {
162
+ const serialNumber = opts?.serialNumber ?? 'VACUUM-001'
163
+ const model = detectModelFromContext(opts?.context)
164
+ const capabilities = capabilitiesFor(model)
95
165
  super(api, log, {
96
166
  uuid: opts?.uuid ?? api.matter.uuid.generate(serialNumber),
97
167
  displayName: opts?.displayName ?? 'Robot Vacuum',
@@ -101,224 +171,217 @@ export class RoboticVacuumAccessory extends BaseMatterAccessory {
101
171
  model: opts?.model ?? 'HB-MATTER-VACUUM-ROBOTIC',
102
172
  firmwareRevision: opts?.firmwareRevision ?? '2.0.0',
103
173
  hardwareRevision: opts?.hardwareRevision ?? '1.0.0',
104
- clusters,
105
- handlers,
106
- context: { deviceId: opts?.deviceId, ...(opts?.context ?? {}) },
174
+ clusters: {
175
+ rvcRunMode: {
176
+ supportedModes: [
177
+ { label: 'Idle', mode: 0, modeTags: [{ value: 16384 }] },
178
+ { label: 'Cleaning', mode: 1, modeTags: [{ value: 16385 }] },
179
+ ],
180
+ currentMode: 0,
181
+ },
182
+ rvcCleanMode: (() => {
183
+ if (capabilities.cleanAction === 'vacuum-only') {
184
+ return {
185
+ supportedModes: [
186
+ { label: 'Quiet', mode: 0, modeTags: [{ value: 2 }] },
187
+ { label: 'Standard', mode: 1, modeTags: [{ value: 16384 }] },
188
+ { label: 'Strong', mode: 2, modeTags: [{ value: 7 }] },
189
+ { label: 'MAX', mode: 3, modeTags: [{ value: 8 }] },
190
+ ],
191
+ currentMode: 1,
192
+ }
193
+ } else if (capabilities.cleanAction === 'vacuum-or-mop') {
194
+ return {
195
+ supportedModes: [
196
+ { label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] },
197
+ { label: 'Mop', mode: 1, modeTags: [{ value: 16386 }] },
198
+ ],
199
+ currentMode: 0,
200
+ }
201
+ } else {
202
+ return {
203
+ supportedModes: [
204
+ { label: 'Vacuum', mode: 0, modeTags: [{ value: 16385 }] },
205
+ { label: 'Vacuum & Mop', mode: 1, modeTags: [{ value: 16385 }, { value: 16386 }] },
206
+ ],
207
+ currentMode: 0,
208
+ }
209
+ }
210
+ })(),
211
+ rvcOperationalState: {
212
+ operationalStateList: [
213
+ { operationalStateId: 0 }, // Stopped
214
+ { operationalStateId: 1 }, // Running
215
+ { operationalStateId: 2 }, // Paused
216
+ { operationalStateId: 64 }, // Seeking Charger
217
+ { operationalStateId: 65 }, // Charging
218
+ { operationalStateId: 66 }, // Docked
219
+ ],
220
+ operationalState: 66,
221
+ },
222
+ },
223
+ handlers: (() => {
224
+ const opHandlers: Record<string, any> = {
225
+ stop: async () => this.handleStop(),
226
+ start: async () => this.handleStart(),
227
+ goHome: async () => this.handleGoHome(),
228
+ }
229
+ if (capabilities.pause) {
230
+ opHandlers.pause = async () => this.handlePause()
231
+ }
232
+ if (capabilities.resume) {
233
+ opHandlers.resume = async () => this.handleResume()
234
+ }
235
+ return {
236
+ rvcRunMode: { changeToMode: async (request: MatterRequests.ChangeToMode) => this.handleChangeRunMode(request) },
237
+ rvcCleanMode: { changeToMode: async (request: MatterRequests.ChangeToMode) => this.handleChangeCleanMode(request) },
238
+ rvcOperationalState: opHandlers as any,
239
+ }
240
+ })(),
241
+ context: { deviceId: opts?.deviceId, ...(opts?.context ?? {}), deviceType: (opts?.context as any)?.deviceType },
107
242
  })
108
243
 
244
+ this.capabilities = capabilities
109
245
  this.logInfo('initialized and ready.')
110
246
  }
111
247
 
112
248
  private async handleChangeRunMode(request: MatterRequests.ChangeToMode): Promise<void> {
113
249
  this.logInfo(`ChangeToMode (run) request received: ${JSON.stringify(request)}`)
114
250
  const { newMode } = request
115
- const modeStr = ['Idle', 'Cleaning', 'Mapping'][newMode] || `Unknown (mode=${newMode})`
251
+ const modeStr = ['Idle', 'Cleaning'][newMode] || `Unknown (mode=${newMode})`
116
252
  this.logInfo(`changing run mode to: ${modeStr}`)
117
- // TODO: await myVacuumAPI.setRunMode(newMode)
118
-
119
- // Clear any existing timers
120
- this.clearTimers()
121
-
122
253
  if (newMode === 1) {
123
- // Switching to Cleaning mode - start the vacuum
124
- this.updateOperationalState(1) // Running
125
-
126
- // Simulate cleaning completion after 15 seconds
127
- const completionTimer = setTimeout(() => {
128
- this.logInfo('cleaning completed, returning to dock.')
129
- this.updateRunMode(0) // Set to Idle - cleaning session ending
130
- this.returnToDock()
131
- }, 15000)
132
-
133
- this.activeTimers.push(completionTimer)
254
+ await this.handleStart()
134
255
  } else if (newMode === 0) {
135
- // Switching to Idle mode - return to dock
136
- this.returnToDock()
137
- } else if (newMode === 2) {
138
- // Switching to Mapping mode - start mapping
139
- this.updateOperationalState(1) // Running
140
-
141
- // Simulate mapping completion after 20 seconds
142
- const completionTimer = setTimeout(() => {
143
- this.logInfo('mapping completed, returning to dock.')
144
- this.returnToDock()
145
- }, 20000)
146
-
147
- this.activeTimers.push(completionTimer)
256
+ await this.handleGoHome()
148
257
  }
149
258
  }
150
259
 
151
260
  private async handleChangeCleanMode(request: MatterRequests.ChangeToMode): Promise<void> {
152
261
  this.logInfo(`ChangeToMode (clean) request received: ${JSON.stringify(request)}`)
153
262
  const { newMode } = request
154
- const modes = [
155
- 'Vacuum',
156
- 'Mop',
157
- 'Vacuum & Mop',
158
- 'Deep Clean',
159
- 'Deep Vacuum',
160
- 'Deep Mop',
161
- 'Quick Clean',
162
- 'Max Clean',
163
- 'Min Clean',
164
- 'Quiet Vacuum',
165
- 'Quiet Mop',
166
- 'Night Mode',
167
- 'Eco Vacuum',
168
- 'Eco Mop',
169
- 'Auto',
170
- ]
171
- const modeStr = modes[newMode] || `Unknown (mode=${newMode})`
172
- this.logInfo(`changing clean mode to: ${modeStr}`)
173
- // TODO: await myVacuumAPI.setCleanMode(newMode)
263
+ if (this.capabilities.cleanAction === 'vacuum-only') {
264
+ const mapPow = ['Quiet', 'Standard', 'Strong', 'MAX'] as const
265
+ const label = mapPow[newMode] ?? `Unknown (${newMode})`
266
+ this.logInfo(`changing suction level to: ${label}`)
267
+ if (this.capabilities.suctionKind === 'powLevel-0-3') {
268
+ try {
269
+ this.logInfo(`[OpenAPI] Sending PowLevel: ${newMode}`)
270
+ await this.sendOpenAPICommand('PowLevel', String(newMode))
271
+ this.logInfo(`[OpenAPI] PowLevel command sent: ${newMode}`)
272
+ } catch (e: any) {
273
+ this.logWarn(`OpenAPI PowLevel failed: ${String(e?.message ?? e)}`)
274
+ }
275
+ } else if (this.capabilities.suctionKind === 'fanLevel-1-4') {
276
+ this.currentFanLevel = (Math.min(3, Math.max(0, Number(newMode))) + 1) as 1 | 2 | 3 | 4
277
+ try {
278
+ this.logInfo(`[OpenAPI] Sending changeParam fanLevel: ${this.currentFanLevel}`)
279
+ await this.sendOpenAPICommand('changeParam', JSON.stringify({ fanLevel: this.currentFanLevel }))
280
+ this.logInfo(`[OpenAPI] changeParam command sent: fanLevel=${this.currentFanLevel}`)
281
+ } catch (e: any) {
282
+ this.logWarn(`OpenAPI changeParam(fanLevel) failed: ${String(e?.message ?? e)}`)
283
+ }
284
+ }
285
+ this.updateCleanMode(newMode)
286
+ return
287
+ }
288
+
289
+ if (this.capabilities.cleanAction === 'vacuum-or-mop') {
290
+ const label = newMode === 0 ? 'Vacuum' : 'Mop'
291
+ this.currentCleanAction = newMode === 0 ? 'vacuum' : 'mop'
292
+ this.logInfo(`changing clean action to: ${label}`)
293
+ this.logInfo(`[OpenAPI] (no command sent for cleanAction change, just updating state)`)
294
+ this.updateCleanMode(newMode)
295
+ return
296
+ }
297
+
298
+ const label = newMode === 0 ? 'Vacuum' : 'Vacuum & Mop'
299
+ this.currentCleanAction = newMode === 0 ? 'vacuum' : 'vacuum_mop'
300
+ this.logInfo(`changing clean action to: ${label}`)
301
+ this.logInfo(`[OpenAPI] (no command sent for cleanAction change, just updating state)`)
302
+ this.updateCleanMode(newMode)
174
303
  }
175
304
 
176
305
  private async handlePause(): Promise<void> {
177
306
  this.logInfo('pausing.')
178
- // TODO: await myVacuumAPI.pause()
179
- this.clearTimers() // Clear cleaning completion timer
180
- this.updateOperationalState(2) // Paused
307
+ try {
308
+ this.logInfo(`[OpenAPI] Sending pause command`)
309
+ await this.sendOpenAPICommand('pause')
310
+ this.logInfo(`[OpenAPI] pause command sent`)
311
+ } catch (e: any) {
312
+ this.logWarn(`OpenAPI pause failed: ${String(e?.message ?? e)}`)
313
+ }
314
+ this.updateOperationalState(2)
181
315
  }
182
316
 
183
317
  private async handleStop(): Promise<void> {
184
318
  this.logInfo('stopping.')
185
- // TODO: await myVacuumAPI.stop()
186
- this.clearTimers()
187
- this.updateRunMode(0) // Reset to Idle
188
- this.updateOperationalState(0) // Stopped
319
+ try {
320
+ if (this.capabilities.cleanAction === 'vacuum-only' && this.capabilities.suctionKind === 'powLevel-0-3') {
321
+ this.logInfo(`[OpenAPI] Sending stop command`)
322
+ await this.sendOpenAPICommand('stop')
323
+ this.logInfo(`[OpenAPI] stop command sent`)
324
+ } else {
325
+ this.logInfo(`[OpenAPI] Sending pause command (for stop)`)
326
+ await this.sendOpenAPICommand('pause')
327
+ this.logInfo(`[OpenAPI] pause command sent (for stop)`)
328
+ }
329
+ } catch (e: any) {
330
+ this.logWarn(`OpenAPI stop/pause failed: ${String(e?.message ?? e)}`)
331
+ }
332
+ this.updateRunMode(0)
333
+ this.updateOperationalState(0)
189
334
  }
190
335
 
191
336
  private async handleStart(): Promise<void> {
192
- this.logInfo('starting (via start command).')
193
- // TODO: await myVacuumAPI.start()
194
-
195
- // Clear any existing timers
196
- this.clearTimers()
197
-
198
- this.updateRunMode(1) // Set to Cleaning mode - this will trigger the run mode handler logic
199
- this.updateOperationalState(1) // Running
200
-
201
- // Simulate cleaning completion after 15 seconds
202
- const completionTimer = setTimeout(() => {
203
- this.logInfo('cleaning completed, returning to dock.')
204
- this.updateRunMode(0) // Set to Idle - cleaning session ending
205
- this.returnToDock()
206
- }, 15000)
207
-
208
- this.activeTimers.push(completionTimer)
337
+ this.logInfo('starting clean.')
338
+ try {
339
+ if (this.capabilities.cleanAction === 'vacuum-only' && this.capabilities.suctionKind === 'powLevel-0-3') {
340
+ this.logInfo(`[OpenAPI] Sending start command`)
341
+ await this.sendOpenAPICommand('start')
342
+ this.logInfo(`[OpenAPI] start command sent`)
343
+ } else {
344
+ const action = this.currentCleanAction === 'vacuum_mop' ? 'sweep_mop' : this.currentCleanAction
345
+ const param: any = { action, param: { fanLevel: this.currentFanLevel } }
346
+ if (this.capabilities.waterLevel) {
347
+ param.param.waterLevel = 1
348
+ }
349
+ this.logInfo(`[OpenAPI] Sending startClean: ${JSON.stringify(param)}`)
350
+ await this.sendOpenAPICommand('startClean', JSON.stringify(param))
351
+ this.logInfo(`[OpenAPI] startClean command sent: ${JSON.stringify(param)}`)
352
+ }
353
+ } catch (e: any) {
354
+ this.logWarn(`OpenAPI start/startClean failed: ${String(e?.message ?? e)}`)
355
+ }
356
+ this.updateRunMode(1)
357
+ this.updateOperationalState(1)
209
358
  }
210
359
 
211
360
  private async handleResume(): Promise<void> {
212
- this.logInfo('resuming.')
213
- // TODO: await myVacuumAPI.resume()
214
-
215
- // Clear any existing timers
216
- this.clearTimers()
217
-
218
- this.updateRunMode(1) // Set to Cleaning mode
219
- this.updateOperationalState(1) // Running
220
-
221
- // Simulate cleaning completion after 10 seconds (shorter since resuming)
222
- const completionTimer = setTimeout(() => {
223
- this.logInfo('cleaning completed, returning to dock.')
224
- this.updateRunMode(0) // Set to Idle - cleaning session ending
225
- this.returnToDock()
226
- }, 10000)
227
-
228
- this.activeTimers.push(completionTimer)
361
+ this.logInfo('resume requested.')
362
+ await this.handleStart()
229
363
  }
230
364
 
231
365
  private async handleGoHome(): Promise<void> {
232
- this.logInfo('goHome command received.')
233
- // TODO: await myVacuumAPI.goHome()
234
-
235
- // Clear any existing timers
236
- this.clearTimers()
237
-
238
- // Defer state updates to ensure handler completes first
239
- setImmediate(() => {
240
- // Set to Idle mode since we're ending the cleaning session
241
- this.updateRunMode(0)
242
-
243
- // Initiate return to dock sequence
244
- this.returnToDock()
245
- })
246
- }
247
-
248
- private async handleSelectAreas(request: MatterRequests.SelectAreas): Promise<void> {
249
- this.logInfo(`SelectAreas request received: ${JSON.stringify(request)}`)
250
- const { newAreas } = request
251
- const areaNames = newAreas.map((id: number) =>
252
- ['Living Room', 'Kitchen', 'Bedroom', 'Bathroom'][id] || `Area ${id}`,
253
- )
254
- this.logInfo(`selecting areas: ${areaNames.join(', ')}`)
255
- // TODO: await myVacuumAPI.selectAreas(newAreas)
256
- }
257
-
258
- private async handleSkipArea(request: MatterRequests.SkipArea): Promise<void> {
259
- this.logInfo(`SkipArea request received: ${JSON.stringify(request)}`)
260
- const { skippedArea } = request
261
- const areaName = ['Living Room', 'Kitchen', 'Bedroom', 'Bathroom'][skippedArea] || `Area ${skippedArea}`
262
- this.logInfo(`skipping area: ${areaName}`)
263
- // TODO: await myVacuumAPI.skipArea(skippedArea)
264
- }
265
-
266
- /**
267
- * Helper method to clear all active timers
268
- */
269
- private clearTimers(): void {
270
- this.activeTimers.forEach(timer => clearTimeout(timer))
271
- this.activeTimers = []
272
- }
273
-
274
- /**
275
- * Helper method to initiate return to dock sequence
276
- * Can be called synchronously from other handlers
277
- */
278
- private returnToDock(): void {
279
- this.logInfo('initiating return to dock sequence.')
280
-
281
- // Defer ALL state updates to ensure handler completes first
282
- setImmediate(() => {
283
- // Start seeking charger directly (skip intermediate Stopped state)
284
- this.updateOperationalState(64) // Seeking Charger
285
-
286
- // After 5 seconds, start charging
287
- const chargingTimer = setTimeout(() => {
288
- this.logInfo('reached dock, now charging.')
289
- this.updateOperationalState(65) // Charging
290
-
291
- // After 3 more seconds, fully docked
292
- const dockedTimer = setTimeout(() => {
293
- this.logInfo('fully charged and docked.')
294
- this.updateOperationalState(66) // Docked
295
- }, 3000)
296
-
297
- this.activeTimers.push(dockedTimer)
298
- }, 5000)
299
-
300
- this.activeTimers.push(chargingTimer)
301
- })
366
+ this.logInfo('returning to dock.')
367
+ try {
368
+ this.logInfo(`[OpenAPI] Sending dock command`)
369
+ await this.sendOpenAPICommand('dock')
370
+ this.logInfo(`[OpenAPI] dock command sent`)
371
+ } catch (e: any) {
372
+ this.logWarn(`OpenAPI dock failed: ${String(e?.message ?? e)}`)
373
+ }
374
+ this.updateRunMode(0)
375
+ this.updateOperationalState(64)
302
376
  }
303
377
 
304
- /**
305
- * Update Methods - Use these to update the vacuum state from your API/device
306
- *
307
- * Since this is a platform accessory, state updates work immediately after registration.
308
- */
309
-
310
378
  public updateOperationalState(state: number): void {
311
379
  this.updateState('rvcOperationalState', { operationalState: state })
312
380
  const states = [
313
381
  'Stopped',
314
382
  'Running',
315
383
  'Paused',
316
- 'Error',
317
- null,
318
- null,
319
- null,
320
- null,
321
- ...Array.from({ length: 56 }).fill(null),
384
+ ...Array.from({ length: 61 }).fill(null),
322
385
  'Seeking Charger',
323
386
  'Charging',
324
387
  'Docked',
@@ -328,76 +391,32 @@ export class RoboticVacuumAccessory extends BaseMatterAccessory {
328
391
 
329
392
  public updateRunMode(mode: number): void {
330
393
  this.updateState('rvcRunMode', { currentMode: mode })
331
- const modes = ['Idle', 'Cleaning', 'Mapping']
394
+ const modes = ['Idle', 'Cleaning']
332
395
  this.logInfo(`run mode updated to: ${modes[mode] || `Unknown (${mode})`}`)
333
396
  }
334
397
 
335
398
  public updateCleanMode(mode: number): void {
336
399
  this.updateState('rvcCleanMode', { currentMode: mode })
337
- const modes = [
338
- 'Vacuum',
339
- 'Mop',
340
- 'Vacuum & Mop',
341
- 'Deep Clean',
342
- 'Deep Vacuum',
343
- 'Deep Mop',
344
- 'Quick Clean',
345
- 'Max Clean',
346
- 'Min Clean',
347
- 'Quiet Vacuum',
348
- 'Quiet Mop',
349
- 'Night Mode',
350
- 'Eco Vacuum',
351
- 'Eco Mop',
352
- 'Auto',
353
- ]
354
- this.logInfo(`clean mode updated to: ${modes[mode] || `Unknown (${mode})`}`)
355
- }
356
-
357
- public updateSelectedAreas(areaIds: number[]): void {
358
- this.updateState('serviceArea', { selectedAreas: areaIds })
359
- const areaNames = areaIds.map(id =>
360
- ['Living Room', 'Kitchen', 'Bedroom', 'Bathroom'][id] || `Area ${id}`,
361
- )
362
- this.logInfo(`selected areas updated to: ${areaNames.join(', ') || 'All Areas'}`)
363
- }
364
-
365
- public updateCurrentArea(areaId: number | null): void {
366
- this.updateState('serviceArea', { currentArea: areaId })
367
- if (areaId !== null) {
368
- const areaName = ['Living Room', 'Kitchen', 'Bedroom', 'Bathroom'][areaId] || `Area ${areaId}`
369
- this.logInfo(`current area updated to: ${areaName}`)
400
+ let label = `Unknown (${mode})`
401
+ if (this.capabilities.cleanAction === 'vacuum-only') {
402
+ label = ['Quiet', 'Standard', 'Strong', 'MAX'][mode] || label
403
+ } else if (this.capabilities.cleanAction === 'vacuum-or-mop') {
404
+ label = ['Vacuum', 'Mop'][mode] || label
370
405
  } else {
371
- this.logInfo('current area cleared')
406
+ label = ['Vacuum', 'Vacuum & Mop'][mode] || label
372
407
  }
373
- }
374
-
375
- public updateProgress(progress: Array<{ areaId: number, status: number }>): void {
376
- this.updateState('serviceArea', { progress })
377
- this.logInfo(`progress updated: ${progress.length} areas`)
408
+ this.logInfo(`clean mode updated to: ${label}`)
378
409
  }
379
410
 
380
411
  /**
381
- * Update battery percentage
382
- *
383
- * Note: The Matter specification for RoboticVacuumCleaner does not include
384
- * the PowerSource cluster. Battery information for robotic vacuums should be
385
- * communicated through device-specific status reporting or via the RVC
386
- * operational state cluster.
387
- *
388
- * This method is retained for API compatibility but does not update any
389
- * Matter cluster state.
390
- *
391
- * @param percentageOrBatPercentRemaining - Battery percentage (0-100) or batPercentRemaining (0-200)
412
+ * Update battery percentage (no PowerSource cluster for this device type)
392
413
  */
393
414
  public async updateBatteryPercentage(percentageOrBatPercentRemaining: number): Promise<void> {
394
- // Accept either 0–100 (percentage) or 0–200 (batPercentRemaining) to be robust
395
415
  const isBatPercent = Number(percentageOrBatPercentRemaining) > 100
396
416
  const percentage = isBatPercent
397
417
  ? Math.max(0, Math.min(100, Math.round(Number(percentageOrBatPercentRemaining) / 2)))
398
418
  : Math.max(0, Math.min(100, Math.round(Number(percentageOrBatPercentRemaining))))
399
419
 
400
- // Determine charge level based on percentage
401
420
  let chargeLevel = 0 // Ok
402
421
  if (percentage < 20) {
403
422
  chargeLevel = 2 // Critical
@@ -405,10 +424,8 @@ export class RoboticVacuumAccessory extends BaseMatterAccessory {
405
424
  chargeLevel = 1 // Warning
406
425
  }
407
426
 
408
- // Log battery status only - PowerSource cluster is not supported for RoboticVacuumCleaner
409
427
  this.logInfo(`battery status: ${percentage}% (${chargeLevel === 0 ? 'Ok' : chargeLevel === 1 ? 'Warning' : 'Critical'})`)
410
428
 
411
- // Store battery info in context for reference
412
429
  if (this.context) {
413
430
  (this.context as any).batteryPercentage = percentage
414
431
  ;(this.context as any).batteryChargeLevel = chargeLevel