@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/lib/socket.mjs ADDED
@@ -0,0 +1,311 @@
1
+ // The wire: one JSON request per line, one reply.
2
+ //
3
+ // Everything above this is shape. This is the whole protocol, and it is small
4
+ // on purpose - a client that needed a framework to speak to a local socket
5
+ // would be a client nobody could debug.
6
+ //
7
+ // Two transports, because one does not travel:
8
+ //
9
+ // - A unix socket, where the operating system has one. The filesystem is the
10
+ // access control, and the kernel enforces it.
11
+ // - Loopback TCP with a token, where it does not. The workbench binds 127.0.0.1
12
+ // on an ephemeral port and writes the address and a 128-bit token to a 0600
13
+ // file; this reads that file and presents the token before anything else.
14
+ //
15
+ // The choice is by operating system, not by language, so all three clients
16
+ // speak the same thing on the same machine.
17
+
18
+ import net from "node:net";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+
23
+ import { PROTOCOL, RELEASE } from "./pairing.mjs";
24
+ import { MeshbenchError, refusal } from "./errors.mjs";
25
+
26
+ /** Chooses where the workbench answers: a path, or "tcp", or "tcp:host:port". */
27
+ export const SOCKET_ENV = "MESHBENCH_CONTROL_SOCKET";
28
+
29
+ /** What to run when a client is asked to start a workbench and nothing named a
30
+ * binary. A checkout has one built but not installed, and every example and
31
+ * every test then needs the same three lines to find it. */
32
+ export const BINARY_ENV = "MESHBENCH_BINARY";
33
+
34
+ /** Chooses the file a TCP listener writes its address and token to. Per user by
35
+ * default, which is wrong for two runs at once - the second would overwrite
36
+ * the first's - so a client that starts a workbench gives it one of its own. */
37
+ export const RENDEZVOUS_ENV = "MESHBENCH_CONTROL_RENDEZVOUS";
38
+
39
+ /** Chooses the directory the session files live in, for a test or a CI job that
40
+ * wants a registry of its own rather than the user's. */
41
+ export const SESSIONS_ENV = "MESHBENCH_CONTROL_SESSIONS";
42
+
43
+ /** How long a call waits for a reply before it gives up, unless a caller says
44
+ * otherwise. Matches the Python client's socket timeout, so a script ported
45
+ * between the two waits the same length of time before it hears about a verb
46
+ * the workbench never answered. */
47
+ export const DEFAULT_CALL_TIMEOUT_MS = 300000;
48
+
49
+ /** The shortest sun_path any platform we run on allows: 108 on Linux, 104 on
50
+ * macOS and the BSDs. Matches the Go and Python clients exactly. */
51
+ export const MAX_UNIX_PATH = 104;
52
+
53
+ /** The per-user cache directory this OS already defines - the same one the Go
54
+ * and Python clients use, so all three read one rendezvous file. */
55
+ export function cacheDir() {
56
+ let base;
57
+ if (process.platform === "win32") base = process.env.LOCALAPPDATA || os.homedir();
58
+ else if (process.platform === "darwin") base = path.join(os.homedir(), "Library", "Caches");
59
+ else base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
60
+ return path.join(base, "meshbench");
61
+ }
62
+
63
+ /** Where a workbench answers on this operating system unless told otherwise.
64
+ * Matches the Go and Python clients exactly, because the choice is by OS, not
65
+ * by language: all three must name the same address on one machine. */
66
+ export function defaultAddress() {
67
+ const env = process.env[SOCKET_ENV];
68
+ if (env) return env;
69
+ if (process.platform === "win32") return "tcp"; // no AF_UNIX on Windows
70
+ const runtime = process.env.XDG_RUNTIME_DIR;
71
+ if (runtime) return path.join(runtime, "meshbench.sock");
72
+ return path.join(cacheDir(), "control.sock");
73
+ }
74
+
75
+ /** Where a TCP listener leaves its address and token. A named path wins over
76
+ * the environment, so a client that started a workbench with a rendezvous of
77
+ * its own reads that one rather than whatever else on this machine left one. */
78
+ export function rendezvousPath(named) {
79
+ return named || process.env[RENDEZVOUS_ENV] || path.join(cacheDir(), "control.json");
80
+ }
81
+
82
+ /** The loopback address and token a workbench wrote for itself. */
83
+ export function readRendezvous(named) {
84
+ const p = rendezvousPath(named);
85
+ let raw;
86
+ try {
87
+ raw = fs.readFileSync(p, "utf8");
88
+ } catch (e) {
89
+ throw new MeshbenchError(`no workbench has left an address at ${p}: ${e.message}`);
90
+ }
91
+ let got;
92
+ try {
93
+ got = JSON.parse(raw);
94
+ } catch (e) {
95
+ throw new MeshbenchError(`${p} is not readable as an address: ${e.message}`);
96
+ }
97
+ return { address: got.address || "", token: got.token || "" };
98
+ }
99
+
100
+ /** The socket an address names, and the token to present on it. Not connected:
101
+ * the two decisions are separated so a caller can be refused for a path too
102
+ * long for sun_path before anything is dialled, which the raw OS error names
103
+ * neither the limit for nor what to do about. */
104
+ function plan(address, token, rendezvous) {
105
+ if (address === "tcp" || address.startsWith("tcp:")) {
106
+ let hostPort;
107
+ if (address === "tcp") {
108
+ const got = readRendezvous(rendezvous);
109
+ hostPort = got.address;
110
+ token = token || got.token;
111
+ } else {
112
+ hostPort = address.slice("tcp:".length);
113
+ if (!hostPort.includes(":")) hostPort = "127.0.0.1:" + hostPort;
114
+ // A port somebody named still needs the token, and without one in hand
115
+ // the rendezvous file is the only place it exists.
116
+ if (!token) token = readRendezvous(rendezvous).token;
117
+ }
118
+ const i = hostPort.lastIndexOf(":");
119
+ return {
120
+ connect: { host: hostPort.slice(0, i) || "127.0.0.1", port: Number(hostPort.slice(i + 1)) },
121
+ token,
122
+ };
123
+ }
124
+ const p = address.startsWith("unix:") ? address.slice("unix:".length) : address;
125
+ if (p.length > MAX_UNIX_PATH) {
126
+ throw new MeshbenchError(
127
+ `${p} is ${p.length} bytes and a unix socket path may be at most ` +
128
+ `${MAX_UNIX_PATH} - choose a shorter one, or use tcp`);
129
+ }
130
+ return { connect: { path: p }, token: "" };
131
+ }
132
+
133
+ /** One connection to a workbench, and the queue that keeps two callers from
134
+ * interleaving a half-frame on the wire.
135
+ *
136
+ * The protocol has request ids but the workbench answers in order, so the
137
+ * simplest correct thing is one call at a time. Replies are still matched by
138
+ * id, because a client that trusted the order would be wrong the day the
139
+ * server stopped keeping it. */
140
+ export class Connection {
141
+ constructor(sock, address, callTimeoutMs) {
142
+ this._sock = sock;
143
+ /** The address this connection was asked for, as the caller wrote it or as
144
+ * `defaultAddress()` chose it, so a script driving more than one workbench
145
+ * can say which of them refused. It is not re-read from the socket: with
146
+ * `tcp` it stays the word `tcp`, not the loopback port behind it. */
147
+ this.address = address;
148
+ /** Called with every frame that carries no id, which is how the socket says
149
+ * "this is not a reply". Set by a subscription; a request/reply client
150
+ * leaves it null and the frames are dropped. */
151
+ this.onNotification = null;
152
+ this._callTimeoutMs = callTimeoutMs;
153
+ this._nextId = 0;
154
+ this._buf = "";
155
+ this._waiters = [];
156
+ this._closed = null;
157
+ sock.setEncoding("utf8");
158
+ sock.on("data", (chunk) => this._onData(chunk));
159
+ sock.on("error", (e) => this._fail(new MeshbenchError(e.message)));
160
+ sock.on("close", () => this._fail(new MeshbenchError("connection closed")));
161
+ }
162
+
163
+ /** Open one, declaring nothing: the handshake is the Workbench's business,
164
+ * because a session probe wants a socket and not a paired client. */
165
+ static open({ address, connectTimeoutMs = DEFAULT_CALL_TIMEOUT_MS,
166
+ callTimeoutMs = DEFAULT_CALL_TIMEOUT_MS, token = "", rendezvous = "" } = {}) {
167
+ const where = address || defaultAddress();
168
+ let made;
169
+ try {
170
+ made = plan(where, token, rendezvous);
171
+ } catch (e) {
172
+ return Promise.reject(e);
173
+ }
174
+ return new Promise((resolve, reject) => {
175
+ const sock = net.connect(made.connect);
176
+ const timer = setTimeout(() => {
177
+ sock.destroy();
178
+ reject(new MeshbenchError(
179
+ `timed out connecting to ${where} after ${connectTimeoutMs} ms`));
180
+ }, connectTimeoutMs);
181
+ sock.once("error", (e) => {
182
+ clearTimeout(timer);
183
+ reject(new MeshbenchError(`connecting to ${where}: ${e.message}`));
184
+ });
185
+ sock.once("connect", () => {
186
+ clearTimeout(timer);
187
+ sock.removeAllListeners("error");
188
+ // The token first, before anything else on the wire, where the OS has
189
+ // no unix socket to stand as the access control. Unix skips it, and
190
+ // declares the same two things on its first request instead.
191
+ if (made.token) {
192
+ sock.write(JSON.stringify(
193
+ { token: made.token, protocol: PROTOCOL, release: RELEASE }) + "\n");
194
+ }
195
+ resolve(new Connection(sock, where, callTimeoutMs));
196
+ });
197
+ });
198
+ }
199
+
200
+ /** Send one verb and resolve with its result, or reject with the refusal.
201
+ *
202
+ * Pass `null` for `timeoutMs` to wait indefinitely, for a call known to take
203
+ * a while; anything else uses the connection's own budget. */
204
+ call(verb, params, timeoutMs) {
205
+ if (this._closed) return Promise.reject(this._closed);
206
+ const id = ++this._nextId;
207
+ const req = { id, method: verb };
208
+ if (id === 1) {
209
+ // Declared on the frame this client was already sending, so a workbench
210
+ // that cannot serve this client refuses before any verb runs and without
211
+ // a round trip of its own. Only the first: neither answer can change
212
+ // while the connection is open.
213
+ req.protocol = PROTOCOL;
214
+ req.release = RELEASE;
215
+ }
216
+ if (params !== undefined && params !== null) req.params = params;
217
+ const budget = timeoutMs === undefined ? this._callTimeoutMs : timeoutMs;
218
+ return new Promise((resolve, reject) => {
219
+ const waiter = { id, verb, resolve, reject, timer: null };
220
+ if (budget) {
221
+ waiter.timer = setTimeout(() => {
222
+ const i = this._waiters.indexOf(waiter);
223
+ if (i >= 0) this._waiters.splice(i, 1);
224
+ reject(new MeshbenchError(`${verb} did not answer within ${budget} ms`));
225
+ }, budget);
226
+ // Never hold the process open only to time out a call nobody is
227
+ // waiting on any more.
228
+ if (typeof waiter.timer.unref === "function") waiter.timer.unref();
229
+ }
230
+ this._waiters.push(waiter);
231
+ this._sock.write(JSON.stringify(req) + "\n");
232
+ });
233
+ }
234
+
235
+ /** Hang up. Calls still in flight reject rather than wait on a socket nobody
236
+ * will answer on, so a script that closes early fails where it closed
237
+ * instead of hanging until its timeout. */
238
+ close() {
239
+ if (!this._closed) this._fail(new MeshbenchError("connection closed by caller"));
240
+ this._sock.end();
241
+ this._sock.destroy();
242
+ }
243
+
244
+ _onData(chunk) {
245
+ this._buf += chunk;
246
+ let nl;
247
+ while ((nl = this._buf.indexOf("\n")) >= 0) {
248
+ const line = this._buf.slice(0, nl);
249
+ this._buf = this._buf.slice(nl + 1);
250
+ if (line.trim() === "") continue;
251
+ let msg;
252
+ try {
253
+ msg = JSON.parse(line);
254
+ } catch {
255
+ continue; // a frame this client cannot parse is not a reply to fail on
256
+ }
257
+ this._deliver(msg);
258
+ }
259
+ }
260
+
261
+ _deliver(msg) {
262
+ // A frame that answers no request and carries an error is the connection
263
+ // itself being refused: the token line on loopback TCP is turned away that
264
+ // way, before any request exists to answer, so the refusal comes back with
265
+ // id 0. Dropping it turned a sentence naming both releases into "connection
266
+ // closed", which is the confusion the declaration exists to end. Failed
267
+ // rather than delivered to a waiter, because there is no connection left to
268
+ // make a second call on.
269
+ if (!msg.id && msg.error) {
270
+ this._fail(refusal("", msg.error, msg.code));
271
+ return;
272
+ }
273
+ if (msg.id === undefined || msg.id === null) {
274
+ if (this.onNotification) this.onNotification(msg);
275
+ return;
276
+ }
277
+ const i = this._waiters.findIndex((w) => w.id === msg.id);
278
+ if (i < 0) return;
279
+ const [w] = this._waiters.splice(i, 1);
280
+ if (w.timer) clearTimeout(w.timer);
281
+ if (msg.error) w.reject(refusal(w.verb, msg.error, msg.code));
282
+ else w.resolve(msg.result);
283
+ }
284
+
285
+ _fail(err) {
286
+ if (this._closed) return;
287
+ this._closed = err;
288
+ const waiters = this._waiters;
289
+ this._waiters = [];
290
+ for (const w of waiters) {
291
+ if (w.timer) clearTimeout(w.timer);
292
+ w.reject(err);
293
+ }
294
+ if (this.onNotification) this.onNotification(null);
295
+ }
296
+ }
297
+
298
+ /** Whether something is already answering there.
299
+ *
300
+ * A connect rather than a stat: a socket file existing says nothing about
301
+ * whether anybody is behind it, and that difference is the whole question. */
302
+ export async function isLive(address, token = "", rendezvous = "") {
303
+ try {
304
+ const conn = await Connection.open(
305
+ { address, token, rendezvous, connectTimeoutMs: 250 });
306
+ conn.close();
307
+ return true;
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
@@ -0,0 +1,86 @@
1
+ // Being told, rather than asking.
2
+ //
3
+ // The socket is request/reply, and stays that way: a script sends a verb and
4
+ // reads its answer. A subscription is the other shape - the workbench writing a
5
+ // line when something changes, unbidden - and it does not fit a call, so it is
6
+ // given a connection of its own to stream on. A client that never subscribes
7
+ // sees exactly the request/reply protocol it always did.
8
+ //
9
+ // Each notification is {"event": ..., "data": ...} with no id. The absent id is
10
+ // the whole distinction: a reply carries the id it answered, a notification
11
+ // never does, so the two can never be confused for one another on the wire.
12
+
13
+ import { Connection } from "./socket.mjs";
14
+
15
+ /** The verb that opens a subscription on a connection. */
16
+ export const SUBSCRIBE = "session.subscribe";
17
+
18
+ /** A live stream of notifications on a connection of its own.
19
+ *
20
+ * Iterate it with `for await`: it waits until the next notification arrives and
21
+ * ends when the workbench hangs up. Close it, so the extra connection does not
22
+ * outlive the interest.
23
+ *
24
+ * Each notification is `{topic, data, dropped}`, where `dropped` is how many
25
+ * snapshot notifications the server coalesced away before this one - zero for
26
+ * every other topic. */
27
+ export class Subscription {
28
+ constructor(conn) {
29
+ this._conn = conn;
30
+ this._queue = [];
31
+ this._waiting = [];
32
+ this._done = false;
33
+ conn.onNotification = (msg) => this._push(msg);
34
+ }
35
+
36
+ /** Open one to the given topics - "status", "snapshot", and whatever else the
37
+ * workbench publishes. */
38
+ static async open(address, topics = []) {
39
+ // No call timeout: a stream waits as long as it must between events, where
40
+ // a call would rather fail than hang.
41
+ const conn = await Connection.open({ address, callTimeoutMs: 0 });
42
+ const sub = new Subscription(conn);
43
+ try {
44
+ await conn.call(SUBSCRIBE, { topics }, 30_000);
45
+ } catch (e) {
46
+ conn.close();
47
+ throw e;
48
+ }
49
+ return sub;
50
+ }
51
+
52
+ _push(msg) {
53
+ const note = msg === null ? null : {
54
+ topic: msg.event || "", data: msg.data, dropped: msg.dropped || 0,
55
+ };
56
+ if (note === null) this._done = true;
57
+ const waiter = this._waiting.shift();
58
+ if (waiter) {
59
+ waiter(note);
60
+ return;
61
+ }
62
+ this._queue.push(note);
63
+ }
64
+
65
+ /** The next notification, or null once the workbench has hung up. */
66
+ next() {
67
+ if (this._queue.length) return Promise.resolve(this._queue.shift());
68
+ if (this._done) return Promise.resolve(null);
69
+ return new Promise((resolve) => this._waiting.push(resolve));
70
+ }
71
+
72
+ async *[Symbol.asyncIterator]() {
73
+ for (;;) {
74
+ const note = await this.next();
75
+ if (note === null) return;
76
+ yield note;
77
+ }
78
+ }
79
+
80
+ /** Hang up the stream. An iterator waiting on the next notification is
81
+ * released rather than left waiting for one that is not coming. */
82
+ close() {
83
+ this._conn.close();
84
+ this._push(null);
85
+ }
86
+ }
package/lib/values.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // The few values this client builds rather than passes through.
2
+ //
3
+ // Everything the socket answers with reaches a script as the object the
4
+ // workbench sent, keys and all: a translation layer that renamed `height_m` to
5
+ // `heightM` would be a second vocabulary to keep in step with the wire, and it
6
+ // would go stale the week a verb grew a field. What is here instead is the
7
+ // handful of values that carry behaviour a caller would otherwise write out -
8
+ // the caveats that must travel with a number, and the two names a build has.
9
+
10
+ /** What a measurement was measured under.
11
+ *
12
+ * Carried with any result that is a number about the world, because a scripted
13
+ * number gets pasted into a report with the caveats stripped. The caveats have
14
+ * to be in the value. */
15
+ export class Provenance {
16
+ constructor({ rf_mode = "", excess_loss_db = 0, calibrated = false, seed = 0 } = {}) {
17
+ /** "calculated" or "waveform". */
18
+ this.rfMode = rf_mode;
19
+ /** The calibration term in force, and whether it was fitted against real
20
+ * receptions rather than left at the default. */
21
+ this.excessLossDb = excess_loss_db;
22
+ this.calibrated = calibrated;
23
+ this.seed = seed;
24
+ }
25
+
26
+ /** One line, meant to be printed above any number a script emits. */
27
+ toString() {
28
+ const fit = this.calibrated
29
+ ? "excess loss fitted to real receptions"
30
+ : "default excess loss";
31
+ return `MeshBench: ${this.rfMode} reception, ${fit} - a best case; ` +
32
+ "no multipath, no body loss, no oscillator error";
33
+ }
34
+ }
35
+
36
+ /** What a fetch found, before anything has been changed.
37
+ *
38
+ * `skipped_no_position` and `uncertain` are the two worth reading before
39
+ * committing. A node with no position cannot be simulated at all, and an
40
+ * uncertain one is being placed to within kilometres - the answer it gives is
41
+ * that vague too, however confident the rest of the output looks. */
42
+ export class ImportPreview {
43
+ constructor(raw = {}) {
44
+ this.records = raw.records || 0;
45
+ this.nodes = raw.nodes || 0;
46
+ this.skippedNoPosition = raw.skipped_no_position || 0;
47
+ this.uncertain = raw.uncertain || 0;
48
+ }
49
+
50
+ toString() {
51
+ let out = `${this.records} records, ${this.nodes} usable`;
52
+ if (this.skippedNoPosition) out += `, ${this.skippedNoPosition} with no position`;
53
+ if (this.uncertain) out += `, ${this.uncertain} placed only roughly`;
54
+ return out;
55
+ }
56
+ }
57
+
58
+ /** How a build is named where a person will read it.
59
+ *
60
+ * Version, board and role travel together because a board image is not a build
61
+ * on its own: "wadamesh" means nothing until it is wadamesh for a LilyGo_TDeck,
62
+ * built as a companion. A host build carries neither of the other two. */
63
+ export function describeBuild(build) {
64
+ if (!build || !build.board) return (build && build.version) || "";
65
+ return `${build.board} - ${build.role} ${build.version}`;
66
+ }
package/lib/wait.mjs ADDED
@@ -0,0 +1,68 @@
1
+ // Waiting, in one place.
2
+ //
3
+ // Every wait in this package is a method, never a sleep in a script. That is
4
+ // not tidiness: tools/soak hand-wrote the same poll loop three times in
5
+ // seventy-two lines, each with its own interval and its own timeout, and its
6
+ // own header records having sampled the wrong moment because of it.
7
+ //
8
+ // They poll. When the socket learns to push, this file changes and no caller
9
+ // does - which is the whole reason the clients are built before the events.
10
+ //
11
+ // # Which clock
12
+ //
13
+ // Two clocks appear in this API and they are not the same one. Simulated is the
14
+ // mesh's own: `sim.run`, `schedule.add`, `sim.waitUntil`. Wall is yours: every
15
+ // `timeoutMs`. Both are milliseconds, so the name is what tells them apart, and
16
+ // nothing that means the mesh's clock is called a timeout.
17
+
18
+ import { Timeout } from "./errors.mjs";
19
+
20
+ /** Firmware coming up on a whole mesh. Real firmware is minutes; emulated
21
+ * boards are longer. */
22
+ export const FIRMWARE_WAIT_MS = 10 * 60_000;
23
+
24
+ /** A run of simulated time finishing, measured on your clock. */
25
+ export const RUN_WAIT_MS = 30 * 60_000;
26
+
27
+ /** A long job - a warm, a download, a build - finishing. */
28
+ export const JOB_WAIT_MS = 30 * 60_000;
29
+
30
+ /** One event arriving. */
31
+ export const EVENT_WAIT_MS = 5 * 60_000;
32
+
33
+ /** Where a wait's polling starts and the slowest it gets.
34
+ *
35
+ * It backs off between the two. Something about to happen is noticed promptly;
36
+ * something that takes ten minutes is not asked four thousand times on the
37
+ * way. nodes.stats in particular costs a /proc read per node, so polling it at
38
+ * ten hertz on a 155-node mesh is fifteen hundred reads a second - during
39
+ * firmware startup, which is the busiest moment there is. */
40
+ export const POLL_FIRST_MS = 50;
41
+ export const POLL_SLOWEST_MS = 1000;
42
+
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ /** Poll until `check` says yes, or the time runs out.
46
+ *
47
+ * `check` resolves to `[done, saw]`: whether it is finished and, if not, what
48
+ * it saw - which is what the Timeout reports. A rejection from `check` stops
49
+ * the wait rather than being retried: a verb refusing because a node does not
50
+ * exist will refuse the same way in ten seconds. */
51
+ export async function waitFor(check, timeoutMs, what) {
52
+ const deadline = Date.now() + timeoutMs;
53
+ let interval = POLL_FIRST_MS;
54
+ let last = "";
55
+ for (;;) {
56
+ const [done, saw] = await check();
57
+ if (done) return;
58
+ if (saw) last = saw;
59
+ if (Date.now() > deadline) throw new Timeout(what, timeoutMs, last);
60
+ await sleep(interval);
61
+ interval = Math.min(interval * 1.5, POLL_SLOWEST_MS);
62
+ }
63
+ }
64
+
65
+ /** A simulated moment, said the way a person reads it. */
66
+ export function secs(ms) {
67
+ return `${(ms / 1000).toFixed(1)}s`;
68
+ }