homebridge-dihool-lifts 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +8 -0
- package/dist/connection/ewelink-lan.d.ts +20 -5
- package/dist/connection/ewelink-lan.js +134 -50
- package/dist/position-tracker.js +6 -6
- package/package.json +1 -1
- package/src/connection/ewelink-lan.ts +148 -53
- package/src/position-tracker.test.ts +21 -17
- package/src/position-tracker.ts +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.3](https://github.com/alampros/homebridge-dihool-lifts/compare/homebridge-dihool-lifts-v0.1.2...homebridge-dihool-lifts-v0.1.3) (2026-07-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* mdns discovery state ([52f4bc8](https://github.com/alampros/homebridge-dihool-lifts/commit/52f4bc84f64f65f8ee36118f10ddb12f37a01732))
|
|
9
|
+
* only calibrate at 0% — 100% uses timed stop ([f8145e1](https://github.com/alampros/homebridge-dihool-lifts/commit/f8145e144a1793f44a70b535d108c6c929e79cd5))
|
|
10
|
+
|
|
3
11
|
## [0.1.2](https://github.com/alampros/homebridge-dihool-lifts/compare/homebridge-dihool-lifts-v0.1.1...homebridge-dihool-lifts-v0.1.2) (2026-07-02)
|
|
4
12
|
|
|
5
13
|
|
|
@@ -21,19 +21,29 @@ export declare class EWeLinkLAN {
|
|
|
21
21
|
*/
|
|
22
22
|
getHosts(): Promise<Map<string, LanDeviceInfo>>;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
24
|
+
* Process all record types from an mDNS response to extract device IPs
|
|
25
|
+
* and update the device map. Used by both initial discovery and the
|
|
26
|
+
* ongoing monitor.
|
|
25
27
|
*/
|
|
26
|
-
private
|
|
28
|
+
private processResponseRecords;
|
|
29
|
+
/**
|
|
30
|
+
* Update a device's IP in the map (unless manually overridden).
|
|
31
|
+
*/
|
|
32
|
+
private updateDeviceIp;
|
|
27
33
|
/**
|
|
28
34
|
* Start listening for mDNS state broadcasts from eWeLink devices.
|
|
29
35
|
*
|
|
30
|
-
* Creates a persistent mDNS listener that
|
|
31
|
-
*
|
|
36
|
+
* Creates a persistent mDNS listener that:
|
|
37
|
+
* 1. Extracts device IPs from A/SRV records (keeping IPs fresh)
|
|
38
|
+
* 2. Decrypts TXT record payloads and emits state change events
|
|
32
39
|
*/
|
|
33
40
|
startMonitor(): Promise<void>;
|
|
34
41
|
/**
|
|
35
42
|
* Send an encrypted command to a device over the LAN.
|
|
36
|
-
*
|
|
43
|
+
*
|
|
44
|
+
* If the device IP is unknown, fires a one-shot mDNS query and waits
|
|
45
|
+
* up to 3 seconds for discovery before giving up. Retries up to 10
|
|
46
|
+
* times on ECONNRESET.
|
|
37
47
|
*/
|
|
38
48
|
sendUpdate(deviceId: string, params: DeviceParams): Promise<boolean>;
|
|
39
49
|
/** Register a callback for device state updates. */
|
|
@@ -42,6 +52,11 @@ export declare class EWeLinkLAN {
|
|
|
42
52
|
addDeviceToMap(deviceId: string, context: AccessoryContext): void;
|
|
43
53
|
/** Stop mDNS monitoring and clean up. */
|
|
44
54
|
closeConnection(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Fire a one-shot mDNS query to try to resolve a device's IP.
|
|
57
|
+
* Waits up to 3 seconds for a response.
|
|
58
|
+
*/
|
|
59
|
+
private reQueryDevice;
|
|
45
60
|
/**
|
|
46
61
|
* Send an HTTP POST using node:http.
|
|
47
62
|
*
|
|
@@ -43,38 +43,72 @@ export class EWeLinkLAN {
|
|
|
43
43
|
existing.ipOverride = true;
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
-
// Create a temporary mDNS instance for discovery
|
|
47
|
-
|
|
46
|
+
// Create a temporary mDNS instance for initial discovery
|
|
47
|
+
let mdns;
|
|
48
|
+
try {
|
|
49
|
+
mdns = multicastDns();
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
this.log.warn('[LAN] Failed to create mDNS socket for discovery: %s', err instanceof Error ? err.message : String(err));
|
|
53
|
+
return this.deviceMap;
|
|
54
|
+
}
|
|
48
55
|
await new Promise((resolve) => {
|
|
49
|
-
|
|
56
|
+
const cleanup = () => {
|
|
50
57
|
mdns.removeAllListeners('response');
|
|
58
|
+
mdns.removeAllListeners('error');
|
|
51
59
|
mdns.destroy();
|
|
52
60
|
resolve();
|
|
53
|
-
}
|
|
61
|
+
};
|
|
62
|
+
const timer = setTimeout(cleanup, 5000);
|
|
63
|
+
mdns.on('error', (err) => {
|
|
64
|
+
this.log.warn('[LAN] mDNS discovery error: %s', err.message);
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
cleanup();
|
|
67
|
+
});
|
|
54
68
|
mdns.on('response', (response) => {
|
|
55
|
-
this.
|
|
69
|
+
this.processResponseRecords(response);
|
|
56
70
|
});
|
|
57
71
|
// Query for eWeLink PTR records
|
|
58
72
|
mdns.query([{ name: MDNS_PTR_NAME, type: 'PTR' }]);
|
|
59
73
|
});
|
|
74
|
+
// Log what we found
|
|
75
|
+
const discovered = [...this.deviceMap.entries()]
|
|
76
|
+
.filter(([, info]) => info.ip)
|
|
77
|
+
.map(([id, info]) => `${id}@${info.ip}${info.ipOverride ? ' (manual)' : ''}`);
|
|
78
|
+
if (discovered.length > 0) {
|
|
79
|
+
this.log.info('[LAN] Discovered devices: %s', discovered.join(', '));
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.log.warn('[LAN] No device IPs discovered via mDNS. Devices with manual IP overrides may still work.');
|
|
83
|
+
}
|
|
60
84
|
return this.deviceMap;
|
|
61
85
|
}
|
|
62
86
|
/**
|
|
63
|
-
*
|
|
87
|
+
* Process all record types from an mDNS response to extract device IPs
|
|
88
|
+
* and update the device map. Used by both initial discovery and the
|
|
89
|
+
* ongoing monitor.
|
|
64
90
|
*/
|
|
65
|
-
|
|
91
|
+
processResponseRecords(response) {
|
|
66
92
|
const allRecords = [
|
|
67
93
|
...(response.answers ?? []),
|
|
68
94
|
...(response.additionals ?? []),
|
|
69
95
|
];
|
|
70
|
-
// Collect
|
|
96
|
+
// Collect A records: hostname → IP
|
|
71
97
|
const hostToIp = new Map();
|
|
72
98
|
for (const record of allRecords) {
|
|
73
99
|
if (record.type === 'A') {
|
|
74
100
|
hostToIp.set(record.name, record.data);
|
|
75
101
|
}
|
|
76
102
|
}
|
|
77
|
-
//
|
|
103
|
+
// Collect SRV records: service name → target hostname
|
|
104
|
+
const srvTargets = new Map();
|
|
105
|
+
for (const record of allRecords) {
|
|
106
|
+
if (record.type === 'SRV') {
|
|
107
|
+
const srvData = record.data;
|
|
108
|
+
srvTargets.set(record.name, srvData.target);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Process PTR records to find eWeLink devices
|
|
78
112
|
for (const record of allRecords) {
|
|
79
113
|
if (record.type === 'PTR' && record.name.includes('_ewelink')) {
|
|
80
114
|
const ptr = record.data;
|
|
@@ -83,52 +117,70 @@ export class EWeLinkLAN {
|
|
|
83
117
|
.replace(/^eWeLink_/, '');
|
|
84
118
|
if (!deviceId)
|
|
85
119
|
continue;
|
|
86
|
-
//
|
|
87
|
-
const srvRecord = allRecords.find((r) => r.type === 'SRV' && r.name === ptr);
|
|
120
|
+
// Resolve IP: PTR → SRV target → A record
|
|
88
121
|
let ip;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
ip = hostToIp.get(
|
|
122
|
+
const srvTarget = srvTargets.get(ptr);
|
|
123
|
+
if (srvTarget) {
|
|
124
|
+
ip = hostToIp.get(srvTarget);
|
|
92
125
|
}
|
|
93
|
-
// Fallback: check A records for the device hostname
|
|
126
|
+
// Fallback: check A records for the device hostname directly
|
|
94
127
|
if (!ip) {
|
|
95
|
-
|
|
96
|
-
ip = hostToIp.get(deviceHost);
|
|
128
|
+
ip = hostToIp.get(`eWeLink_${deviceId}.local`);
|
|
97
129
|
}
|
|
98
|
-
if (ip
|
|
99
|
-
this.
|
|
100
|
-
if (this.debug) {
|
|
101
|
-
this.log.debug('[LAN] Discovered %s at %s', deviceId, ip);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
else if (ip) {
|
|
105
|
-
const info = this.deviceMap.get(deviceId);
|
|
106
|
-
if (!info.ipOverride) {
|
|
107
|
-
info.ip = ip;
|
|
108
|
-
}
|
|
130
|
+
if (ip) {
|
|
131
|
+
this.updateDeviceIp(deviceId, ip);
|
|
109
132
|
}
|
|
110
133
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.deviceMap.set(txt.id, { ipOverride: false });
|
|
118
|
-
}
|
|
119
|
-
}
|
|
134
|
+
}
|
|
135
|
+
// Also try to extract IPs from A records matching the eWeLink_<id>.local pattern
|
|
136
|
+
for (const [hostname, ip] of hostToIp) {
|
|
137
|
+
const match = hostname.match(/^eWeLink_(\w+)\.local$/);
|
|
138
|
+
if (match?.[1]) {
|
|
139
|
+
this.updateDeviceIp(match[1], ip);
|
|
120
140
|
}
|
|
121
141
|
}
|
|
122
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Update a device's IP in the map (unless manually overridden).
|
|
145
|
+
*/
|
|
146
|
+
updateDeviceIp(deviceId, ip) {
|
|
147
|
+
const existing = this.deviceMap.get(deviceId);
|
|
148
|
+
if (!existing) {
|
|
149
|
+
this.deviceMap.set(deviceId, { ip, ipOverride: false });
|
|
150
|
+
this.log.info('[LAN] Discovered %s at %s', deviceId, ip);
|
|
151
|
+
}
|
|
152
|
+
else if (!existing.ipOverride && existing.ip !== ip) {
|
|
153
|
+
const oldIp = existing.ip;
|
|
154
|
+
existing.ip = ip;
|
|
155
|
+
this.log.info('[LAN] Updated %s: %s → %s', deviceId, oldIp ?? 'none', ip);
|
|
156
|
+
}
|
|
157
|
+
else if (!existing.ipOverride && !existing.ip) {
|
|
158
|
+
existing.ip = ip;
|
|
159
|
+
this.log.info('[LAN] Resolved %s at %s', deviceId, ip);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
123
162
|
/**
|
|
124
163
|
* Start listening for mDNS state broadcasts from eWeLink devices.
|
|
125
164
|
*
|
|
126
|
-
* Creates a persistent mDNS listener that
|
|
127
|
-
*
|
|
165
|
+
* Creates a persistent mDNS listener that:
|
|
166
|
+
* 1. Extracts device IPs from A/SRV records (keeping IPs fresh)
|
|
167
|
+
* 2. Decrypts TXT record payloads and emits state change events
|
|
128
168
|
*/
|
|
129
169
|
async startMonitor() {
|
|
130
|
-
|
|
170
|
+
try {
|
|
171
|
+
this.mdns = multicastDns();
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
this.log.error('[LAN] Failed to create mDNS socket for monitoring: %s. LAN control will not work.', err instanceof Error ? err.message : String(err));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.mdns.on('error', (err) => {
|
|
178
|
+
this.log.warn('[LAN] mDNS monitor error: %s', err.message);
|
|
179
|
+
});
|
|
131
180
|
this.mdns.on('response', (response) => {
|
|
181
|
+
// Always process A/SRV/PTR records to keep IPs current
|
|
182
|
+
this.processResponseRecords(response);
|
|
183
|
+
// Process TXT records for state updates
|
|
132
184
|
const allRecords = [
|
|
133
185
|
...(response.answers ?? []),
|
|
134
186
|
...(response.additionals ?? []),
|
|
@@ -149,9 +201,6 @@ export class EWeLinkLAN {
|
|
|
149
201
|
if (txt.iv === info.lastIV)
|
|
150
202
|
continue;
|
|
151
203
|
info.lastIV = txt.iv;
|
|
152
|
-
// Update IP if it changed
|
|
153
|
-
// response object doesn't directly carry the sender IP in multicast-dns,
|
|
154
|
-
// but SRV/A records in additionals might
|
|
155
204
|
// Concatenate encrypted data fragments
|
|
156
205
|
const data = [txt.data1, txt.data2, txt.data3, txt.data4]
|
|
157
206
|
.filter(Boolean)
|
|
@@ -186,23 +235,33 @@ export class EWeLinkLAN {
|
|
|
186
235
|
this.emitter.emit('update', { deviceid: deviceId, params });
|
|
187
236
|
}
|
|
188
237
|
});
|
|
189
|
-
this.log.info('LAN
|
|
238
|
+
this.log.info('[LAN] Monitoring started.');
|
|
190
239
|
}
|
|
191
240
|
/**
|
|
192
241
|
* Send an encrypted command to a device over the LAN.
|
|
193
|
-
*
|
|
242
|
+
*
|
|
243
|
+
* If the device IP is unknown, fires a one-shot mDNS query and waits
|
|
244
|
+
* up to 3 seconds for discovery before giving up. Retries up to 10
|
|
245
|
+
* times on ECONNRESET.
|
|
194
246
|
*/
|
|
195
247
|
async sendUpdate(deviceId, params) {
|
|
196
248
|
const info = this.deviceMap.get(deviceId);
|
|
197
|
-
if (!info?.
|
|
198
|
-
this.log.warn('[LAN] Cannot send to %s:
|
|
249
|
+
if (!info?.lanKey) {
|
|
250
|
+
this.log.warn('[LAN] Cannot send to %s: no lanKey', deviceId);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
// If we don't have an IP, try a quick mDNS re-query
|
|
254
|
+
if (!info.ip) {
|
|
255
|
+
this.log.info('[LAN] No IP for %s — sending mDNS query...', deviceId);
|
|
256
|
+
await this.reQueryDevice(deviceId);
|
|
257
|
+
}
|
|
258
|
+
if (!info.ip) {
|
|
259
|
+
this.log.warn('[LAN] Cannot send to %s: ip not discovered', deviceId);
|
|
199
260
|
return false;
|
|
200
261
|
}
|
|
201
262
|
const { encryptedData, iv } = this.encryptData(params, info.lanKey);
|
|
202
263
|
const url = `http://${info.ip}:${EWELINK_LAN_PORT}/zeroconf/switches`;
|
|
203
|
-
|
|
204
|
-
this.log.debug('[LAN] Sending to %s → %s', deviceId, url);
|
|
205
|
-
}
|
|
264
|
+
this.log.info('[LAN] Sending to %s → %s', deviceId, url);
|
|
206
265
|
const body = JSON.stringify({
|
|
207
266
|
deviceid: deviceId,
|
|
208
267
|
data: encryptedData,
|
|
@@ -260,6 +319,31 @@ export class EWeLinkLAN {
|
|
|
260
319
|
this.emitter.removeAllListeners();
|
|
261
320
|
}
|
|
262
321
|
// -- Private helpers --------------------------------------------------
|
|
322
|
+
/**
|
|
323
|
+
* Fire a one-shot mDNS query to try to resolve a device's IP.
|
|
324
|
+
* Waits up to 3 seconds for a response.
|
|
325
|
+
*/
|
|
326
|
+
async reQueryDevice(deviceId) {
|
|
327
|
+
const mdns = this.mdns;
|
|
328
|
+
if (!mdns)
|
|
329
|
+
return;
|
|
330
|
+
return new Promise((resolve) => {
|
|
331
|
+
const timer = setTimeout(() => {
|
|
332
|
+
resolve();
|
|
333
|
+
}, 3000);
|
|
334
|
+
const onResponse = (response) => {
|
|
335
|
+
this.processResponseRecords(response);
|
|
336
|
+
const info = this.deviceMap.get(deviceId);
|
|
337
|
+
if (info?.ip) {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
mdns.removeListener('response', onResponse);
|
|
340
|
+
resolve();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
mdns.on('response', onResponse);
|
|
344
|
+
mdns.query([{ name: MDNS_PTR_NAME, type: 'PTR' }]);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
263
347
|
/**
|
|
264
348
|
* Send an HTTP POST using node:http.
|
|
265
349
|
*
|
package/dist/position-tracker.js
CHANGED
|
@@ -66,11 +66,10 @@ export class PositionTracker {
|
|
|
66
66
|
*/
|
|
67
67
|
startMovement(target) {
|
|
68
68
|
const clamped = clamp(target, 0, 100);
|
|
69
|
-
// If unknown position,
|
|
69
|
+
// If unknown position, calibrate down to 0% (the only limit-switch endpoint)
|
|
70
70
|
if (this.state.phase === 'unknown') {
|
|
71
|
-
|
|
72
|
-
this.
|
|
73
|
-
return this.startCalibration(dir);
|
|
71
|
+
this.log('Position unknown, starting calibration down to 0%');
|
|
72
|
+
return this.startCalibration('down');
|
|
74
73
|
}
|
|
75
74
|
// If calibrating, ignore new commands
|
|
76
75
|
if (this.state.phase === 'calibrating') {
|
|
@@ -88,8 +87,9 @@ export class PositionTracker {
|
|
|
88
87
|
const travelTimeSec = direction === 'up'
|
|
89
88
|
? this.config.travelTimeUpSec
|
|
90
89
|
: this.config.travelTimeDownSec;
|
|
91
|
-
//
|
|
92
|
-
|
|
90
|
+
// Only 0% is a true calibration move (physical limit switch at bottom).
|
|
91
|
+
// 100% is a timed stop — the top is defined by travel time, not a switch.
|
|
92
|
+
const isCalibration = clamped === 0;
|
|
93
93
|
const durationMs = isCalibration
|
|
94
94
|
? (travelTimeSec + this.config.calibrationExtraSec) * 1000
|
|
95
95
|
: (travel / 100) * travelTimeSec * 1000;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "homebridge-dihool-lifts",
|
|
3
3
|
"displayName": "Homebridge DIHOOL Lifts",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.3",
|
|
6
6
|
"description": "Homebridge plugin to control DIHOOL IPS-S2 scissor lifts via the eWeLink LAN protocol.",
|
|
7
7
|
"author": "Aaron Lampros",
|
|
8
8
|
"license": "MIT",
|
|
@@ -59,37 +59,67 @@ export class EWeLinkLAN {
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Create a temporary mDNS instance for discovery
|
|
63
|
-
|
|
62
|
+
// Create a temporary mDNS instance for initial discovery
|
|
63
|
+
let mdns: ReturnType<typeof multicastDns>;
|
|
64
|
+
try {
|
|
65
|
+
mdns = multicastDns();
|
|
66
|
+
} catch (err) {
|
|
67
|
+
this.log.warn(
|
|
68
|
+
'[LAN] Failed to create mDNS socket for discovery: %s',
|
|
69
|
+
err instanceof Error ? err.message : String(err),
|
|
70
|
+
);
|
|
71
|
+
return this.deviceMap;
|
|
72
|
+
}
|
|
64
73
|
|
|
65
74
|
await new Promise<void>((resolve) => {
|
|
66
|
-
|
|
75
|
+
const cleanup = () => {
|
|
67
76
|
mdns.removeAllListeners('response');
|
|
77
|
+
mdns.removeAllListeners('error');
|
|
68
78
|
mdns.destroy();
|
|
69
79
|
resolve();
|
|
70
|
-
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const timer = setTimeout(cleanup, 5000);
|
|
83
|
+
|
|
84
|
+
mdns.on('error', (err: Error) => {
|
|
85
|
+
this.log.warn('[LAN] mDNS discovery error: %s', err.message);
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
cleanup();
|
|
88
|
+
});
|
|
71
89
|
|
|
72
90
|
mdns.on('response', (response) => {
|
|
73
|
-
this.
|
|
91
|
+
this.processResponseRecords(response);
|
|
74
92
|
});
|
|
75
93
|
|
|
76
94
|
// Query for eWeLink PTR records
|
|
77
95
|
mdns.query([{ name: MDNS_PTR_NAME, type: 'PTR' }]);
|
|
78
96
|
});
|
|
79
97
|
|
|
98
|
+
// Log what we found
|
|
99
|
+
const discovered = [...this.deviceMap.entries()]
|
|
100
|
+
.filter(([, info]) => info.ip)
|
|
101
|
+
.map(([id, info]) => `${id}@${info.ip}${info.ipOverride ? ' (manual)' : ''}`);
|
|
102
|
+
if (discovered.length > 0) {
|
|
103
|
+
this.log.info('[LAN] Discovered devices: %s', discovered.join(', '));
|
|
104
|
+
} else {
|
|
105
|
+
this.log.warn('[LAN] No device IPs discovered via mDNS. Devices with manual IP overrides may still work.');
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
return this.deviceMap;
|
|
81
109
|
}
|
|
82
110
|
|
|
83
111
|
/**
|
|
84
|
-
*
|
|
112
|
+
* Process all record types from an mDNS response to extract device IPs
|
|
113
|
+
* and update the device map. Used by both initial discovery and the
|
|
114
|
+
* ongoing monitor.
|
|
85
115
|
*/
|
|
86
|
-
private
|
|
116
|
+
private processResponseRecords(response: multicastDns.ResponsePacket): void {
|
|
87
117
|
const allRecords = [
|
|
88
118
|
...(response.answers ?? []),
|
|
89
119
|
...(response.additionals ?? []),
|
|
90
120
|
];
|
|
91
121
|
|
|
92
|
-
// Collect
|
|
122
|
+
// Collect A records: hostname → IP
|
|
93
123
|
const hostToIp = new Map<string, string>();
|
|
94
124
|
for (const record of allRecords) {
|
|
95
125
|
if (record.type === 'A') {
|
|
@@ -97,7 +127,16 @@ export class EWeLinkLAN {
|
|
|
97
127
|
}
|
|
98
128
|
}
|
|
99
129
|
|
|
100
|
-
//
|
|
130
|
+
// Collect SRV records: service name → target hostname
|
|
131
|
+
const srvTargets = new Map<string, string>();
|
|
132
|
+
for (const record of allRecords) {
|
|
133
|
+
if (record.type === 'SRV') {
|
|
134
|
+
const srvData = record.data as { target: string };
|
|
135
|
+
srvTargets.set(record.name, srvData.target);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Process PTR records to find eWeLink devices
|
|
101
140
|
for (const record of allRecords) {
|
|
102
141
|
if (record.type === 'PTR' && record.name.includes('_ewelink')) {
|
|
103
142
|
const ptr = record.data as string;
|
|
@@ -107,58 +146,77 @@ export class EWeLinkLAN {
|
|
|
107
146
|
|
|
108
147
|
if (!deviceId) continue;
|
|
109
148
|
|
|
110
|
-
//
|
|
111
|
-
const srvRecord = allRecords.find(
|
|
112
|
-
(r) => r.type === 'SRV' && r.name === ptr,
|
|
113
|
-
);
|
|
149
|
+
// Resolve IP: PTR → SRV target → A record
|
|
114
150
|
let ip: string | undefined;
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
ip = hostToIp.get(
|
|
151
|
+
const srvTarget = srvTargets.get(ptr);
|
|
152
|
+
if (srvTarget) {
|
|
153
|
+
ip = hostToIp.get(srvTarget);
|
|
118
154
|
}
|
|
119
|
-
|
|
120
|
-
// Fallback: check A records for the device hostname
|
|
155
|
+
// Fallback: check A records for the device hostname directly
|
|
121
156
|
if (!ip) {
|
|
122
|
-
|
|
123
|
-
ip = hostToIp.get(deviceHost);
|
|
157
|
+
ip = hostToIp.get(`eWeLink_${deviceId}.local`);
|
|
124
158
|
}
|
|
125
159
|
|
|
126
|
-
if (ip
|
|
127
|
-
this.
|
|
128
|
-
if (this.debug) {
|
|
129
|
-
this.log.debug('[LAN] Discovered %s at %s', deviceId, ip);
|
|
130
|
-
}
|
|
131
|
-
} else if (ip) {
|
|
132
|
-
const info = this.deviceMap.get(deviceId)!;
|
|
133
|
-
if (!info.ipOverride) {
|
|
134
|
-
info.ip = ip;
|
|
135
|
-
}
|
|
160
|
+
if (ip) {
|
|
161
|
+
this.updateDeviceIp(deviceId, ip);
|
|
136
162
|
}
|
|
137
163
|
}
|
|
164
|
+
}
|
|
138
165
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (!this.deviceMap.has(txt.id)) {
|
|
145
|
-
this.deviceMap.set(txt.id, { ipOverride: false });
|
|
146
|
-
}
|
|
147
|
-
}
|
|
166
|
+
// Also try to extract IPs from A records matching the eWeLink_<id>.local pattern
|
|
167
|
+
for (const [hostname, ip] of hostToIp) {
|
|
168
|
+
const match = hostname.match(/^eWeLink_(\w+)\.local$/);
|
|
169
|
+
if (match?.[1]) {
|
|
170
|
+
this.updateDeviceIp(match[1], ip);
|
|
148
171
|
}
|
|
149
172
|
}
|
|
150
173
|
}
|
|
151
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Update a device's IP in the map (unless manually overridden).
|
|
177
|
+
*/
|
|
178
|
+
private updateDeviceIp(deviceId: string, ip: string): void {
|
|
179
|
+
const existing = this.deviceMap.get(deviceId);
|
|
180
|
+
if (!existing) {
|
|
181
|
+
this.deviceMap.set(deviceId, { ip, ipOverride: false });
|
|
182
|
+
this.log.info('[LAN] Discovered %s at %s', deviceId, ip);
|
|
183
|
+
} else if (!existing.ipOverride && existing.ip !== ip) {
|
|
184
|
+
const oldIp = existing.ip;
|
|
185
|
+
existing.ip = ip;
|
|
186
|
+
this.log.info('[LAN] Updated %s: %s → %s', deviceId, oldIp ?? 'none', ip);
|
|
187
|
+
} else if (!existing.ipOverride && !existing.ip) {
|
|
188
|
+
existing.ip = ip;
|
|
189
|
+
this.log.info('[LAN] Resolved %s at %s', deviceId, ip);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
152
193
|
/**
|
|
153
194
|
* Start listening for mDNS state broadcasts from eWeLink devices.
|
|
154
195
|
*
|
|
155
|
-
* Creates a persistent mDNS listener that
|
|
156
|
-
*
|
|
196
|
+
* Creates a persistent mDNS listener that:
|
|
197
|
+
* 1. Extracts device IPs from A/SRV records (keeping IPs fresh)
|
|
198
|
+
* 2. Decrypts TXT record payloads and emits state change events
|
|
157
199
|
*/
|
|
158
200
|
async startMonitor(): Promise<void> {
|
|
159
|
-
|
|
201
|
+
try {
|
|
202
|
+
this.mdns = multicastDns();
|
|
203
|
+
} catch (err) {
|
|
204
|
+
this.log.error(
|
|
205
|
+
'[LAN] Failed to create mDNS socket for monitoring: %s. LAN control will not work.',
|
|
206
|
+
err instanceof Error ? err.message : String(err),
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this.mdns.on('error', (err: Error) => {
|
|
212
|
+
this.log.warn('[LAN] mDNS monitor error: %s', err.message);
|
|
213
|
+
});
|
|
160
214
|
|
|
161
215
|
this.mdns.on('response', (response) => {
|
|
216
|
+
// Always process A/SRV/PTR records to keep IPs current
|
|
217
|
+
this.processResponseRecords(response);
|
|
218
|
+
|
|
219
|
+
// Process TXT records for state updates
|
|
162
220
|
const allRecords = [
|
|
163
221
|
...(response.answers ?? []),
|
|
164
222
|
...(response.additionals ?? []),
|
|
@@ -179,10 +237,6 @@ export class EWeLinkLAN {
|
|
|
179
237
|
if (txt.iv === info.lastIV) continue;
|
|
180
238
|
info.lastIV = txt.iv;
|
|
181
239
|
|
|
182
|
-
// Update IP if it changed
|
|
183
|
-
// response object doesn't directly carry the sender IP in multicast-dns,
|
|
184
|
-
// but SRV/A records in additionals might
|
|
185
|
-
|
|
186
240
|
// Concatenate encrypted data fragments
|
|
187
241
|
const data = [txt.data1, txt.data2, txt.data3, txt.data4]
|
|
188
242
|
.filter(Boolean)
|
|
@@ -226,26 +280,39 @@ export class EWeLinkLAN {
|
|
|
226
280
|
}
|
|
227
281
|
});
|
|
228
282
|
|
|
229
|
-
this.log.info('LAN
|
|
283
|
+
this.log.info('[LAN] Monitoring started.');
|
|
230
284
|
}
|
|
231
285
|
|
|
232
286
|
/**
|
|
233
287
|
* Send an encrypted command to a device over the LAN.
|
|
234
|
-
*
|
|
288
|
+
*
|
|
289
|
+
* If the device IP is unknown, fires a one-shot mDNS query and waits
|
|
290
|
+
* up to 3 seconds for discovery before giving up. Retries up to 10
|
|
291
|
+
* times on ECONNRESET.
|
|
235
292
|
*/
|
|
236
293
|
async sendUpdate(deviceId: string, params: DeviceParams): Promise<boolean> {
|
|
237
294
|
const info = this.deviceMap.get(deviceId);
|
|
238
|
-
if (!info?.
|
|
239
|
-
this.log.warn('[LAN] Cannot send to %s:
|
|
295
|
+
if (!info?.lanKey) {
|
|
296
|
+
this.log.warn('[LAN] Cannot send to %s: no lanKey', deviceId);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// If we don't have an IP, try a quick mDNS re-query
|
|
301
|
+
if (!info.ip) {
|
|
302
|
+
this.log.info('[LAN] No IP for %s — sending mDNS query...', deviceId);
|
|
303
|
+
await this.reQueryDevice(deviceId);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!info.ip) {
|
|
307
|
+
this.log.warn('[LAN] Cannot send to %s: ip not discovered', deviceId);
|
|
240
308
|
return false;
|
|
241
309
|
}
|
|
242
310
|
|
|
243
311
|
const { encryptedData, iv } = this.encryptData(params, info.lanKey);
|
|
244
312
|
const url = `http://${info.ip}:${EWELINK_LAN_PORT}/zeroconf/switches`;
|
|
245
313
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
314
|
+
this.log.info('[LAN] Sending to %s → %s', deviceId, url);
|
|
315
|
+
|
|
249
316
|
const body = JSON.stringify({
|
|
250
317
|
deviceid: deviceId,
|
|
251
318
|
data: encryptedData,
|
|
@@ -316,6 +383,34 @@ export class EWeLinkLAN {
|
|
|
316
383
|
|
|
317
384
|
// -- Private helpers --------------------------------------------------
|
|
318
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Fire a one-shot mDNS query to try to resolve a device's IP.
|
|
388
|
+
* Waits up to 3 seconds for a response.
|
|
389
|
+
*/
|
|
390
|
+
private async reQueryDevice(deviceId: string): Promise<void> {
|
|
391
|
+
const mdns = this.mdns;
|
|
392
|
+
if (!mdns) return;
|
|
393
|
+
|
|
394
|
+
return new Promise<void>((resolve) => {
|
|
395
|
+
const timer = setTimeout(() => {
|
|
396
|
+
resolve();
|
|
397
|
+
}, 3000);
|
|
398
|
+
|
|
399
|
+
const onResponse = (response: multicastDns.ResponsePacket) => {
|
|
400
|
+
this.processResponseRecords(response);
|
|
401
|
+
const info = this.deviceMap.get(deviceId);
|
|
402
|
+
if (info?.ip) {
|
|
403
|
+
clearTimeout(timer);
|
|
404
|
+
mdns.removeListener('response', onResponse);
|
|
405
|
+
resolve();
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
mdns.on('response', onResponse);
|
|
410
|
+
mdns.query([{ name: MDNS_PTR_NAME, type: 'PTR' }]);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
319
414
|
/**
|
|
320
415
|
* Send an HTTP POST using node:http.
|
|
321
416
|
*
|
|
@@ -80,26 +80,27 @@ describe('PositionTracker', () => {
|
|
|
80
80
|
expect(plan!.isCalibration).toBe(true);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('startMovement(100) from unknown state
|
|
83
|
+
it('startMovement(100) from unknown state calibrates down first (not up)', () => {
|
|
84
84
|
const { tracker } = createTracker(tempDir);
|
|
85
85
|
const plan = tracker.startMovement(100);
|
|
86
86
|
expect(plan).not.toBeNull();
|
|
87
|
-
|
|
87
|
+
// Always calibrates down — 0% is the only limit-switch endpoint
|
|
88
|
+
expect(plan!.direction).toBe('down');
|
|
88
89
|
expect(plan!.isCalibration).toBe(true);
|
|
89
90
|
});
|
|
90
91
|
|
|
91
|
-
it('startMovement(50) from unknown
|
|
92
|
+
it('startMovement(50) from unknown calibrates down', () => {
|
|
92
93
|
const { tracker } = createTracker(tempDir);
|
|
93
94
|
const plan = tracker.startMovement(50);
|
|
94
95
|
expect(plan).not.toBeNull();
|
|
95
96
|
expect(plan!.direction).toBe('down');
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
it('startMovement(51) from unknown
|
|
99
|
+
it('startMovement(51) from unknown calibrates down', () => {
|
|
99
100
|
const { tracker } = createTracker(tempDir);
|
|
100
101
|
const plan = tracker.startMovement(51);
|
|
101
102
|
expect(plan).not.toBeNull();
|
|
102
|
-
expect(plan!.direction).toBe('
|
|
103
|
+
expect(plan!.direction).toBe('down');
|
|
103
104
|
});
|
|
104
105
|
|
|
105
106
|
it('calibration plan duration equals (travelTime + calibrationExtra) * 1000', () => {
|
|
@@ -121,9 +122,10 @@ describe('PositionTracker', () => {
|
|
|
121
122
|
expect(tracker.getCurrentPosition()).toBe(0);
|
|
122
123
|
});
|
|
123
124
|
|
|
124
|
-
it('completeMovement() after
|
|
125
|
+
it('completeMovement() after startCalibration(up) sets position to 100', () => {
|
|
125
126
|
const { tracker } = createTracker(tempDir);
|
|
126
|
-
|
|
127
|
+
// Use startCalibration directly — startMovement always calibrates down
|
|
128
|
+
tracker.startCalibration('up');
|
|
127
129
|
tracker.completeMovement();
|
|
128
130
|
expect(tracker.getState().phase).toBe('stopped');
|
|
129
131
|
expect(tracker.getCurrentPosition()).toBe(100);
|
|
@@ -142,11 +144,12 @@ describe('PositionTracker', () => {
|
|
|
142
144
|
// ========================================================================
|
|
143
145
|
|
|
144
146
|
describe('basic movement', () => {
|
|
145
|
-
it('from position 0, startMovement(100) returns up plan with
|
|
147
|
+
it('from position 0, startMovement(100) returns up plan with full travel duration (not calibration)', () => {
|
|
146
148
|
const { tracker } = createTracker(tempDir);
|
|
147
149
|
tracker.markCalibrated(0);
|
|
148
150
|
const plan = tracker.startMovement(100);
|
|
149
|
-
|
|
151
|
+
// 100% is NOT calibration — only 0% has a limit switch
|
|
152
|
+
expect(plan).toEqual({ direction: 'up', durationMs: 10000, isCalibration: false });
|
|
150
153
|
});
|
|
151
154
|
|
|
152
155
|
it('from position 0, startMovement(50) returns up plan with half duration and isCalibration=false', () => {
|
|
@@ -163,7 +166,7 @@ describe('PositionTracker', () => {
|
|
|
163
166
|
expect(plan).toEqual({ direction: 'down', durationMs: 5000, isCalibration: false });
|
|
164
167
|
});
|
|
165
168
|
|
|
166
|
-
it('from position 100, startMovement(0) returns down plan with
|
|
169
|
+
it('from position 100, startMovement(0) returns down plan with calibration (limit switch at bottom)', () => {
|
|
167
170
|
const { tracker } = createTracker(tempDir);
|
|
168
171
|
tracker.markCalibrated(100);
|
|
169
172
|
const plan = tracker.startMovement(0);
|
|
@@ -248,14 +251,15 @@ describe('PositionTracker', () => {
|
|
|
248
251
|
// ========================================================================
|
|
249
252
|
|
|
250
253
|
describe('asymmetric travel times', () => {
|
|
251
|
-
it('movement 0→100 uses up travel time
|
|
254
|
+
it('movement 0→100 uses full up travel time (no calibration extra)', () => {
|
|
252
255
|
const { tracker } = createTracker(tempDir, {
|
|
253
256
|
travelTimeUpSec: 10,
|
|
254
257
|
travelTimeDownSec: 5,
|
|
255
258
|
});
|
|
256
259
|
tracker.markCalibrated(0);
|
|
257
260
|
const plan = tracker.startMovement(100);
|
|
258
|
-
|
|
261
|
+
// 100% is timed, not calibration — no extra time
|
|
262
|
+
expect(plan!.durationMs).toBe(10000);
|
|
259
263
|
});
|
|
260
264
|
|
|
261
265
|
it('movement 100→0 uses down travel time plus calibration extra (7s)', () => {
|
|
@@ -343,7 +347,7 @@ describe('PositionTracker', () => {
|
|
|
343
347
|
expect(tracker.getState().phase).toBe('unknown');
|
|
344
348
|
});
|
|
345
349
|
|
|
346
|
-
it('after interrupting calibration, next startMovement triggers fresh calibration', () => {
|
|
350
|
+
it('after interrupting calibration, next startMovement triggers fresh calibration down', () => {
|
|
347
351
|
const { tracker, clock } = createTracker(tempDir);
|
|
348
352
|
// Start calibration down
|
|
349
353
|
tracker.startMovement(0);
|
|
@@ -353,11 +357,11 @@ describe('PositionTracker', () => {
|
|
|
353
357
|
tracker.snapshotPosition();
|
|
354
358
|
expect(tracker.getState().phase).toBe('unknown');
|
|
355
359
|
|
|
356
|
-
// Next move should trigger a new calibration
|
|
360
|
+
// Next move should trigger a new calibration (always down — only limit switch)
|
|
357
361
|
const plan = tracker.startMovement(100);
|
|
358
362
|
expect(plan).not.toBeNull();
|
|
359
363
|
expect(plan!.isCalibration).toBe(true);
|
|
360
|
-
expect(plan!.direction).toBe('
|
|
364
|
+
expect(plan!.direction).toBe('down');
|
|
361
365
|
expect(tracker.getState().phase).toBe('calibrating');
|
|
362
366
|
});
|
|
363
367
|
});
|
|
@@ -500,7 +504,7 @@ describe('PositionTracker', () => {
|
|
|
500
504
|
const plan = tracker.startMovement(150);
|
|
501
505
|
expect(plan).not.toBeNull();
|
|
502
506
|
expect(plan!.direction).toBe('up');
|
|
503
|
-
expect(plan!.isCalibration).toBe(
|
|
507
|
+
expect(plan!.isCalibration).toBe(false); // 100% is timed, not calibration
|
|
504
508
|
});
|
|
505
509
|
|
|
506
510
|
it('position never goes below 0 during interpolation', () => {
|
|
@@ -597,7 +601,7 @@ describe('PositionTracker', () => {
|
|
|
597
601
|
it('logs calibration start from unknown', () => {
|
|
598
602
|
const { tracker, logs } = createTracker(tempDir);
|
|
599
603
|
tracker.startMovement(0);
|
|
600
|
-
expect(logs.some((m) => m.includes('Position unknown') && m.includes('calibration down'))).toBe(true);
|
|
604
|
+
expect(logs.some((m) => m.includes('Position unknown') && m.includes('calibration down to 0%'))).toBe(true);
|
|
601
605
|
});
|
|
602
606
|
|
|
603
607
|
it('logs when ignoring command during calibration', () => {
|
package/src/position-tracker.ts
CHANGED
|
@@ -167,11 +167,10 @@ export class PositionTracker {
|
|
|
167
167
|
startMovement(target: number): MovementPlan | null {
|
|
168
168
|
const clamped = clamp(target, 0, 100);
|
|
169
169
|
|
|
170
|
-
// If unknown position,
|
|
170
|
+
// If unknown position, calibrate down to 0% (the only limit-switch endpoint)
|
|
171
171
|
if (this.state.phase === 'unknown') {
|
|
172
|
-
|
|
173
|
-
this.
|
|
174
|
-
return this.startCalibration(dir);
|
|
172
|
+
this.log('Position unknown, starting calibration down to 0%');
|
|
173
|
+
return this.startCalibration('down');
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
// If calibrating, ignore new commands
|
|
@@ -194,8 +193,9 @@ export class PositionTracker {
|
|
|
194
193
|
? this.config.travelTimeUpSec
|
|
195
194
|
: this.config.travelTimeDownSec;
|
|
196
195
|
|
|
197
|
-
//
|
|
198
|
-
|
|
196
|
+
// Only 0% is a true calibration move (physical limit switch at bottom).
|
|
197
|
+
// 100% is a timed stop — the top is defined by travel time, not a switch.
|
|
198
|
+
const isCalibration = clamped === 0;
|
|
199
199
|
const durationMs = isCalibration
|
|
200
200
|
? (travelTimeSec + this.config.calibrationExtraSec) * 1000
|
|
201
201
|
: (travel / 100) * travelTimeSec * 1000;
|