@meshbench/client 0.0.1
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 +216 -0
- package/lib/boundary.mjs +123 -0
- package/lib/checks.mjs +178 -0
- package/lib/device.mjs +97 -0
- package/lib/errors.mjs +150 -0
- package/lib/firmware.mjs +249 -0
- package/lib/launch.mjs +112 -0
- package/lib/live.mjs +103 -0
- package/lib/nodes.mjs +416 -0
- package/lib/pairing.mjs +65 -0
- package/lib/parts.mjs +190 -0
- package/lib/sessions.mjs +122 -0
- package/lib/sets.mjs +275 -0
- package/lib/sim.mjs +125 -0
- package/lib/socket.mjs +311 -0
- package/lib/subscribe.mjs +86 -0
- package/lib/values.mjs +66 -0
- package/lib/wait.mjs +68 -0
- package/lib/workbench.mjs +376 -0
- package/meshbench.mjs +82 -0
- package/package.json +31 -0
package/lib/nodes.mjs
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
// The network: what is in it, and what one node can be asked to do.
|
|
2
|
+
|
|
3
|
+
import { NotFound } from "./errors.mjs";
|
|
4
|
+
import { Device } from "./device.mjs";
|
|
5
|
+
import { Kind, Transport } from "./sets.mjs";
|
|
6
|
+
import { FIRMWARE_WAIT_MS, waitFor } from "./wait.mjs";
|
|
7
|
+
|
|
8
|
+
/** The score below which `find` will not act on a top answer.
|
|
9
|
+
*
|
|
10
|
+
* Taking the top result unconditionally is how a script ends up sending an
|
|
11
|
+
* advert from a node that merely shared a word with what was asked for, and it
|
|
12
|
+
* does that silently. */
|
|
13
|
+
export const FIND_LEAST = 0.5;
|
|
14
|
+
|
|
15
|
+
/** Names, whether handles or strings were passed.
|
|
16
|
+
*
|
|
17
|
+
* `search` and `near` hand back handles and every verb takes names, so without
|
|
18
|
+
* this each caller writes the same map - and the one that forgets sends an
|
|
19
|
+
* object down the socket and is told there is no node named "[object
|
|
20
|
+
* Object]". */
|
|
21
|
+
const names = (ns) => ns.map((n) => String(n));
|
|
22
|
+
|
|
23
|
+
/** The collection. Live: every call reads the session. */
|
|
24
|
+
export class Nodes {
|
|
25
|
+
constructor(wb) { this._wb = wb; }
|
|
26
|
+
|
|
27
|
+
/** Every node, as the network currently has them. */
|
|
28
|
+
async list() {
|
|
29
|
+
return ((await this._wb.call("nodes.list")) || {}).nodes || [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How many there are. */
|
|
33
|
+
async count() { return (await this.list()).length; }
|
|
34
|
+
|
|
35
|
+
/** One by name. */
|
|
36
|
+
async info(name) {
|
|
37
|
+
for (const n of await this.list()) if (n.name === String(name)) return n;
|
|
38
|
+
throw new NotFound("nodes.list", `no node named "${name}"`, "not_found");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether the network holds one by that name. */
|
|
42
|
+
async has(name) {
|
|
43
|
+
return (await this.list()).some((n) => n.name === String(name));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A handle, checked: a typo fails here rather than three calls later. */
|
|
47
|
+
async get(name) {
|
|
48
|
+
await this.info(name);
|
|
49
|
+
return new Node(this._wb, String(name));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find nodes by name, best first, when you cannot type the name.
|
|
53
|
+
*
|
|
54
|
+
* Imported names carry emoji and accents - "\u{1F3D4}️ West Lomond \u{1F4E1}"
|
|
55
|
+
* is one real node - so matching is done on letters and digits alone, with
|
|
56
|
+
* accents folded and word order ignored. The ranking happens at the workbench
|
|
57
|
+
* rather than here, so all three clients agree about which result is the top
|
|
58
|
+
* one.
|
|
59
|
+
*
|
|
60
|
+
* An empty result is not an error: "nothing matched" is an answer, and the
|
|
61
|
+
* caller usually wants to widen the query rather than handle a refusal. */
|
|
62
|
+
async search(query, limit = 10) {
|
|
63
|
+
const got = await this._wb.call("nodes.search", { query, limit });
|
|
64
|
+
return (got || {}).matches || [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The one node a search meant, or a refusal naming what it did find. */
|
|
68
|
+
async find(query, least = FIND_LEAST) {
|
|
69
|
+
const matches = await this.search(query, 5);
|
|
70
|
+
if (matches.length === 0 || matches[0].score < least) {
|
|
71
|
+
const near = matches.slice(0, 3)
|
|
72
|
+
.map((m) => `"${m.name}" (${m.score.toFixed(2)})`).join(", ");
|
|
73
|
+
throw new NotFound("nodes.search",
|
|
74
|
+
`nothing matches "${query}" well enough` +
|
|
75
|
+
(near ? `; nearest were ${near}` : ""), "not_found");
|
|
76
|
+
}
|
|
77
|
+
return new Node(this._wb, matches[0].name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The nodes closest to this one, nearest first, at most `count` of them (all
|
|
81
|
+
* of them when it is zero).
|
|
82
|
+
*
|
|
83
|
+
* Trimming an imported deployment to a neighbourhood is the first thing
|
|
84
|
+
* anybody does with one, and the distance is the workbench's own - the same
|
|
85
|
+
* great circle its path losses use. */
|
|
86
|
+
async near(node, count = 0) {
|
|
87
|
+
const got = await this._wb.call("nodes.near", { node: String(node), count });
|
|
88
|
+
return (got || {}).near || [];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Filter by kind. Evaluated here rather than at the workbench: it is a
|
|
92
|
+
* question about a list somebody already has. */
|
|
93
|
+
async ofKind(kind) {
|
|
94
|
+
return (await this.list()).filter((n) => n.kind === kind);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Put one node down, and hand back a handle to it.
|
|
98
|
+
*
|
|
99
|
+
* It inherits its neighbours' regions and their firmware, because somebody
|
|
100
|
+
* dropping a repeater on a map is adding a repeater to this network, not
|
|
101
|
+
* choosing a firmware strategy.
|
|
102
|
+
*
|
|
103
|
+
* A board name nothing matches is refused rather than ignored: the board
|
|
104
|
+
* decides the transmit ceiling, the noise figure and the battery, so a silent
|
|
105
|
+
* fallback would be a different node answering the question. */
|
|
106
|
+
async place({ name, kind = Kind.SIMPLE_REPEATER, lat = 0, lon = 0,
|
|
107
|
+
heightM, txDbm, board } = {}) {
|
|
108
|
+
const params = { name, kind, lat, lon };
|
|
109
|
+
if (heightM !== undefined) params.height_m = heightM;
|
|
110
|
+
if (txDbm !== undefined) params.tx_dbm = txDbm;
|
|
111
|
+
if (board) params.board = board;
|
|
112
|
+
await this._wb.call("nodes.place", params);
|
|
113
|
+
return new Node(this._wb, name);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Put several down, then measure the links once.
|
|
117
|
+
*
|
|
118
|
+
* One warm at the end rather than one per node: nodes.place re-measures the
|
|
119
|
+
* matrix each time, and on a national network that is minutes repeated. */
|
|
120
|
+
async placeMany(placements) {
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const p of placements) out.push(await this.place(p));
|
|
123
|
+
await this._wb.call("links.recompute");
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Remove them, in one rebuild.
|
|
128
|
+
*
|
|
129
|
+
* All or none: a name that is not there refuses and removes nothing, because
|
|
130
|
+
* half a deletion leaves a scenario nobody described and no way to tell which
|
|
131
|
+
* half survived without asking again. */
|
|
132
|
+
async delete(...nodes) {
|
|
133
|
+
if (nodes.length) await this._wb.call("nodes.delete_many", names(nodes));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Delete everything these do not name.
|
|
137
|
+
*
|
|
138
|
+
* The complement is worked out at the workbench rather than here, so it
|
|
139
|
+
* cannot be computed against a list that changed in between. */
|
|
140
|
+
async keep(...nodes) {
|
|
141
|
+
await this._wb.call("nodes.keep", names(nodes));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Replace the selection, or add to it. */
|
|
145
|
+
async select(nodes, { add = false } = {}) {
|
|
146
|
+
await this._wb.call(add ? "nodes.add_to_selection" : "nodes.select_many",
|
|
147
|
+
names(nodes));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Who is selected now. */
|
|
151
|
+
async selected() {
|
|
152
|
+
return (await this.list()).filter((n) => n.selected).map((n) => n.name);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Sample what every node is costing, rather than waiting for the window to
|
|
156
|
+
* ask. */
|
|
157
|
+
async stats() { return this._wb.nodeStats(); }
|
|
158
|
+
|
|
159
|
+
/** Give every node the same antenna, or every node of one kind.
|
|
160
|
+
*
|
|
161
|
+
* The fleet-level default, and the only way a large scenario gets one:
|
|
162
|
+
* setting fifty-eight nodes by hand is not a workflow anybody will use. What
|
|
163
|
+
* is not named is left alone, so this can retune the whole mesh's feedlines
|
|
164
|
+
* without restating what is on top of the masts. */
|
|
165
|
+
async setAntenna({ kind = "", ...change } = {}) {
|
|
166
|
+
const p = antennaParams(change);
|
|
167
|
+
if (kind) p.kind = kind;
|
|
168
|
+
await this._wb.call("nodes.antenna", p);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** What an antenna change says, in the wire's own spelling.
|
|
173
|
+
*
|
|
174
|
+
* What is left out is left alone, because "leave this" and "set it to zero"
|
|
175
|
+
* are different answers and one number cannot say both. */
|
|
176
|
+
function antennaParams({ pattern, gainDbiPeak, beamwidthDeg, frontToBackDb,
|
|
177
|
+
bearingDeg, downtiltDeg, polarisation, feedlineDb } = {}) {
|
|
178
|
+
const p = {};
|
|
179
|
+
if (pattern !== undefined) p.pattern = pattern;
|
|
180
|
+
if (gainDbiPeak !== undefined) p.gain_dbi_peak = gainDbiPeak;
|
|
181
|
+
if (beamwidthDeg !== undefined) p.beamwidth_deg = beamwidthDeg;
|
|
182
|
+
if (frontToBackDb !== undefined) p.front_to_back_db = frontToBackDb;
|
|
183
|
+
if (bearingDeg !== undefined) p.bearing_deg = bearingDeg;
|
|
184
|
+
if (downtiltDeg !== undefined) p.downtilt_deg = downtiltDeg;
|
|
185
|
+
if (polarisation !== undefined) p.polarisation = polarisation;
|
|
186
|
+
if (feedlineDb !== undefined) p.feedline_db = feedlineDb;
|
|
187
|
+
return p;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One node. Live: a handle, not a copy - it holds a name and asks. */
|
|
191
|
+
export class Node {
|
|
192
|
+
constructor(wb, name) {
|
|
193
|
+
this._wb = wb;
|
|
194
|
+
this.name = name;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
toString() { return this.name; }
|
|
198
|
+
|
|
199
|
+
// ---- what it is ------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/** What the network says about it, now. */
|
|
202
|
+
async info() { return new Nodes(this._wb).info(this.name); }
|
|
203
|
+
|
|
204
|
+
/** Its row from the statistics sample, or null when it has none yet. */
|
|
205
|
+
async stat() {
|
|
206
|
+
return (await this._wb.nodeStats()).find((s) => s.name === this.name) || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Whether its firmware process is up. */
|
|
210
|
+
async running() {
|
|
211
|
+
const s = await this.stat();
|
|
212
|
+
return Boolean(s && s.running);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** "running", "stopped", or one of the transitions. A boolean cannot say
|
|
216
|
+
* "changing firmware", and a row that goes blank while it happens looks like
|
|
217
|
+
* a node that has died. */
|
|
218
|
+
async state() {
|
|
219
|
+
const s = await this.stat();
|
|
220
|
+
return s ? s.state : "unknown";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The build this node runs, or null when it is pinned to nothing.
|
|
224
|
+
*
|
|
225
|
+
* The whole row rather than the version string, because deleting a build or
|
|
226
|
+
* comparing two needs its path and its board, and reassembling those from a
|
|
227
|
+
* version is the kind of guesswork that deletes the wrong file. */
|
|
228
|
+
async build() {
|
|
229
|
+
const want = (await this.info()).firmware;
|
|
230
|
+
if (!want) return null;
|
|
231
|
+
return (await this._wb.firmware.library()).find((b) => b.version === want) || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** What this node's radio is set to - the same thing the workbench shows
|
|
235
|
+
* under Radio. What the model assumes, and, for a node that is running, what
|
|
236
|
+
* it reports back and where the two differ. Left as the workbench sent it
|
|
237
|
+
* because a repeater and a companion answer it differently. */
|
|
238
|
+
async radio() {
|
|
239
|
+
return (await this._wb.call("node.radio", { node: this.name })) || {};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** What this node stands under, and which way it points.
|
|
243
|
+
*
|
|
244
|
+
* Gain is directional in azimuth, so `bearing_deg` is not decoration: a beam
|
|
245
|
+
* is twenty decibels or more down off its boresight, and which way it faces
|
|
246
|
+
* decides which links close. */
|
|
247
|
+
async antenna() {
|
|
248
|
+
return (await this._wb.call("node.antenna", { node: this.name })) || {};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Choose and aim this node's antenna. What is not named is left alone, so
|
|
252
|
+
* turning a beam does not restate the beam. */
|
|
253
|
+
async setAntenna(change) {
|
|
254
|
+
const p = antennaParams(change);
|
|
255
|
+
p.node = this.name;
|
|
256
|
+
await this._wb.call("nodes.antenna", p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Turn this node's antenna towards another node.
|
|
260
|
+
*
|
|
261
|
+
* The bearing between two placed nodes is exact, so this is a better answer
|
|
262
|
+
* than reading one off a map and typing it back. What comes back says what
|
|
263
|
+
* the turn won, which on an omni is nothing. */
|
|
264
|
+
async aim(at) {
|
|
265
|
+
return (await this._wb.call("node.aim",
|
|
266
|
+
{ node: this.name, at: String(at) })) || {};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- what it does ----------------------------------------------------
|
|
270
|
+
|
|
271
|
+
async start() { await this._wb.call("node.start", this.name); }
|
|
272
|
+
|
|
273
|
+
async stop() { await this._wb.call("node.stop", this.name); }
|
|
274
|
+
|
|
275
|
+
/** Remove it from the scenario, and re-measure what is left. */
|
|
276
|
+
async delete() { await this._wb.call("nodes.delete", { node: this.name }); }
|
|
277
|
+
|
|
278
|
+
/** Put it somewhere else. The physics moves with it: cached losses for this
|
|
279
|
+
* node are forgotten. */
|
|
280
|
+
async move(lat, lon) {
|
|
281
|
+
await this._wb.call("nodes.move", { node: this.name, lat, lon });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** What this node relays flood traffic for. */
|
|
285
|
+
async setRegions(...regions) {
|
|
286
|
+
await this._wb.call("nodes.regions", { node: this.name, regions });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Change what it runs.
|
|
290
|
+
*
|
|
291
|
+
* Applied by default, which means stop, provision, start: firmware is chosen
|
|
292
|
+
* when a node launches, so recording it and leaving the node on its old build
|
|
293
|
+
* is the control somebody presses twice and then distrusts. Pass
|
|
294
|
+
* `{apply: false}` to record it for the next start instead - and know that is
|
|
295
|
+
* what you have done. */
|
|
296
|
+
async setFirmware(build, { apply = true } = {}) {
|
|
297
|
+
const b = typeof build === "string" ? { version: build } : build;
|
|
298
|
+
await this._wb.call(apply ? "node.set_firmware" : "node.set_firmware_only", {
|
|
299
|
+
node: this.name, version: b.version,
|
|
300
|
+
board: b.board || "", role: b.role || "",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** What hardware this node is.
|
|
305
|
+
*
|
|
306
|
+
* A change to the physics rather than a label, so it rebuilds and re-warms -
|
|
307
|
+
* and it clears a firmware pin made for a different board, because that image
|
|
308
|
+
* cannot run on this one and a pin nobody can honour reads as a configured
|
|
309
|
+
* node right up until it refuses to start. */
|
|
310
|
+
async setBoard(board) {
|
|
311
|
+
await this._wb.call("node.set_board", { node: this.name, board });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Take waveform verdicts whatever the run's mode - the hybrid flag, for
|
|
315
|
+
* measuring one node honestly inside a cheap run. */
|
|
316
|
+
async setTrueRF(on = true) {
|
|
317
|
+
await this._wb.call("node.truerf", { node: this.name, on });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Originate a packet without firmware.
|
|
321
|
+
*
|
|
322
|
+
* It exercises the radio model and the channel; what it does not exercise is
|
|
323
|
+
* relaying, which is a firmware behaviour and needs a firmware. */
|
|
324
|
+
async inject() { await this._wb.call("sim.inject", this.name); }
|
|
325
|
+
|
|
326
|
+
/** What this node is told at boot, in the console's own words. */
|
|
327
|
+
async provisioning() {
|
|
328
|
+
return ((await this._wb.call("node.provisioning", this.name)) || {}).commands || [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Hand this companion to a real client - meshcore-cli, or an app over a
|
|
332
|
+
* bridge - and say where to point it. */
|
|
333
|
+
async serve(over = Transport.TCP) {
|
|
334
|
+
const got = await this._wb.call("bench.serve", { node: this.name, kind: over });
|
|
335
|
+
return (got || {}).addr || "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Take it back. */
|
|
339
|
+
async unserve() { await this._wb.call("bench.drop", { node: this.name }); }
|
|
340
|
+
|
|
341
|
+
// ---- looking at it ---------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/** What this node printed, from one of four voices - the lines, not a count
|
|
344
|
+
* of them.
|
|
345
|
+
*
|
|
346
|
+
* "serial" is the board's own port (a native node's standard error), "boot"
|
|
347
|
+
* is the ROM's on a board whose application talks over USB, "emulator" is
|
|
348
|
+
* what QEMU or Renode said about running it, and "radio" is the radio
|
|
349
|
+
* model's log. A board that has gone quiet is read by looking at what it last
|
|
350
|
+
* said. */
|
|
351
|
+
async output(source = "serial", lines = 200) {
|
|
352
|
+
const got = await this._wb.call("node.output",
|
|
353
|
+
{ node: this.name, source, lines });
|
|
354
|
+
return (got || {}).tail || [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Open one of this node's logs in a window of its own.
|
|
358
|
+
*
|
|
359
|
+
* A tab is one pane. What people do while a board is misbehaving is watch its
|
|
360
|
+
* screen and two of its logs together - what the board printed beside what
|
|
361
|
+
* the emulator said about running it - and that needs windows. */
|
|
362
|
+
async outputWindow(source = "serial") {
|
|
363
|
+
await this._wb.call("node.output_window", { node: this.name, source });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** This node's console, on whichever of the two verbs its kind reads. */
|
|
367
|
+
get console() { return this._wb.console(this.name); }
|
|
368
|
+
|
|
369
|
+
/** This node as a device to drive: its screen, buttons and panel. All of it
|
|
370
|
+
* works headless - the display is the framebuffer the controller holds, not a
|
|
371
|
+
* picture of the desktop. */
|
|
372
|
+
get device() { return new Device(this._wb, this.name); }
|
|
373
|
+
|
|
374
|
+
// ---- its storage -----------------------------------------------------
|
|
375
|
+
|
|
376
|
+
/** What is in this node's card slot, and changing it.
|
|
377
|
+
*
|
|
378
|
+
* A slot is not a fitted card: the board says the slot exists, this says
|
|
379
|
+
* whether it is filled. `file` hands the node a card of your own - shared
|
|
380
|
+
* between runs, or prepared in advance; an empty string returns it to its
|
|
381
|
+
* own, named after it and kept beside its flash. `wipe` erases it, which is
|
|
382
|
+
* what reformatting one is, and is refused while the node is running.
|
|
383
|
+
*
|
|
384
|
+
* A firmware marked as needing a card fills the slot whatever this says,
|
|
385
|
+
* because a build that keeps its settings there boots into nothing without
|
|
386
|
+
* one. */
|
|
387
|
+
async card({ fitted, file, wipe = false } = {}) {
|
|
388
|
+
const p = { node: this.name };
|
|
389
|
+
if (fitted !== undefined) p.fitted = fitted;
|
|
390
|
+
if (file !== undefined) p.file = file;
|
|
391
|
+
if (wipe) p.wipe = true;
|
|
392
|
+
return (await this._wb.call("node.card", p)) || {};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Put this board back to factory: its flash, its card, its files.
|
|
396
|
+
*
|
|
397
|
+
* A board keeps what it was told between runs, as hardware does, so a node
|
|
398
|
+
* configured into a corner stays there until this is called. Refused while it
|
|
399
|
+
* is running, rather than rewriting a flash underneath the emulator holding
|
|
400
|
+
* it. */
|
|
401
|
+
async wipe() { await this._wb.call("node.wipe", { node: this.name }); }
|
|
402
|
+
|
|
403
|
+
// ---- waiting ---------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
/** Wait for its firmware process to be up.
|
|
406
|
+
*
|
|
407
|
+
* `timeoutMs` is wall clock - how long you are prepared to sit here - not
|
|
408
|
+
* simulated time. Starting a process is real work on the real machine. */
|
|
409
|
+
waitRunning(timeoutMs = FIRMWARE_WAIT_MS) {
|
|
410
|
+
return waitFor(async () => {
|
|
411
|
+
const s = await this.stat();
|
|
412
|
+
if (s && s.running) return [true, ""];
|
|
413
|
+
return [false, s ? s.state : "no stat row yet"];
|
|
414
|
+
}, timeoutMs, `firmware on ${this.name}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
package/lib/pairing.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Which workbench this client may drive.
|
|
2
|
+
//
|
|
3
|
+
// A client and the workbench it drives must be the same release. The protocol
|
|
4
|
+
// number beside this rule says whether two ends can understand each other's
|
|
5
|
+
// frames; it moves rarely and on purpose, so it cannot answer the question a
|
|
6
|
+
// script actually has, which is whether the package in this node_modules is the
|
|
7
|
+
// one that came with the workbench on the PATH. Two releases apart with no
|
|
8
|
+
// protocol bump between them connect happily and then disagree about a verb's
|
|
9
|
+
// parameters, forty calls in, looking like the simulation misbehaving.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
/** The wire version this client speaks. A workbench answering anything else is
|
|
14
|
+
* refused rather than failing halfway through a script. */
|
|
15
|
+
export const PROTOCOL = 1;
|
|
16
|
+
|
|
17
|
+
/** The release this client belongs to, as npm spells it.
|
|
18
|
+
*
|
|
19
|
+
* Read from its own package.json rather than kept as a second literal here:
|
|
20
|
+
* the release workflow runs `npm version`, which rewrites that file and
|
|
21
|
+
* nothing else, and a copy in this module would be a copy somebody has to
|
|
22
|
+
* remember. Empty if it cannot be read, which is the same thing a build from a
|
|
23
|
+
* working copy would say and is treated the same way. */
|
|
24
|
+
export const RELEASE = readRelease();
|
|
25
|
+
|
|
26
|
+
function readRelease() {
|
|
27
|
+
try {
|
|
28
|
+
const p = new URL("../package.json", import.meta.url);
|
|
29
|
+
return JSON.parse(fs.readFileSync(p, "utf8")).version || "";
|
|
30
|
+
} catch {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether these two releases may be used together: an exact match, or one of
|
|
36
|
+
* the two ends not being a release at all.
|
|
37
|
+
*
|
|
38
|
+
* The second half of the rule is what keeps the tree usable by the people
|
|
39
|
+
* working on it: a workbench built from a working copy has no release stamped
|
|
40
|
+
* in it, so insisting on equality would refuse every pair a developer has, for
|
|
41
|
+
* a disagreement that does not exist. Nothing is lost, because what the rule
|
|
42
|
+
* catches is a released client meeting a released workbench of another number,
|
|
43
|
+
* and both ends of that pair carry their stamp. */
|
|
44
|
+
export function pairedRelease(ours, theirs) {
|
|
45
|
+
return !ours || !theirs || ours === theirs;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What to say about a check that did not compare anything, so a pair nothing
|
|
49
|
+
* verified is visible rather than quietly assumed sound. Returned rather than
|
|
50
|
+
* logged: a client is a library, and a script that wants the line can print
|
|
51
|
+
* it. */
|
|
52
|
+
export function pairingNote(ours, theirs) {
|
|
53
|
+
if (!ours && !theirs) {
|
|
54
|
+
return "release check skipped: neither this client nor the workbench is a release build";
|
|
55
|
+
}
|
|
56
|
+
if (!ours) {
|
|
57
|
+
return "release check skipped: this client is a development build; " +
|
|
58
|
+
`the workbench is ${theirs}`;
|
|
59
|
+
}
|
|
60
|
+
if (!theirs) {
|
|
61
|
+
return "release check skipped: the workbench is a development build; " +
|
|
62
|
+
`this client is ${ours}`;
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
package/lib/parts.mjs
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// The rest of a scripted run: the project, what happened, and what a node said.
|
|
2
|
+
|
|
3
|
+
import { refusal } from "./errors.mjs";
|
|
4
|
+
import { Kind } from "./sets.mjs";
|
|
5
|
+
import { EVENT_WAIT_MS, JOB_WAIT_MS, waitFor } from "./wait.mjs";
|
|
6
|
+
|
|
7
|
+
/** Opening, saving, and starting over. Live. */
|
|
8
|
+
export class Project {
|
|
9
|
+
constructor(wb) { this._wb = wb; }
|
|
10
|
+
|
|
11
|
+
/** An empty network.
|
|
12
|
+
*
|
|
13
|
+
* With a place it becomes the study area and the map is framed on it,
|
|
14
|
+
* because those are the same wish - and because a blank network with no
|
|
15
|
+
* place is a map in the middle of the Atlantic. */
|
|
16
|
+
async new(place = "") {
|
|
17
|
+
await this._wb.call("project.new", place ? { place } : {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Load a fixture or a saved project. */
|
|
21
|
+
async open(path) { await this._wb.call("project.open", path); }
|
|
22
|
+
|
|
23
|
+
/** Write the current one out. Worth doing before anything that might restart
|
|
24
|
+
* the process: the scenario lives in the process, not on disk. */
|
|
25
|
+
async save(name) {
|
|
26
|
+
return ((await this._wb.call("project.save", { name })) || {}).path || "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What has been saved. */
|
|
30
|
+
async list() {
|
|
31
|
+
return ((await this._wb.call("project.list")) || {}).projects || [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the engine has done. Live. */
|
|
36
|
+
export class Events {
|
|
37
|
+
constructor(wb) { this._wb = wb; }
|
|
38
|
+
|
|
39
|
+
/** The tail - the events themselves, not a count of them.
|
|
40
|
+
*
|
|
41
|
+
* A tail, and only a tail: the store keeps a bounded one because a long run
|
|
42
|
+
* has millions, so a script that needs all of them dumps per round rather
|
|
43
|
+
* than polling this. Reading only the tail after a busy flood samples the
|
|
44
|
+
* most congested moment of it, which is a mistake already made once here. */
|
|
45
|
+
async recent(limit = 50) {
|
|
46
|
+
return ((await this._wb.call("events.recent", { limit })) || {}).events || [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How many there have been, which is the cheap question. */
|
|
50
|
+
async total() {
|
|
51
|
+
return ((await this._wb.call("events.recent", { limit: 1 })) || {}).total || 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Write every event held to a file, one JSON object per line. */
|
|
55
|
+
async dump(path) {
|
|
56
|
+
return ((await this._wb.call("events.dump", { path })) || {}).written || 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Wait for an event to match, and return it.
|
|
60
|
+
*
|
|
61
|
+
* Empty fields match anything, so waiting for "any reception at Glenrothes"
|
|
62
|
+
* is `{kind: "rx", to: "Glenrothes"}` and not a predicate somebody has to
|
|
63
|
+
* write. */
|
|
64
|
+
async wait({ kind = "", from = "", to = "", timeoutMs = EVENT_WAIT_MS } = {}) {
|
|
65
|
+
const matches = (e) =>
|
|
66
|
+
(!kind || e.kind === kind) && (!from || e.from === from) && (!to || e.to === to);
|
|
67
|
+
let found = null;
|
|
68
|
+
const want = [kind, from && `from ${from}`, to && `to ${to}`]
|
|
69
|
+
.filter(Boolean).join(" ") || "anything";
|
|
70
|
+
await waitFor(async () => {
|
|
71
|
+
const evs = await this.recent(500);
|
|
72
|
+
found = evs.find(matches) || null;
|
|
73
|
+
if (found) return [true, ""];
|
|
74
|
+
return [false, `${evs.length} events, none matching`];
|
|
75
|
+
}, timeoutMs, `an event matching ${want}`);
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One node's firmware console. Live.
|
|
81
|
+
*
|
|
82
|
+
* Two consoles, not one, and which you get depends on what the node is.
|
|
83
|
+
*
|
|
84
|
+
* A repeater has a text CLI and reads typed bytes. A companion does not: it
|
|
85
|
+
* speaks the framed companion protocol, and its command line is meshcore-cli's
|
|
86
|
+
* vocabulary - `advert`, `public <msg>`, `chan <n> <msg>`, and there is no
|
|
87
|
+
* `send`. Typing text at a companion goes nowhere, is echoed locally, and
|
|
88
|
+
* reads exactly like a command that ran and did nothing.
|
|
89
|
+
*
|
|
90
|
+
* So this picks the right one from the node's kind. A caller should not have to
|
|
91
|
+
* know, and every caller that did know got it wrong at least once. */
|
|
92
|
+
export class Console {
|
|
93
|
+
constructor(wb, node) {
|
|
94
|
+
this._wb = wb;
|
|
95
|
+
this.node = node;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether this node's console is the framed protocol.
|
|
99
|
+
*
|
|
100
|
+
* A node this client cannot see is not one to guess about; the typed verb is
|
|
101
|
+
* the fallback and its refusal says so in its own words. */
|
|
102
|
+
async _framed() {
|
|
103
|
+
try {
|
|
104
|
+
const info = await this._wb.nodes.info(this.node);
|
|
105
|
+
return info.kind === Kind.COMPANION || info.kind === Kind.ROOM_SERVER;
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Type a line at it. */
|
|
112
|
+
async send(line) {
|
|
113
|
+
const verb = (await this._framed()) ? "console.cli" : "console.type";
|
|
114
|
+
await this._wb.call(verb, { node: this.node, command: line });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The scrollback, newest last - the lines themselves.
|
|
118
|
+
*
|
|
119
|
+
* They come back under "tail" and "lines" is how many there are in total, so
|
|
120
|
+
* reading "lines" hands you a number where you asked for text. The tail is
|
|
121
|
+
* the last 200; a node up for an hour has thousands and nobody reads the
|
|
122
|
+
* first one. */
|
|
123
|
+
async read() {
|
|
124
|
+
return ((await this._wb.call("console.read", { node: this.node })) || {}).tail || [];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Send a line and wait for the node to answer it.
|
|
128
|
+
*
|
|
129
|
+
* The important one. A node reads its serial input on its next loop and its
|
|
130
|
+
* loop only runs when the engine steps, so reading straight after sending
|
|
131
|
+
* reads the moment before the command was sent - every script that has done
|
|
132
|
+
* this by hand got an empty reply and concluded the console was broken. This
|
|
133
|
+
* gives the mesh its own time first, by stepping when the run is paused. */
|
|
134
|
+
async ask(line, steps = 100) {
|
|
135
|
+
const before = await this.read();
|
|
136
|
+
await this.send(line);
|
|
137
|
+
const sim = this._wb.sim;
|
|
138
|
+
const st = await sim.state();
|
|
139
|
+
if (st.playing) {
|
|
140
|
+
// Already moving, so it will be answered on its own; give it the same
|
|
141
|
+
// amount of the mesh's time a settle would.
|
|
142
|
+
await sim.waitUntil((st.now_ms || 0) + steps * Math.max(st.step_ms || 1, 1),
|
|
143
|
+
2 * 60_000);
|
|
144
|
+
} else {
|
|
145
|
+
await sim.settle(steps);
|
|
146
|
+
}
|
|
147
|
+
const after = await this.read();
|
|
148
|
+
return after.length > before.length ? after.slice(before.length).join("\n") : "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A long operation the workbench is doing. Live: a handle to an id. */
|
|
153
|
+
export class Job {
|
|
154
|
+
constructor(wb, id) {
|
|
155
|
+
this._wb = wb;
|
|
156
|
+
this.id = id;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Where this job has got to, or null when it is no longer listed - which
|
|
160
|
+
* means finished, because a job that has ended is removed. */
|
|
161
|
+
async info() {
|
|
162
|
+
return (await this._wb.jobs()).find((j) => j.id === this.id) || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Stop it, where whoever started it left a way to.
|
|
166
|
+
*
|
|
167
|
+
* A job with no cancel refuses by name rather than silently doing nothing:
|
|
168
|
+
* an operator who asked deserves to be told, not left watching a bar that
|
|
169
|
+
* carries on. */
|
|
170
|
+
async cancel() { await this._wb.call("job.cancel", { id: this.id }); }
|
|
171
|
+
|
|
172
|
+
/** Wait for it to finish, and throw if it finished badly.
|
|
173
|
+
*
|
|
174
|
+
* Ended is not the same as worked: a read that failed used to finish the job
|
|
175
|
+
* with the reason in its title and nothing else, so every caller either
|
|
176
|
+
* carried on as though it had succeeded or matched on the wording. */
|
|
177
|
+
async wait(timeoutMs = JOB_WAIT_MS) {
|
|
178
|
+
let last = null;
|
|
179
|
+
await waitFor(async () => {
|
|
180
|
+
const info = await this.info();
|
|
181
|
+
if (info === null) return [true, ""];
|
|
182
|
+
last = info;
|
|
183
|
+
if (info.finished) return [true, ""];
|
|
184
|
+
return [false, `${info.what}, ${info.done} of ${info.total}`];
|
|
185
|
+
}, timeoutMs, `job ${this.id}`);
|
|
186
|
+
if (last && last.failed) {
|
|
187
|
+
throw refusal("job", `job ${this.id} failed: ${last.what}`, "internal");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|