@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 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).
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
127
+ .replaceAll('"', "&quot;").replaceAll("'", "&apos;");
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
+ }