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
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
export class ValetudoClient {
|
|
3
|
+
baseUrl;
|
|
4
|
+
log;
|
|
5
|
+
constructor(ip, log) {
|
|
6
|
+
this.baseUrl = `http://${ip}`;
|
|
7
|
+
this.log = log;
|
|
8
|
+
}
|
|
9
|
+
async getInfo() {
|
|
10
|
+
try {
|
|
11
|
+
const url = `${this.baseUrl}/api/v2/valetudo`;
|
|
12
|
+
this.log.debug(`Fetching Valetudo info from: ${url}`);
|
|
13
|
+
const data = await this.httpGet(url);
|
|
14
|
+
this.log.debug(`Valetudo info received: ${JSON.stringify(data)}`);
|
|
15
|
+
return data;
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
this.log.error(`Error fetching Valetudo info: ${error instanceof Error ? error.message : String(error)}`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async getRobotInfo() {
|
|
23
|
+
try {
|
|
24
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot`);
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.log.error(`Error fetching robot info: ${error instanceof Error ? error.message : String(error)}`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async getCapabilities() {
|
|
33
|
+
try {
|
|
34
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities`);
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
this.log.error(`Error fetching capabilities: ${error instanceof Error ? error.message : String(error)}`);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async getStateAttributes() {
|
|
43
|
+
try {
|
|
44
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/state/attributes`);
|
|
45
|
+
return data;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
this.log.error(`Error fetching state attributes: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async executeBasicControl(action) {
|
|
53
|
+
try {
|
|
54
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/BasicControlCapability`, {
|
|
55
|
+
action,
|
|
56
|
+
});
|
|
57
|
+
return result !== null;
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
this.log.error(`Error executing basic control (${action}): ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async start() {
|
|
65
|
+
return this.executeBasicControl('start');
|
|
66
|
+
}
|
|
67
|
+
async stop() {
|
|
68
|
+
return this.executeBasicControl('stop');
|
|
69
|
+
}
|
|
70
|
+
async pause() {
|
|
71
|
+
return this.executeBasicControl('pause');
|
|
72
|
+
}
|
|
73
|
+
async home() {
|
|
74
|
+
return this.executeBasicControl('home');
|
|
75
|
+
}
|
|
76
|
+
async startCleaning() {
|
|
77
|
+
return this.start();
|
|
78
|
+
}
|
|
79
|
+
async stopCleaning() {
|
|
80
|
+
return this.stop();
|
|
81
|
+
}
|
|
82
|
+
async pauseCleaning() {
|
|
83
|
+
return this.pause();
|
|
84
|
+
}
|
|
85
|
+
async returnHome() {
|
|
86
|
+
return this.home();
|
|
87
|
+
}
|
|
88
|
+
async getFanSpeedPresets() {
|
|
89
|
+
try {
|
|
90
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/FanSpeedControlCapability/presets`);
|
|
91
|
+
return data;
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
this.log.error(`Error fetching fan speed presets: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async setFanSpeed(preset) {
|
|
99
|
+
try {
|
|
100
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/FanSpeedControlCapability/preset`, {
|
|
101
|
+
name: preset,
|
|
102
|
+
});
|
|
103
|
+
return result !== null;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
this.log.error(`Error setting fan speed: ${error instanceof Error ? error.message : String(error)}`);
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async getWaterUsagePresets() {
|
|
111
|
+
try {
|
|
112
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/WaterUsageControlCapability/presets`);
|
|
113
|
+
return data;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.log.error(`Error fetching water usage presets: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async setWaterUsage(preset) {
|
|
121
|
+
try {
|
|
122
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/WaterUsageControlCapability/preset`, {
|
|
123
|
+
name: preset,
|
|
124
|
+
});
|
|
125
|
+
return result !== null;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
this.log.error(`Error setting water usage: ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async getOperationModePresets() {
|
|
133
|
+
try {
|
|
134
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/OperationModeControlCapability/presets`);
|
|
135
|
+
return data;
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
this.log.error(`Error fetching operation mode presets: ${error instanceof Error ? error.message : String(error)}`);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async setOperationMode(preset) {
|
|
143
|
+
try {
|
|
144
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/OperationModeControlCapability/preset`, {
|
|
145
|
+
name: preset,
|
|
146
|
+
});
|
|
147
|
+
return result !== null;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
this.log.error(`Error setting operation mode: ${error instanceof Error ? error.message : String(error)}`);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
async getMapSegments() {
|
|
155
|
+
try {
|
|
156
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/MapSegmentationCapability`);
|
|
157
|
+
return data;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.log.error(`Error fetching map segments: ${error instanceof Error ? error.message : String(error)}`);
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async getMapSegmentationProperties() {
|
|
165
|
+
try {
|
|
166
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/MapSegmentationCapability/properties`);
|
|
167
|
+
return data;
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
this.log.error(`Error fetching map segmentation properties: ${error instanceof Error ? error.message : String(error)}`);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async cleanSegments(segmentIds, iterations = 1, customOrder = false) {
|
|
175
|
+
try {
|
|
176
|
+
const payload = {
|
|
177
|
+
action: 'start_segment_action',
|
|
178
|
+
segment_ids: segmentIds,
|
|
179
|
+
iterations,
|
|
180
|
+
customOrder,
|
|
181
|
+
};
|
|
182
|
+
this.log.debug(`cleanSegments: ${JSON.stringify(payload)}`);
|
|
183
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/MapSegmentationCapability`, payload);
|
|
184
|
+
return result !== null;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
this.log.error(`Error cleaning segments: ${error instanceof Error ? error.message : String(error)}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async getMapDataWithTimeout(timeoutMs) {
|
|
192
|
+
try {
|
|
193
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/state/map`, timeoutMs);
|
|
194
|
+
return data;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.log.error(`Error fetching map data: ${error instanceof Error ? error.message : String(error)}`);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async getMapPositionData() {
|
|
202
|
+
try {
|
|
203
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/state/map`);
|
|
204
|
+
const mapData = data;
|
|
205
|
+
return {
|
|
206
|
+
entities: mapData.entities,
|
|
207
|
+
metaData: mapData.metaData,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
this.log.error(`Error fetching position data: ${error instanceof Error ? error.message : String(error)}`);
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
findSegmentAtPositionCached(cachedLayers, x, y) {
|
|
216
|
+
const segments = cachedLayers.layers.filter((layer) => layer.type === 'segment');
|
|
217
|
+
const matchingSegments = [];
|
|
218
|
+
for (const segment of segments) {
|
|
219
|
+
const dims = segment.dimensions;
|
|
220
|
+
const inBounds = x >= dims.x.min && x <= dims.x.max && y >= dims.y.min && y <= dims.y.max;
|
|
221
|
+
if (inBounds) {
|
|
222
|
+
const distanceFromMid = Math.sqrt(Math.pow(x - dims.x.mid, 2) + Math.pow(y - dims.y.mid, 2));
|
|
223
|
+
matchingSegments.push({ segment, distance: distanceFromMid });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (matchingSegments.length === 0) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
if (matchingSegments.length === 1) {
|
|
230
|
+
return matchingSegments[0].segment;
|
|
231
|
+
}
|
|
232
|
+
matchingSegments.sort((a, b) => a.distance - b.distance);
|
|
233
|
+
const closest = matchingSegments[0];
|
|
234
|
+
this.log.debug(`Multiple segments at (${x}, ${y}) - selected "${closest.segment.metaData.segmentId}" (closest midpoint, distance: ${closest.distance.toFixed(1)})`);
|
|
235
|
+
return closest.segment;
|
|
236
|
+
}
|
|
237
|
+
createCachedLayers(mapData) {
|
|
238
|
+
return {
|
|
239
|
+
layers: mapData.layers,
|
|
240
|
+
size: mapData.size,
|
|
241
|
+
pixelSize: mapData.pixelSize,
|
|
242
|
+
timestamp: Date.now(),
|
|
243
|
+
version: mapData.metaData.version,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async locate() {
|
|
247
|
+
try {
|
|
248
|
+
const result = await this.httpPut(`${this.baseUrl}/api/v2/robot/capabilities/LocateCapability`, {
|
|
249
|
+
action: 'locate',
|
|
250
|
+
});
|
|
251
|
+
return result !== null;
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
this.log.error(`Error locating robot: ${error instanceof Error ? error.message : String(error)}`);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
async getConsumables() {
|
|
259
|
+
try {
|
|
260
|
+
const data = await this.httpGet(`${this.baseUrl}/api/v2/robot/capabilities/ConsumableMonitoringCapability`);
|
|
261
|
+
return data;
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
this.log.error(`Error fetching consumables: ${error instanceof Error ? error.message : String(error)}`);
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async httpGet(url, timeoutMs) {
|
|
269
|
+
return new Promise((resolve, reject) => {
|
|
270
|
+
const timeout = timeoutMs ?? 10000;
|
|
271
|
+
let timeoutId = null;
|
|
272
|
+
const req = http
|
|
273
|
+
.get(url, { headers: { accept: 'application/json' } }, (res) => {
|
|
274
|
+
let data = '';
|
|
275
|
+
if (res.statusCode !== 200) {
|
|
276
|
+
const error = new Error(`HTTP GET failed with status code: ${res.statusCode} for ${url}`);
|
|
277
|
+
this.log.error(error.message);
|
|
278
|
+
reject(error);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
res.on('data', (chunk) => {
|
|
282
|
+
data += chunk;
|
|
283
|
+
});
|
|
284
|
+
res.on('end', () => {
|
|
285
|
+
if (timeoutId)
|
|
286
|
+
clearTimeout(timeoutId);
|
|
287
|
+
try {
|
|
288
|
+
const parsed = JSON.parse(data);
|
|
289
|
+
resolve(parsed);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
const parseError = new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`);
|
|
293
|
+
this.log.error(parseError.message);
|
|
294
|
+
reject(parseError);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
})
|
|
298
|
+
.on('error', (error) => {
|
|
299
|
+
if (timeoutId)
|
|
300
|
+
clearTimeout(timeoutId);
|
|
301
|
+
this.log.error(`HTTP GET error: ${error.message}`);
|
|
302
|
+
reject(error);
|
|
303
|
+
});
|
|
304
|
+
timeoutId = setTimeout(() => {
|
|
305
|
+
req.destroy();
|
|
306
|
+
const timeoutError = new Error(`HTTP GET request timed out after ${timeout}ms for ${url}`);
|
|
307
|
+
this.log.error(timeoutError.message);
|
|
308
|
+
reject(timeoutError);
|
|
309
|
+
}, timeout);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async httpPut(url, body) {
|
|
313
|
+
return new Promise((resolve, reject) => {
|
|
314
|
+
const timeout = 10000;
|
|
315
|
+
let timeoutId = null;
|
|
316
|
+
const bodyString = JSON.stringify(body);
|
|
317
|
+
const urlObj = new URL(url);
|
|
318
|
+
const options = {
|
|
319
|
+
hostname: urlObj.hostname,
|
|
320
|
+
port: urlObj.port || 80,
|
|
321
|
+
path: urlObj.pathname,
|
|
322
|
+
method: 'PUT',
|
|
323
|
+
headers: {
|
|
324
|
+
'Content-Type': 'application/json',
|
|
325
|
+
'Content-Length': Buffer.byteLength(bodyString),
|
|
326
|
+
'Accept': 'application/json',
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
const req = http.request(options, (res) => {
|
|
330
|
+
let data = '';
|
|
331
|
+
if (res.statusCode !== 200) {
|
|
332
|
+
const error = new Error(`HTTP PUT failed with status code: ${res.statusCode} for ${url}`);
|
|
333
|
+
this.log.error(error.message);
|
|
334
|
+
reject(error);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
res.on('data', (chunk) => {
|
|
338
|
+
data += chunk;
|
|
339
|
+
});
|
|
340
|
+
res.on('end', () => {
|
|
341
|
+
if (timeoutId)
|
|
342
|
+
clearTimeout(timeoutId);
|
|
343
|
+
try {
|
|
344
|
+
if (data) {
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(data);
|
|
347
|
+
resolve(parsed);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
if (data.trim() === 'OK' || data.trim() === 'ok') {
|
|
351
|
+
resolve({ success: true });
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const parseError = new Error(`Failed to parse JSON response: ${data}`);
|
|
355
|
+
this.log.error(parseError.message);
|
|
356
|
+
reject(parseError);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
resolve({});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
const handleError = new Error(`Failed to handle response: ${error instanceof Error ? error.message : String(error)}`);
|
|
366
|
+
this.log.error(handleError.message);
|
|
367
|
+
reject(handleError);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
req.on('error', (error) => {
|
|
372
|
+
if (timeoutId)
|
|
373
|
+
clearTimeout(timeoutId);
|
|
374
|
+
this.log.error(`HTTP PUT error: ${error.message}`);
|
|
375
|
+
reject(error);
|
|
376
|
+
});
|
|
377
|
+
timeoutId = setTimeout(() => {
|
|
378
|
+
req.destroy();
|
|
379
|
+
const timeoutError = new Error(`HTTP PUT request timed out after ${timeout}ms for ${url}`);
|
|
380
|
+
this.log.error(timeoutError.message);
|
|
381
|
+
reject(timeoutError);
|
|
382
|
+
}, timeout);
|
|
383
|
+
req.write(bodyString);
|
|
384
|
+
req.end();
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
async testConnection() {
|
|
388
|
+
const info = await this.getInfo();
|
|
389
|
+
return info !== null;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import multicastdns from 'multicast-dns';
|
|
2
|
+
export class ValetudoDiscovery {
|
|
3
|
+
mdns = null;
|
|
4
|
+
log;
|
|
5
|
+
constructor(log) {
|
|
6
|
+
this.log = log;
|
|
7
|
+
}
|
|
8
|
+
async discover(timeoutMs = 5000) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const discovered = new Map();
|
|
11
|
+
const instanceNames = new Set();
|
|
12
|
+
const allRecords = {
|
|
13
|
+
srv: [],
|
|
14
|
+
a: [],
|
|
15
|
+
txt: [],
|
|
16
|
+
};
|
|
17
|
+
let queryTimeout;
|
|
18
|
+
try {
|
|
19
|
+
this.mdns = multicastdns();
|
|
20
|
+
this.mdns.on('response', (response) => {
|
|
21
|
+
try {
|
|
22
|
+
const httpPtrRecords = response.answers.filter((answer) => answer.type === 'PTR' && answer.name === '_http._tcp.local');
|
|
23
|
+
for (const ptrRecord of httpPtrRecords) {
|
|
24
|
+
if ('data' in ptrRecord && typeof ptrRecord.data === 'string') {
|
|
25
|
+
const instanceName = ptrRecord.data;
|
|
26
|
+
if (!instanceNames.has(instanceName)) {
|
|
27
|
+
instanceNames.add(instanceName);
|
|
28
|
+
this.log.debug(`Found HTTP service: ${instanceName}`);
|
|
29
|
+
if (this.mdns) {
|
|
30
|
+
this.mdns.query({
|
|
31
|
+
questions: [
|
|
32
|
+
{ name: instanceName, type: 'SRV' },
|
|
33
|
+
{ name: instanceName, type: 'TXT' },
|
|
34
|
+
{ name: instanceName, type: 'A' },
|
|
35
|
+
],
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const srvRecords = response.answers.filter((answer) => answer.type === 'SRV');
|
|
42
|
+
if (srvRecords.length > 0) {
|
|
43
|
+
for (const srvRecord of srvRecords) {
|
|
44
|
+
if ('data' in srvRecord && srvRecord.data) {
|
|
45
|
+
const srvData = srvRecord.data;
|
|
46
|
+
if (srvData.target && this.mdns) {
|
|
47
|
+
this.mdns.query({
|
|
48
|
+
questions: [{ name: srvData.target, type: 'A' }],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
allRecords.srv.push(...srvRecords);
|
|
55
|
+
const aRecords = response.answers.filter((answer) => answer.type === 'A');
|
|
56
|
+
allRecords.a.push(...aRecords);
|
|
57
|
+
const txtRecords = response.answers.filter((answer) => answer.type === 'TXT');
|
|
58
|
+
allRecords.txt.push(...txtRecords);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.log.error(`Error processing mDNS response: ${error instanceof Error ? error.message : String(error)}`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
this.mdns.on('error', (error) => {
|
|
65
|
+
this.log.error(`mDNS error: ${error.message}`);
|
|
66
|
+
});
|
|
67
|
+
this.log.info('Querying for _http._tcp.local services...');
|
|
68
|
+
this.mdns.query({
|
|
69
|
+
questions: [
|
|
70
|
+
{
|
|
71
|
+
name: '_http._tcp.local',
|
|
72
|
+
type: 'PTR',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
this.log.info(`mDNS query sent, waiting ${timeoutMs}ms for responses...`);
|
|
77
|
+
queryTimeout = setTimeout(() => {
|
|
78
|
+
this.log.debug(`Discovery timeout reached, processing ${instanceNames.size} collected service(s)`);
|
|
79
|
+
for (const instanceName of instanceNames) {
|
|
80
|
+
try {
|
|
81
|
+
const srvRecord = allRecords.srv.find((r) => r.name === instanceName);
|
|
82
|
+
if (!srvRecord) {
|
|
83
|
+
this.log.debug(`No SRV record found for ${instanceName}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const srvData = srvRecord.data;
|
|
87
|
+
const port = srvData?.port || 80;
|
|
88
|
+
const targetHostname = srvData?.target || '';
|
|
89
|
+
let ip = '';
|
|
90
|
+
const aRecord = allRecords.a.find((r) => r.name === targetHostname);
|
|
91
|
+
if (aRecord && 'data' in aRecord) {
|
|
92
|
+
ip = aRecord.data;
|
|
93
|
+
}
|
|
94
|
+
if (!ip) {
|
|
95
|
+
this.log.debug(`No IP found for ${instanceName}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const txtRecord = allRecords.txt.find((r) => r.name === instanceName);
|
|
99
|
+
const txt = {};
|
|
100
|
+
if (txtRecord && 'data' in txtRecord) {
|
|
101
|
+
const txtData = txtRecord.data;
|
|
102
|
+
if (Array.isArray(txtData)) {
|
|
103
|
+
for (const entry of txtData) {
|
|
104
|
+
if (Buffer.isBuffer(entry)) {
|
|
105
|
+
const str = entry.toString('utf8');
|
|
106
|
+
const [key, ...valueParts] = str.split('=');
|
|
107
|
+
if (key) {
|
|
108
|
+
txt[key] = valueParts.join('=') || '';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!txt.id) {
|
|
115
|
+
this.log.debug(`Skipping ${instanceName} - no Valetudo 'id' field`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const hasValetudoFields = txt.model || txt.manufacturer || txt.version;
|
|
119
|
+
if (!hasValetudoFields) {
|
|
120
|
+
this.log.debug(`Skipping ${instanceName} - missing Valetudo metadata`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!discovered.has(ip)) {
|
|
124
|
+
const hostname = instanceName.replace(/\._http\._tcp\.local$/, '').replace(/\.local$/, '');
|
|
125
|
+
discovered.set(ip, {
|
|
126
|
+
ip,
|
|
127
|
+
port,
|
|
128
|
+
hostname,
|
|
129
|
+
txt: Object.keys(txt).length > 0 ? txt : undefined,
|
|
130
|
+
});
|
|
131
|
+
this.log.info(`Discovered Valetudo vacuum at ${ip}:${port} (${hostname}, id: ${txt.id}, model: ${txt.model || 'unknown'})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
this.log.error(`Error processing instance ${instanceName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
this.cleanup();
|
|
139
|
+
resolve(Array.from(discovered.values()));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (queryTimeout)
|
|
144
|
+
clearTimeout(queryTimeout);
|
|
145
|
+
this.cleanup();
|
|
146
|
+
reject(error);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
destroy() {
|
|
151
|
+
this.cleanup();
|
|
152
|
+
}
|
|
153
|
+
cleanup() {
|
|
154
|
+
if (this.mdns) {
|
|
155
|
+
try {
|
|
156
|
+
this.mdns.destroy();
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
this.log.debug(`Error destroying mDNS: ${error instanceof Error ? error.message : String(error)}`);
|
|
160
|
+
}
|
|
161
|
+
this.mdns = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|