dsh-ensp 0.1.3 → 0.1.6
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/README.md +30 -1
- package/package.json +1 -13
- package/src/core/index.mjs +9 -1
- package/src/core/launcher.mjs +284 -24
- package/src/core/provision.mjs +81 -0
- package/src/core/topology.mjs +23 -5
- package/src/dsh/index.mjs +595 -387
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ const topo = autoLayout(
|
|
|
43
43
|
)
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
拓扑格式有几处约束来自 eNSP 本身:接口分组必须与机型一致,链路锚点必须落在图标包围盒内。写错任意一项,eNSP 会打开一个没有任何设备的空白画布,并且不报错。规则见 [`docs/ENSP-FORMAT.md`](docs/ENSP-FORMAT.md)。
|
|
47
|
+
|
|
46
48
|
### 启动与停止设备
|
|
47
49
|
|
|
48
50
|
自动定位工具栏按钮,无需手动点击。启动后按设备逐一确认就绪,窗口随即最小化,桌面可继续使用。
|
|
@@ -53,6 +55,8 @@ ensp up lab.topo # 打开、启动、确认就绪、最小化
|
|
|
53
55
|
ensp stop # 关闭全部设备
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
eNSP 只允许运行一个实例,异常退出时可能留下不占线程的僵尸进程,它会一直持有这个单实例锁,使下一次启动打开空白窗口。工具在启动前会识别并清理这种进程。
|
|
59
|
+
|
|
56
60
|
### 下发配置
|
|
57
61
|
|
|
58
62
|
逐条发送,每条命令确认提示符返回后才继续。任一命令出错即中断,并回报行号、命令与设备原始输出。
|
|
@@ -79,6 +83,20 @@ if (!result.ok) {
|
|
|
79
83
|
}
|
|
80
84
|
```
|
|
81
85
|
|
|
86
|
+
### 等待协议收敛
|
|
87
|
+
|
|
88
|
+
路由协议是异步收敛的,配置下发完不代表邻居已经建立。这类断言一律走轮询,直到状态达成或超时。
|
|
89
|
+
|
|
90
|
+
```js
|
|
91
|
+
import { waitForOspfFull, waitForOspfRoute, waitForOutput } from 'dsh-ensp'
|
|
92
|
+
|
|
93
|
+
await waitForOspfFull({ port: 2000, neighbourId: '2.2.2.2' })
|
|
94
|
+
await waitForOspfRoute({ port: 2000, prefix: '2.2.2.2/32' })
|
|
95
|
+
await waitForOutput({ port: 2000, command: 'display ip routing-table', until: /2\.2\.2\.2/ })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
三个函数都返回 `{ ok, converged, output, attempts, waitedMs }`。固定等待一段时间再查询,在慢机器上会误判为失败,在快机器上纯属浪费。
|
|
99
|
+
|
|
82
100
|
### 场景预设
|
|
83
101
|
|
|
84
102
|
内置 23 个场景,覆盖 VLAN、STP、静态路由、RIP、OSPF、BGP、NAT、DHCP、ACL、IPsec、IPv6、WLAN、IS-IS 等主题。
|
|
@@ -132,6 +150,8 @@ ensp status
|
|
|
132
150
|
| Node.js | 18 或更高版本 |
|
|
133
151
|
| 权限 | 插件与 eNSP 需处于相同权限级别 |
|
|
134
152
|
|
|
153
|
+
系统缩放设为 125% 或 150% 时,程序会自行完成坐标换算,不需要手动调分辨率。若 eNSP 升级后按钮位置改变,执行 `ensp probe` 重新标定。
|
|
154
|
+
|
|
135
155
|
## 快速开始
|
|
136
156
|
|
|
137
157
|
```bash
|
|
@@ -146,6 +166,16 @@ ensp status --topology lab.topo # 查看控制台状态
|
|
|
146
166
|
ensp apply ./configs --topology lab.topo --save
|
|
147
167
|
```
|
|
148
168
|
|
|
169
|
+
一次两级 OSPF 实验的完整耗时(本机实测,Windows 11、i5、系统缩放 150%):
|
|
170
|
+
|
|
171
|
+
| 阶段 | 耗时 |
|
|
172
|
+
|---|---|
|
|
173
|
+
| 生成 `.topo` | 2s |
|
|
174
|
+
| 打开并渲染拓扑 | 14s |
|
|
175
|
+
| 两台 AR2220 启动到可登录 | 64s |
|
|
176
|
+
| 下发两侧各 9 条配置 | 8s |
|
|
177
|
+
| OSPF 邻居到 Full | 40s |
|
|
178
|
+
|
|
149
179
|
## 致谢与许可
|
|
150
180
|
|
|
151
181
|
### 开源协议
|
|
@@ -165,7 +195,6 @@ ensp apply ./configs --topology lab.topo --save
|
|
|
165
195
|
### 致谢
|
|
166
196
|
|
|
167
197
|
- Huawei eNSP:仿真器本体。
|
|
168
|
-
- dsh-ops:本 README 的组织方式参考了该项目。
|
|
169
198
|
- 在 issue 中反馈问题的使用者。
|
|
170
199
|
|
|
171
200
|
## 免责声明
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ensp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Automate Huawei eNSP labs: generate .topo files, start and stop devices, and push configuration one command at a time with per-command error reporting.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -54,18 +54,6 @@
|
|
|
54
54
|
"patch": "./cordis.patch.yml"
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
|
-
"peerDependencies": {
|
|
58
|
-
"@deepseek-ai/dsh-client-ui": "*",
|
|
59
|
-
"@deepseek-ai/dsh-tools": "*"
|
|
60
|
-
},
|
|
61
|
-
"peerDependenciesMeta": {
|
|
62
|
-
"@deepseek-ai/dsh-client-ui": {
|
|
63
|
-
"optional": true
|
|
64
|
-
},
|
|
65
|
-
"@deepseek-ai/dsh-tools": {
|
|
66
|
-
"optional": true
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
57
|
"author": {
|
|
70
58
|
"name": "lizi600jin"
|
|
71
59
|
},
|
package/src/core/index.mjs
CHANGED
|
@@ -49,7 +49,15 @@ export {
|
|
|
49
49
|
DEFAULT_ANCHORS,
|
|
50
50
|
} from './calibrate.mjs'
|
|
51
51
|
|
|
52
|
-
export {
|
|
52
|
+
export {
|
|
53
|
+
pushConfig,
|
|
54
|
+
pushConfigMany,
|
|
55
|
+
queryDevice,
|
|
56
|
+
parseScript,
|
|
57
|
+
waitForOutput,
|
|
58
|
+
waitForOspfFull,
|
|
59
|
+
waitForOspfRoute,
|
|
60
|
+
} from './provision.mjs'
|
|
53
61
|
|
|
54
62
|
export {
|
|
55
63
|
SCENARIOS,
|
package/src/core/launcher.mjs
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { spawn, spawnSync } from 'node:child_process'
|
|
32
32
|
import net from 'node:net'
|
|
33
|
+
import os from 'node:os'
|
|
34
|
+
import path from 'node:path'
|
|
33
35
|
|
|
34
36
|
import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
|
|
35
37
|
|
|
@@ -180,17 +182,61 @@ ${body}
|
|
|
180
182
|
return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* PID of a *live* eNSP client, or null.
|
|
187
|
+
*
|
|
188
|
+
* eNSP leaves zombie processes behind: the process object survives with
|
|
189
|
+
* `Threads.Count === 0` and holds the client's single-instance lock, so a new
|
|
190
|
+
* launch inherits an empty canvas. Such a PID must never be reused.
|
|
191
|
+
*
|
|
192
|
+
* @returns {number|null}
|
|
193
|
+
*/
|
|
184
194
|
export function enspPid() {
|
|
185
195
|
const r = spawnSync(
|
|
186
196
|
'powershell',
|
|
187
|
-
[
|
|
197
|
+
[
|
|
198
|
+
'-NoProfile',
|
|
199
|
+
'-Command',
|
|
200
|
+
'Get-Process eNSP_Client -ErrorAction SilentlyContinue | ' +
|
|
201
|
+
'Where-Object { $_.Threads.Count -gt 0 } | ' +
|
|
202
|
+
'Select-Object -First 1 -ExpandProperty Id',
|
|
203
|
+
],
|
|
188
204
|
{ encoding: 'utf8', windowsHide: true },
|
|
189
205
|
)
|
|
190
206
|
const pid = Number((r.stdout || '').trim())
|
|
191
207
|
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Is this window handle still valid and owned by a live process?
|
|
212
|
+
*
|
|
213
|
+
* @param {string} hwnd
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function windowAlive(hwnd) {
|
|
217
|
+
const r = runPowerShell(
|
|
218
|
+
`$h = [IntPtr]::new([int64]${hwnd})
|
|
219
|
+
if (-not [EnspWin]::IsWindow($h)) { 'no'; exit 0 }
|
|
220
|
+
$owned = Get-Process eNSP_Client -ErrorAction SilentlyContinue | Where-Object { $_.Threads.Count -gt 0 }
|
|
221
|
+
if ($owned) { 'yes' } else { 'no' }`,
|
|
222
|
+
{ timeoutMs: 20000 },
|
|
223
|
+
)
|
|
224
|
+
return r.ok && r.stdout.trim().endsWith('yes')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Every eNSP client PID, including zero-thread zombies. */
|
|
228
|
+
export function enspPids() {
|
|
229
|
+
const r = spawnSync(
|
|
230
|
+
'powershell',
|
|
231
|
+
['-NoProfile', '-Command', 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id'],
|
|
232
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
233
|
+
)
|
|
234
|
+
return (r.stdout || '')
|
|
235
|
+
.split(/\s+/)
|
|
236
|
+
.map(Number)
|
|
237
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
/**
|
|
195
241
|
* Launch the eNSP client, optionally opening a topology.
|
|
196
242
|
*
|
|
@@ -200,38 +246,236 @@ export function enspPid() {
|
|
|
200
246
|
* @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
|
|
201
247
|
* @returns {Promise<{pid: number, hwnd: string}>}
|
|
202
248
|
*/
|
|
203
|
-
export async function launchEnsp({ topology, exspPath, readyTimeoutMs =
|
|
249
|
+
export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
|
|
204
250
|
const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
251
|
+
const target = topology ? path.resolve(topology) : null
|
|
252
|
+
|
|
253
|
+
// Reuse an existing window only when it renders something; a blank window is a
|
|
254
|
+
// dead instance that would otherwise be reported as success.
|
|
255
|
+
const existing = enspPid()
|
|
256
|
+
if (existing) {
|
|
257
|
+
const hwnd = locateWindow(existing)
|
|
258
|
+
if (hwnd && (!target || topologyRendered(hwnd))) {
|
|
259
|
+
return { pid: existing, hwnd, reused: true, rendered: Boolean(hwnd) }
|
|
260
|
+
}
|
|
261
|
+
if (hwnd && target) {
|
|
262
|
+
// A live window showing nothing: reopen the file in it first.
|
|
263
|
+
reopenTopology(target)
|
|
264
|
+
if (await waitForRender(hwnd, { attempts: 6, intervalMs: 2500 })) {
|
|
265
|
+
return { pid: existing, hwnd, reused: true, rendered: true }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Give up on it: stale clients hold eNSP's single-instance lock and make every
|
|
269
|
+
// new window open blank.
|
|
270
|
+
killAllClients()
|
|
271
|
+
await sleep(2500)
|
|
209
272
|
}
|
|
210
273
|
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
274
|
+
/** Launch once and wait for a frame window. */
|
|
275
|
+
const boot = async () => {
|
|
276
|
+
startDetached(exe, target)
|
|
277
|
+
const started = Date.now()
|
|
278
|
+
const deadline = started + readyTimeoutMs
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
const pid = enspPid()
|
|
281
|
+
if (pid) {
|
|
282
|
+
const hwnd = locateWindow(pid)
|
|
283
|
+
if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
|
|
284
|
+
}
|
|
285
|
+
await sleep(250)
|
|
286
|
+
}
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
214
289
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
290
|
+
// Up to three attempts: the client occasionally opens with an empty canvas on a
|
|
291
|
+
// cold start, and a clean relaunch always renders. Verified empirically.
|
|
292
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
293
|
+
const booted = await boot()
|
|
294
|
+
if (!booted) {
|
|
295
|
+
killAllClients()
|
|
296
|
+
await sleep(1500)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
if (!target) return { ...booted, reused: false, rendered: true }
|
|
300
|
+
if (await waitForRender(booted.hwnd, { attempts: 6, intervalMs: 2000 })) {
|
|
301
|
+
return { ...booted, reused: false, rendered: true }
|
|
226
302
|
}
|
|
227
|
-
|
|
303
|
+
// Blank canvas: tear the instance down and try again from a clean state.
|
|
304
|
+
killAllClients()
|
|
305
|
+
await sleep(2500)
|
|
228
306
|
}
|
|
307
|
+
|
|
229
308
|
throw new Error(
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
'eNSP opened but never rendered the topology after 3 attempts. ' +
|
|
310
|
+
'Close every eNSP window and retry; a leftover client holds the single-instance lock.',
|
|
232
311
|
)
|
|
233
312
|
}
|
|
234
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Force-terminate every eNSP client process.
|
|
316
|
+
*
|
|
317
|
+
* Zombie processes (zero threads) cannot be killed by any API; they linger until
|
|
318
|
+
* the OS reaps them. This reports what survived so the caller can decide whether
|
|
319
|
+
* retrying is worthwhile.
|
|
320
|
+
*
|
|
321
|
+
* @returns {{killed: boolean, survivors: number[]}}
|
|
322
|
+
*/
|
|
323
|
+
function killAllClients() {
|
|
324
|
+
spawnSync('taskkill', ['/F', '/IM', 'eNSP_Client.exe'], { windowsHide: true, encoding: 'utf8' })
|
|
325
|
+
return { killed: true, survivors: enspPids() }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Poll until the canvas actually shows the topology.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} hwnd
|
|
332
|
+
* @param {{attempts?: number, intervalMs?: number}} [options]
|
|
333
|
+
* @returns {Promise<boolean>}
|
|
334
|
+
*/
|
|
335
|
+
async function waitForRender(hwnd, { attempts = 6, intervalMs = 2000 } = {}) {
|
|
336
|
+
for (let i = 0; i < attempts; i++) {
|
|
337
|
+
await sleep(intervalMs)
|
|
338
|
+
if (topologyRendered(hwnd)) return true
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Start the client so that it outlives this Node process.
|
|
345
|
+
*
|
|
346
|
+
* `spawn(..., {detached:true})` is not enough on Windows: eNSP is a GUI application
|
|
347
|
+
* that terminates when the console session that launched it goes away. Going
|
|
348
|
+
* through `cmd /c start` hands ownership to the shell, which is what a double-click
|
|
349
|
+
* in Explorer does, and the client then survives its launcher.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} exe
|
|
352
|
+
* @param {string|null} [topology]
|
|
353
|
+
*/
|
|
354
|
+
function startDetached(exe, topology) {
|
|
355
|
+
// Win32_Process.Create via WMI is the only launch path measured to leave the
|
|
356
|
+
// client fully independent of this Node process:
|
|
357
|
+
//
|
|
358
|
+
// spawn(..., {detached:true}) -> client dies with its launcher
|
|
359
|
+
// cmd /c start -> client dies with its launcher
|
|
360
|
+
// Start-Process -WindowStyle Hidden -> client exits before creating a window
|
|
361
|
+
// Start-Process (visible) -> unreliable when called from a hidden shell
|
|
362
|
+
// WMI Win32_Process.Create -> survives; returns the real PID
|
|
363
|
+
//
|
|
364
|
+
// The client must outlive the tool call, otherwise every follow-up (start
|
|
365
|
+
// devices, push config) finds a dead console.
|
|
366
|
+
const cmdline = topology ? `"${exe}" "${topology}"` : `"${exe}"`
|
|
367
|
+
const r = runPowerShell(
|
|
368
|
+
`
|
|
369
|
+
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
|
370
|
+
CommandLine = '${cmdline.replace(/'/g, "''")}'
|
|
371
|
+
}
|
|
372
|
+
if ($r.ReturnValue -ne 0) { 'ERR ' + $r.ReturnValue; exit 0 }
|
|
373
|
+
'pid=' + $r.ProcessId
|
|
374
|
+
`,
|
|
375
|
+
{ timeoutMs: 30000 },
|
|
376
|
+
)
|
|
377
|
+
if (!r.ok || !/pid=\d+/.test(r.stdout)) {
|
|
378
|
+
throw new Error(`could not launch eNSP: ${r.stdout || r.stderr || 'WMI refused the request'}`)
|
|
379
|
+
}
|
|
380
|
+
return Number(/pid=(\d+)/.exec(r.stdout)[1])
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ask the running client to open a topology again.
|
|
385
|
+
*
|
|
386
|
+
* A second instance is normally refused, so this relaunches the client with the
|
|
387
|
+
* same file; the window handle changes and the caller re-locates it.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} topology
|
|
390
|
+
*/
|
|
391
|
+
function reopenTopology(topology) {
|
|
392
|
+
const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
393
|
+
startDetached(exe, topology)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Take a screenshot of the client's canvas, cropped to the drawing area.
|
|
398
|
+
*
|
|
399
|
+
* Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
|
|
400
|
+
* left device palette and the toolbar, so anything found inside it came from the
|
|
401
|
+
* open topology.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} hwnd
|
|
404
|
+
* @returns {string} absolute path to the PNG, or '' on failure
|
|
405
|
+
*/
|
|
406
|
+
export function captureCanvas(hwnd) {
|
|
407
|
+
const out = path.join(os.tmpdir(), `dsh-ensp-canvas-${Date.now()}.png`)
|
|
408
|
+
const r = runPowerShell(
|
|
409
|
+
`
|
|
410
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
411
|
+
[void][EnspWin]::FocusWindow($h)
|
|
412
|
+
Start-Sleep -Milliseconds 900
|
|
413
|
+
$wr = New-Object EnspWin+RECT
|
|
414
|
+
[void][EnspWin]::GetWindowRect($h, [ref]$wr)
|
|
415
|
+
$w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
|
|
416
|
+
if ($w -lt 400 -or $ht -lt 300) { 'ERR small'; exit 0 }
|
|
417
|
+
Add-Type -AssemblyName System.Drawing
|
|
418
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
|
419
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
420
|
+
$g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
|
|
421
|
+
$bmp.Save('${out.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
422
|
+
$bmp.Dispose(); $g.Dispose()
|
|
423
|
+
'ok'
|
|
424
|
+
`,
|
|
425
|
+
{ timeoutMs: 40000 },
|
|
426
|
+
)
|
|
427
|
+
return r.ok ? out : ''
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Does the open topology show devices on the canvas?
|
|
432
|
+
*
|
|
433
|
+
* eNSP paints with DirectX, so there is no window tree to query. Two independent
|
|
434
|
+
* signals are combined, because either alone is weak:
|
|
435
|
+
*
|
|
436
|
+
* 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
|
|
437
|
+
* empty canvas has no such pixels.
|
|
438
|
+
* 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
|
|
439
|
+
*
|
|
440
|
+
* This is the check that gates "the topology actually opened". Callers retry on a
|
|
441
|
+
* negative result rather than reporting success for a blank window.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} hwnd
|
|
444
|
+
* @returns {boolean}
|
|
445
|
+
*/
|
|
446
|
+
export function topologyRendered(hwnd) {
|
|
447
|
+
const png = captureCanvas(hwnd)
|
|
448
|
+
if (!png) return false
|
|
449
|
+
const r = runPowerShell(
|
|
450
|
+
`
|
|
451
|
+
Add-Type -AssemblyName System.Drawing
|
|
452
|
+
$bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
|
|
453
|
+
$w = $bmp.Width; $ht = $bmp.Height
|
|
454
|
+
# Canvas only: skip the palette column and the toolbar band.
|
|
455
|
+
$x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
|
|
456
|
+
$y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
|
|
457
|
+
$icon = 0; $dark = 0
|
|
458
|
+
for ($y = $y0; $y -lt $y1; $y += 2) {
|
|
459
|
+
for ($x = $x0; $x -lt $x1; $x += 2) {
|
|
460
|
+
$c = $bmp.GetPixel($x, $y)
|
|
461
|
+
if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
|
|
462
|
+
elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
$bmp.Dispose()
|
|
466
|
+
'icon=' + $icon + ' dark=' + $dark
|
|
467
|
+
`,
|
|
468
|
+
{ timeoutMs: 40000 },
|
|
469
|
+
)
|
|
470
|
+
if (!r.ok) return false
|
|
471
|
+
const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
|
|
472
|
+
if (!m) return false
|
|
473
|
+
const icon = Number(m[1])
|
|
474
|
+
const dark = Number(m[2])
|
|
475
|
+
// A rendered lab shows dozens of icon pixels and a few hundred link pixels.
|
|
476
|
+
return icon >= 15 && dark >= 40
|
|
477
|
+
}
|
|
478
|
+
|
|
235
479
|
/**
|
|
236
480
|
* Locate the eNSP main frame window for a PID.
|
|
237
481
|
* @param {number} pid
|
|
@@ -399,6 +643,12 @@ export async function startAllDevices({
|
|
|
399
643
|
|
|
400
644
|
// Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
|
|
401
645
|
// this survives eNSP upgrades, DPI changes and different display sizes.
|
|
646
|
+
// The window must still exist: a stale handle means the client already exited,
|
|
647
|
+
// and clicking a dead handle does nothing while reporting success below.
|
|
648
|
+
if (!hwnd || !windowAlive(hwnd)) {
|
|
649
|
+
throw new Error('eNSP window is gone; reopen the topology before starting devices')
|
|
650
|
+
}
|
|
651
|
+
|
|
402
652
|
report({ phase: 'calibrate', hwnd })
|
|
403
653
|
let anchors
|
|
404
654
|
try {
|
|
@@ -406,6 +656,16 @@ export async function startAllDevices({
|
|
|
406
656
|
} catch (error) {
|
|
407
657
|
anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
|
|
408
658
|
}
|
|
659
|
+
|
|
660
|
+
// A default anchor means calibration failed, and an unmeasured click can land on
|
|
661
|
+
// any toolbar control (including the one that closes the document). Refuse
|
|
662
|
+
// instead of guessing.
|
|
663
|
+
if (anchors.method !== 'measured') {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`toolbar calibration failed (${anchors.detail?.reason ?? 'unknown'}). ` +
|
|
666
|
+
'Make sure the eNSP window is visible and run ensp_probe to re-measure.',
|
|
667
|
+
)
|
|
668
|
+
}
|
|
409
669
|
report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
|
|
410
670
|
|
|
411
671
|
report({ phase: 'focus', hwnd })
|
package/src/core/provision.mjs
CHANGED
|
@@ -280,3 +280,84 @@ export async function queryDevice({ port, command, timeoutMs = 25000 }) {
|
|
|
280
280
|
session?.close()
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Poll a device until a predicate accepts its command output, or time runs out.
|
|
286
|
+
*
|
|
287
|
+
* Routing protocols converge asynchronously: right after `ospf 1` is committed a
|
|
288
|
+
* peering sits in ExStart/Exchange/Loading for a while, and a single query taken
|
|
289
|
+
* too early reads as "failed" when it is merely "not finished". Waiting a fixed
|
|
290
|
+
* number of seconds either wastes time or is still too short on a slow machine,
|
|
291
|
+
* so every convergence assertion in this package goes through this helper.
|
|
292
|
+
*
|
|
293
|
+
* @param {{
|
|
294
|
+
* port: number,
|
|
295
|
+
* command: string,
|
|
296
|
+
* until: (output: string) => boolean,
|
|
297
|
+
* timeoutMs?: number,
|
|
298
|
+
* intervalMs?: number,
|
|
299
|
+
* probeTimeoutMs?: number,
|
|
300
|
+
* }} options
|
|
301
|
+
* @returns {Promise<{ok: boolean, converged: boolean, output: string, attempts: number, waitedMs: number}>}
|
|
302
|
+
*/
|
|
303
|
+
export async function waitForOutput({
|
|
304
|
+
port,
|
|
305
|
+
command,
|
|
306
|
+
until,
|
|
307
|
+
timeoutMs = 120000,
|
|
308
|
+
intervalMs = 3000,
|
|
309
|
+
probeTimeoutMs = 20000,
|
|
310
|
+
}) {
|
|
311
|
+
const started = Date.now()
|
|
312
|
+
let attempts = 0
|
|
313
|
+
let output = ''
|
|
314
|
+
while (Date.now() - started < timeoutMs) {
|
|
315
|
+
attempts += 1
|
|
316
|
+
const r = await queryDevice({ port, command, timeoutMs: probeTimeoutMs })
|
|
317
|
+
if (r.ok) output = r.output
|
|
318
|
+
if (r.ok && until(r.output)) {
|
|
319
|
+
return { ok: true, converged: true, output, attempts, waitedMs: Date.now() - started }
|
|
320
|
+
}
|
|
321
|
+
await sleep(intervalMs)
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
converged: false,
|
|
326
|
+
output,
|
|
327
|
+
attempts,
|
|
328
|
+
waitedMs: Date.now() - started,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Wait until an OSPF adjacency reaches Full on `port`, optionally naming the peer.
|
|
334
|
+
*
|
|
335
|
+
* @param {{port: number, neighbourId?: string, timeoutMs?: number}} options
|
|
336
|
+
*/
|
|
337
|
+
export async function waitForOspfFull({ port, neighbourId, timeoutMs = 120000 }) {
|
|
338
|
+
const r = await waitForOutput({
|
|
339
|
+
port,
|
|
340
|
+
command: 'display ospf peer brief',
|
|
341
|
+
until: (out) =>
|
|
342
|
+
out
|
|
343
|
+
.split('\n')
|
|
344
|
+
.filter((l) => /\bFull\b/.test(l))
|
|
345
|
+
.some((l) => !neighbourId || l.includes(neighbourId)),
|
|
346
|
+
timeoutMs,
|
|
347
|
+
})
|
|
348
|
+
return { ...r, neighbourId: neighbourId || null }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Wait until a prefix shows up in the OSPF routing table on `port`.
|
|
353
|
+
*
|
|
354
|
+
* @param {{port: number, prefix: string, timeoutMs?: number}} options
|
|
355
|
+
*/
|
|
356
|
+
export async function waitForOspfRoute({ port, prefix, timeoutMs = 120000 }) {
|
|
357
|
+
return waitForOutput({
|
|
358
|
+
port,
|
|
359
|
+
command: 'display ip routing-table protocol ospf',
|
|
360
|
+
until: (out) => out.includes(prefix),
|
|
361
|
+
timeoutMs,
|
|
362
|
+
})
|
|
363
|
+
}
|
package/src/core/topology.mjs
CHANGED
|
@@ -43,8 +43,25 @@ const MODEL_SPECS = {
|
|
|
43
43
|
Cloud: { groups: [['Ethernet', 'Ethernet', 3, 0]] },
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Icon bounding box, measured from every cable anchor in eNSP's own samples.
|
|
48
|
+
*
|
|
49
|
+
* The box is **not** centred on (cx, cy): it runs from `cx - 16.2` to
|
|
50
|
+
* `cx + 70.2` horizontally, so its centre is `cx + 27`, not `cx + 43`. Using
|
|
51
|
+
* `height / 2` puts the anchor outside the icon and eNSP then rejects the whole
|
|
52
|
+
* `<lines>` block, which shows up as an empty canvas with no error message.
|
|
53
|
+
*
|
|
54
|
+
* Frequencies across the bundled samples: (+70.2,+27.0) x35, (-16.2,+27.0) x35,
|
|
55
|
+
* (+27.0,+70.2) x29, (+27.0,-16.2) x27.
|
|
56
|
+
*/
|
|
57
|
+
const ICON = {
|
|
58
|
+
left: -16.2,
|
|
59
|
+
right: 70.2,
|
|
60
|
+
top: -16.2,
|
|
61
|
+
bottom: 70.2,
|
|
62
|
+
centerX: 27.0,
|
|
63
|
+
centerY: 27.0,
|
|
64
|
+
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Interfaces exposed by each model eNSP can run, in declaration order.
|
|
@@ -255,11 +272,12 @@ export function serialiseTopology(topo) {
|
|
|
255
272
|
const dst = byId.get(l.dstId)
|
|
256
273
|
if (!src || !dst) throw new Error(`link references unknown device: ${l.srcId} -> ${l.dstId}`)
|
|
257
274
|
|
|
258
|
-
// Anchor on the facing sides: right edge of the source, left edge of the target
|
|
275
|
+
// Anchor on the facing sides: right edge of the source, left edge of the target,
|
|
276
|
+
// both at the icon's true vertical centre.
|
|
259
277
|
const sx = src.x + ICON.right
|
|
260
|
-
const sy = src.y + ICON.
|
|
278
|
+
const sy = src.y + ICON.centerY
|
|
261
279
|
const tx = dst.x + ICON.left
|
|
262
|
-
const ty = dst.y + ICON.
|
|
280
|
+
const ty = dst.y + ICON.centerY
|
|
263
281
|
|
|
264
282
|
return (
|
|
265
283
|
` <line srcDeviceID="${src.id}" destDeviceID="${dst.id}">\n` +
|