dsh-ensp 0.1.3 → 0.1.7

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,465 +1,884 @@
1
- /**
2
- * eNSP launcher: open a topology, start its devices, and hand the desktop back.
3
- *
4
- * ## Why this drives the GUI at all
5
- *
6
- * Starting devices has no API. It was tested exhaustively and ruled out:
7
- *
8
- * | Approach | Result |
9
- * |---------------------------------|-----------------------------------------------------|
10
- * | `bootmode="1"` in the .topo | devices still stay off |
11
- * | VBoxManage `startvm` on a clone | VM runs, but no console: AR_Base has `uart1="off"` |
12
- * | eNSP control port (65514/7301) | refuses connections / undocumented protocol |
13
- * | writing `WM_COMMAND` ids | eNSP crashes; the toolbar has no enumerable ids |
14
- *
15
- * eNSP's console is **not** on the VM's serial port — a guest agent dials back to
16
- * a `-consoleip/-consoleport` pair that only the eNSP client allocates. So the
17
- * client must be the one to start devices, and the only way to ask it is the
18
- * "Start all devices" toolbar button.
19
- *
20
- * ## Readiness is measured, never guessed
21
- *
22
- * Boot time varies enormously between models, images and host load, so **no fixed
23
- * timeout decides success**. Each device is polled on its own console port until
24
- * VRP actually answers with a prompt; a device that never answers is reported as
25
- * such instead of being assumed ready. The overall deadline only bounds how long
26
- * we are willing to wait, and it is generous.
27
- *
28
- * @module dsh-ensp/launcher
29
- */
30
-
31
- import { spawn, spawnSync } from 'node:child_process'
32
- import net from 'node:net'
33
-
34
- import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
35
-
36
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
37
-
38
- /** Does the host currently listen on `port`? */
39
- function listens(port, host = '127.0.0.1', timeoutMs = 1200) {
40
- return new Promise((resolve) => {
41
- const socket = net.createConnection({ host, port })
42
- const done = (value) => {
43
- socket.destroy()
44
- resolve(value)
45
- }
46
- socket.setTimeout(timeoutMs)
47
- socket.once('connect', () => done(true))
48
- socket.once('error', () => done(false))
49
- socket.once('timeout', () => done(false))
50
- })
51
- }
52
-
53
- /** PowerShell one-liner that lists eNSP's own listening console ports. */
54
- function listenPortsScript() {
55
- return (
56
- 'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ' +
57
- 'Where-Object { $_.LocalPort -ge 2000 -and $_.LocalPort -le 2100 } | ' +
58
- 'ForEach-Object { $_.LocalPort } | Sort-Object -Unique'
59
- )
60
- }
61
-
62
- /** Snapshot of ports currently listening in the eNSP console range. */
63
- function consolePortsSnapshot() {
64
- const r = spawnSync('powershell', ['-NoProfile', '-Command', listenPortsScript()], {
65
- encoding: 'utf8',
66
- windowsHide: true,
67
- })
68
- if (r.status !== 0) return []
69
- return (r.stdout || '')
70
- .split(/\s+/)
71
- .map(Number)
72
- .filter((n) => Number.isInteger(n) && n >= 2000 && n <= 2100)
73
- }
74
-
75
- /**
76
- * Win32 helper source. Kept in one place because Add-Type is expensive and the
77
- * same C# is reused for locating, focusing and clicking.
78
- */
79
- const WIN32_SOURCE = String.raw`
80
- using System;
81
- using System.Text;
82
- using System.Runtime.InteropServices;
83
-
84
- public class EnspWin {
85
- public delegate bool EnumProc(IntPtr h, IntPtr l);
86
-
87
- // Declaring DPI awareness makes GetWindowRect/SetCursorPos use physical pixels,
88
- // matching the calibration screenshot. Without it every click is scaled by the
89
- // monitor factor (150% here) and lands in the wrong place.
90
- [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
91
-
92
- [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
93
- [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);
94
- [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
95
- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
96
- [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
97
- [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
98
- [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
99
- [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
100
- [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
101
- [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
102
- [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
103
- [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
104
- [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
105
- [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, IntPtr e);
106
- [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
107
-
108
- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
109
-
110
- public static IntPtr FindMain(uint pid) {
111
- IntPtr found = IntPtr.Zero;
112
- IntPtr fallback = IntPtr.Zero;
113
- EnumWindows((h, l) => {
114
- uint p; GetWindowThreadProcessId(h, out p);
115
- if (p != pid) return true;
116
- var sb = new StringBuilder(256); GetClassNameW(h, sb, 256);
117
- if (!sb.ToString().StartsWith("Afx:")) return true;
118
- RECT r; GetWindowRect(h, out r);
119
- int w = r.R - r.L, hgt = r.B - r.T;
120
- // Normal case: the full frame. A minimised frame collapses to a small
121
- // rect (e.g. 237x39), so keep the first Afx window as a fallback and
122
- // prefer the large one when it exists.
123
- if (w > 400 && hgt > 300) { found = h; return false; }
124
- if (fallback == IntPtr.Zero) fallback = h;
125
- return true;
126
- }, IntPtr.Zero);
127
- return found != IntPtr.Zero ? found : fallback;
128
- }
129
-
130
- public static bool FocusWindow(IntPtr h) {
131
- IntPtr fg = GetForegroundWindow();
132
- uint dummy;
133
- uint fgt = GetWindowThreadProcessId(fg, out dummy);
134
- uint myt = GetCurrentThreadId();
135
- AttachThreadInput(myt, fgt, true);
136
- ShowWindow(h, 9); // SW_RESTORE
137
- BringWindowToTop(h);
138
- bool ok = SetForegroundWindow(h);
139
- AttachThreadInput(myt, fgt, false);
140
- return ok;
141
- }
142
-
143
- public static void InitDpi() {
144
- SetProcessDPIAware();
145
- }
146
-
147
- public static void Click(int x, int y) {
148
- // A synthetic click must look human enough for MFC to accept it: move first,
149
- // settle, then hold the button down long enough to be past the double-click
150
- // timer's lower bound. The previous 110 ms press was silently ignored.
151
- SetCursorPos(x, y);
152
- System.Threading.Thread.Sleep(150);
153
- mouse_event(0x0002, 0, 0, 0, IntPtr.Zero); // LEFTDOWN
154
- System.Threading.Thread.Sleep(90);
155
- mouse_event(0x0004, 0, 0, 0, IntPtr.Zero); // LEFTUP
156
- System.Threading.Thread.Sleep(120);
157
- // Second, slightly offset move to force an WM_MOUSEMOVE on the button.
158
- SetCursorPos(x + 1, y);
159
- System.Threading.Thread.Sleep(60);
160
- SetCursorPos(x, y);
161
- }
162
- }
163
- `
164
-
165
- /** Run a PowerShell snippet that uses the helper above. */
166
- function runPowerShell(body, { timeoutMs = 30000 } = {}) {
167
- const script = `
168
- $ErrorActionPreference = 'Stop'
169
- Add-Type -TypeDefinition @'
170
- ${WIN32_SOURCE}
171
- '@ -ErrorAction Stop
172
- [EnspWin]::InitDpi()
173
- ${body}
174
- `
175
- const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
176
- encoding: 'utf8',
177
- timeout: timeoutMs,
178
- windowsHide: true,
179
- })
180
- return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
181
- }
182
-
183
- /** PID of the running eNSP client, or null. */
184
- export function enspPid() {
185
- const r = spawnSync(
186
- 'powershell',
187
- ['-NoProfile', '-Command', '(Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -First 1).Id'],
188
- { encoding: 'utf8', windowsHide: true },
189
- )
190
- const pid = Number((r.stdout || '').trim())
191
- return Number.isInteger(pid) && pid > 0 ? pid : null
192
- }
193
-
194
- /**
195
- * Launch the eNSP client, optionally opening a topology.
196
- *
197
- * @param {object} [options]
198
- * @param {string} [options.topology] - absolute path to a `.topo` file
199
- * @param {string} [options.exspPath] - override the client executable
200
- * @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
201
- * @returns {Promise<{pid: number, hwnd: string}>}
202
- */
203
- export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 90000 } = {}) {
204
- const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
205
- const already = enspPid()
206
- if (already) {
207
- const hwnd = locateWindow(already)
208
- if (hwnd) return { pid: already, hwnd, reused: true }
209
- }
210
-
211
- const args = topology ? [topology] : []
212
- const child = spawn(exe, args, { detached: true, stdio: 'ignore', windowsHide: false })
213
- child.unref()
214
-
215
- // Poll tightly: eNSP creates its frame before it finishes painting, and every
216
- // second spent here is dead time the caller cannot overlap with anything.
217
- const started = Date.now()
218
- const deadline = started + readyTimeoutMs
219
- let pid = null
220
- let hwnd = null
221
- while (Date.now() < deadline) {
222
- pid = enspPid()
223
- if (pid) {
224
- hwnd = locateWindow(pid)
225
- if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
226
- }
227
- await sleep(250)
228
- }
229
- throw new Error(
230
- `eNSP window did not appear within ${readyTimeoutMs} ms` +
231
- (pid ? ` (pid ${pid} started but no frame window)` : ' (process never started)'),
232
- )
233
- }
234
-
235
- /**
236
- * Locate the eNSP main frame window for a PID.
237
- * @param {number} pid
238
- * @returns {string|null} the HWND as a decimal string (JS has no uintptr type)
239
- */
240
- export function locateWindow(pid) {
241
- const r = runPowerShell(`[EnspWin]::FindMain([uint32]${pid}).ToString()`, { timeoutMs: 20000 })
242
- if (!r.ok) return null
243
- const value = r.stdout.split(/\s+/).pop()
244
- return value && value !== '0' ? value : null
245
- }
246
-
247
- /**
248
- * Bring eNSP to the foreground so its toolbar can be clicked.
249
- *
250
- * Focus is unavoidable: driving the toolbar while the window is occluded or
251
- * minimised was observed to kill the process. The caller is expected to
252
- * {@link minimiseEnsp} afterwards so the user gets the desktop back.
253
- *
254
- * @param {string} hwnd
255
- * @returns {boolean} whether the window ended up in front
256
- */
257
- export function focusEnsp(hwnd) {
258
- const r = runPowerShell(
259
- `
260
- $h = [IntPtr]::new([int64]${hwnd})
261
- $ok = [EnspWin]::FocusWindow($h)
262
- Start-Sleep -Milliseconds 250
263
- ($ok -and ([EnspWin]::GetForegroundWindow() -eq $h)).ToString()
264
- `,
265
- { timeoutMs: 20000 },
266
- )
267
- return r.ok && r.stdout.split(/\s+/).pop() === 'True'
268
- }
269
-
270
- /**
271
- * Minimise the eNSP window, handing the desktop back to the user.
272
- * @param {string} hwnd
273
- */
274
- export function minimiseEnsp(hwnd) {
275
- runPowerShell(
276
- `[EnspWin]::ShowWindow([IntPtr]::new([int64]${hwnd}), 6) | Out-Null; 'ok'`,
277
- { timeoutMs: 15000 },
278
- )
279
- }
280
-
281
- /**
282
- * Click the "start all devices" toolbar button.
283
- *
284
- * Coordinates are **window-relative** and were measured against the client's
285
- * 1280×800 layout; the toolbar is a fixed strip whose origin does not move with
286
- * the window. See `docs/UI-ANCHORS.md` for how they were obtained and how to
287
- * re-measure after an eNSP or DPI change.
288
- *
289
- * @param {string} hwnd
290
- * @param {{button?: 'start'|'stop', offsets?: {start: [number, number], stop: [number, number]}}} [options]
291
- */
292
- export function clickToolbar(hwnd, options = {}) {
293
- const { button = 'start' } = options
294
- const anchors = options.anchors ?? {
295
- start: { x: DEFAULT_ANCHORS.start.x, y: DEFAULT_ANCHORS.start.y },
296
- stop: { x: DEFAULT_ANCHORS.stop.x, y: DEFAULT_ANCHORS.stop.y },
297
- }
298
- const { x: ox, y: oy } = anchors[button] ?? anchors.start
299
- const r = runPowerShell(
300
- `
301
- $h = [IntPtr]::new([int64]${hwnd})
302
- $rect = New-Object EnspWin+RECT
303
- [void][EnspWin]::GetWindowRect($h, [ref]$rect)
304
- # Anchors are measured in the SAME frame as the window rect (origin = window
305
- # top-left, title bar included), which is also the frame the calibration
306
- # screenshot uses. Do not add a client-area offset here.
307
- $cx = $rect.L + ${ox}
308
- $cy = $rect.T + ${oy}
309
- [EnspWin]::Click($cx, $cy)
310
- 'clicked ' + $cx + ',' + $cy
311
- `,
312
- { timeoutMs: 20000 },
313
- )
314
- return { ok: r.ok, detail: r.stdout.split('\n').pop() || r.stderr }
315
- }
316
-
317
- /**
318
- * Wait until one device console answers VRP with a prompt.
319
- *
320
- * Readiness means "the device replied", not "enough time has passed".
321
- *
322
- * @param {object} options
323
- * @param {number} options.port - console TCP port from the .topo
324
- * @param {number} [options.timeoutMs=180000] - per-device ceiling, generous on purpose
325
- * @param {number} [options.pollMs=1500]
326
- * @returns {Promise<{port: number, ready: boolean, prompt: string|null, waitedMs: number, error?: string}>}
327
- */
328
- export async function waitForConsole({ port, timeoutMs = 180000, pollMs = 1500 }) {
329
- const started = Date.now()
330
- let lastError = null
331
- while (Date.now() - started < timeoutMs) {
332
- if (await listens(port)) {
333
- // Port is open; now prove VRP is actually talking on it.
334
- const answer = await new Promise((resolve) => {
335
- const socket = net.createConnection({ host: '127.0.0.1', port })
336
- let buf = ''
337
- const finish = (value) => {
338
- socket.destroy()
339
- resolve(value)
340
- }
341
- socket.setEncoding('utf8')
342
- socket.setTimeout(4000)
343
- socket.on('connect', () => setTimeout(() => socket.write('\r'), 400))
344
- socket.on('data', (chunk) => {
345
- buf += chunk
346
- // A prompt at the end of the buffer means VRP is alive.
347
- if (/[<\[][\w~\-./:]*[>\]]\s*$/.test(buf.replace(/\r/g, ''))) {
348
- const prompt = (buf.match(/[<\[][\w~\-./:]*[>\]]/g) || []).pop() ?? null
349
- finish({ prompt })
350
- }
351
- })
352
- socket.on('error', (e) => finish({ error: e.message }))
353
- socket.on('timeout', () => finish({ error: 'no reply' }))
354
- })
355
- if (answer.prompt) {
356
- return { port, ready: true, prompt: answer.prompt, waitedMs: Date.now() - started }
357
- }
358
- lastError = answer.error ?? 'no prompt yet'
359
- }
360
- await sleep(pollMs)
361
- }
362
- return {
363
- port,
364
- ready: false,
365
- prompt: null,
366
- waitedMs: Date.now() - started,
367
- error: lastError ?? 'console never opened',
368
- }
369
- }
370
-
371
- /**
372
- * Start every device of a topology and verify each one individually.
373
- *
374
- * The sequence is: focus → click start → **poll each console until it answers**.
375
- * Devices are verified concurrently so a slow model does not serialise the wait,
376
- * and the result distinguishes "all ready" from "some never came up" so a caller
377
- * can act on the difference.
378
- *
379
- * @param {object} options
380
- * @param {Array<{name: string, comPort: number|null}>} options.devices
381
- * @param {string} options.hwnd
382
- * @param {number} [options.perDeviceTimeoutMs=180000]
383
- * @param {boolean} [options.minimise=true] - restore the desktop as soon as the
384
- * click has been delivered (devices keep booting in the background)
385
- * @param {(event: object) => void} [options.onProgress]
386
- * @returns {Promise<{clicked: boolean, devices: Array<object>, allReady: boolean}>}
387
- */
388
- export async function startAllDevices({
389
- devices,
390
- hwnd,
391
- perDeviceTimeoutMs = 180000,
392
- minimise = true,
393
- onProgress,
394
- }) {
395
- const report = (event) => onProgress?.(event)
396
- const targets = devices.filter((d) => d.comPort)
397
-
398
- const before = new Set(consolePortsSnapshot())
399
-
400
- // Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
401
- // this survives eNSP upgrades, DPI changes and different display sizes.
402
- report({ phase: 'calibrate', hwnd })
403
- let anchors
404
- try {
405
- anchors = calibrateAnchors(hwnd)
406
- } catch (error) {
407
- anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
408
- }
409
- report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
410
-
411
- report({ phase: 'focus', hwnd })
412
- const focused = focusEnsp(hwnd)
413
- if (!focused) report({ phase: 'focus-failed', hwnd })
414
-
415
- report({ phase: 'click', button: 'start', anchor: anchors.start })
416
- const click = clickToolbar(hwnd, { button: 'start', anchors })
417
-
418
- // Hand the desktop back immediately; boot continues without the window.
419
- if (minimise) {
420
- minimiseEnsp(hwnd)
421
- report({ phase: 'minimised' })
422
- }
423
-
424
- report({ phase: 'wait', devices: targets.length })
425
- const results = await Promise.all(
426
- targets.map(async (device) => {
427
- const outcome = await waitForConsole({ port: device.comPort, timeoutMs: perDeviceTimeoutMs })
428
- report({ phase: 'device', name: device.name, ...outcome })
429
- return { name: device.name, model: device.model, ...outcome }
430
- }),
431
- )
432
-
433
- const after = new Set(consolePortsSnapshot())
434
- const newPorts = [...after].filter((p) => !before.has(p))
435
-
436
- return {
437
- clicked: click.ok,
438
- clickDetail: click.detail,
439
- anchors,
440
- focused,
441
- newPorts,
442
- devices: results,
443
- allReady: results.length > 0 && results.every((r) => r.ready),
444
- ready: results.filter((r) => r.ready).length,
445
- total: results.length,
446
- }
447
- }
448
-
449
- /**
450
- * Stop every device (red square next to the green triangle).
451
- * @param {string} hwnd
452
- */
453
- export function stopAllDevices(hwnd, options = {}) {
454
- let anchors = options.anchors
455
- if (!anchors) {
456
- try {
457
- anchors = calibrateAnchors(hwnd)
458
- } catch {
459
- anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
460
- }
461
- }
462
- const focused = focusEnsp(hwnd)
463
- const result = clickToolbar(hwnd, { button: 'stop', anchors })
464
- return { focused, anchors, ...result }
465
- }
1
+ /**
2
+ * eNSP launcher: open a topology, start its devices, and hand the desktop back.
3
+ *
4
+ * ## Why this drives the GUI at all
5
+ *
6
+ * Starting devices has no API. It was tested exhaustively and ruled out:
7
+ *
8
+ * | Approach | Result |
9
+ * |---------------------------------|-----------------------------------------------------|
10
+ * | `bootmode="1"` in the .topo | devices still stay off |
11
+ * | VBoxManage `startvm` on a clone | VM runs, but no console: AR_Base has `uart1="off"` |
12
+ * | eNSP control port (65514/7301) | refuses connections / undocumented protocol |
13
+ * | writing `WM_COMMAND` ids | eNSP crashes; the toolbar has no enumerable ids |
14
+ *
15
+ * eNSP's console is **not** on the VM's serial port — a guest agent dials back to
16
+ * a `-consoleip/-consoleport` pair that only the eNSP client allocates. So the
17
+ * client must be the one to start devices, and the only way to ask it is the
18
+ * "Start all devices" toolbar button.
19
+ *
20
+ * ## Readiness is measured, never guessed
21
+ *
22
+ * Boot time varies enormously between models, images and host load, so **no fixed
23
+ * timeout decides success**. Each device is polled on its own console port until
24
+ * VRP actually answers with a prompt; a device that never answers is reported as
25
+ * such instead of being assumed ready. The overall deadline only bounds how long
26
+ * we are willing to wait, and it is generous.
27
+ *
28
+ * @module dsh-ensp/launcher
29
+ */
30
+
31
+ import { spawn, spawnSync } from 'node:child_process'
32
+ import net from 'node:net'
33
+ import os from 'node:os'
34
+ import path from 'node:path'
35
+
36
+ import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
37
+ import { checkTopologySupport, describeBlocked, rootFromExe } from './compat.mjs'
38
+ import { readTopology } from './topology.mjs'
39
+
40
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
41
+
42
+ /** Does the host currently listen on `port`? */
43
+ function listens(port, host = '127.0.0.1', timeoutMs = 1200) {
44
+ return new Promise((resolve) => {
45
+ const socket = net.createConnection({ host, port })
46
+ const done = (value) => {
47
+ socket.destroy()
48
+ resolve(value)
49
+ }
50
+ socket.setTimeout(timeoutMs)
51
+ socket.once('connect', () => done(true))
52
+ socket.once('error', () => done(false))
53
+ socket.once('timeout', () => done(false))
54
+ })
55
+ }
56
+
57
+ /** PowerShell one-liner that lists eNSP's own listening console ports. */
58
+ function listenPortsScript() {
59
+ return (
60
+ 'Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ' +
61
+ 'Where-Object { $_.LocalPort -ge 2000 -and $_.LocalPort -le 2100 } | ' +
62
+ 'ForEach-Object { $_.LocalPort } | Sort-Object -Unique'
63
+ )
64
+ }
65
+
66
+ /** Snapshot of ports currently listening in the eNSP console range. */
67
+ function consolePortsSnapshot() {
68
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', listenPortsScript()], {
69
+ encoding: 'utf8',
70
+ windowsHide: true,
71
+ })
72
+ if (r.status !== 0) return []
73
+ return (r.stdout || '')
74
+ .split(/\s+/)
75
+ .map(Number)
76
+ .filter((n) => Number.isInteger(n) && n >= 2000 && n <= 2100)
77
+ }
78
+
79
+ /**
80
+ * Win32 helper source. Kept in one place because Add-Type is expensive and the
81
+ * same C# is reused for locating, focusing and clicking.
82
+ */
83
+ const WIN32_SOURCE = String.raw`
84
+ using System;
85
+ using System.Text;
86
+ using System.Runtime.InteropServices;
87
+
88
+ public class EnspWin {
89
+ public delegate bool EnumProc(IntPtr h, IntPtr l);
90
+
91
+ // Declaring DPI awareness makes GetWindowRect/SetCursorPos use physical pixels,
92
+ // matching the calibration screenshot. Without it every click is scaled by the
93
+ // monitor factor (150% here) and lands in the wrong place.
94
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
95
+
96
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);
97
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);
98
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr h, StringBuilder s, int n);
99
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
100
+ [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr h, out RECT r);
101
+ [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
102
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
103
+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);
104
+ [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
105
+ [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);
106
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
107
+ [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool f);
108
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
109
+ [DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, IntPtr e);
110
+ [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();
111
+ [DllImport("user32.dll")] public static extern IntPtr WindowFromPoint(POINT p);
112
+ [DllImport("user32.dll")] public static extern IntPtr GetAncestor(IntPtr h, uint flags);
113
+
114
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }
115
+ [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }
116
+
117
+ /** PID that owns a window handle. */
118
+ public static uint PidOf(IntPtr h) {
119
+ if (h == IntPtr.Zero) return 0;
120
+ uint p; GetWindowThreadProcessId(h, out p); return p;
121
+ }
122
+
123
+ /**
124
+ * PID of the top-level window that would receive a click at (x, y).
125
+ *
126
+ * This is how occlusion is detected: a click goes to whatever window owns the
127
+ * pixel, not to the window we think we are driving. When another application
128
+ * covers eNSP the returned PID is that application's, so the caller can refuse
129
+ * to click instead of silently clicking the wrong window.
130
+ */
131
+ public static uint OwnerPidAt(int x, int y) {
132
+ var pt = new POINT(); pt.X = x; pt.Y = y;
133
+ IntPtr h = WindowFromPoint(pt);
134
+ if (h == IntPtr.Zero) return 0;
135
+ IntPtr root = GetAncestor(h, 2); // GA_ROOT
136
+ return PidOf(root != IntPtr.Zero ? root : h);
137
+ }
138
+
139
+ /** Display name of a PID, for error messages. Never throws. */
140
+ public static string ProcessNameOf(uint pid) {
141
+ try { return System.Diagnostics.Process.GetProcessById((int)pid).ProcessName; }
142
+ catch { return "pid " + pid; }
143
+ }
144
+
145
+ public static IntPtr FindMain(uint pid) {
146
+ IntPtr found = IntPtr.Zero;
147
+ IntPtr fallback = IntPtr.Zero;
148
+ EnumWindows((h, l) => {
149
+ uint p; GetWindowThreadProcessId(h, out p);
150
+ if (p != pid) return true;
151
+ var sb = new StringBuilder(256); GetClassNameW(h, sb, 256);
152
+ if (!sb.ToString().StartsWith("Afx:")) return true;
153
+ RECT r; GetWindowRect(h, out r);
154
+ int w = r.R - r.L, hgt = r.B - r.T;
155
+ // Normal case: the full frame. A minimised frame collapses to a small
156
+ // rect (e.g. 237x39), so keep the first Afx window as a fallback and
157
+ // prefer the large one when it exists.
158
+ if (w > 400 && hgt > 300) { found = h; return false; }
159
+ if (fallback == IntPtr.Zero) fallback = h;
160
+ return true;
161
+ }, IntPtr.Zero);
162
+ return found != IntPtr.Zero ? found : fallback;
163
+ }
164
+
165
+ public static bool FocusWindow(IntPtr h) {
166
+ IntPtr fg = GetForegroundWindow();
167
+ uint dummy;
168
+ uint fgt = GetWindowThreadProcessId(fg, out dummy);
169
+ uint myt = GetCurrentThreadId();
170
+ AttachThreadInput(myt, fgt, true);
171
+ ShowWindow(h, 9); // SW_RESTORE
172
+ BringWindowToTop(h);
173
+ bool ok = SetForegroundWindow(h);
174
+ AttachThreadInput(myt, fgt, false);
175
+ return ok;
176
+ }
177
+
178
+ public static void InitDpi() {
179
+ SetProcessDPIAware();
180
+ }
181
+
182
+ public static void Click(int x, int y) {
183
+ // A synthetic click must look human enough for MFC to accept it: move first,
184
+ // settle, then hold the button down long enough to be past the double-click
185
+ // timer's lower bound. The previous 110 ms press was silently ignored.
186
+ SetCursorPos(x, y);
187
+ System.Threading.Thread.Sleep(150);
188
+ mouse_event(0x0002, 0, 0, 0, IntPtr.Zero); // LEFTDOWN
189
+ System.Threading.Thread.Sleep(90);
190
+ mouse_event(0x0004, 0, 0, 0, IntPtr.Zero); // LEFTUP
191
+ System.Threading.Thread.Sleep(120);
192
+ // Second, slightly offset move to force an WM_MOUSEMOVE on the button.
193
+ SetCursorPos(x + 1, y);
194
+ System.Threading.Thread.Sleep(60);
195
+ SetCursorPos(x, y);
196
+ }
197
+ }
198
+ `
199
+
200
+ /** Run a PowerShell snippet that uses the helper above. */
201
+ function runPowerShell(body, { timeoutMs = 30000 } = {}) {
202
+ const script = `
203
+ $ErrorActionPreference = 'Stop'
204
+ Add-Type -TypeDefinition @'
205
+ ${WIN32_SOURCE}
206
+ '@ -ErrorAction Stop
207
+ [EnspWin]::InitDpi()
208
+ ${body}
209
+ `
210
+ const r = spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
211
+ encoding: 'utf8',
212
+ timeout: timeoutMs,
213
+ windowsHide: true,
214
+ })
215
+ return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
216
+ }
217
+
218
+ /**
219
+ * PID of a *live* eNSP client, or null.
220
+ *
221
+ * eNSP leaves zombie processes behind: the process object survives with
222
+ * `Threads.Count === 0` and holds the client's single-instance lock, so a new
223
+ * launch inherits an empty canvas. Such a PID must never be reused.
224
+ *
225
+ * @returns {number|null}
226
+ */
227
+ export function enspPid() {
228
+ const r = spawnSync(
229
+ 'powershell',
230
+ [
231
+ '-NoProfile',
232
+ '-Command',
233
+ 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | ' +
234
+ 'Where-Object { $_.Threads.Count -gt 0 } | ' +
235
+ 'Select-Object -First 1 -ExpandProperty Id',
236
+ ],
237
+ { encoding: 'utf8', windowsHide: true },
238
+ )
239
+ const pid = Number((r.stdout || '').trim())
240
+ return Number.isInteger(pid) && pid > 0 ? pid : null
241
+ }
242
+
243
+ /**
244
+ * Is this window handle still valid and owned by a live process?
245
+ *
246
+ * @param {string} hwnd
247
+ * @returns {boolean}
248
+ */
249
+ export function windowAlive(hwnd) {
250
+ const r = runPowerShell(
251
+ `$h = [IntPtr]::new([int64]${hwnd})
252
+ if (-not [EnspWin]::IsWindow($h)) { 'no'; exit 0 }
253
+ $owned = Get-Process eNSP_Client -ErrorAction SilentlyContinue | Where-Object { $_.Threads.Count -gt 0 }
254
+ if ($owned) { 'yes' } else { 'no' }`,
255
+ { timeoutMs: 20000 },
256
+ )
257
+ return r.ok && r.stdout.trim().endsWith('yes')
258
+ }
259
+
260
+ /** Every eNSP client PID, including zero-thread zombies. */
261
+ export function enspPids() {
262
+ const r = spawnSync(
263
+ 'powershell',
264
+ ['-NoProfile', '-Command', 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id'],
265
+ { encoding: 'utf8', windowsHide: true },
266
+ )
267
+ return (r.stdout || '')
268
+ .split(/\s+/)
269
+ .map(Number)
270
+ .filter((n) => Number.isInteger(n) && n > 0)
271
+ }
272
+
273
+ /**
274
+ * Launch the eNSP client, optionally opening a topology.
275
+ *
276
+ * @param {object} [options]
277
+ * @param {string} [options.topology] - absolute path to a `.topo` file
278
+ * @param {string} [options.exspPath] - override the client executable
279
+ * @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
280
+ * @returns {Promise<{pid: number, hwnd: string}>}
281
+ */
282
+ export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
283
+ const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
284
+ const target = topology ? path.resolve(topology) : null
285
+
286
+ // Reject topologies whose devices have no installed image *before* starting the
287
+ // client. Opening one otherwise looks fine for a few seconds — eNSP paints the
288
+ // canvas and then exits by itself, which surfaces later as a pile of
289
+ // "console never answered" errors that point at the wrong thing.
290
+ if (target) {
291
+ const parsed = await readTopology(target)
292
+ const support = checkTopologySupport(parsed, { root: rootFromExe(exe) })
293
+ if (!support.ok) throw new Error(describeBlocked(support))
294
+ }
295
+
296
+ // Reuse an existing window only when it renders something; a blank window is a
297
+ // dead instance that would otherwise be reported as success.
298
+ const existing = enspPid()
299
+ if (existing) {
300
+ const hwnd = locateWindow(existing)
301
+ if (hwnd && (!target || topologyRendered(hwnd))) {
302
+ return { pid: existing, hwnd, reused: true, rendered: Boolean(hwnd) }
303
+ }
304
+ if (hwnd && target) {
305
+ // A live window showing nothing: reopen the file in it first.
306
+ reopenTopology(target)
307
+ if (await waitForRender(hwnd, { attempts: 6, intervalMs: 2500 })) {
308
+ return { pid: existing, hwnd, reused: true, rendered: true }
309
+ }
310
+ }
311
+ // Give up on it: stale clients hold eNSP's single-instance lock and make every
312
+ // new window open blank.
313
+ killAllClients()
314
+ await sleep(2500)
315
+ }
316
+
317
+ /** Launch once and wait for a frame window. */
318
+ const boot = async () => {
319
+ startDetached(exe, target)
320
+ const started = Date.now()
321
+ const deadline = started + readyTimeoutMs
322
+ while (Date.now() < deadline) {
323
+ const pid = enspPid()
324
+ if (pid) {
325
+ const hwnd = locateWindow(pid)
326
+ if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
327
+ }
328
+ await sleep(250)
329
+ }
330
+ return null
331
+ }
332
+
333
+ // Up to three attempts: the client occasionally opens with an empty canvas on a
334
+ // cold start, and a clean relaunch always renders. Verified empirically.
335
+ for (let attempt = 1; attempt <= 3; attempt++) {
336
+ const booted = await boot()
337
+ if (!booted) {
338
+ killAllClients()
339
+ await sleep(1500)
340
+ continue
341
+ }
342
+ if (!target) return { ...booted, reused: false, rendered: true }
343
+ if (await waitForRender(booted.hwnd, { attempts: 6, intervalMs: 2000 })) {
344
+ return { ...booted, reused: false, rendered: true }
345
+ }
346
+ // Blank canvas: tear the instance down and try again from a clean state.
347
+ killAllClients()
348
+ await sleep(2500)
349
+ }
350
+
351
+ throw new Error(
352
+ 'eNSP opened but never rendered the topology after 3 attempts. ' +
353
+ 'Close every eNSP window and retry; a leftover client holds the single-instance lock.',
354
+ )
355
+ }
356
+
357
+ /**
358
+ * Force-terminate every eNSP client process.
359
+ *
360
+ * Zombie processes (zero threads) cannot be killed by any API; they linger until
361
+ * the OS reaps them. This reports what survived so the caller can decide whether
362
+ * retrying is worthwhile.
363
+ *
364
+ * @returns {{killed: boolean, survivors: number[]}}
365
+ */
366
+ function killAllClients() {
367
+ spawnSync('taskkill', ['/F', '/IM', 'eNSP_Client.exe'], { windowsHide: true, encoding: 'utf8' })
368
+ return { killed: true, survivors: enspPids() }
369
+ }
370
+
371
+ /**
372
+ * Poll until the canvas actually shows the topology.
373
+ *
374
+ * @param {string} hwnd
375
+ * @param {{attempts?: number, intervalMs?: number}} [options]
376
+ * @returns {Promise<boolean>}
377
+ */
378
+ async function waitForRender(hwnd, { attempts = 6, intervalMs = 2000 } = {}) {
379
+ for (let i = 0; i < attempts; i++) {
380
+ await sleep(intervalMs)
381
+ if (topologyRendered(hwnd)) return true
382
+ }
383
+ return false
384
+ }
385
+
386
+ /**
387
+ * Start the client so that it outlives this Node process.
388
+ *
389
+ * `spawn(..., {detached:true})` is not enough on Windows: eNSP is a GUI application
390
+ * that terminates when the console session that launched it goes away. Going
391
+ * through `cmd /c start` hands ownership to the shell, which is what a double-click
392
+ * in Explorer does, and the client then survives its launcher.
393
+ *
394
+ * @param {string} exe
395
+ * @param {string|null} [topology]
396
+ */
397
+ function startDetached(exe, topology) {
398
+ // Win32_Process.Create via WMI is the only launch path measured to leave the
399
+ // client fully independent of this Node process:
400
+ //
401
+ // spawn(..., {detached:true}) -> client dies with its launcher
402
+ // cmd /c start -> client dies with its launcher
403
+ // Start-Process -WindowStyle Hidden -> client exits before creating a window
404
+ // Start-Process (visible) -> unreliable when called from a hidden shell
405
+ // WMI Win32_Process.Create -> survives; returns the real PID
406
+ //
407
+ // The client must outlive the tool call, otherwise every follow-up (start
408
+ // devices, push config) finds a dead console.
409
+ const cmdline = topology ? `"${exe}" "${topology}"` : `"${exe}"`
410
+
411
+ // Two launch paths, tried in order. WMI is the cleanest when it works: one
412
+ // call, a real PID, and the client owns no parent. On some machines WMI
413
+ // refuses the request outright with `ReturnValue 8` (the process could not be
414
+ // started, usually a permissions or token change); `Start-Process -PassThru`
415
+ // from the same hidden PowerShell then works and the client still outlives it.
416
+ const attempts = [
417
+ `
418
+ $r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
419
+ CommandLine = '${cmdline.replace(/'/g, "''")}'
420
+ }
421
+ if ($r.ReturnValue -ne 0) { 'ERR ' + $r.ReturnValue; exit 0 }
422
+ 'pid=' + $r.ProcessId
423
+ `,
424
+ `
425
+ $p = Start-Process -FilePath '${exe.replace(/'/g, "''")}'${topology ? ` -ArgumentList '${topology.replace(/'/g, "''")}'` : ''} -PassThru
426
+ 'pid=' + $p.Id
427
+ `,
428
+ ]
429
+
430
+ const failures = []
431
+ for (const body of attempts) {
432
+ const r = runPowerShell(body, { timeoutMs: 30000 })
433
+ if (r.ok) {
434
+ const m = /pid=(\d+)/.exec(r.stdout)
435
+ if (m) return Number(m[1])
436
+ }
437
+ failures.push(r.stdout || r.stderr || `exit ${r.status}`)
438
+ }
439
+ throw new Error(`could not launch eNSP: ${failures.join(' / ')}`)
440
+ }
441
+
442
+ /**
443
+ * Ask the running client to open a topology again.
444
+ *
445
+ * A second instance is normally refused, so this relaunches the client with the
446
+ * same file; the window handle changes and the caller re-locates it.
447
+ *
448
+ * @param {string} topology
449
+ */
450
+ function reopenTopology(topology) {
451
+ const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
452
+ startDetached(exe, topology)
453
+ }
454
+
455
+ /**
456
+ * Take a screenshot of the client's canvas, cropped to the drawing area.
457
+ *
458
+ * Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
459
+ * left device palette and the toolbar, so anything found inside it came from the
460
+ * open topology.
461
+ *
462
+ * @param {string} hwnd
463
+ * @returns {string} absolute path to the PNG, or '' on failure
464
+ */
465
+ export function captureCanvas(hwnd) {
466
+ const out = path.join(os.tmpdir(), `dsh-ensp-canvas-${Date.now()}.png`)
467
+ const r = runPowerShell(
468
+ `
469
+ $h = [IntPtr]::new([int64]${hwnd})
470
+ [void][EnspWin]::FocusWindow($h)
471
+ Start-Sleep -Milliseconds 900
472
+ $wr = New-Object EnspWin+RECT
473
+ [void][EnspWin]::GetWindowRect($h, [ref]$wr)
474
+ $w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
475
+ if ($w -lt 400 -or $ht -lt 300) { 'ERR small'; exit 0 }
476
+ Add-Type -AssemblyName System.Drawing
477
+ $bmp = New-Object System.Drawing.Bitmap $w, $ht
478
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
479
+ $g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
480
+ $bmp.Save('${out.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
481
+ $bmp.Dispose(); $g.Dispose()
482
+ 'ok'
483
+ `,
484
+ { timeoutMs: 40000 },
485
+ )
486
+ return r.ok ? out : ''
487
+ }
488
+
489
+ /**
490
+ * Does the open topology show devices on the canvas?
491
+ *
492
+ * eNSP paints with DirectX, so there is no window tree to query. Two independent
493
+ * signals are combined, because either alone is weak:
494
+ *
495
+ * 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
496
+ * empty canvas has no such pixels.
497
+ * 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
498
+ *
499
+ * This is the check that gates "the topology actually opened". Callers retry on a
500
+ * negative result rather than reporting success for a blank window.
501
+ *
502
+ * @param {string} hwnd
503
+ * @returns {boolean}
504
+ */
505
+ export function topologyRendered(hwnd) {
506
+ const png = captureCanvas(hwnd)
507
+ if (!png) return false
508
+ const r = runPowerShell(
509
+ `
510
+ Add-Type -AssemblyName System.Drawing
511
+ $bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
512
+ $w = $bmp.Width; $ht = $bmp.Height
513
+ # Canvas only: skip the palette column and the toolbar band.
514
+ $x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
515
+ $y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
516
+ $icon = 0; $dark = 0
517
+ for ($y = $y0; $y -lt $y1; $y += 2) {
518
+ for ($x = $x0; $x -lt $x1; $x += 2) {
519
+ $c = $bmp.GetPixel($x, $y)
520
+ if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
521
+ elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
522
+ }
523
+ }
524
+ $bmp.Dispose()
525
+ 'icon=' + $icon + ' dark=' + $dark
526
+ `,
527
+ { timeoutMs: 40000 },
528
+ )
529
+ if (!r.ok) return false
530
+ const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
531
+ if (!m) return false
532
+ const icon = Number(m[1])
533
+ const dark = Number(m[2])
534
+ // A rendered lab shows dozens of icon pixels and a few hundred link pixels.
535
+ return icon >= 15 && dark >= 40
536
+ }
537
+
538
+ /**
539
+ * Locate the eNSP main frame window for a PID.
540
+ * @param {number} pid
541
+ * @returns {string|null} the HWND as a decimal string (JS has no uintptr type)
542
+ */
543
+ export function locateWindow(pid) {
544
+ const r = runPowerShell(`[EnspWin]::FindMain([uint32]${pid}).ToString()`, { timeoutMs: 20000 })
545
+ if (!r.ok) return null
546
+ const value = r.stdout.split(/\s+/).pop()
547
+ return value && value !== '0' ? value : null
548
+ }
549
+
550
+ /**
551
+ * Bring eNSP to the foreground so its toolbar can be clicked.
552
+ *
553
+ * Focus is unavoidable: driving the toolbar while the window is occluded or
554
+ * minimised was observed to kill the process. The caller is expected to
555
+ * {@link minimiseEnsp} afterwards so the user gets the desktop back.
556
+ *
557
+ * @param {string} hwnd
558
+ * @returns {boolean} whether the window ended up in front
559
+ */
560
+ export function focusEnsp(hwnd) {
561
+ const r = runPowerShell(
562
+ `
563
+ $h = [IntPtr]::new([int64]${hwnd})
564
+ $ok = [EnspWin]::FocusWindow($h)
565
+ Start-Sleep -Milliseconds 250
566
+ ($ok -and ([EnspWin]::GetForegroundWindow() -eq $h)).ToString()
567
+ `,
568
+ { timeoutMs: 20000 },
569
+ )
570
+ return r.ok && r.stdout.split(/\s+/).pop() === 'True'
571
+ }
572
+
573
+ /**
574
+ * Minimise the eNSP window, handing the desktop back to the user.
575
+ * @param {string} hwnd
576
+ */
577
+ export function minimiseEnsp(hwnd) {
578
+ runPowerShell(
579
+ `[EnspWin]::ShowWindow([IntPtr]::new([int64]${hwnd}), 6) | Out-Null; 'ok'`,
580
+ { timeoutMs: 15000 },
581
+ )
582
+ }
583
+
584
+ /**
585
+ * Is the eNSP window covered by something else?
586
+ *
587
+ * Calibration reads the screen, so an occluded window produces a screenshot of
588
+ * whatever is on top of it and the measured anchors are garbage. Rather than
589
+ * clicking on a guess, probe several points of the window frame and report the
590
+ * first foreign process that owns one of them.
591
+ *
592
+ * @param {string} hwnd
593
+ * @returns {{occluded: boolean, by?: string, at?: string}}
594
+ */
595
+ export function checkOcclusion(hwnd) {
596
+ const r = runPowerShell(
597
+ `
598
+ $h = [IntPtr]::new([int64]${hwnd})
599
+ $rect = New-Object EnspWin+RECT
600
+ [void][EnspWin]::GetWindowRect($h, [ref]$rect)
601
+ $self = [EnspWin]::PidOf($h)
602
+ $w = $rect.R - $rect.L
603
+ $hg = $rect.B - $rect.T
604
+ # A grid rather than a handful of points: partial coverage of the toolbar strip
605
+ # is exactly the case that corrupts calibration, and it is easy to miss when
606
+ # only the middle of the window is sampled.
607
+ $xs = @(0.5, 0.2, 0.35, 0.65, 0.8)
608
+ $ys = @(0.06, 0.25, 0.5, 0.75)
609
+ foreach ($fy in $ys) {
610
+ foreach ($fx in $xs) {
611
+ $px = $rect.L + [int]($w * $fx)
612
+ $py = $rect.T + [int]($hg * $fy)
613
+ $owner = [EnspWin]::OwnerPidAt($px, $py)
614
+ if ($owner -ne 0 -and $owner -ne $self) {
615
+ 'OCCLUDED ' + $px + ',' + $py + ' ' + $owner + ' ' + [EnspWin]::ProcessNameOf($owner)
616
+ return
617
+ }
618
+ }
619
+ }
620
+ 'clear'
621
+ `,
622
+ { timeoutMs: 20000 },
623
+ )
624
+ const text = r.stdout.split('\n').filter(Boolean).pop() || ''
625
+ if (text.startsWith('OCCLUDED')) {
626
+ const f = text.split(' ')
627
+ return { occluded: true, at: f[1], by: `${f[3] ?? ''} (${f[4] ?? 'unknown'})`.trim() }
628
+ }
629
+ return { occluded: false }
630
+ }
631
+
632
+ /**
633
+ * Click the "start all devices" toolbar button.
634
+ *
635
+ * Coordinates are **window-relative** and were measured against the client's
636
+ * 1280×800 layout; the toolbar is a fixed strip whose origin does not move with
637
+ * the window. See `docs/UI-ANCHORS.md` for how they were obtained and how to
638
+ * re-measure after an eNSP or DPI change.
639
+ *
640
+ * ## The click is verified, not assumed
641
+ *
642
+ * A synthetic click lands on whichever window owns the pixel. When another
643
+ * application covers eNSP the mouse events go there and eNSP never sees them,
644
+ * yet every step reports success and the caller then waits for devices that
645
+ * were never started. That failure was observed for real (a full-screen VMware
646
+ * window sitting on top). So the owner of the pixel is looked up with
647
+ * `WindowFromPoint` first: if it is not the eNSP process, no click is sent and
648
+ * the error names the process that is in the way.
649
+ *
650
+ * @param {string} hwnd
651
+ * @param {{button?: 'start'|'stop', anchors?: object}} [options]
652
+ * @returns {{ok: boolean, clicked: boolean, detail: string, occludedBy?: string}}
653
+ */
654
+ export function clickToolbar(hwnd, options = {}) {
655
+ const { button = 'start' } = options
656
+ const anchors = options.anchors ?? {
657
+ start: { x: DEFAULT_ANCHORS.start.x, y: DEFAULT_ANCHORS.start.y },
658
+ stop: { x: DEFAULT_ANCHORS.stop.x, y: DEFAULT_ANCHORS.stop.y },
659
+ }
660
+ const { x: ox, y: oy } = anchors[button] ?? anchors.start
661
+ const r = runPowerShell(
662
+ `
663
+ $h = [IntPtr]::new([int64]${hwnd})
664
+ $rect = New-Object EnspWin+RECT
665
+ [void][EnspWin]::GetWindowRect($h, [ref]$rect)
666
+ # Anchors are measured in the SAME frame as the window rect (origin = window
667
+ # top-left, title bar included), which is also the frame the calibration
668
+ # screenshot uses. Do not add a client-area offset here.
669
+ $cx = $rect.L + ${ox}
670
+ $cy = $rect.T + ${oy}
671
+ $self = [EnspWin]::PidOf($h)
672
+ $owner = [EnspWin]::OwnerPidAt($cx, $cy)
673
+ if ($owner -ne 0 -and $owner -ne $self) {
674
+ 'OCCLUDED ' + $cx + ',' + $cy + ' ' + $owner + ' ' + [EnspWin]::ProcessNameOf($owner)
675
+ exit 3
676
+ }
677
+ [EnspWin]::Click($cx, $cy)
678
+ $after = [EnspWin]::OwnerPidAt($cx, $cy)
679
+ if ($after -ne 0 -and $after -ne $self) {
680
+ 'STOLEN ' + $cx + ',' + $cy + ' ' + $after + ' ' + [EnspWin]::ProcessNameOf($after)
681
+ exit 4
682
+ }
683
+ 'clicked ' + $cx + ',' + $cy
684
+ `,
685
+ { timeoutMs: 20000 },
686
+ )
687
+ const detail = r.stdout.split('\n').filter(Boolean).pop() || r.stderr || ''
688
+ const fields = detail.split(' ')
689
+ if (fields[0] === 'OCCLUDED' || fields[0] === 'STOLEN') {
690
+ const who = `${fields[3] ?? ''} (${fields[4] ?? 'unknown'})`.trim()
691
+ return {
692
+ ok: false,
693
+ clicked: false,
694
+ occludedBy: who,
695
+ detail:
696
+ `eNSP is covered by another window at ${fields[1]}: ${who}. ` +
697
+ 'The mouse events would go to that window, so nothing was clicked. ' +
698
+ 'Bring eNSP to the front (restore it from the taskbar) and try again.',
699
+ }
700
+ }
701
+ return { ok: r.ok, clicked: r.ok, detail }
702
+ }
703
+
704
+ /**
705
+ * Wait until one device console answers VRP with a prompt.
706
+ *
707
+ * Readiness means "the device replied", not "enough time has passed".
708
+ *
709
+ * @param {object} options
710
+ * @param {number} options.port - console TCP port from the .topo
711
+ * @param {number} [options.timeoutMs=180000] - per-device ceiling, generous on purpose
712
+ * @param {number} [options.pollMs=1500]
713
+ * @returns {Promise<{port: number, ready: boolean, prompt: string|null, waitedMs: number, error?: string}>}
714
+ */
715
+ export async function waitForConsole({ port, timeoutMs = 180000, pollMs = 1500 }) {
716
+ const started = Date.now()
717
+ let lastError = null
718
+ while (Date.now() - started < timeoutMs) {
719
+ if (await listens(port)) {
720
+ // Port is open; now prove VRP is actually talking on it.
721
+ const answer = await new Promise((resolve) => {
722
+ const socket = net.createConnection({ host: '127.0.0.1', port })
723
+ let buf = ''
724
+ const finish = (value) => {
725
+ socket.destroy()
726
+ resolve(value)
727
+ }
728
+ socket.setEncoding('utf8')
729
+ socket.setTimeout(4000)
730
+ socket.on('connect', () => setTimeout(() => socket.write('\r'), 400))
731
+ socket.on('data', (chunk) => {
732
+ buf += chunk
733
+ // A prompt at the end of the buffer means VRP is alive.
734
+ if (/[<\[][\w~\-./:]*[>\]]\s*$/.test(buf.replace(/\r/g, ''))) {
735
+ const prompt = (buf.match(/[<\[][\w~\-./:]*[>\]]/g) || []).pop() ?? null
736
+ finish({ prompt })
737
+ }
738
+ })
739
+ socket.on('error', (e) => finish({ error: e.message }))
740
+ socket.on('timeout', () => finish({ error: 'no reply' }))
741
+ })
742
+ if (answer.prompt) {
743
+ return { port, ready: true, prompt: answer.prompt, waitedMs: Date.now() - started }
744
+ }
745
+ lastError = answer.error ?? 'no prompt yet'
746
+ }
747
+ await sleep(pollMs)
748
+ }
749
+ return {
750
+ port,
751
+ ready: false,
752
+ prompt: null,
753
+ waitedMs: Date.now() - started,
754
+ error: lastError ?? 'console never opened',
755
+ }
756
+ }
757
+
758
+ /**
759
+ * Start every device of a topology and verify each one individually.
760
+ *
761
+ * The sequence is: focus → click start → **poll each console until it answers**.
762
+ * Devices are verified concurrently so a slow model does not serialise the wait,
763
+ * and the result distinguishes "all ready" from "some never came up" so a caller
764
+ * can act on the difference.
765
+ *
766
+ * @param {object} options
767
+ * @param {Array<{name: string, comPort: number|null}>} options.devices
768
+ * @param {string} options.hwnd
769
+ * @param {number} [options.perDeviceTimeoutMs=180000]
770
+ * @param {boolean} [options.minimise=true] - restore the desktop as soon as the
771
+ * click has been delivered (devices keep booting in the background)
772
+ * @param {(event: object) => void} [options.onProgress]
773
+ * @returns {Promise<{clicked: boolean, devices: Array<object>, allReady: boolean}>}
774
+ */
775
+ export async function startAllDevices({
776
+ devices,
777
+ hwnd,
778
+ perDeviceTimeoutMs = 180000,
779
+ minimise = true,
780
+ onProgress,
781
+ }) {
782
+ const report = (event) => onProgress?.(event)
783
+ const targets = devices.filter((d) => d.comPort)
784
+
785
+ const before = new Set(consolePortsSnapshot())
786
+
787
+ // Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
788
+ // this survives eNSP upgrades, DPI changes and different display sizes.
789
+ // The window must still exist: a stale handle means the client already exited,
790
+ // and clicking a dead handle does nothing while reporting success below.
791
+ if (!hwnd || !windowAlive(hwnd)) {
792
+ throw new Error('eNSP window is gone; reopen the topology before starting devices')
793
+ }
794
+
795
+ report({ phase: 'calibrate', hwnd })
796
+ // Calibration photographs the screen, so an occluded window yields anchors
797
+ // measured on the wrong pixels. Check first, and say who is in the way.
798
+ const occlusion = checkOcclusion(hwnd)
799
+ if (occlusion.occluded) {
800
+ throw new Error(
801
+ `eNSP is covered by ${occlusion.by} at ${occlusion.at}, so its toolbar cannot be located or clicked. ` +
802
+ 'Windows does not let another process raise a foreign window, so this has to be fixed by hand: ' +
803
+ 'minimise or close the window that is on top, bring eNSP to the front, then run the command again.',
804
+ )
805
+ }
806
+
807
+ let anchors
808
+ try {
809
+ anchors = calibrateAnchors(hwnd)
810
+ } catch (error) {
811
+ anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
812
+ }
813
+
814
+ // A default anchor means calibration failed, and an unmeasured click can land on
815
+ // any toolbar control (including the one that closes the document). Refuse
816
+ // instead of guessing.
817
+ if (anchors.method !== 'measured') {
818
+ throw new Error(
819
+ `toolbar calibration failed (${anchors.detail?.reason ?? 'unknown'}). ` +
820
+ 'Make sure the eNSP window is visible and run ensp_probe to re-measure.',
821
+ )
822
+ }
823
+ report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
824
+
825
+ report({ phase: 'focus', hwnd })
826
+ const focused = focusEnsp(hwnd)
827
+ if (!focused) report({ phase: 'focus-failed', hwnd })
828
+
829
+ report({ phase: 'click', button: 'start', anchor: anchors.start })
830
+ const click = clickToolbar(hwnd, { button: 'start', anchors })
831
+ if (!click.clicked) {
832
+ // Never fall through to waiting on consoles: without a click no device will
833
+ // ever boot, and a long wait would hide the real cause.
834
+ throw new Error(click.detail || 'the start button could not be clicked')
835
+ }
836
+
837
+ // Hand the desktop back immediately; boot continues without the window.
838
+ if (minimise) {
839
+ minimiseEnsp(hwnd)
840
+ report({ phase: 'minimised' })
841
+ }
842
+
843
+ report({ phase: 'wait', devices: targets.length })
844
+ const results = await Promise.all(
845
+ targets.map(async (device) => {
846
+ const outcome = await waitForConsole({ port: device.comPort, timeoutMs: perDeviceTimeoutMs })
847
+ report({ phase: 'device', name: device.name, ...outcome })
848
+ return { name: device.name, model: device.model, ...outcome }
849
+ }),
850
+ )
851
+
852
+ const after = new Set(consolePortsSnapshot())
853
+ const newPorts = [...after].filter((p) => !before.has(p))
854
+
855
+ return {
856
+ clicked: click.ok,
857
+ clickDetail: click.detail,
858
+ anchors,
859
+ focused,
860
+ newPorts,
861
+ devices: results,
862
+ allReady: results.length > 0 && results.every((r) => r.ready),
863
+ ready: results.filter((r) => r.ready).length,
864
+ total: results.length,
865
+ }
866
+ }
867
+
868
+ /**
869
+ * Stop every device (red square next to the green triangle).
870
+ * @param {string} hwnd
871
+ */
872
+ export function stopAllDevices(hwnd, options = {}) {
873
+ let anchors = options.anchors
874
+ if (!anchors) {
875
+ try {
876
+ anchors = calibrateAnchors(hwnd)
877
+ } catch {
878
+ anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: {} }
879
+ }
880
+ }
881
+ const focused = focusEnsp(hwnd)
882
+ const result = clickToolbar(hwnd, { button: 'stop', anchors })
883
+ return { focused, anchors, ...result }
884
+ }