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.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.1.2"
2
+ ".": "0.1.3"
3
3
  }
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
- * Parse a discovery response to extract device IDs and IPs.
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 processDiscoveryResponse;
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 decrypts TXT record
31
- * payloads and emits 'update' events on state changes.
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
- * Retries up to 10 times on ECONNRESET.
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
- const mdns = multicastDns();
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
- setTimeout(() => {
56
+ const cleanup = () => {
50
57
  mdns.removeAllListeners('response');
58
+ mdns.removeAllListeners('error');
51
59
  mdns.destroy();
52
60
  resolve();
53
- }, 5000);
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.processDiscoveryResponse(response);
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
- * Parse a discovery response to extract device IDs and IPs.
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
- processDiscoveryResponse(response) {
91
+ processResponseRecords(response) {
66
92
  const allRecords = [
67
93
  ...(response.answers ?? []),
68
94
  ...(response.additionals ?? []),
69
95
  ];
70
- // Collect SRV/A records to map hostnames to IPs
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
- // Look for PTR records pointing to eWeLink devices
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
- // Try to find the IP from SRV → A record chain
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
- if (srvRecord && srvRecord.type === 'SRV') {
90
- const srvData = srvRecord.data;
91
- ip = hostToIp.get(srvData.target);
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
- const deviceHost = `eWeLink_${deviceId}.local`;
96
- ip = hostToIp.get(deviceHost);
128
+ ip = hostToIp.get(`eWeLink_${deviceId}.local`);
97
129
  }
98
- if (ip && !this.deviceMap.has(deviceId)) {
99
- this.deviceMap.set(deviceId, { ip, ipOverride: false });
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
- // Also extract device IDs from TXT records (which include the id field)
112
- if (record.type === 'TXT' && record.name.includes('_ewelink')) {
113
- const txt = this.parseTxtData(record.data);
114
- if (txt?.id) {
115
- // We may not have the IP from this record alone, but we know the device exists
116
- if (!this.deviceMap.has(txt.id)) {
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 decrypts TXT record
127
- * payloads and emits 'update' events on state changes.
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
- this.mdns = multicastDns();
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 monitoring started.');
238
+ this.log.info('[LAN] Monitoring started.');
190
239
  }
191
240
  /**
192
241
  * Send an encrypted command to a device over the LAN.
193
- * Retries up to 10 times on ECONNRESET.
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?.ip || !info?.lanKey) {
198
- this.log.warn('[LAN] Cannot send to %s: ip=%s lanKey=%s', deviceId, info?.ip ?? 'none', info?.lanKey ? 'set' : 'none');
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
- if (this.debug) {
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
  *
@@ -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, only allow calibration moves (0 or 100)
69
+ // If unknown position, calibrate down to 0% (the only limit-switch endpoint)
70
70
  if (this.state.phase === 'unknown') {
71
- const dir = clamped <= 50 ? 'down' : 'up';
72
- this.log(`Position unknown, starting calibration ${dir}`);
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
- // Is this a full-travel calibration move? (target is an endpoint)
92
- const isCalibration = clamped === 0 || clamped === 100;
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.2",
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
- const mdns = multicastDns();
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
- setTimeout(() => {
75
+ const cleanup = () => {
67
76
  mdns.removeAllListeners('response');
77
+ mdns.removeAllListeners('error');
68
78
  mdns.destroy();
69
79
  resolve();
70
- }, 5000);
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.processDiscoveryResponse(response);
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
- * Parse a discovery response to extract device IDs and IPs.
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 processDiscoveryResponse(response: multicastDns.ResponsePacket): void {
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 SRV/A records to map hostnames to IPs
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
- // Look for PTR records pointing to eWeLink devices
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
- // Try to find the IP from SRV → A record chain
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
- if (srvRecord && srvRecord.type === 'SRV') {
116
- const srvData = srvRecord.data as { target: string };
117
- ip = hostToIp.get(srvData.target);
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
- const deviceHost = `eWeLink_${deviceId}.local`;
123
- ip = hostToIp.get(deviceHost);
157
+ ip = hostToIp.get(`eWeLink_${deviceId}.local`);
124
158
  }
125
159
 
126
- if (ip && !this.deviceMap.has(deviceId)) {
127
- this.deviceMap.set(deviceId, { ip, ipOverride: false });
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
- // Also extract device IDs from TXT records (which include the id field)
140
- if (record.type === 'TXT' && record.name.includes('_ewelink')) {
141
- const txt = this.parseTxtData(record.data);
142
- if (txt?.id) {
143
- // We may not have the IP from this record alone, but we know the device exists
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 decrypts TXT record
156
- * payloads and emits 'update' events on state changes.
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
- this.mdns = multicastDns();
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 monitoring started.');
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
- * Retries up to 10 times on ECONNRESET.
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?.ip || !info?.lanKey) {
239
- this.log.warn('[LAN] Cannot send to %s: ip=%s lanKey=%s', deviceId, info?.ip ?? 'none', info?.lanKey ? 'set' : 'none');
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
- if (this.debug) {
247
- this.log.debug('[LAN] Sending to %s → %s', deviceId, url);
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 returns calibration plan going up', () => {
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
- expect(plan!.direction).toBe('up');
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 defaults to calibration down (<= 50)', () => {
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 defaults to calibration up (> 50)', () => {
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('up');
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 calibration up sets position to 100 and phase to stopped', () => {
125
+ it('completeMovement() after startCalibration(up) sets position to 100', () => {
125
126
  const { tracker } = createTracker(tempDir);
126
- tracker.startMovement(100);
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 calibration duration and isCalibration=true', () => {
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
- expect(plan).toEqual({ direction: 'up', durationMs: 12000, isCalibration: true });
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 isCalibration=true', () => {
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 plus calibration extra (12s)', () => {
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
- expect(plan!.durationMs).toBe(12000);
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('up');
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(true);
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', () => {
@@ -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, only allow calibration moves (0 or 100)
170
+ // If unknown position, calibrate down to 0% (the only limit-switch endpoint)
171
171
  if (this.state.phase === 'unknown') {
172
- const dir: 'up' | 'down' = clamped <= 50 ? 'down' : 'up';
173
- this.log(`Position unknown, starting calibration ${dir}`);
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
- // Is this a full-travel calibration move? (target is an endpoint)
198
- const isCalibration = clamped === 0 || clamped === 100;
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;