matterbridge-ha-roborock 3.0.0

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.
@@ -0,0 +1,601 @@
1
+ import {
2
+ Matterbridge,
3
+ PlatformConfig,
4
+ MatterbridgeAccessoryPlatform
5
+ } from 'matterbridge';
6
+
7
+ import { RoboticVacuumCleaner } from 'matterbridge/devices';
8
+ import { RvcCleanMode, RvcRunMode, RvcOperationalState, ServiceArea, PowerSource, Identify } from 'matterbridge/matter/clusters';
9
+
10
+ import { HomeAssistantClient } from './homeassistant-client.js';
11
+ import { HAVacuumController, VacuumState, CleanModeConfig } from './ha-vacuum-controller.js';
12
+ import { RoborockPlatformConfig, validateConfig } from './platform-config.js';
13
+
14
+ // Clean Mode 定义
15
+ const CLEAN_MODE = {
16
+ VacMopQuiet: 5, VacMopQuick: 6, VacMopMax: 7, VacMopDeep: 8,
17
+ MopQuiet: 31, MopQuick: 32, MopMax: 33, MopDeep: 34,
18
+ VacQuiet: 66, VacQuick: 67, VacMax: 68, VacDeep: 69,
19
+ };
20
+
21
+ const CLEAN_MODE_LABELS: Record<number, string> = {
22
+ [CLEAN_MODE.VacMopQuiet]: 'Mop & Vacuum: Quiet',
23
+ [CLEAN_MODE.VacMopQuick]: 'Mop & Vacuum: Quick',
24
+ [CLEAN_MODE.VacMopMax]: 'Mop & Vacuum: Max',
25
+ [CLEAN_MODE.VacMopDeep]: 'Mop & Vacuum: DeepClean',
26
+ [CLEAN_MODE.MopQuiet]: 'Mop: Quiet',
27
+ [CLEAN_MODE.MopQuick]: 'Mop: Quick',
28
+ [CLEAN_MODE.MopMax]: 'Mop: Max',
29
+ [CLEAN_MODE.MopDeep]: 'Mop: DeepClean',
30
+ [CLEAN_MODE.VacQuiet]: 'Vacuum: Quiet',
31
+ [CLEAN_MODE.VacQuick]: 'Vacuum: Quick',
32
+ [CLEAN_MODE.VacMax]: 'Vacuum: Max',
33
+ [CLEAN_MODE.VacDeep]: 'Vacuum: DeepClean',
34
+ };
35
+
36
+ const SUPPORTED_RUN_MODES: RvcRunMode.ModeOption[] = [
37
+ { label: 'Idle', mode: 1, modeTags: [{ value: RvcRunMode.ModeTag.Idle }] },
38
+ { label: 'Cleaning', mode: 2, modeTags: [{ value: RvcRunMode.ModeTag.Cleaning }] },
39
+ { label: 'Mapping', mode: 3, modeTags: [{ value: RvcRunMode.ModeTag.Mapping }] },
40
+ ];
41
+
42
+ const SUPPORTED_CLEAN_MODES: RvcCleanMode.ModeOption[] = [
43
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacMopQuiet], mode: CLEAN_MODE.VacMopQuiet, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Quiet }] },
44
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacMopQuick], mode: CLEAN_MODE.VacMopQuick, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Quick }] },
45
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacMopMax], mode: CLEAN_MODE.VacMopMax, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Max }] },
46
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacMopDeep], mode: CLEAN_MODE.VacMopDeep, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.DeepClean }] },
47
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.MopQuiet], mode: CLEAN_MODE.MopQuiet, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Quiet }] },
48
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.MopQuick], mode: CLEAN_MODE.MopQuick, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Quick }] },
49
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.MopMax], mode: CLEAN_MODE.MopMax, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.Max }] },
50
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.MopDeep], mode: CLEAN_MODE.MopDeep, modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: RvcCleanMode.ModeTag.DeepClean }] },
51
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacQuiet], mode: CLEAN_MODE.VacQuiet, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Quiet }] },
52
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacQuick], mode: CLEAN_MODE.VacQuick, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Quick }] },
53
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacMax], mode: CLEAN_MODE.VacMax, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Max }] },
54
+ { label: CLEAN_MODE_LABELS[CLEAN_MODE.VacDeep], mode: CLEAN_MODE.VacDeep, modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.DeepClean }] },
55
+ ];
56
+
57
+ // 清洁模式映射:Matter模式 -> HA配置(清洁模式 + 风速)
58
+ const CLEAN_MODE_TO_HA: Record<number, CleanModeConfig> = {
59
+ // 吸尘+拖地 (sweeping_and_mopping)
60
+ [CLEAN_MODE.VacMopQuiet]: { cleaningMode: 'sweeping_and_mopping', fanSpeed: 'Silent' },
61
+ [CLEAN_MODE.VacMopQuick]: { cleaningMode: 'sweeping_and_mopping', fanSpeed: 'Standard' },
62
+ [CLEAN_MODE.VacMopMax]: { cleaningMode: 'sweeping_and_mopping', fanSpeed: 'Strong' },
63
+ [CLEAN_MODE.VacMopDeep]: { cleaningMode: 'sweeping_and_mopping', fanSpeed: 'Turbo' },
64
+
65
+ // 仅拖地 (mopping)
66
+ [CLEAN_MODE.MopQuiet]: { cleaningMode: 'mopping', fanSpeed: 'Silent' },
67
+ [CLEAN_MODE.MopQuick]: { cleaningMode: 'mopping', fanSpeed: 'Standard' },
68
+ [CLEAN_MODE.MopMax]: { cleaningMode: 'mopping', fanSpeed: 'Strong' },
69
+ [CLEAN_MODE.MopDeep]: { cleaningMode: 'mopping', fanSpeed: 'Turbo' },
70
+
71
+ // 仅吸尘 (sweeping)
72
+ [CLEAN_MODE.VacQuiet]: { cleaningMode: 'sweeping', fanSpeed: 'Silent' },
73
+ [CLEAN_MODE.VacQuick]: { cleaningMode: 'sweeping', fanSpeed: 'Standard' },
74
+ [CLEAN_MODE.VacMax]: { cleaningMode: 'sweeping', fanSpeed: 'Strong' },
75
+ [CLEAN_MODE.VacDeep]: { cleaningMode: 'sweeping', fanSpeed: 'Turbo' },
76
+ };
77
+
78
+ // Matter 区域定义(Apple Home 中显示的名称)
79
+ const SUPPORTED_AREAS: ServiceArea.Area[] = [
80
+ { areaId: 1, mapId: null, areaInfo: { locationInfo: { locationName: '浴室', floorNumber: null, areaType: null }, landmarkInfo: null } },
81
+ { areaId: 2, mapId: null, areaInfo: { locationInfo: { locationName: '儿童房', floorNumber: null, areaType: null }, landmarkInfo: null } },
82
+ { areaId: 3, mapId: null, areaInfo: { locationInfo: { locationName: '卧室', floorNumber: null, areaType: null }, landmarkInfo: null } },
83
+ { areaId: 4, mapId: null, areaInfo: { locationInfo: { locationName: '阳光房', floorNumber: null, areaType: null }, landmarkInfo: null } },
84
+ ];
85
+
86
+ // Matter areaId -> Dreame segment ID 映射
87
+ const MATTER_TO_DREAME_SEGMENT: Record<number, string> = {
88
+ 1: '2', // 浴室 -> Dreame segment 2
89
+ 2: '3', // 儿童房 -> Dreame segment 3
90
+ 3: '5', // 卧室 -> Dreame segment 5
91
+ 4: '6', // 阳光房 -> Dreame segment 6
92
+ };
93
+
94
+ const OPERATIONAL_STATES: RvcOperationalState.OperationalStateStruct[] = [
95
+ { operationalStateId: RvcOperationalState.OperationalState.Stopped, operationalStateLabel: 'Stopped' },
96
+ { operationalStateId: RvcOperationalState.OperationalState.Running, operationalStateLabel: 'Running' },
97
+ { operationalStateId: RvcOperationalState.OperationalState.Paused, operationalStateLabel: 'Paused' },
98
+ { operationalStateId: RvcOperationalState.OperationalState.Error, operationalStateLabel: 'Error' },
99
+ { operationalStateId: RvcOperationalState.OperationalState.SeekingCharger, operationalStateLabel: 'SeekingCharger' },
100
+ { operationalStateId: RvcOperationalState.OperationalState.Charging, operationalStateLabel: 'Charging' },
101
+ { operationalStateId: RvcOperationalState.OperationalState.Docked, operationalStateLabel: 'Docked' },
102
+ ];
103
+
104
+ // 状态映射函数
105
+ function mapHAStateToOperationalState(
106
+ haState: string,
107
+ isCharging: boolean,
108
+ errorMsg?: string,
109
+ rawAttributes?: any
110
+ ): RvcOperationalState.OperationalState {
111
+ // 检查错误
112
+ if (errorMsg && errorMsg !== 'no_error' && errorMsg !== 'null' && errorMsg !== 'unavailable') {
113
+ return RvcOperationalState.OperationalState.Error;
114
+ }
115
+
116
+ // 优先检查 docked 状态(在基站上)
117
+ if (haState === 'docked') {
118
+ // 只信任充电传感器的值,不检查 rawAttributes.charging
119
+ if (isCharging) {
120
+ return RvcOperationalState.OperationalState.Charging;
121
+ }
122
+ // 如果不在充电,显示已停靠
123
+ return RvcOperationalState.OperationalState.Docked;
124
+ }
125
+
126
+ // 检查充电状态(不在基站但在充电,理论上不应该发生)
127
+ if (isCharging) {
128
+ return RvcOperationalState.OperationalState.Charging;
129
+ }
130
+
131
+ // 特殊处理 cleaning 状态(基于 running/started/paused 属性)
132
+ if (haState === 'cleaning') {
133
+ // paused 优先级最高
134
+ if (rawAttributes?.paused === true) {
135
+ return RvcOperationalState.OperationalState.Paused;
136
+ }
137
+
138
+ // running = true → 正在清扫
139
+ if (rawAttributes?.running === true) {
140
+ return RvcOperationalState.OperationalState.Running;
141
+ }
142
+
143
+ // running = false 但 started = true → 正在前往目的地
144
+ if (rawAttributes?.started === true && rawAttributes?.running === false) {
145
+ return RvcOperationalState.OperationalState.SeekingCharger;
146
+ }
147
+
148
+ // 默认:正在运行(兼容没有 running 属性的情况)
149
+ return RvcOperationalState.OperationalState.Running;
150
+ }
151
+
152
+ // 处理 returning 状态
153
+ if (haState === 'returning' || rawAttributes?.returning === true) {
154
+ return RvcOperationalState.OperationalState.SeekingCharger;
155
+ }
156
+
157
+ // 处理 paused 状态
158
+ if (haState === 'paused' || rawAttributes?.paused === true) {
159
+ return RvcOperationalState.OperationalState.Paused;
160
+ }
161
+
162
+ // 优先使用详细状态(attributes.status)- 兼容其他扫地机
163
+ if (rawAttributes?.status) {
164
+ const detailedMap: Record<string, RvcOperationalState.OperationalState> = {
165
+ 'Cruising': RvcOperationalState.OperationalState.SeekingCharger,
166
+ 'Back home': RvcOperationalState.OperationalState.SeekingCharger,
167
+ 'Go Charging': RvcOperationalState.OperationalState.SeekingCharger,
168
+ 'Segment cleaning': RvcOperationalState.OperationalState.Running,
169
+ 'Zone cleaning': RvcOperationalState.OperationalState.Running,
170
+ 'Room cleaning': RvcOperationalState.OperationalState.Running,
171
+ 'Spot cleaning': RvcOperationalState.OperationalState.Running,
172
+ 'Cleaning': RvcOperationalState.OperationalState.Running,
173
+ 'Fast mapping': RvcOperationalState.OperationalState.Running,
174
+ 'Charging': RvcOperationalState.OperationalState.Charging,
175
+ 'Docked': RvcOperationalState.OperationalState.Docked,
176
+ 'Idle': RvcOperationalState.OperationalState.Stopped,
177
+ 'Paused': RvcOperationalState.OperationalState.Paused,
178
+ 'Error': RvcOperationalState.OperationalState.Error,
179
+ };
180
+
181
+ if (detailedMap[rawAttributes.status] !== undefined) {
182
+ return detailedMap[rawAttributes.status];
183
+ }
184
+ }
185
+
186
+ // 基本状态映射(idle 等其他状态)
187
+ const stateMap: Record<string, RvcOperationalState.OperationalState> = {
188
+ 'idle': RvcOperationalState.OperationalState.Stopped,
189
+ 'error': RvcOperationalState.OperationalState.Error,
190
+ };
191
+
192
+ return stateMap[haState.toLowerCase()] || RvcOperationalState.OperationalState.Stopped;
193
+ }
194
+
195
+ function mapHAStateToRunMode(haState: string): number {
196
+ const runModeMap: Record<string, number> = {
197
+ 'cleaning': 2, 'docked': 1, 'paused': 2, 'idle': 1, 'returning': 1, 'charging': 1, 'error': 1,
198
+ };
199
+ return runModeMap[haState.toLowerCase()] || 1;
200
+ }
201
+
202
+ export class RoborockHAPlatform extends MatterbridgeAccessoryPlatform {
203
+ private haClient?: HomeAssistantClient;
204
+ private vacuumControllers = new Map<string, HAVacuumController>();
205
+ private matterDevices = new Map<string, RoboticVacuumCleaner>();
206
+ private platformConfig?: RoborockPlatformConfig;
207
+
208
+ constructor(matterbridge: Matterbridge, log: any, config: PlatformConfig) {
209
+ super(matterbridge, log, config);
210
+ this.log.info('🔧 初始化扫地机插件...');
211
+ }
212
+
213
+ override async onStart(reason?: string): Promise<void> {
214
+ this.log.info(`▶️ 启动插件: ${reason || '未知原因'}`);
215
+ this.platformConfig = validateConfig(this.config as unknown as RoborockPlatformConfig);
216
+
217
+ this.haClient = new HomeAssistantClient(this.platformConfig.haUrl, this.platformConfig.haToken);
218
+ const ok = await this.haClient.testConnection();
219
+ if (!ok) throw new Error('Failed to connect to Home Assistant');
220
+
221
+ this.log.info('✅ HA 连接成功');
222
+
223
+ try {
224
+ await this.haClient.connectWebSocket();
225
+ this.log.info('✅ WebSocket 实时连接成功');
226
+ } catch {
227
+ this.log.warn('⚠️ WebSocket 连接失败,使用轮询模式');
228
+ }
229
+
230
+ await this.discoverDevices(this.platformConfig);
231
+ this.log.info('✅ 插件启动完成\n');
232
+ }
233
+
234
+ private async discoverDevices(config: RoborockPlatformConfig): Promise<void> {
235
+ if (!this.haClient) return;
236
+
237
+ const vacuums = await this.haClient.getVacuumEntities();
238
+ this.log.info(`🔍 发现 ${vacuums.length} 台扫地机`);
239
+
240
+ const filtered = vacuums.filter(v => {
241
+ if (config.deviceBlacklist?.includes(v.entity_id)) {
242
+ this.log.info(`⏭ 跳过 ${v.entity_id} (黑名单)`);
243
+ return false;
244
+ }
245
+ if (config.deviceWhitelist?.length && !config.deviceWhitelist.includes(v.entity_id)) {
246
+ this.log.info(`⏭ 跳过 ${v.entity_id} (不在白名单)`);
247
+ return false;
248
+ }
249
+ return true;
250
+ });
251
+
252
+ this.log.info(`📝 注册 ${filtered.length} 台设备\n`);
253
+
254
+ for (const vacuum of filtered) {
255
+ try {
256
+ await this.registerVacuum(vacuum.entity_id, config);
257
+ } catch (err) {
258
+ this.log.error(`❌ 注册失败 ${vacuum.entity_id}:`, err);
259
+ }
260
+ }
261
+ }
262
+
263
+ private async registerVacuum(entityId: string, config: RoborockPlatformConfig): Promise<void> {
264
+ if (!this.haClient) return;
265
+
266
+ this.log.info(`📝 ${entityId}`);
267
+
268
+ // 获取传感器映射
269
+ const batterySensorId = config.batterySensorMap?.[entityId];
270
+ const chargingSensorId = config.chargingSensorMap?.[entityId];
271
+ const cleaningModeEntityId = config.cleaningModeEntityMap?.[entityId];
272
+ const errorSensorId = config.errorSensorMap?.[entityId];
273
+
274
+ if (batterySensorId) {
275
+ this.log.info(` 电池: ${batterySensorId}`);
276
+ }
277
+ if (chargingSensorId) {
278
+ this.log.info(` 充电: ${chargingSensorId}`);
279
+ }
280
+ if (cleaningModeEntityId) {
281
+ this.log.info(` 模式: ${cleaningModeEntityId}`);
282
+ }
283
+ if (errorSensorId) {
284
+ this.log.info(` 错误: ${errorSensorId}`);
285
+ }
286
+
287
+ const controller = new HAVacuumController(
288
+ this.haClient,
289
+ entityId,
290
+ batterySensorId,
291
+ chargingSensorId,
292
+ cleaningModeEntityId,
293
+ errorSensorId
294
+ );
295
+ await controller.initialize();
296
+
297
+ const state = controller.getState();
298
+ if (!state) throw new Error('No vacuum state available');
299
+
300
+ this.log.info(
301
+ ` 状态:${state.state} | 电量:${state.batteryLevel}% | 充电:${state.isCharging ? '是' : '否'} | ` +
302
+ `模式:${state.cleaningMode || '无'} | 风速:${state.fanSpeed || '无'}`
303
+ );
304
+
305
+ const device = this.createMatterDevice(entityId, state, controller);
306
+ await this.registerDevice(device);
307
+
308
+ this.vacuumControllers.set(entityId, controller);
309
+ this.matterDevices.set(entityId, device);
310
+
311
+ this.log.info(`✅ 注册完成\n`);
312
+ }
313
+
314
+ private createMatterDevice(
315
+ entityId: string,
316
+ state: VacuumState,
317
+ controller: HAVacuumController
318
+ ): RoboticVacuumCleaner {
319
+ const name = state.rawAttributes?.friendly_name || entityId;
320
+ this.log.info(` 创建 Matter 设备: ${name}`);
321
+
322
+ const initialOperationalState = mapHAStateToOperationalState(state.state, state.isCharging);
323
+ const initialRunMode = mapHAStateToRunMode(state.state);
324
+
325
+ const device = new RoboticVacuumCleaner(
326
+ name, entityId, 'server', initialRunMode, SUPPORTED_RUN_MODES,
327
+ CLEAN_MODE.VacMopQuiet, SUPPORTED_CLEAN_MODES,
328
+ undefined, undefined, initialOperationalState, OPERATIONAL_STATES,
329
+ SUPPORTED_AREAS, undefined, SUPPORTED_AREAS[0]?.areaId, []
330
+ );
331
+
332
+ this.log.info(` 初始状态 → 运行:${initialOperationalState} | 模式:${initialRunMode} | 充电:${state.isCharging ? '是' : '否'}`);
333
+
334
+ // 设置初始电量
335
+ this.updateBatteryState(device, state);
336
+
337
+ let selectedAreaIds: number[] = [];
338
+
339
+ // Command handlers
340
+ device.addCommandHandler('selectAreas', async ({ request }) => {
341
+ const { newAreas } = request as ServiceArea.SelectAreasRequest;
342
+ selectedAreaIds = newAreas || [];
343
+ const areaNames = selectedAreaIds.map(id =>
344
+ SUPPORTED_AREAS.find(a => a.areaId === id)?.areaInfo?.locationInfo?.locationName || `Area ${id}`
345
+ );
346
+ this.log.info(`📍 选择区域: ${areaNames.join('、')}`);
347
+ });
348
+
349
+ device.addCommandHandler('pause', async () => {
350
+ this.log.info(`⏸ 暂停清扫`);
351
+ await controller.pause();
352
+ });
353
+
354
+ device.addCommandHandler('resume', async () => {
355
+ this.log.info(`▶️ 继续清扫`);
356
+ if (selectedAreaIds.length > 0 && selectedAreaIds.length < SUPPORTED_AREAS.length) {
357
+ await controller.cleanSegments(selectedAreaIds, MATTER_TO_DREAME_SEGMENT);
358
+
359
+ // 15秒后主动刷新状态
360
+ setTimeout(async () => {
361
+ try {
362
+ this.log.info(`⏰ 15秒后自动刷新状态`);
363
+ await controller.forceRefreshState();
364
+ } catch (error) {
365
+ this.log.error(`❌ 自动刷新状态失败:`, error);
366
+ }
367
+ }, 15000);
368
+ } else {
369
+ await controller.start();
370
+ }
371
+ });
372
+
373
+ device.addCommandHandler('goHome', async () => {
374
+ this.log.info(`🏠 返回充电`);
375
+ selectedAreaIds = [];
376
+ await controller.returnToBase();
377
+ });
378
+
379
+ // Locate/Identify 命令
380
+ device.addCommandHandler('identify', async ({ request }) => {
381
+ this.log.info(`📍 定位扫地机`);
382
+ try {
383
+ await controller.locate();
384
+ this.log.info(`✅ 定位成功`);
385
+ } catch (error) {
386
+ this.log.error(`❌ 定位失败:`, error);
387
+ }
388
+ });
389
+
390
+ device.addCommandHandler('changeToMode', async ({ request }) => {
391
+ const mode = (request as any).newMode;
392
+ const modeName = CLEAN_MODE_LABELS[mode] || `Unknown(${mode})`;
393
+ const haConfig = CLEAN_MODE_TO_HA[mode];
394
+
395
+ if (haConfig) {
396
+ this.log.info(`🔄 切换模式: ${modeName} → ${haConfig.cleaningMode} + ${haConfig.fanSpeed}`);
397
+
398
+ try {
399
+ // 设置清洁模式和风速
400
+ await controller.setCleaningModeAndFanSpeed(haConfig);
401
+ this.log.info(`✅ 模式切换成功`);
402
+ } catch (error) {
403
+ this.log.error(`❌ 模式切换失败:`, error);
404
+ }
405
+ } else {
406
+ this.log.warn(`⚠️ 未知模式: ${mode}`);
407
+ }
408
+
409
+ // 如果是 Cleaning 模式,启动清扫
410
+ if (mode === 2 || modeName.includes('Cleaning')) {
411
+ if (selectedAreaIds.length > 0 && selectedAreaIds.length < SUPPORTED_AREAS.length) {
412
+ await controller.cleanSegments(selectedAreaIds, MATTER_TO_DREAME_SEGMENT);
413
+
414
+ // 15秒后主动刷新状态
415
+ setTimeout(async () => {
416
+ try {
417
+ this.log.info(`⏰ 15秒后自动刷新状态`);
418
+ await controller.forceRefreshState();
419
+ } catch (error) {
420
+ this.log.error(`❌ 自动刷新状态失败:`, error);
421
+ }
422
+ }, 15000);
423
+ } else {
424
+ await controller.start();
425
+ }
426
+ }
427
+ });
428
+
429
+ // State sync
430
+ controller.on('stateChanged', (s: VacuumState) => {
431
+ const errorStr = s.errorMsg ? ` | ❌ ${s.errorMsg}` : '';
432
+
433
+ // 显示关键状态标志(running/started/paused)
434
+ const runningStr = s.rawAttributes?.running ? '🏃 running' : '';
435
+ const startedStr = s.rawAttributes?.started ? '▶️ started' : '';
436
+ const pausedStr = s.rawAttributes?.paused ? '⏸️ paused' : '';
437
+ const returningStr = s.rawAttributes?.returning ? '🔙 returning' : '';
438
+ const statusFlags = [runningStr, startedStr, pausedStr, returningStr].filter(Boolean).join(' | ');
439
+
440
+ this.log.info(
441
+ `🔄 ${s.state} | ${s.batteryLevel}% | ${s.isCharging ? '充电中' : '未充电'} | ` +
442
+ `${s.cleaningMode || '无模式'} | ${s.fanSpeed || '无风速'}${errorStr}`
443
+ );
444
+
445
+ if (statusFlags) {
446
+ this.log.info(` 状态标志: ${statusFlags}`);
447
+ }
448
+
449
+ // 更新电池状态
450
+ this.updateBatteryState(device, s);
451
+
452
+ // 更新运行状态(传递 rawAttributes 而不是 detailedStatus)
453
+ const newOperationalState = mapHAStateToOperationalState(
454
+ s.state,
455
+ s.isCharging,
456
+ s.errorMsg,
457
+ s.rawAttributes
458
+ );
459
+
460
+ // 如果有错误,显示错误详情
461
+ if (s.errorMsg && s.errorMsg !== 'no_error' && s.errorMsg !== 'unavailable') {
462
+ const errorInfo = mapDreameErrorToMatter(s.errorMsg);
463
+ if (errorInfo) {
464
+ this.log.info(` ✓ 运行状态: ${newOperationalState} (Error)`);
465
+ this.log.info(` ✓ 错误详情: ${errorInfo.errorStateLabel} - ${s.errorMsg}`);
466
+ }
467
+ }
468
+ try {
469
+ device.setAttribute(
470
+ RvcOperationalState.Cluster.id,
471
+ 'operationalState',
472
+ newOperationalState,
473
+ this.log
474
+ );
475
+ this.log.debug(` ✓ 运行状态: ${newOperationalState}`);
476
+ } catch (error) {
477
+ this.log.error(` ✗ 运行状态更新失败:`, error);
478
+ }
479
+
480
+ // 更新运行模式
481
+ const newRunMode = mapHAStateToRunMode(s.state);
482
+ try {
483
+ device.setAttribute(
484
+ RvcRunMode.Cluster.id,
485
+ 'currentMode',
486
+ newRunMode,
487
+ this.log
488
+ );
489
+ this.log.debug(` ✓ 运行模式: ${newRunMode}`);
490
+ } catch (error) {
491
+ this.log.error(` ✗ 运行模式更新失败:`, error);
492
+ }
493
+ });
494
+
495
+ return device;
496
+ }
497
+
498
+ private updateBatteryState(device: RoboticVacuumCleaner, state: VacuumState): void {
499
+ try {
500
+ const batPercentRemaining = Math.min(200, Math.max(0, state.batteryLevel * 2));
501
+ const batChargeLevel = state.isCharging
502
+ ? PowerSource.BatChargeLevel.Ok
503
+ : (state.batteryLevel > 20 ? PowerSource.BatChargeLevel.Ok : PowerSource.BatChargeLevel.Critical);
504
+
505
+ const batChargeState = state.isCharging
506
+ ? PowerSource.BatChargeState.IsCharging
507
+ : PowerSource.BatChargeState.IsNotCharging;
508
+
509
+ this.log.debug(
510
+ ` 🔋 电量: ${state.batteryLevel}% (Matter值:${batPercentRemaining}) | ` +
511
+ `充电:${state.isCharging ? '是' : '否'}`
512
+ );
513
+
514
+ device.setAttribute(PowerSource.Cluster.id, 'batPercentRemaining', batPercentRemaining, this.log);
515
+ device.setAttribute(PowerSource.Cluster.id, 'batChargeLevel', batChargeLevel, this.log);
516
+ device.setAttribute(PowerSource.Cluster.id, 'batChargeState', batChargeState, this.log);
517
+
518
+ } catch (error) {
519
+ this.log.error('❌ 电池状态更新失败:', error);
520
+ }
521
+ }
522
+
523
+ override async onConfigure(): Promise<void> {
524
+ this.log.info('⚙️ 配置插件');
525
+ }
526
+
527
+ override async onShutdown(reason?: string): Promise<void> {
528
+ this.log.info(`🛑 关闭插件: ${reason || '未知原因'}`);
529
+ for (const ctrl of this.vacuumControllers.values()) {
530
+ ctrl.destroy();
531
+ }
532
+ await this.haClient?.disconnect();
533
+ this.vacuumControllers.clear();
534
+ this.matterDevices.clear();
535
+ this.log.info('✅ 插件已关闭');
536
+ }
537
+ }
538
+ /**
539
+ * 将 Dreame 错误消息映射为 Matter 错误
540
+ * 基于真实历史数据优化
541
+ */
542
+ function mapDreameErrorToMatter(errorMsg?: string): { errorStateId: number; errorStateLabel: string; errorStateDetails: string } | null {
543
+ if (!errorMsg || errorMsg === 'no_error' || errorMsg === 'null') {
544
+ return null;
545
+ }
546
+
547
+ if (errorMsg === 'unavailable') {
548
+ return null;
549
+ }
550
+
551
+ const lowerError = errorMsg.toLowerCase().replace(/\s+/g, '_');
552
+
553
+ const knownErrors: Record<string, { id: number; label: string }> = {
554
+ 'drop': { id: 0x02, label: '悬崖传感器' },
555
+ 'bumper': { id: 0x02, label: '碰撞传感器异常' },
556
+ 'route': { id: 0x02, label: '路径规划失败' },
557
+ 'mop_removed': { id: 0x03, label: '拖布未安装' },
558
+ 'clean_mop_pad': { id: 0x03, label: '需要清洁拖布' },
559
+ 'unknown': { id: 0x02, label: '未知错误' },
560
+ };
561
+
562
+ if (knownErrors[lowerError]) {
563
+ return {
564
+ errorStateId: knownErrors[lowerError].id,
565
+ errorStateLabel: knownErrors[lowerError].label,
566
+ errorStateDetails: errorMsg
567
+ };
568
+ }
569
+
570
+ if (lowerError.includes('wheel') || lowerError.includes('brush') ||
571
+ lowerError.includes('stuck') || lowerError.includes('sensor')) {
572
+ return {
573
+ errorStateId: 0x02,
574
+ errorStateLabel: '机械/传感器故障',
575
+ errorStateDetails: errorMsg
576
+ };
577
+ }
578
+
579
+ if (lowerError.includes('dustbin') || lowerError.includes('filter') ||
580
+ lowerError.includes('mop') || lowerError.includes('water')) {
581
+ return {
582
+ errorStateId: 0x03,
583
+ errorStateLabel: '需要维护',
584
+ errorStateDetails: errorMsg
585
+ };
586
+ }
587
+
588
+ if (lowerError.includes('battery') || lowerError.includes('charging')) {
589
+ return {
590
+ errorStateId: 0x01,
591
+ errorStateLabel: '电源问题',
592
+ errorStateDetails: errorMsg
593
+ };
594
+ }
595
+
596
+ return {
597
+ errorStateId: 0x02,
598
+ errorStateLabel: '设备错误',
599
+ errorStateDetails: errorMsg
600
+ };
601
+ }
@@ -0,0 +1,53 @@
1
+ import { HomeAssistantClient } from '../src/homeassistant-client.js';
2
+ import { HAVacuumController } from '../src/ha-vacuum-controller.js';
3
+
4
+ async function test() {
5
+ console.log('=== Testing HA Connection ===\n');
6
+
7
+ const HA_URL = process.env.HA_URL || 'http://homeassistant.local:8123';
8
+ const HA_TOKEN = process.env.HA_TOKEN || '';
9
+
10
+ if (!HA_TOKEN) {
11
+ console.error('❌ Set HA_TOKEN: export HA_TOKEN="your-token"');
12
+ process.exit(1);
13
+ }
14
+
15
+ try {
16
+ console.log('1. Creating client...');
17
+ const client = new HomeAssistantClient(HA_URL, HA_TOKEN);
18
+
19
+ console.log('2. Testing connection...');
20
+ await client.testConnection();
21
+
22
+ console.log('3. Getting vacuums...');
23
+ const vacuums = await client.getVacuumEntities();
24
+ console.log(` Found ${vacuums.length} vacuum(s)`);
25
+
26
+ for (const v of vacuums) {
27
+ console.log(` - ${v.entity_id} (${v.state}, ${v.attributes.battery_level}%)`);
28
+ }
29
+
30
+ if (vacuums.length > 0) {
31
+ console.log('\n4. Testing controller...');
32
+ const ctrl = new HAVacuumController(client, vacuums[0].entity_id);
33
+ await ctrl.initialize();
34
+ console.log(' ✅ Controller OK');
35
+
36
+ console.log('\n5. Testing WebSocket...');
37
+ await client.connectWebSocket();
38
+ console.log(' ✅ WebSocket OK');
39
+
40
+ console.log('\n6. Cleanup...');
41
+ ctrl.destroy();
42
+ client.disconnect();
43
+ }
44
+
45
+ console.log('\n✅ All tests passed!');
46
+ process.exit(0);
47
+ } catch (error) {
48
+ console.error('\n❌ Test failed:', error);
49
+ process.exit(1);
50
+ }
51
+ }
52
+
53
+ test();
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "./dist",
7
+ "rootDir": "./",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "declaration": true,
14
+ "sourceMap": true,
15
+ "types": ["node"]
16
+ },
17
+ "include": ["src/**/*", "test/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }