matterbridge-valetudo 1.0.2
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/CHANGELOG.md +110 -0
- package/LICENSE +202 -0
- package/README.md +253 -0
- package/bmc-button.svg +22 -0
- package/dist/module.js +933 -0
- package/dist/valetudo-client.js +391 -0
- package/dist/valetudo-discovery.js +164 -0
- package/matterbridge-valetudo.schema.json +279 -0
- package/matterbridge.svg +50 -0
- package/npm-shrinkwrap.json +127 -0
- package/package.json +46 -0
package/dist/module.js
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
import { MatterbridgeDynamicPlatform, MatterbridgeEndpoint, contactSensor } from 'matterbridge';
|
|
2
|
+
import { RoboticVacuumCleaner } from 'matterbridge/devices';
|
|
3
|
+
import { RvcCleanMode, RvcRunMode } from 'matterbridge/matter/clusters';
|
|
4
|
+
import { ValetudoClient } from './valetudo-client.js';
|
|
5
|
+
import { ValetudoDiscovery } from './valetudo-discovery.js';
|
|
6
|
+
var RvcRunModeValue;
|
|
7
|
+
(function (RvcRunModeValue) {
|
|
8
|
+
RvcRunModeValue[RvcRunModeValue["Idle"] = 1] = "Idle";
|
|
9
|
+
RvcRunModeValue[RvcRunModeValue["Cleaning"] = 2] = "Cleaning";
|
|
10
|
+
RvcRunModeValue[RvcRunModeValue["Mapping"] = 3] = "Mapping";
|
|
11
|
+
})(RvcRunModeValue || (RvcRunModeValue = {}));
|
|
12
|
+
var RvcCleanModeValue;
|
|
13
|
+
(function (RvcCleanModeValue) {
|
|
14
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMopQuiet"] = 5] = "VacuumMopQuiet";
|
|
15
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMopAuto"] = 6] = "VacuumMopAuto";
|
|
16
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMopQuick"] = 7] = "VacuumMopQuick";
|
|
17
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMopMax"] = 8] = "VacuumMopMax";
|
|
18
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMopTurbo"] = 9] = "VacuumMopTurbo";
|
|
19
|
+
RvcCleanModeValue[RvcCleanModeValue["MopMin"] = 31] = "MopMin";
|
|
20
|
+
RvcCleanModeValue[RvcCleanModeValue["MopLow"] = 32] = "MopLow";
|
|
21
|
+
RvcCleanModeValue[RvcCleanModeValue["MopMedium"] = 33] = "MopMedium";
|
|
22
|
+
RvcCleanModeValue[RvcCleanModeValue["MopHigh"] = 34] = "MopHigh";
|
|
23
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumQuiet"] = 66] = "VacuumQuiet";
|
|
24
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumAuto"] = 67] = "VacuumAuto";
|
|
25
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumQuick"] = 68] = "VacuumQuick";
|
|
26
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumMax"] = 69] = "VacuumMax";
|
|
27
|
+
RvcCleanModeValue[RvcCleanModeValue["VacuumTurbo"] = 70] = "VacuumTurbo";
|
|
28
|
+
})(RvcCleanModeValue || (RvcCleanModeValue = {}));
|
|
29
|
+
var OperationalStateValue;
|
|
30
|
+
(function (OperationalStateValue) {
|
|
31
|
+
OperationalStateValue[OperationalStateValue["Stopped"] = 0] = "Stopped";
|
|
32
|
+
OperationalStateValue[OperationalStateValue["Running"] = 1] = "Running";
|
|
33
|
+
OperationalStateValue[OperationalStateValue["Paused"] = 2] = "Paused";
|
|
34
|
+
OperationalStateValue[OperationalStateValue["Error"] = 3] = "Error";
|
|
35
|
+
OperationalStateValue[OperationalStateValue["SeekingCharger"] = 64] = "SeekingCharger";
|
|
36
|
+
OperationalStateValue[OperationalStateValue["Charging"] = 65] = "Charging";
|
|
37
|
+
OperationalStateValue[OperationalStateValue["Docked"] = 66] = "Docked";
|
|
38
|
+
})(OperationalStateValue || (OperationalStateValue = {}));
|
|
39
|
+
export default function initializePlugin(matterbridge, log, config) {
|
|
40
|
+
return new ValetudoPlatform(matterbridge, log, config);
|
|
41
|
+
}
|
|
42
|
+
export class ValetudoPlatform extends MatterbridgeDynamicPlatform {
|
|
43
|
+
vacuums = new Map();
|
|
44
|
+
mdns = null;
|
|
45
|
+
discoveryInterval = null;
|
|
46
|
+
constructor(matterbridge, log, config) {
|
|
47
|
+
super(matterbridge, log, config);
|
|
48
|
+
if (!this.verifyMatterbridgeVersion?.('3.4.0')) {
|
|
49
|
+
throw new Error(`This plugin requires Matterbridge version >= "3.4.0". Please update Matterbridge from ${this.matterbridge.matterbridgeVersion} to the latest version in the frontend.`);
|
|
50
|
+
}
|
|
51
|
+
this.log.info('Initializing platform for multi-vacuum support...');
|
|
52
|
+
}
|
|
53
|
+
async onStart(reason) {
|
|
54
|
+
this.log.info(`onStart called with reason: ${reason ?? 'none'}`);
|
|
55
|
+
await this.ready;
|
|
56
|
+
await this.clearSelect();
|
|
57
|
+
await this.discoverDevices();
|
|
58
|
+
}
|
|
59
|
+
async onConfigure() {
|
|
60
|
+
await super.onConfigure();
|
|
61
|
+
this.log.info('onConfigure called');
|
|
62
|
+
}
|
|
63
|
+
async onChangeLoggerLevel(logLevel) {
|
|
64
|
+
this.log.info(`onChangeLoggerLevel called with: ${logLevel}`);
|
|
65
|
+
}
|
|
66
|
+
async onShutdown(reason) {
|
|
67
|
+
await super.onShutdown(reason);
|
|
68
|
+
this.log.info(`onShutdown called with reason: ${reason ?? 'none'}`);
|
|
69
|
+
if (this.discoveryInterval) {
|
|
70
|
+
clearInterval(this.discoveryInterval);
|
|
71
|
+
this.discoveryInterval = null;
|
|
72
|
+
}
|
|
73
|
+
if (this.mdns) {
|
|
74
|
+
this.mdns.destroy();
|
|
75
|
+
this.mdns = null;
|
|
76
|
+
}
|
|
77
|
+
for (const vacuum of this.vacuums.values()) {
|
|
78
|
+
if (vacuum.pollingInterval) {
|
|
79
|
+
clearInterval(vacuum.pollingInterval);
|
|
80
|
+
vacuum.pollingInterval = null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.vacuums.clear();
|
|
84
|
+
if (this.config.unregisterOnShutdown === true)
|
|
85
|
+
await this.unregisterAllDevices();
|
|
86
|
+
}
|
|
87
|
+
async loadManualVacuums() {
|
|
88
|
+
const config = this.config;
|
|
89
|
+
const manualVacuums = config.vacuums || [];
|
|
90
|
+
this.log.info(`Loading ${manualVacuums.length} manually configured vacuums...`);
|
|
91
|
+
for (const vacuumConfig of manualVacuums) {
|
|
92
|
+
if (vacuumConfig.enabled === false) {
|
|
93
|
+
this.log.info(`Skipping disabled vacuum at ${vacuumConfig.ip}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await this.addVacuum(vacuumConfig.ip, vacuumConfig.name, 'manual');
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
this.log.error(`Failed to add manual vacuum at ${vacuumConfig.ip}: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async discoverAndAddVacuums() {
|
|
105
|
+
const config = this.config;
|
|
106
|
+
const discoveryEnabled = config.discovery?.enabled !== false;
|
|
107
|
+
if (!discoveryEnabled) {
|
|
108
|
+
this.log.info('mDNS discovery is disabled');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.log.info('Starting mDNS discovery for Valetudo vacuums...');
|
|
112
|
+
try {
|
|
113
|
+
this.mdns = new ValetudoDiscovery(this.log);
|
|
114
|
+
const timeout = config.discovery?.timeout || 5000;
|
|
115
|
+
const discovered = await this.mdns.discover(timeout);
|
|
116
|
+
this.log.info(`mDNS discovery found ${discovered.length} vacuum(s)`);
|
|
117
|
+
for (const vacuum of discovered) {
|
|
118
|
+
try {
|
|
119
|
+
const existing = Array.from(this.vacuums.values()).find((v) => v.ip === vacuum.ip);
|
|
120
|
+
if (existing) {
|
|
121
|
+
this.log.info(`Vacuum at ${vacuum.ip} already added manually, skipping mDNS entry`);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
await this.addVacuum(vacuum.ip, undefined, 'mdns');
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
this.log.error(`Failed to add discovered vacuum at ${vacuum.ip}: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
this.log.error(`mDNS discovery failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
if (this.mdns) {
|
|
136
|
+
this.mdns.destroy();
|
|
137
|
+
this.mdns = null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
async addVacuum(ip, customName, source) {
|
|
142
|
+
this.log.info(`Adding vacuum from ${source}: ${ip}${customName ? ` (${customName})` : ''}`);
|
|
143
|
+
const client = new ValetudoClient(ip, this.log);
|
|
144
|
+
const isConnected = await client.testConnection();
|
|
145
|
+
if (!isConnected) {
|
|
146
|
+
throw new Error(`Failed to connect to Valetudo at ${ip}`);
|
|
147
|
+
}
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
149
|
+
const info = await client.getInfo();
|
|
150
|
+
if (!info) {
|
|
151
|
+
throw new Error(`Failed to fetch Valetudo info from ${ip}`);
|
|
152
|
+
}
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
154
|
+
const existing = this.vacuums.get(info.systemId);
|
|
155
|
+
if (existing) {
|
|
156
|
+
if (existing.ip !== ip) {
|
|
157
|
+
this.log.warn(`Vacuum ${info.systemId} already exists at ${existing.ip}, now found at ${ip}. Updating IP address.`);
|
|
158
|
+
existing.ip = ip;
|
|
159
|
+
existing.client = client;
|
|
160
|
+
existing.lastSeen = Date.now();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.log.warn(`Vacuum ${info.systemId} at ${ip} already added, skipping`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const robotInfo = await client.getRobotInfo();
|
|
169
|
+
if (!robotInfo) {
|
|
170
|
+
throw new Error(`Failed to fetch robot info from ${ip}`);
|
|
171
|
+
}
|
|
172
|
+
let deviceName;
|
|
173
|
+
if (customName) {
|
|
174
|
+
deviceName = customName;
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
deviceName = `${robotInfo.manufacturer} ${robotInfo.modelName}`;
|
|
178
|
+
}
|
|
179
|
+
const vacuum = {
|
|
180
|
+
id: info.systemId,
|
|
181
|
+
ip,
|
|
182
|
+
name: deviceName,
|
|
183
|
+
client,
|
|
184
|
+
device: null,
|
|
185
|
+
pollingInterval: null,
|
|
186
|
+
capabilities: [],
|
|
187
|
+
operationModes: [],
|
|
188
|
+
areaToSegmentMap: new Map(),
|
|
189
|
+
selectedSegmentIds: [],
|
|
190
|
+
selectedRoomNames: [],
|
|
191
|
+
consumableMap: new Map(),
|
|
192
|
+
mapLayersCache: null,
|
|
193
|
+
mapCacheValidUntil: 0,
|
|
194
|
+
lastCurrentArea: null,
|
|
195
|
+
lastConsumablesCheck: 0,
|
|
196
|
+
lastBatteryLevel: null,
|
|
197
|
+
lastBatteryChargeState: null,
|
|
198
|
+
lastOperationalState: null,
|
|
199
|
+
lastRunMode: null,
|
|
200
|
+
initialStatePending: true,
|
|
201
|
+
source,
|
|
202
|
+
lastSeen: Date.now(),
|
|
203
|
+
online: true,
|
|
204
|
+
};
|
|
205
|
+
this.vacuums.set(info.systemId, vacuum);
|
|
206
|
+
this.log.info(`Added vacuum: ${deviceName} (ID: ${info.systemId}, IP: ${ip})`);
|
|
207
|
+
await this.initializeVacuum(vacuum);
|
|
208
|
+
}
|
|
209
|
+
async initializeVacuum(vacuum) {
|
|
210
|
+
this.log.info(`Initializing vacuum: ${vacuum.name}`);
|
|
211
|
+
try {
|
|
212
|
+
const capabilities = await vacuum.client.getCapabilities();
|
|
213
|
+
if (capabilities) {
|
|
214
|
+
vacuum.capabilities = capabilities;
|
|
215
|
+
this.log.info(` Capabilities: ${capabilities.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
await this.createDeviceForVacuum(vacuum);
|
|
218
|
+
this.startPollingForVacuum(vacuum);
|
|
219
|
+
this.log.info(`Successfully initialized vacuum: ${vacuum.name}`);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
this.log.error(`Failed to initialize vacuum ${vacuum.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
223
|
+
vacuum.online = false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async createDeviceForVacuum(vacuum) {
|
|
227
|
+
this.log.info(`Creating Matter device for vacuum: ${vacuum.name}`);
|
|
228
|
+
try {
|
|
229
|
+
const robotInfo = await vacuum.client.getRobotInfo();
|
|
230
|
+
if (!robotInfo) {
|
|
231
|
+
throw new Error('Failed to fetch robot information');
|
|
232
|
+
}
|
|
233
|
+
let supportedAreas;
|
|
234
|
+
if (vacuum.capabilities.includes('MapSegmentationCapability')) {
|
|
235
|
+
const segments = await vacuum.client.getMapSegments();
|
|
236
|
+
if (segments && segments.length > 0) {
|
|
237
|
+
const usedNames = new Map();
|
|
238
|
+
const validSegments = segments.filter((segment) => segment.name && segment.name.trim().length > 0);
|
|
239
|
+
supportedAreas = validSegments.map((segment, index) => {
|
|
240
|
+
let locationName = segment.name.trim() || `Room ${index + 1}`;
|
|
241
|
+
if (usedNames.has(locationName)) {
|
|
242
|
+
const count = (usedNames.get(locationName) ?? 0) + 1;
|
|
243
|
+
usedNames.set(locationName, count);
|
|
244
|
+
locationName = `${locationName} ${count}`;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
usedNames.set(locationName, 1);
|
|
248
|
+
}
|
|
249
|
+
const areaId = index + 1;
|
|
250
|
+
vacuum.areaToSegmentMap.set(areaId, { id: segment.id, name: locationName });
|
|
251
|
+
return {
|
|
252
|
+
areaId,
|
|
253
|
+
mapId: null,
|
|
254
|
+
areaInfo: {
|
|
255
|
+
locationInfo: {
|
|
256
|
+
locationName,
|
|
257
|
+
floorNumber: 0,
|
|
258
|
+
areaType: null,
|
|
259
|
+
},
|
|
260
|
+
landmarkInfo: null,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
if (supportedAreas && supportedAreas.length > 0) {
|
|
265
|
+
this.log.info(` Found ${supportedAreas.length} areas: ${supportedAreas.map((a) => a.areaInfo.locationInfo?.locationName || 'Unknown').join(', ')}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const supportedRunModes = [
|
|
270
|
+
{ label: 'Idle', mode: 1, modeTags: [{ value: RvcRunMode.ModeTag.Idle }] },
|
|
271
|
+
{ label: 'Cleaning', mode: 2, modeTags: [{ value: RvcRunMode.ModeTag.Cleaning }] },
|
|
272
|
+
];
|
|
273
|
+
if (vacuum.capabilities.includes('MappingPassCapability')) {
|
|
274
|
+
supportedRunModes.push({
|
|
275
|
+
label: 'Mapping',
|
|
276
|
+
mode: 3,
|
|
277
|
+
modeTags: [{ value: RvcRunMode.ModeTag.Mapping }],
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const supportedCleanModes = [];
|
|
281
|
+
let fanSpeedPresets = null;
|
|
282
|
+
let waterUsagePresets = null;
|
|
283
|
+
if (vacuum.capabilities.includes('FanSpeedControlCapability')) {
|
|
284
|
+
fanSpeedPresets = await vacuum.client.getFanSpeedPresets();
|
|
285
|
+
}
|
|
286
|
+
if (vacuum.capabilities.includes('WaterUsageControlCapability')) {
|
|
287
|
+
waterUsagePresets = await vacuum.client.getWaterUsagePresets();
|
|
288
|
+
}
|
|
289
|
+
if (vacuum.capabilities.includes('OperationModeControlCapability')) {
|
|
290
|
+
const operationModes = await vacuum.client.getOperationModePresets();
|
|
291
|
+
if (operationModes && operationModes.length > 0) {
|
|
292
|
+
vacuum.operationModes = operationModes;
|
|
293
|
+
if (operationModes.includes('vacuum') || operationModes.includes('vaccum')) {
|
|
294
|
+
supportedCleanModes.push(...this.createIntensityVariants('vacuum', 66, [RvcCleanMode.ModeTag.Vacuum], fanSpeedPresets));
|
|
295
|
+
}
|
|
296
|
+
if (operationModes.includes('mop')) {
|
|
297
|
+
supportedCleanModes.push(...this.createMopVariants(fanSpeedPresets, waterUsagePresets));
|
|
298
|
+
}
|
|
299
|
+
if (operationModes.includes('vacuum_and_mop')) {
|
|
300
|
+
supportedCleanModes.push(...this.createIntensityVariants('vacuum_and_mop', 5, [RvcCleanMode.ModeTag.Mop, RvcCleanMode.ModeTag.Vacuum], fanSpeedPresets));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (supportedCleanModes.length === 0) {
|
|
305
|
+
supportedCleanModes.push({
|
|
306
|
+
label: 'Vacuum',
|
|
307
|
+
mode: 66,
|
|
308
|
+
modeTags: [{ value: RvcCleanMode.ModeTag.Vacuum }, { value: RvcCleanMode.ModeTag.Auto }],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const useServerMode = this.config.enableServerMode === true;
|
|
312
|
+
vacuum.device = new RoboticVacuumCleaner(vacuum.name, vacuum.id, useServerMode ? 'server' : undefined, 1, supportedRunModes, supportedCleanModes.length > 0 ? supportedCleanModes[0].mode : 1, supportedCleanModes, null, null, undefined, undefined, supportedAreas, [], undefined, undefined);
|
|
313
|
+
this.setupCommandHandlersForVacuum(vacuum);
|
|
314
|
+
vacuum.device.softwareVersion = 1;
|
|
315
|
+
vacuum.device.softwareVersionString = this.version || '1.0.0';
|
|
316
|
+
vacuum.device.hardwareVersion = 1;
|
|
317
|
+
vacuum.device.hardwareVersionString = this.matterbridge.matterbridgeVersion;
|
|
318
|
+
if (!vacuum.device.mode) {
|
|
319
|
+
vacuum.device.createDefaultBridgedDeviceBasicInformationClusterServer(vacuum.device.deviceName || vacuum.name, vacuum.device.serialNumber || vacuum.id, this.matterbridge.aggregatorVendorId, vacuum.device.vendorName || 'Valetudo', vacuum.device.productName || 'Robot Vacuum', vacuum.device.softwareVersion, vacuum.device.softwareVersionString, vacuum.device.hardwareVersion, vacuum.device.hardwareVersionString);
|
|
320
|
+
}
|
|
321
|
+
await this.registerDevice(vacuum.device);
|
|
322
|
+
this.log.info(` Matter device created and registered successfully`);
|
|
323
|
+
await this.setInitialVacuumState(vacuum);
|
|
324
|
+
await this.setupConsumablesForVacuum(vacuum);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
throw new Error(`Failed to create device: ${error instanceof Error ? error.message : String(error)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
startPollingForVacuum(vacuum) {
|
|
331
|
+
const config = this.config;
|
|
332
|
+
const baseInterval = Math.max(5000, Math.min(60000, config.pollingInterval || 30000));
|
|
333
|
+
const MIN_INITIAL_DELAY = 10000;
|
|
334
|
+
const vacuumIndex = Array.from(this.vacuums.keys()).indexOf(vacuum.id);
|
|
335
|
+
const staggerOffset = vacuumIndex * 1000;
|
|
336
|
+
const totalDelay = MIN_INITIAL_DELAY + staggerOffset;
|
|
337
|
+
setTimeout(async () => {
|
|
338
|
+
try {
|
|
339
|
+
this.log.info(`[${vacuum.name}] Running initial state update...`);
|
|
340
|
+
await this.updateVacuumState(vacuum);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
this.log.error(`[${vacuum.name}] Error in initial poll: ${error instanceof Error ? error.message : String(error)}`);
|
|
344
|
+
}
|
|
345
|
+
vacuum.pollingInterval = setInterval(async () => {
|
|
346
|
+
try {
|
|
347
|
+
await this.updateVacuumState(vacuum);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
this.log.error(`Error polling vacuum ${vacuum.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
351
|
+
vacuum.online = false;
|
|
352
|
+
}
|
|
353
|
+
}, baseInterval);
|
|
354
|
+
this.log.info(`Started polling for ${vacuum.name} (${baseInterval}ms interval, ${totalDelay}ms initial delay)`);
|
|
355
|
+
}, totalDelay);
|
|
356
|
+
}
|
|
357
|
+
setupCommandHandlersForVacuum(vacuum) {
|
|
358
|
+
if (!vacuum.device)
|
|
359
|
+
return;
|
|
360
|
+
this.log.info(`Setting up command handlers for vacuum: ${vacuum.name}`);
|
|
361
|
+
vacuum.device.addCommandHandler('identify', async () => {
|
|
362
|
+
this.log.info(`[${vacuum.name}] Identify/Locate handler called`);
|
|
363
|
+
const success = await vacuum.client.locate();
|
|
364
|
+
if (success) {
|
|
365
|
+
this.log.info(`[${vacuum.name}] Successfully triggered locate sound`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
this.log.error(`[${vacuum.name}] Failed to trigger locate sound`);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
vacuum.device.addCommandHandler('changeToMode', async (data) => {
|
|
372
|
+
this.log.info(`[${vacuum.name}] changeToMode called: ${JSON.stringify(data)}`);
|
|
373
|
+
const request = data.request;
|
|
374
|
+
const isRunMode = request.newMode >= 1 && request.newMode <= 3;
|
|
375
|
+
if (isRunMode) {
|
|
376
|
+
if (request.newMode === 2) {
|
|
377
|
+
if (vacuum.selectedSegmentIds.length > 0) {
|
|
378
|
+
this.log.info(`[${vacuum.name}] Starting room cleaning: ${vacuum.selectedRoomNames.join(', ')}`);
|
|
379
|
+
const properties = await vacuum.client.getMapSegmentationProperties();
|
|
380
|
+
await vacuum.client.cleanSegments(vacuum.selectedSegmentIds, 1, properties?.customOrderSupported ?? false);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
this.log.info(`[${vacuum.name}] Starting full home cleaning`);
|
|
384
|
+
await vacuum.client.startCleaning();
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else if (request.newMode === 1) {
|
|
388
|
+
this.log.info(`[${vacuum.name}] Stopping cleaning`);
|
|
389
|
+
await vacuum.client.stopCleaning();
|
|
390
|
+
vacuum.selectedSegmentIds = [];
|
|
391
|
+
vacuum.selectedRoomNames = [];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const settings = this.getIntensitySettings(request.newMode);
|
|
396
|
+
const mode = this.getBaseModeFromNumber(request.newMode);
|
|
397
|
+
if (mode) {
|
|
398
|
+
this.log.info(`[${vacuum.name}] Setting mode '${mode}' with fan '${settings.fan}', water '${settings.water}'`);
|
|
399
|
+
await vacuum.client.setOperationMode(mode);
|
|
400
|
+
if (settings.fan && vacuum.capabilities.includes('FanSpeedControlCapability')) {
|
|
401
|
+
await vacuum.client.setFanSpeed(settings.fan);
|
|
402
|
+
}
|
|
403
|
+
if (settings.water && vacuum.capabilities.includes('WaterUsageControlCapability')) {
|
|
404
|
+
await vacuum.client.setWaterUsage(settings.water);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
vacuum.device.addCommandHandler('pause', async () => {
|
|
410
|
+
this.log.info(`[${vacuum.name}] Pause called`);
|
|
411
|
+
await vacuum.client.pauseCleaning();
|
|
412
|
+
});
|
|
413
|
+
vacuum.device.addCommandHandler('resume', async () => {
|
|
414
|
+
this.log.info(`[${vacuum.name}] Resume called`);
|
|
415
|
+
await vacuum.client.startCleaning();
|
|
416
|
+
});
|
|
417
|
+
vacuum.device.addCommandHandler('goHome', async () => {
|
|
418
|
+
this.log.info(`[${vacuum.name}] GoHome called`);
|
|
419
|
+
await vacuum.client.returnHome();
|
|
420
|
+
});
|
|
421
|
+
vacuum.device.addCommandHandler('selectAreas', async (data) => {
|
|
422
|
+
this.log.info(`[${vacuum.name}] selectAreas called: ${JSON.stringify(data)}`);
|
|
423
|
+
const request = data.request;
|
|
424
|
+
if (!request.newAreas || request.newAreas.length === 0) {
|
|
425
|
+
vacuum.selectedSegmentIds = [];
|
|
426
|
+
vacuum.selectedRoomNames = [];
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const segmentIds = [];
|
|
430
|
+
const roomNames = [];
|
|
431
|
+
for (const areaId of request.newAreas) {
|
|
432
|
+
const segmentInfo = vacuum.areaToSegmentMap.get(areaId);
|
|
433
|
+
if (segmentInfo) {
|
|
434
|
+
segmentIds.push(segmentInfo.id);
|
|
435
|
+
roomNames.push(segmentInfo.name);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
vacuum.selectedSegmentIds = segmentIds;
|
|
439
|
+
vacuum.selectedRoomNames = roomNames;
|
|
440
|
+
this.log.info(`[${vacuum.name}] Selected rooms: ${roomNames.join(', ')}`);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
getBaseModeFromNumber(mode) {
|
|
444
|
+
if (mode >= 66 && mode <= 70)
|
|
445
|
+
return 'vacuum';
|
|
446
|
+
if (mode >= 31 && mode <= 42)
|
|
447
|
+
return 'mop';
|
|
448
|
+
if (mode >= 5 && mode <= 9)
|
|
449
|
+
return 'vacuum_and_mop';
|
|
450
|
+
return '';
|
|
451
|
+
}
|
|
452
|
+
async setupConsumablesForVacuum(vacuum) {
|
|
453
|
+
const config = this.config;
|
|
454
|
+
if (!config.consumables?.enabled) {
|
|
455
|
+
this.log.debug(`[${vacuum.name}] Consumable tracking disabled`);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (!vacuum.capabilities.includes('ConsumableMonitoringCapability')) {
|
|
459
|
+
this.log.warn(`[${vacuum.name}] ConsumableMonitoringCapability not supported`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const consumables = await vacuum.client.getConsumables();
|
|
463
|
+
if (!consumables || consumables.length === 0) {
|
|
464
|
+
this.log.info(`[${vacuum.name}] No consumables found`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
this.log.info(`[${vacuum.name}] Found ${consumables.length} consumables`);
|
|
468
|
+
const maxLifetimes = config.consumables?.maxLifetimes || {
|
|
469
|
+
mainBrush: 18000,
|
|
470
|
+
sideBrush: 12000,
|
|
471
|
+
dustFilter: 9000,
|
|
472
|
+
sensor: 1800,
|
|
473
|
+
};
|
|
474
|
+
const exposeAsContactSensors = config.consumables?.exposeAsContactSensors === true;
|
|
475
|
+
const warningThreshold = config.consumables?.warningThreshold ?? 10;
|
|
476
|
+
for (const consumable of consumables) {
|
|
477
|
+
const name = this.getConsumableName(consumable);
|
|
478
|
+
const maxLifetime = this.getMaxLifetime(consumable, maxLifetimes);
|
|
479
|
+
const remainingMinutes = consumable.remaining.value;
|
|
480
|
+
const lifePercent = Math.round((remainingMinutes / maxLifetime) * 100);
|
|
481
|
+
const needsReplacement = lifePercent <= warningThreshold;
|
|
482
|
+
this.log.info(` ${name}: ${remainingMinutes}min (${lifePercent}%)`);
|
|
483
|
+
if (exposeAsContactSensors) {
|
|
484
|
+
const sensorName = `${vacuum.name} ${name}`;
|
|
485
|
+
const sensorId = `${vacuum.id}-consumable-${consumable.type}-${consumable.subType}`.replace(/[^a-zA-Z0-9-]/g, '_');
|
|
486
|
+
this.log.info(` Creating contact sensor: ${sensorName} (ID: ${sensorId})`);
|
|
487
|
+
const sensor = new MatterbridgeEndpoint(contactSensor, { id: sensorId }, this.config.debug);
|
|
488
|
+
sensor.createDefaultBridgedDeviceBasicInformationClusterServer(sensorName, sensorId, this.matterbridge.aggregatorVendorId, 'Valetudo', name);
|
|
489
|
+
sensor.createDefaultBooleanStateClusterServer(!needsReplacement);
|
|
490
|
+
await this.registerDevice(sensor);
|
|
491
|
+
vacuum.consumableMap.set(name, { endpoint: sensor, consumable, lastState: needsReplacement });
|
|
492
|
+
this.log.info(` Contact sensor registered: ${sensorName} (${needsReplacement ? 'OPEN - needs replacement' : 'CLOSED - OK'})`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
vacuum.consumableMap.set(name, { consumable });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async setInitialVacuumState(vacuum) {
|
|
500
|
+
if (!vacuum.device)
|
|
501
|
+
return;
|
|
502
|
+
try {
|
|
503
|
+
const attributes = await vacuum.client.getStateAttributes();
|
|
504
|
+
if (!attributes) {
|
|
505
|
+
this.log.warn(`[${vacuum.name}] Failed to fetch initial state attributes`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const battery = attributes.find((attr) => attr.__class === 'BatteryStateAttribute');
|
|
509
|
+
if (battery) {
|
|
510
|
+
const batPercentRemaining = Math.round(battery.level * 2);
|
|
511
|
+
let batChargeState = 0;
|
|
512
|
+
if (battery.flag === 'charging') {
|
|
513
|
+
batChargeState = 1;
|
|
514
|
+
}
|
|
515
|
+
else if (battery.flag === 'charged') {
|
|
516
|
+
batChargeState = 2;
|
|
517
|
+
}
|
|
518
|
+
else if (battery.flag === 'discharging' || battery.flag === 'none') {
|
|
519
|
+
batChargeState = 3;
|
|
520
|
+
}
|
|
521
|
+
await vacuum.device.setAttribute('PowerSource', 'batPercentRemaining', batPercentRemaining, this.log);
|
|
522
|
+
await vacuum.device.setAttribute('PowerSource', 'batChargeState', batChargeState, this.log);
|
|
523
|
+
vacuum.lastBatteryLevel = batPercentRemaining;
|
|
524
|
+
vacuum.lastBatteryChargeState = batChargeState;
|
|
525
|
+
this.log.info(` Initial battery: ${battery.level}% (${batPercentRemaining}/200), charge state: ${batChargeState}`);
|
|
526
|
+
}
|
|
527
|
+
const statusAttr = attributes.find((attr) => attr.__class === 'StatusStateAttribute');
|
|
528
|
+
const dockStatus = attributes.find((attr) => attr.__class === 'DockStatusStateAttribute');
|
|
529
|
+
if (statusAttr) {
|
|
530
|
+
const operationalState = this.mapValetudoStatusToOperationalState(statusAttr.value, dockStatus?.value);
|
|
531
|
+
await vacuum.device.setAttribute('RvcOperationalState', 'operationalState', operationalState, this.log);
|
|
532
|
+
vacuum.lastOperationalState = operationalState;
|
|
533
|
+
const runMode = this.mapValetudoStatusToRunMode(statusAttr.value);
|
|
534
|
+
await vacuum.device.setAttribute('RvcRunMode', 'currentMode', runMode, this.log);
|
|
535
|
+
vacuum.lastRunMode = runMode;
|
|
536
|
+
this.log.info(` Initial state: "${statusAttr.value}" (operational: ${operationalState}, run mode: ${runMode})`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch (error) {
|
|
540
|
+
this.log.error(`[${vacuum.name}] Error setting initial state: ${error instanceof Error ? error.message : String(error)}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
async updateVacuumState(vacuum) {
|
|
544
|
+
if (!vacuum.device)
|
|
545
|
+
return;
|
|
546
|
+
try {
|
|
547
|
+
const attributes = await vacuum.client.getStateAttributes();
|
|
548
|
+
if (!attributes) {
|
|
549
|
+
this.log.warn(`[${vacuum.name}] Failed to fetch state attributes`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const battery = attributes.find((attr) => attr.__class === 'BatteryStateAttribute');
|
|
553
|
+
if (battery) {
|
|
554
|
+
const batPercentRemaining = Math.round(battery.level * 2);
|
|
555
|
+
let batChargeState = 0;
|
|
556
|
+
if (battery.flag === 'charging') {
|
|
557
|
+
batChargeState = 1;
|
|
558
|
+
}
|
|
559
|
+
else if (battery.flag === 'charged') {
|
|
560
|
+
batChargeState = 2;
|
|
561
|
+
}
|
|
562
|
+
else if (battery.flag === 'discharging' || battery.flag === 'none') {
|
|
563
|
+
batChargeState = 3;
|
|
564
|
+
}
|
|
565
|
+
const batteryChanged = vacuum.lastBatteryLevel !== batPercentRemaining;
|
|
566
|
+
const chargeStateChanged = vacuum.lastBatteryChargeState !== batChargeState;
|
|
567
|
+
if (vacuum.initialStatePending || batteryChanged) {
|
|
568
|
+
this.log.info(`[${vacuum.name}] Battery: ${battery.level}% (${batPercentRemaining}/200)`);
|
|
569
|
+
await vacuum.device.setAttribute('PowerSource', 'batPercentRemaining', batPercentRemaining, this.log);
|
|
570
|
+
vacuum.lastBatteryLevel = batPercentRemaining;
|
|
571
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
572
|
+
}
|
|
573
|
+
if (vacuum.initialStatePending || chargeStateChanged) {
|
|
574
|
+
this.log.info(`[${vacuum.name}] Battery charge state: ${batChargeState}`);
|
|
575
|
+
await vacuum.device.setAttribute('PowerSource', 'batChargeState', batChargeState, this.log);
|
|
576
|
+
vacuum.lastBatteryChargeState = batChargeState;
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
581
|
+
const statusAttr = attributes.find((attr) => attr.__class === 'StatusStateAttribute');
|
|
582
|
+
const dockStatus = attributes.find((attr) => attr.__class === 'DockStatusStateAttribute');
|
|
583
|
+
if (statusAttr) {
|
|
584
|
+
const status = statusAttr;
|
|
585
|
+
const operationalState = this.mapValetudoStatusToOperationalState(status.value, dockStatus?.value);
|
|
586
|
+
const operationalStateChanged = vacuum.lastOperationalState !== operationalState;
|
|
587
|
+
if (vacuum.initialStatePending || operationalStateChanged) {
|
|
588
|
+
this.log.info(`[${vacuum.name}] Operational state: "${status.value}" → ${operationalState}`);
|
|
589
|
+
await vacuum.device.setAttribute('RvcOperationalState', 'operationalState', operationalState, this.log);
|
|
590
|
+
vacuum.lastOperationalState = operationalState;
|
|
591
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
592
|
+
}
|
|
593
|
+
const runMode = this.mapValetudoStatusToRunMode(status.value);
|
|
594
|
+
const runModeChanged = vacuum.lastRunMode !== runMode;
|
|
595
|
+
if (vacuum.initialStatePending || runModeChanged) {
|
|
596
|
+
this.log.info(`[${vacuum.name}] Run mode: ${status.value} → ${runMode === 1 ? 'Idle' : 'Cleaning'}`);
|
|
597
|
+
await vacuum.device.setAttribute('RvcRunMode', 'currentMode', runMode, this.log);
|
|
598
|
+
vacuum.lastRunMode = runMode;
|
|
599
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (vacuum.initialStatePending) {
|
|
603
|
+
vacuum.initialStatePending = false;
|
|
604
|
+
this.log.debug(`[${vacuum.name}] Initial state set successfully`);
|
|
605
|
+
}
|
|
606
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
607
|
+
const config = this.config;
|
|
608
|
+
if (config.positionTracking?.enabled !== false && vacuum.mapLayersCache && vacuum.areaToSegmentMap.size > 0) {
|
|
609
|
+
try {
|
|
610
|
+
if (Date.now() > vacuum.mapCacheValidUntil) {
|
|
611
|
+
await this.refreshMapCacheForVacuum(vacuum);
|
|
612
|
+
}
|
|
613
|
+
const positionData = await vacuum.client.getMapPositionData();
|
|
614
|
+
if (positionData) {
|
|
615
|
+
if (positionData.metaData?.version !== undefined && positionData.metaData.version !== vacuum.mapLayersCache.version) {
|
|
616
|
+
this.log.warn(`[${vacuum.name}] Map version changed, refreshing cache...`);
|
|
617
|
+
await this.refreshMapCacheForVacuum(vacuum);
|
|
618
|
+
}
|
|
619
|
+
const robotEntity = positionData.entities.find((entity) => entity.type === 'robot_position');
|
|
620
|
+
if (robotEntity && robotEntity.points.length >= 2) {
|
|
621
|
+
const robotPos = {
|
|
622
|
+
x: Math.round(robotEntity.points[0] / vacuum.mapLayersCache.pixelSize),
|
|
623
|
+
y: Math.round(robotEntity.points[1] / vacuum.mapLayersCache.pixelSize),
|
|
624
|
+
};
|
|
625
|
+
const currentSegment = vacuum.client.findSegmentAtPositionCached(vacuum.mapLayersCache, robotPos.x, robotPos.y);
|
|
626
|
+
if (currentSegment) {
|
|
627
|
+
let foundAreaId = null;
|
|
628
|
+
for (const [areaId, segmentInfo] of vacuum.areaToSegmentMap.entries()) {
|
|
629
|
+
if (segmentInfo.id === currentSegment.metaData.segmentId) {
|
|
630
|
+
foundAreaId = areaId;
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (foundAreaId !== null && vacuum.lastCurrentArea !== foundAreaId) {
|
|
635
|
+
const segmentInfo = vacuum.areaToSegmentMap.get(foundAreaId);
|
|
636
|
+
this.log.info(`[${vacuum.name}] Location: ${segmentInfo?.name || 'Unknown'} (area ${foundAreaId})`);
|
|
637
|
+
await vacuum.device.setAttribute('ServiceArea', 'currentArea', foundAreaId, this.log);
|
|
638
|
+
vacuum.lastCurrentArea = foundAreaId;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
this.log.debug(`[${vacuum.name}] Position tracking error: ${error instanceof Error ? error.message : String(error)}`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (vacuum.consumableMap.size > 0) {
|
|
649
|
+
await this.updateConsumableStatesForVacuum(vacuum);
|
|
650
|
+
}
|
|
651
|
+
vacuum.lastSeen = Date.now();
|
|
652
|
+
vacuum.online = true;
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
this.log.error(`[${vacuum.name}] Error updating state: ${error instanceof Error ? error.message : String(error)}`);
|
|
656
|
+
vacuum.online = false;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
async refreshMapCacheForVacuum(vacuum) {
|
|
660
|
+
const config = this.config;
|
|
661
|
+
const refreshHours = Math.max(0.1, Math.min(24, config.mapCache?.refreshIntervalHours ?? 1));
|
|
662
|
+
const mapData = await vacuum.client.getMapDataWithTimeout(60000);
|
|
663
|
+
if (mapData) {
|
|
664
|
+
vacuum.mapLayersCache = vacuum.client.createCachedLayers(mapData);
|
|
665
|
+
vacuum.mapCacheValidUntil = Date.now() + refreshHours * 60 * 60 * 1000;
|
|
666
|
+
this.log.debug(`[${vacuum.name}] Map cache refreshed`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
async updateConsumableStatesForVacuum(vacuum) {
|
|
670
|
+
const CONSUMABLES_CHECK_INTERVAL = 5 * 60 * 1000;
|
|
671
|
+
const now = Date.now();
|
|
672
|
+
if (now - vacuum.lastConsumablesCheck < CONSUMABLES_CHECK_INTERVAL) {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
vacuum.lastConsumablesCheck = now;
|
|
676
|
+
const config = this.config;
|
|
677
|
+
const maxLifetimes = config.consumables?.maxLifetimes || {
|
|
678
|
+
mainBrush: 18000,
|
|
679
|
+
sideBrush: 12000,
|
|
680
|
+
dustFilter: 9000,
|
|
681
|
+
sensor: 1800,
|
|
682
|
+
};
|
|
683
|
+
const warningThreshold = config.consumables?.warningThreshold || 10;
|
|
684
|
+
try {
|
|
685
|
+
const consumables = await vacuum.client.getConsumables();
|
|
686
|
+
if (!consumables)
|
|
687
|
+
return;
|
|
688
|
+
for (const consumable of consumables) {
|
|
689
|
+
const name = this.getConsumableName(consumable);
|
|
690
|
+
const entry = vacuum.consumableMap.get(name);
|
|
691
|
+
if (!entry)
|
|
692
|
+
continue;
|
|
693
|
+
const maxLifetime = this.getMaxLifetime(consumable, maxLifetimes);
|
|
694
|
+
const remainingMinutes = consumable.remaining.value;
|
|
695
|
+
const lifePercent = Math.round((remainingMinutes / maxLifetime) * 100);
|
|
696
|
+
entry.consumable = consumable;
|
|
697
|
+
const needsReplacement = lifePercent <= warningThreshold;
|
|
698
|
+
if (entry.lastState === undefined || entry.lastState !== needsReplacement) {
|
|
699
|
+
const status = needsReplacement ? '⚠️ NEEDS REPLACEMENT' : '✓ OK';
|
|
700
|
+
this.log.info(`[${vacuum.name}] ${name}: ${remainingMinutes}min (${lifePercent}%) - ${status}`);
|
|
701
|
+
entry.lastState = needsReplacement;
|
|
702
|
+
}
|
|
703
|
+
if (entry.endpoint) {
|
|
704
|
+
await entry.endpoint.setAttribute('BooleanState', 'stateValue', !needsReplacement, this.log);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
this.log.debug(`[${vacuum.name}] Error updating consumables: ${error instanceof Error ? error.message : String(error)}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
mapValetudoStatusToOperationalState(status, dockStatus) {
|
|
713
|
+
const statusLower = status.toLowerCase();
|
|
714
|
+
const statusMap = {
|
|
715
|
+
idle: 66,
|
|
716
|
+
docked: 66,
|
|
717
|
+
cleaning: 1,
|
|
718
|
+
returning: 64,
|
|
719
|
+
manual_control: 1,
|
|
720
|
+
moving: 66,
|
|
721
|
+
paused: 2,
|
|
722
|
+
error: 3,
|
|
723
|
+
charging: 65,
|
|
724
|
+
};
|
|
725
|
+
const baseState = statusMap[statusLower] ?? 0;
|
|
726
|
+
if (dockStatus && (statusLower === 'docked' || statusLower === 'idle' || statusLower === 'charging')) {
|
|
727
|
+
const dockStatusLower = dockStatus.toLowerCase();
|
|
728
|
+
if (dockStatusLower === 'emptying' || dockStatusLower === 'drying' || dockStatusLower === 'cleaning') {
|
|
729
|
+
return 66;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return baseState;
|
|
733
|
+
}
|
|
734
|
+
mapValetudoStatusToRunMode(status) {
|
|
735
|
+
const statusLower = status.toLowerCase();
|
|
736
|
+
if (statusLower === 'cleaning') {
|
|
737
|
+
return 2;
|
|
738
|
+
}
|
|
739
|
+
return 1;
|
|
740
|
+
}
|
|
741
|
+
createIntensityVariants(baseMode, baseModeNumber, baseTags, fanSpeedPresets) {
|
|
742
|
+
const modes = [];
|
|
743
|
+
const intensityMap = {
|
|
744
|
+
off: { tag: RvcCleanMode.ModeTag.Min, label: 'Off', offset: 4 },
|
|
745
|
+
min: { tag: RvcCleanMode.ModeTag.Min, label: 'Min', offset: 4 },
|
|
746
|
+
low: { tag: RvcCleanMode.ModeTag.Quiet, label: 'Quiet', offset: 2 },
|
|
747
|
+
medium: { tag: RvcCleanMode.ModeTag.Auto, label: 'Auto', offset: 0 },
|
|
748
|
+
high: { tag: RvcCleanMode.ModeTag.Quick, label: 'Quick', offset: 1 },
|
|
749
|
+
max: { tag: RvcCleanMode.ModeTag.Max, label: 'Max', offset: 3 },
|
|
750
|
+
turbo: { tag: RvcCleanMode.ModeTag.Max, label: 'Turbo', offset: 3 },
|
|
751
|
+
};
|
|
752
|
+
const modeLabel = baseMode === 'vacuum_and_mop' ? 'Vacuum & Mop' : baseMode.charAt(0).toUpperCase() + baseMode.slice(1);
|
|
753
|
+
if (!fanSpeedPresets || fanSpeedPresets.length === 0) {
|
|
754
|
+
modes.push({
|
|
755
|
+
label: modeLabel,
|
|
756
|
+
mode: baseModeNumber,
|
|
757
|
+
modeTags: [...baseTags.map((tag) => ({ value: tag })), { value: RvcCleanMode.ModeTag.Auto }],
|
|
758
|
+
});
|
|
759
|
+
return modes;
|
|
760
|
+
}
|
|
761
|
+
const usedOffsets = new Set();
|
|
762
|
+
for (const preset of fanSpeedPresets) {
|
|
763
|
+
const presetLower = preset.toLowerCase();
|
|
764
|
+
const intensity = intensityMap[presetLower];
|
|
765
|
+
if (intensity && !usedOffsets.has(intensity.offset)) {
|
|
766
|
+
usedOffsets.add(intensity.offset);
|
|
767
|
+
modes.push({
|
|
768
|
+
label: `${modeLabel} (${intensity.label})`,
|
|
769
|
+
mode: baseModeNumber + intensity.offset,
|
|
770
|
+
modeTags: [...baseTags.map((tag) => ({ value: tag })), { value: intensity.tag }],
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (modes.length === 0) {
|
|
775
|
+
modes.push({
|
|
776
|
+
label: modeLabel,
|
|
777
|
+
mode: baseModeNumber,
|
|
778
|
+
modeTags: [...baseTags.map((tag) => ({ value: tag })), { value: RvcCleanMode.ModeTag.Auto }],
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
return modes;
|
|
782
|
+
}
|
|
783
|
+
createMopVariants(fanSpeedPresets, waterUsagePresets) {
|
|
784
|
+
if (!fanSpeedPresets || !waterUsagePresets) {
|
|
785
|
+
return this.createIntensityVariants('mop', 31, [RvcCleanMode.ModeTag.Mop], fanSpeedPresets);
|
|
786
|
+
}
|
|
787
|
+
const modes = [];
|
|
788
|
+
const mopConfigs = [
|
|
789
|
+
{ fanPreset: 'medium', waterPreset: 'medium', tag: RvcCleanMode.ModeTag.Auto, label: 'Mop (Auto)', mode: 31 },
|
|
790
|
+
{ fanPreset: 'low', waterPreset: 'low', tag: RvcCleanMode.ModeTag.Quiet, label: 'Mop (Quiet)', mode: 32 },
|
|
791
|
+
{ fanPreset: 'high', waterPreset: 'high', tag: RvcCleanMode.ModeTag.Quick, label: 'Mop (Quick)', mode: 33 },
|
|
792
|
+
{ fanPreset: 'max', waterPreset: 'high', tag: RvcCleanMode.ModeTag.Max, label: 'Mop (Max)', mode: 34 },
|
|
793
|
+
];
|
|
794
|
+
for (const config of mopConfigs) {
|
|
795
|
+
const hasFan = fanSpeedPresets.includes(config.fanPreset);
|
|
796
|
+
const hasWater = waterUsagePresets.includes(config.waterPreset);
|
|
797
|
+
if (hasFan && hasWater) {
|
|
798
|
+
modes.push({
|
|
799
|
+
label: config.label,
|
|
800
|
+
mode: config.mode,
|
|
801
|
+
modeTags: [{ value: RvcCleanMode.ModeTag.Mop }, { value: config.tag }],
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (modes.length === 0) {
|
|
806
|
+
return this.createIntensityVariants('mop', 31, [RvcCleanMode.ModeTag.Mop], fanSpeedPresets);
|
|
807
|
+
}
|
|
808
|
+
return modes;
|
|
809
|
+
}
|
|
810
|
+
getIntensitySettings(mode) {
|
|
811
|
+
let intensityKey;
|
|
812
|
+
let defaultFan;
|
|
813
|
+
let defaultWater;
|
|
814
|
+
if (mode >= 66 && mode <= 70) {
|
|
815
|
+
const offset = mode - 66;
|
|
816
|
+
const offsetMap = {
|
|
817
|
+
0: { key: 'quiet', fan: 'low', water: 'low' },
|
|
818
|
+
1: { key: 'auto', fan: 'medium', water: 'medium' },
|
|
819
|
+
2: { key: 'quick', fan: 'high', water: 'high' },
|
|
820
|
+
3: { key: 'max', fan: 'max', water: 'high' },
|
|
821
|
+
4: { key: 'max', fan: 'turbo', water: 'high' },
|
|
822
|
+
};
|
|
823
|
+
const mapping = offsetMap[offset] || offsetMap[1];
|
|
824
|
+
intensityKey = mapping.key;
|
|
825
|
+
defaultFan = mapping.fan;
|
|
826
|
+
defaultWater = mapping.water;
|
|
827
|
+
}
|
|
828
|
+
else if (mode >= 31 && mode <= 34) {
|
|
829
|
+
const modeMap = {
|
|
830
|
+
[31]: { key: 'auto', fan: 'medium', water: 'medium' },
|
|
831
|
+
[32]: { key: 'quiet', fan: 'low', water: 'low' },
|
|
832
|
+
[33]: { key: 'quick', fan: 'high', water: 'high' },
|
|
833
|
+
[34]: { key: 'max', fan: 'max', water: 'high' },
|
|
834
|
+
};
|
|
835
|
+
const mapping = modeMap[mode] || modeMap[31];
|
|
836
|
+
intensityKey = mapping.key;
|
|
837
|
+
defaultFan = mapping.fan;
|
|
838
|
+
defaultWater = mapping.water;
|
|
839
|
+
}
|
|
840
|
+
else if (mode >= 5 && mode <= 9) {
|
|
841
|
+
const offset = mode - 5;
|
|
842
|
+
const offsetMap = {
|
|
843
|
+
0: { key: 'quiet', fan: 'low', water: 'low' },
|
|
844
|
+
1: { key: 'auto', fan: 'medium', water: 'medium' },
|
|
845
|
+
2: { key: 'quick', fan: 'high', water: 'high' },
|
|
846
|
+
3: { key: 'max', fan: 'max', water: 'high' },
|
|
847
|
+
4: { key: 'max', fan: 'turbo', water: 'high' },
|
|
848
|
+
};
|
|
849
|
+
const mapping = offsetMap[offset] || offsetMap[1];
|
|
850
|
+
intensityKey = mapping.key;
|
|
851
|
+
defaultFan = mapping.fan;
|
|
852
|
+
defaultWater = mapping.water;
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
return { fan: 'medium', water: 'medium' };
|
|
856
|
+
}
|
|
857
|
+
const config = this.config;
|
|
858
|
+
const overrides = config.intensityPresets?.[intensityKey];
|
|
859
|
+
return {
|
|
860
|
+
fan: overrides?.fanSpeed || defaultFan,
|
|
861
|
+
water: overrides?.waterUsage || defaultWater,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
getConsumableName(consumable) {
|
|
865
|
+
const typeMap = {
|
|
866
|
+
'brush-main': 'Main Brush',
|
|
867
|
+
'brush-side_right': 'Side Brush',
|
|
868
|
+
'brush-side_left': 'Side Brush Left',
|
|
869
|
+
'filter-main': 'Dust Filter',
|
|
870
|
+
'cleaning-sensor': 'Sensor',
|
|
871
|
+
'cleaning-wheel': 'Wheel',
|
|
872
|
+
'consumable-detergent': 'Detergent',
|
|
873
|
+
};
|
|
874
|
+
const key = `${consumable.type}-${consumable.subType}`;
|
|
875
|
+
if (consumable.subType.includes('dock')) {
|
|
876
|
+
return 'Detergent';
|
|
877
|
+
}
|
|
878
|
+
return typeMap[key] || `${consumable.type} ${consumable.subType}`;
|
|
879
|
+
}
|
|
880
|
+
getMaxLifetime(consumable, maxLifetimes) {
|
|
881
|
+
if (consumable.remaining.value <= 100 && consumable.remaining.value >= 0) {
|
|
882
|
+
const isPercentage = consumable.type === 'consumable' || consumable.subType.includes('detergent') || consumable.subType.includes('dock');
|
|
883
|
+
if (isPercentage) {
|
|
884
|
+
return 100;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
let key;
|
|
888
|
+
if (consumable.type === 'brush' && consumable.subType === 'main') {
|
|
889
|
+
key = 'mainBrush';
|
|
890
|
+
}
|
|
891
|
+
else if (consumable.type === 'brush' && (consumable.subType === 'side_right' || consumable.subType === 'side_left')) {
|
|
892
|
+
key = 'sideBrush';
|
|
893
|
+
}
|
|
894
|
+
else if (consumable.type === 'filter' && consumable.subType === 'main') {
|
|
895
|
+
key = 'dustFilter';
|
|
896
|
+
}
|
|
897
|
+
else if (consumable.type === 'cleaning' && consumable.subType === 'sensor') {
|
|
898
|
+
key = 'sensor';
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
return 10000;
|
|
902
|
+
}
|
|
903
|
+
return maxLifetimes[key] || 10000;
|
|
904
|
+
}
|
|
905
|
+
startPeriodicDiscovery() {
|
|
906
|
+
const config = this.config;
|
|
907
|
+
const discoveryEnabled = config.discovery?.enabled !== false;
|
|
908
|
+
if (!discoveryEnabled) {
|
|
909
|
+
this.log.debug('Periodic mDNS discovery not started (mDNS discovery is disabled)');
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const intervalSeconds = config.discovery?.scanIntervalSeconds || 0;
|
|
913
|
+
if (intervalSeconds > 0) {
|
|
914
|
+
const intervalMs = intervalSeconds * 1000;
|
|
915
|
+
this.log.info(`Starting periodic mDNS discovery (every ${intervalSeconds} seconds)`);
|
|
916
|
+
this.discoveryInterval = setInterval(async () => {
|
|
917
|
+
this.log.info('Running periodic mDNS discovery...');
|
|
918
|
+
await this.discoverAndAddVacuums();
|
|
919
|
+
}, intervalMs);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async discoverDevices() {
|
|
923
|
+
this.log.info('Discovering Valetudo devices with multi-vacuum support...');
|
|
924
|
+
await this.loadManualVacuums();
|
|
925
|
+
await this.discoverAndAddVacuums();
|
|
926
|
+
if (this.vacuums.size === 0) {
|
|
927
|
+
this.log.error('No vacuums found! Please configure vacuums manually or enable mDNS discovery.');
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
this.log.info(`Successfully configured ${this.vacuums.size} vacuum(s)`);
|
|
931
|
+
this.startPeriodicDiscovery();
|
|
932
|
+
}
|
|
933
|
+
}
|