@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/README.md
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# MeshBench Node client
|
|
2
|
+
|
|
3
|
+
Drive a MeshBench workbench from Node, over the same control socket the
|
|
4
|
+
[Go](../client-go) and [Python](../client-python) clients speak. The three are
|
|
5
|
+
peers on the wire and peers in shape: one protocol, one handshake, one set of
|
|
6
|
+
transports, one generated set of closed enums, and the same helpers with the
|
|
7
|
+
same names.
|
|
8
|
+
|
|
9
|
+
No dependencies, no build step, no TypeScript compiler. It is ES modules on
|
|
10
|
+
Node's own `net`.
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { Workbench } from "@meshbench/client"; // or "./meshbench.mjs" from a checkout
|
|
14
|
+
|
|
15
|
+
const wb = await Workbench.headless({ fixture: "fife-strict", seed: 9001 });
|
|
16
|
+
try {
|
|
17
|
+
await wb.sim.start(); // warm, firmware, then play
|
|
18
|
+
await wb.sim.run(5 * 60_000); // five minutes of the mesh's clock
|
|
19
|
+
console.log(String(await wb.provenance()));
|
|
20
|
+
console.log(await wb.events.total(), "events");
|
|
21
|
+
} finally {
|
|
22
|
+
await wb.close();
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Two layers, both public
|
|
27
|
+
|
|
28
|
+
`wb.call(verb, params)` is the whole protocol, so a verb this package has not
|
|
29
|
+
shaped is one line away rather than a blocker. The shape above it is what a
|
|
30
|
+
script should reach for first, because **every helper on it exists to stop a
|
|
31
|
+
mistake somebody has already made**:
|
|
32
|
+
|
|
33
|
+
| | |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `wb.sim` | the clock, and `start()` - see below |
|
|
36
|
+
| `wb.nodes`, `wb.node(name)` | the network, and one node: place, move, firmware, board, antenna, card, console, device |
|
|
37
|
+
| `wb.firmware` | the library: find, import, build, `useWhatIsHere()`, `waitStarted()` |
|
|
38
|
+
| `wb.events` | the log: `recent()`, `total()`, `dump()`, `wait()` |
|
|
39
|
+
| `wb.project`, `wb.boundary`, `wb.live` | the network as a whole, the study area, and the import chain |
|
|
40
|
+
| `wb.schedule`, `wb.assertions` | what a run is told to send, and what has to be true of it |
|
|
41
|
+
| `wb.subscribe(...)` | being told rather than asking |
|
|
42
|
+
| `sessions()` | which workbenches are running on this machine |
|
|
43
|
+
|
|
44
|
+
### `sim.start()`, and why it is not `sim.start`
|
|
45
|
+
|
|
46
|
+
The verb `sim.start` is the play button's own handler and answers four ways: it
|
|
47
|
+
**pauses** if already playing, declines while links are being measured, or
|
|
48
|
+
starts firmware and does not play. Worse, it starts firmware only when *no* node
|
|
49
|
+
is running, so a mesh where you pinned a build onto two nodes is "started" with
|
|
50
|
+
the other fifty-six down.
|
|
51
|
+
|
|
52
|
+
`wb.sim.start()` asks for the three things a script actually means, in order,
|
|
53
|
+
and checks each: wait out the warm, start whatever firmware is down, wait for
|
|
54
|
+
it, then `sim.play` by its own name.
|
|
55
|
+
|
|
56
|
+
### Waits are methods, and there are two clocks
|
|
57
|
+
|
|
58
|
+
Every wait is a method - `node.waitRunning()`, `sim.run()`,
|
|
59
|
+
`firmware.waitStarted()`, `wb.waitIdle()` - never a sleep in a script. They poll
|
|
60
|
+
today and will subscribe later, and no script changes when they do.
|
|
61
|
+
|
|
62
|
+
Simulated time is the mesh's own: `sim.run(5 * 60_000)` is five minutes of *its*
|
|
63
|
+
clock, and on 155 emulated nodes that is a great deal longer than five of yours.
|
|
64
|
+
Wall clock is yours: every `timeoutMs`, and `sim.run(..., {waitMs})`. Both are
|
|
65
|
+
milliseconds, so the name is what tells them apart, and nothing that means the
|
|
66
|
+
mesh's clock is called a timeout.
|
|
67
|
+
|
|
68
|
+
## Refusals
|
|
69
|
+
|
|
70
|
+
**The workbench answers "no" by returning a value, not by raising** - so read
|
|
71
|
+
the reply of anything that looks like a command. What this client raises is a
|
|
72
|
+
`WorkbenchError` carrying the verb, the workbench's own words and its `code`,
|
|
73
|
+
subclassed by that code so `instanceof` works where Go has `errors.Is` and
|
|
74
|
+
Python has an exception hierarchy:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { NotFound, Timeout, VersionMismatch } from "@meshbench/client";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
await wb.nodes.find("West Lomond");
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (e instanceof NotFound) … // its message names what it did find
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`MeshbenchError` is the base. `UnknownVerb`, `BadParams`, `NotFound`,
|
|
87
|
+
`Conflict`, `Unavailable` and `Closing` are the workbench's own codes;
|
|
88
|
+
`ProtocolMismatch` and `VersionMismatch` are the two ends refusing each other;
|
|
89
|
+
`Timeout` is a wait that ran out, and says what it last saw rather than only
|
|
90
|
+
that it gave up.
|
|
91
|
+
|
|
92
|
+
## Enums, not strings
|
|
93
|
+
|
|
94
|
+
`Kind`, `Board`, `Preset`, `Role`, `Class`, `Tab`, `Strategy` and `Transport`
|
|
95
|
+
are generated by `tools/clientgen` from `internal/world/scenario`, the same one
|
|
96
|
+
place the Go and Python clients are generated from, and CI fails when the three
|
|
97
|
+
drift. Never spell one as a free string: a board name nothing matches produces a
|
|
98
|
+
different node, silently.
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { Board, Kind, Role } from "@meshbench/client";
|
|
102
|
+
|
|
103
|
+
await wb.nodes.place({ name: "Deck", kind: Kind.COMPANION, lat: 56.19, lon: -3.17,
|
|
104
|
+
board: Board.LILYGO_TDECK });
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Each is a frozen object of plain strings, so a member goes on the wire as itself
|
|
108
|
+
and a literal is still accepted anywhere one is asked for. The member names match
|
|
109
|
+
the Python client's exactly, so a script moved between the two changes the dots
|
|
110
|
+
and nothing else.
|
|
111
|
+
|
|
112
|
+
## Installing, and starting a workbench
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
npm install @meshbench/client
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
You also need the `meshbench` binary: this package drives a workbench, it does
|
|
119
|
+
not contain one. Put it on `PATH` or name it in `MESHBENCH_BINARY`.
|
|
120
|
+
|
|
121
|
+
- `Workbench.headless(opts)` starts one with no window and owns it: no display,
|
|
122
|
+
no GPU, no toolkit. The one to use from a test or from CI.
|
|
123
|
+
- `Workbench.launch(opts)` opens the desktop workbench and owns it.
|
|
124
|
+
- `Workbench.attach(opts)` connects to one already running and never owns it -
|
|
125
|
+
`close()` hangs up, and whatever was on screen stays on screen.
|
|
126
|
+
- `Workbench.attachOrHeadless(opts)` / `attachOrLaunch(opts)` use the session
|
|
127
|
+
that is running, or start one. Ask `wb.ownsProcess` which you got.
|
|
128
|
+
|
|
129
|
+
`headless` and `launch` take `{fixture, seed, socket, binary, args,
|
|
130
|
+
startTimeoutMs, callTimeoutMs, stderr}`. Given a `fixture` they wait for it to
|
|
131
|
+
open, not merely for the socket: the windowed build loads a fixture on a worker
|
|
132
|
+
so the window appears first, and a client that believed the socket would be told
|
|
133
|
+
there are no nodes and no jobs, and `waitIdle` would return in 0.00s having
|
|
134
|
+
waited for work nobody had queued.
|
|
135
|
+
|
|
136
|
+
Each owns its own private address unless you name a socket, so two of them do
|
|
137
|
+
not fight over the per-user default.
|
|
138
|
+
|
|
139
|
+
## Connecting
|
|
140
|
+
|
|
141
|
+
`Workbench.attach()` finds the workbench the same way the other clients do, so
|
|
142
|
+
all three agree on one machine:
|
|
143
|
+
|
|
144
|
+
- `MESHBENCH_CONTROL_SOCKET` if set - a path, `tcp`, or `tcp:host:port`.
|
|
145
|
+
- otherwise a unix socket at `$XDG_RUNTIME_DIR/meshbench.sock` (Linux) or the
|
|
146
|
+
per-user cache directory (macOS).
|
|
147
|
+
- Windows has no unix socket, so the workbench listens on loopback TCP and
|
|
148
|
+
writes its address and a token to a rendezvous file; this reads that file and
|
|
149
|
+
presents the token.
|
|
150
|
+
|
|
151
|
+
A unix socket path over 104 bytes is refused before it ever reaches `connect`,
|
|
152
|
+
the same limit and the same message the other two give - the raw OS error names
|
|
153
|
+
neither the limit nor what to do about it.
|
|
154
|
+
|
|
155
|
+
Where several workbenches are running, `sessions()` lists them and a row goes
|
|
156
|
+
straight to `attach`, which is the only way to reach a second TCP session: its
|
|
157
|
+
token sits beside its address in its own file, where the per-user rendezvous
|
|
158
|
+
file two of them share has only one of the two.
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
import { sessions, Workbench } from "@meshbench/client";
|
|
162
|
+
|
|
163
|
+
const running = await sessions();
|
|
164
|
+
const wb = await Workbench.attach({ session: running.at(-1) });
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
There is deliberately no "attach to whatever is running": where several are up
|
|
168
|
+
and none was named, guessing is how a script ends up driving the session
|
|
169
|
+
somebody else was watching.
|
|
170
|
+
|
|
171
|
+
**A client and the workbench it drives must be the same release.** This package
|
|
172
|
+
declares its own version on the wire, the workbench refuses a pair it cannot be
|
|
173
|
+
half of, and the refusal arrives as a `VersionMismatch` naming both releases and
|
|
174
|
+
what to install. An end that is not a release build - a workbench compiled from
|
|
175
|
+
a checkout - is served instead, because there is no second version there to
|
|
176
|
+
disagree with; when that happens `wb.versionCheck` says which end was skipped
|
|
177
|
+
and why.
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
const wb = await Workbench.attach({
|
|
181
|
+
socket: "/run/user/1000/meshbench.sock",
|
|
182
|
+
timeoutMs: 5000, // how long to wait for the connection itself
|
|
183
|
+
callTimeoutMs: 20000, // the default budget for every call() on it
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// A single call known to take longer gets its own budget, or none:
|
|
187
|
+
await wb.call("firmware.build", { source: "~/src/MeshCore" }, 20 * 60_000);
|
|
188
|
+
await wb.call("sim.run", { for_ms: 10 * 60_000 }, null); // no timeout
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`callTimeoutMs` defaults to 300000 (five minutes), matching the Python client's
|
|
192
|
+
socket timeout, so a script ported between the two waits the same length of time
|
|
193
|
+
before it hears about a verb the workbench never answered.
|
|
194
|
+
|
|
195
|
+
## Running
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
node --test # the client's own tests, no workbench needed
|
|
199
|
+
node examples/small-mesh-with-traffic.mjs # needs meshbench on PATH
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The tests stand up a fake workbench on a unix socket and check the wire and
|
|
203
|
+
every helper above it - no real workbench, no network. They also import every
|
|
204
|
+
example, which is Node's answer to `go build ./...` compiling the Go ones: an
|
|
205
|
+
example that has stopped parsing, or that reaches for a helper this client no
|
|
206
|
+
longer has, is a red test run rather than something somebody finds by trying it.
|
|
207
|
+
|
|
208
|
+
See [examples/](examples/) for the set, one file each, matching the Go and
|
|
209
|
+
Python examples one for one.
|
|
210
|
+
|
|
211
|
+
## What a scripted result is
|
|
212
|
+
|
|
213
|
+
A number this prints is a simulated number, kinder than the air, exactly as it
|
|
214
|
+
is from the application. The limits travel with it - `wb.provenance()` is the
|
|
215
|
+
line to print above any number a script emits - see
|
|
216
|
+
[what it does not do](https://meshbench.github.io/docs/what-it-does-not-do.html).
|
package/lib/boundary.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The study area: which nodes are in the question being asked.
|
|
2
|
+
//
|
|
3
|
+
// Not the firmware's region concept. A boundary decides what is studied; a
|
|
4
|
+
// region decides what is forwarded. Both words are in this application, and
|
|
5
|
+
// confusing them is how somebody concludes the RF model is broken.
|
|
6
|
+
//
|
|
7
|
+
// Set it before importing. The import filters at fetch time, so a boundary set
|
|
8
|
+
// afterwards prunes what has already been paid for rather than never fetching
|
|
9
|
+
// it.
|
|
10
|
+
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
|
|
13
|
+
import { MeshbenchError, NotFound } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
/** The study area, however you have it. Live. */
|
|
16
|
+
export class Boundary {
|
|
17
|
+
constructor(wb) { this._wb = wb; }
|
|
18
|
+
|
|
19
|
+
/** Take a study area from a place name or from GeoJSON.
|
|
20
|
+
*
|
|
21
|
+
* The one to call. A path to a .geojson file is loaded; anything else is
|
|
22
|
+
* searched for by name and the best match accepted. Both end with the area in
|
|
23
|
+
* the study, which is the only thing the caller wanted to say.
|
|
24
|
+
*
|
|
25
|
+
* `name` renames a single loaded polygon, so a file called `export(3).geojson`
|
|
26
|
+
* can still join the study as "Tay catchment". */
|
|
27
|
+
async use(area, { name = "" } = {}) {
|
|
28
|
+
if (isGeoJSONPath(area)) return this.load(area, { name });
|
|
29
|
+
const found = await this.search(String(area));
|
|
30
|
+
return [await this.accept(found[0])];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Places matching a name, best first. Needs the network.
|
|
34
|
+
*
|
|
35
|
+
* Names rather than geometry: the geometry stays at the workbench, and the
|
|
36
|
+
* name is what `accept` takes. */
|
|
37
|
+
async search(query) {
|
|
38
|
+
const got = (await this._wb.call("boundary.set", { query })) || {};
|
|
39
|
+
const found = got.names || [];
|
|
40
|
+
if (found.length === 0) {
|
|
41
|
+
throw new NotFound("boundary.set", `nothing is called "${query}"`, "not_found");
|
|
42
|
+
}
|
|
43
|
+
return found;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Take one of the search results into the study area.
|
|
47
|
+
*
|
|
48
|
+
* Areas union rather than replace: a study is often two council areas rather
|
|
49
|
+
* than one. */
|
|
50
|
+
async accept(name) {
|
|
51
|
+
const got = (await this._wb.call("boundary.accept", { name })) || {};
|
|
52
|
+
return got.accepted || name;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Take a study area from GeoJSON: a path, the document itself, or an object.
|
|
56
|
+
*
|
|
57
|
+
* A Polygon, a MultiPolygon, a Feature or a FeatureCollection. Each polygon
|
|
58
|
+
* becomes an area named from its "name" property, or from `name`, or from the
|
|
59
|
+
* file.
|
|
60
|
+
*
|
|
61
|
+
* The one way to study an area nothing has an administrative name for - a
|
|
62
|
+
* catchment, a valley, the bit north of the river - and the only one that
|
|
63
|
+
* works with no network at all. */
|
|
64
|
+
async load(source, { name = "" } = {}) {
|
|
65
|
+
const params = {};
|
|
66
|
+
if (source && typeof source === "object") {
|
|
67
|
+
params.geojson = JSON.stringify(source);
|
|
68
|
+
} else if (isGeoJSONPath(source)) {
|
|
69
|
+
params.path = String(source);
|
|
70
|
+
} else if (isJSONDocument(source)) {
|
|
71
|
+
params.geojson = String(source);
|
|
72
|
+
} else {
|
|
73
|
+
// Said here rather than at the workbench, which would report it as a parse
|
|
74
|
+
// failure on a document that is really a mistyped path.
|
|
75
|
+
throw new MeshbenchError(
|
|
76
|
+
`"${source}" is neither a .geojson path that exists nor a GeoJSON document`);
|
|
77
|
+
}
|
|
78
|
+
if (name) params.name = name;
|
|
79
|
+
return ((await this._wb.call("boundary.load", params)) || {}).loaded || [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the study area is made of - the names, not a count of them. */
|
|
83
|
+
async list() {
|
|
84
|
+
return ((await this._wb.call("boundary.list")) || {}).names || [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Take one area back out.
|
|
88
|
+
*
|
|
89
|
+
* Changes what is measured, never what is loaded: the nodes stay until
|
|
90
|
+
* something prunes them. */
|
|
91
|
+
async remove(name) { await this._wb.call("boundary.remove", { name }); }
|
|
92
|
+
|
|
93
|
+
/** Delete the nodes outside the study area, and say how many went.
|
|
94
|
+
*
|
|
95
|
+
* For a mesh that was imported before the boundary was set. The margin is
|
|
96
|
+
* kept on purpose and zero means the session's own: a node just outside still
|
|
97
|
+
* interferes with one just inside, and dropping it makes the inside look
|
|
98
|
+
* quieter than it is. */
|
|
99
|
+
async prune({ marginKm = 0 } = {}) {
|
|
100
|
+
const params = marginKm > 0 ? { margin_km: marginKm } : {};
|
|
101
|
+
return ((await this._wb.call("boundary.prune", params)) || {}).removed || 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isJSONDocument(s) {
|
|
106
|
+
return typeof s === "string" && s.trimStart().startsWith("{");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A path, rather than a place name or a GeoJSON document.
|
|
110
|
+
*
|
|
111
|
+
* Judged by extension as well as by existence, so a mistyped path is reported
|
|
112
|
+
* as a missing file rather than searched for as a place - which answers
|
|
113
|
+
* "nothing is called ./bounds/fife.geojson" and sends the reader looking in
|
|
114
|
+
* entirely the wrong direction. */
|
|
115
|
+
function isGeoJSONPath(s) {
|
|
116
|
+
if (typeof s !== "string" || isJSONDocument(s)) return false;
|
|
117
|
+
if (s.endsWith(".geojson") || s.endsWith(".json")) return true;
|
|
118
|
+
try {
|
|
119
|
+
return fs.statSync(s).isFile();
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
package/lib/checks.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// What a run is told to send, and what has to be true of it afterwards.
|
|
2
|
+
//
|
|
3
|
+
// These were reachable only through call("schedule.add", {at_ms: 5000,
|
|
4
|
+
// every_ms: 20000}), which is the shape this package exists to remove: a verb
|
|
5
|
+
// name spelled by hand, parameters in milliseconds because that is what the
|
|
6
|
+
// wire happens to use, and nothing to tell a reader which clock they are in. An
|
|
7
|
+
// example written that way is an advertisement for not using the library.
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
|
|
11
|
+
/** What the mesh is told to send, and when. Live. */
|
|
12
|
+
export class Schedule {
|
|
13
|
+
constructor(wb) { this._wb = wb; }
|
|
14
|
+
|
|
15
|
+
/** Have a node send something, once or repeatedly.
|
|
16
|
+
*
|
|
17
|
+
* `atMs` and `everyMs` are simulated time - the mesh's own clock, not
|
|
18
|
+
* yours - which is why neither is called a timeout.
|
|
19
|
+
*
|
|
20
|
+
* Repeating traffic has worked all along and nothing said so, which to
|
|
21
|
+
* somebody writing a script is the same as it not existing. */
|
|
22
|
+
async add({ node, command, atMs = 0, everyMs = 0 }) {
|
|
23
|
+
const params = { node: String(node), command };
|
|
24
|
+
if (atMs > 0) params.at_ms = atMs;
|
|
25
|
+
if (everyMs > 0) params.every_ms = everyMs;
|
|
26
|
+
return ((await this._wb.call("schedule.add", params)) || {}).sends || 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Forget all of them. */
|
|
30
|
+
async clear() {
|
|
31
|
+
return ((await this._wb.call("schedule.clear")) || {}).cleared || 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How many are scheduled. */
|
|
35
|
+
async count() {
|
|
36
|
+
return (await this._wb.snapshot()).scheduled_sends || 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One assertion, and what the run made of it. */
|
|
41
|
+
export class Check {
|
|
42
|
+
constructor(raw = {}) {
|
|
43
|
+
this.kind = raw.kind || "";
|
|
44
|
+
this.node = raw.node || "";
|
|
45
|
+
this.passed = Boolean(raw.pass);
|
|
46
|
+
this.got = raw.got || "";
|
|
47
|
+
this.want = raw.want || "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
toString() {
|
|
51
|
+
const mark = this.passed ? "pass" : "FAIL";
|
|
52
|
+
const where = this.node ? ` at ${this.node}` : "";
|
|
53
|
+
return `${mark} ${this.kind}${where}: got ${this.got}, want ${this.want}`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What a run passed and failed, with what it was measured under. */
|
|
58
|
+
export class Report {
|
|
59
|
+
constructor(raw = {}, provenance = null) {
|
|
60
|
+
this.passed = raw.passed || 0;
|
|
61
|
+
this.total = raw.total || 0;
|
|
62
|
+
this.checks = (raw.results || []).map((r) => new Check(r));
|
|
63
|
+
/** What the numbers were measured under, carried with the verdict because a
|
|
64
|
+
* delivery figure without it is the number this project exists not to
|
|
65
|
+
* publish. */
|
|
66
|
+
this.provenance = provenance;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether every assertion held.
|
|
70
|
+
*
|
|
71
|
+
* A report with no assertions is not ok. A fixture that carries none can
|
|
72
|
+
* report but cannot pass, and a green tick that checked nothing is the worst
|
|
73
|
+
* outcome available here. */
|
|
74
|
+
get ok() { return this.total > 0 && this.passed === this.total; }
|
|
75
|
+
|
|
76
|
+
get failures() { return this.checks.filter((c) => !c.passed); }
|
|
77
|
+
|
|
78
|
+
toString() {
|
|
79
|
+
const lines = [];
|
|
80
|
+
if (this.provenance) lines.push(String(this.provenance));
|
|
81
|
+
lines.push(this.total === 0
|
|
82
|
+
? "no assertions, so this run checked nothing"
|
|
83
|
+
: `${this.passed} of ${this.total} assertions passed`);
|
|
84
|
+
for (const c of this.failures) lines.push(` ${c}`);
|
|
85
|
+
return lines.join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Write a JUnit file, with the caveats inside it.
|
|
89
|
+
*
|
|
90
|
+
* In the file rather than only on stdout, because the file is what a CI
|
|
91
|
+
* system keeps and shows six months later - and a delivery figure with no
|
|
92
|
+
* note of what the model assumed is exactly the number this project exists
|
|
93
|
+
* not to publish. */
|
|
94
|
+
writeJUnit(path, suite = "meshbench") {
|
|
95
|
+
const lines = ['<?xml version="1.0" encoding="utf-8"?>',
|
|
96
|
+
`<testsuite name="${xml(suite)}" tests="${this.total}" ` +
|
|
97
|
+
`failures="${this.failures.length}">`];
|
|
98
|
+
if (this.provenance) {
|
|
99
|
+
lines.push(" <properties>",
|
|
100
|
+
` <property name="meshbench.provenance" value="${xml(this.provenance)}"></property>`,
|
|
101
|
+
" </properties>");
|
|
102
|
+
}
|
|
103
|
+
for (const c of this.checks) {
|
|
104
|
+
const name = c.kind + (c.node ? ` at ${c.node}` : "");
|
|
105
|
+
const open = ` <testcase classname="${xml(suite)}.assertions" name="${xml(name)}"`;
|
|
106
|
+
if (c.passed) {
|
|
107
|
+
lines.push(open + "></testcase>");
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
lines.push(open + ">",
|
|
111
|
+
` <failure message="got ${xml(c.got)}, want ${xml(c.want)}"></failure>`,
|
|
112
|
+
" </testcase>");
|
|
113
|
+
}
|
|
114
|
+
lines.push("</testsuite>", "");
|
|
115
|
+
fs.writeFileSync(path, lines.join("\n"));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Escape text for an XML attribute or body.
|
|
120
|
+
*
|
|
121
|
+
* Written out rather than pulled in: a dependency for five replacements would
|
|
122
|
+
* cost this package the one thing it promises, which is that `npm install`
|
|
123
|
+
* fetches nothing but itself. */
|
|
124
|
+
function xml(s) {
|
|
125
|
+
return String(s)
|
|
126
|
+
.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
127
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What has to be true for a run to have passed. Live. */
|
|
131
|
+
export class Assertions {
|
|
132
|
+
constructor(wb) { this._wb = wb; }
|
|
133
|
+
|
|
134
|
+
/** At least this many nodes received something. */
|
|
135
|
+
async delivered(atLeast, { withinMs = 0 } = {}) {
|
|
136
|
+
return this.add({ kind: "delivered", atLeast, withinMs });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** This node - or the whole mesh - transmitted within these bounds.
|
|
140
|
+
*
|
|
141
|
+
* `atMost` is the interesting one: it is how a relay-suppression change is
|
|
142
|
+
* held to not having made the mesh chattier. */
|
|
143
|
+
async sent({ node = "", atLeast = 0, atMost = 0, withinMs = 0 } = {}) {
|
|
144
|
+
return this.add({ kind: "sent", node, atLeast, atMost, withinMs });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The general form, for a kind this package has no name for yet.
|
|
148
|
+
*
|
|
149
|
+
* `withinMs` is simulated time, like everything else the mesh is measured
|
|
150
|
+
* over. A kind this build does not understand is a failure rather than a
|
|
151
|
+
* pass, because a green run that checked nothing is the worst outcome
|
|
152
|
+
* available here. */
|
|
153
|
+
async add({ kind, node = "", atLeast = 0, atMost = 0, maxPct = 0, withinMs = 0 }) {
|
|
154
|
+
const params = { kind };
|
|
155
|
+
if (node) params.node = String(node);
|
|
156
|
+
if (atLeast) params.at_least = atLeast;
|
|
157
|
+
if (atMost) params.at_most = atMost;
|
|
158
|
+
if (maxPct) params.max_pct = maxPct;
|
|
159
|
+
if (withinMs) params.within_ms = withinMs;
|
|
160
|
+
return ((await this._wb.call("assert.add", params)) || {}).assertions || 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Measure every assertion against the run so far.
|
|
164
|
+
*
|
|
165
|
+
* The provenance travels with the verdict, because a delivery figure without
|
|
166
|
+
* what the model assumed is the number this project exists not to publish. */
|
|
167
|
+
async check() {
|
|
168
|
+
const got = (await this._wb.call("assert.check")) || {};
|
|
169
|
+
return new Report(got, await this._wb.provenance());
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** How many are recorded. */
|
|
173
|
+
async count() { return (await this._wb.snapshot()).assertions || 0; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The assertion kinds this build understands. */
|
|
177
|
+
export const ASSERTION_KINDS = Object.freeze(
|
|
178
|
+
["delivered", "deliveries", "unique_deliveries", "sent", "transmissions"]);
|
package/lib/device.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// A running board, as something a script can look at and prod.
|
|
2
|
+
//
|
|
3
|
+
// Read what the display is showing, capture it as an image, press the buttons,
|
|
4
|
+
// type at the keyboard, touch the panel. All of it works headless - the display
|
|
5
|
+
// is the framebuffer the controller holds, not a picture of anybody's desktop -
|
|
6
|
+
// which is the point: a board test that needs a screen in front of it does not
|
|
7
|
+
// run in CI.
|
|
8
|
+
|
|
9
|
+
import { MeshbenchError } from "./errors.mjs";
|
|
10
|
+
|
|
11
|
+
/** How long to wait for the screen to change before giving up, when a caller
|
|
12
|
+
* names no timeout of its own. */
|
|
13
|
+
export const SCREEN_WAIT_MS = 30_000;
|
|
14
|
+
|
|
15
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
16
|
+
|
|
17
|
+
/** One running node's board, as a device to drive. A handle, not a copy. */
|
|
18
|
+
export class Device {
|
|
19
|
+
constructor(wb, name) {
|
|
20
|
+
this._wb = wb;
|
|
21
|
+
this.name = name;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---- looking ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** What the display is showing, as numbers rather than a picture.
|
|
27
|
+
*
|
|
28
|
+
* Enough to answer "did anything change" after a press or a touch, which is
|
|
29
|
+
* what every check of an input comes down to; for the picture itself, ask for
|
|
30
|
+
* a screenshot. `digest` identifies the frame - two screens with the same one
|
|
31
|
+
* are the same picture, which `lit` cannot promise. */
|
|
32
|
+
async screen() {
|
|
33
|
+
return (await this._wb.call("board.screen", { node: this.name })) || {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Write the display to a PNG and say where it landed. The frame is exactly
|
|
37
|
+
* what the controller holds, at the size it holds it. */
|
|
38
|
+
async screenshot() {
|
|
39
|
+
return (await this._wb.call("board.screenshot", { node: this.name })) || {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- prodding --------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Hold a button pin down, or release it.
|
|
45
|
+
*
|
|
46
|
+
* Held rather than clicked because the firmware cares: MeshCore wakes a
|
|
47
|
+
* sleeping display on a press and powers the board off on a long one, so a
|
|
48
|
+
* caller times the release itself - or uses `tap`, which does not hold. */
|
|
49
|
+
async press(pin, down = true) {
|
|
50
|
+
await this._wb.call("board.press", { node: this.name, pin, down });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Press a button and let go - the ordinary click. */
|
|
54
|
+
async tap(pin) {
|
|
55
|
+
await this.press(pin, true);
|
|
56
|
+
await this.press(pin, false);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Enter text at the board's own keyboard, one character at a time - which is
|
|
60
|
+
* what the keyboard sends, and what the firmware polls for. */
|
|
61
|
+
async type(text) {
|
|
62
|
+
await this._wb.call("board.key", { node: this.name, text });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Put a finger on the panel at a point, or lift it off. */
|
|
66
|
+
async touch(x, y, down = true) {
|
|
67
|
+
await this._wb.call("board.touch", { node: this.name, x, y, down });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Touch a point and lift off - a tap on the panel. */
|
|
71
|
+
async tapAt(x, y) {
|
|
72
|
+
await this.touch(x, y, true);
|
|
73
|
+
await this.touch(x, y, false);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---- waiting ---------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Wait until the display changes from what it shows now and return the new
|
|
79
|
+
* frame, or fail with what it was still showing when the time ran out.
|
|
80
|
+
*
|
|
81
|
+
* This is the honest way to check an input. Half duplex eats stimuli - a
|
|
82
|
+
* board handed a packet while transmitting never hears it - so a tap followed
|
|
83
|
+
* by an immediate screen read will intermittently read the frame from before
|
|
84
|
+
* the tap landed. Change is by digest, so a redraw that keeps the same number
|
|
85
|
+
* of lit pixels still counts. */
|
|
86
|
+
async waitScreen(timeoutMs = SCREEN_WAIT_MS) {
|
|
87
|
+
const before = await this.screen();
|
|
88
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
|
+
while (Date.now() < deadline) {
|
|
90
|
+
await sleep(50);
|
|
91
|
+
const now = await this.screen();
|
|
92
|
+
if (now.digest !== before.digest) return now;
|
|
93
|
+
}
|
|
94
|
+
throw new MeshbenchError(
|
|
95
|
+
`board ${this.name}: the screen did not change within ${timeoutMs} ms`);
|
|
96
|
+
}
|
|
97
|
+
}
|