@andrian.yablonskyy/thub-agent 1.1.19 → 1.1.20

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 CHANGED
@@ -45,6 +45,10 @@ thub cancel <jobId> Cancel a job
45
45
  thub power on|off|reset <jobId> [--delay <sec>] [--port <n>] Switch the USB power of the Client running your job (owner only)
46
46
  thub report <jobId> [--post gitlab|bitbucket|github] [--commit-status] The job's report, or a sticky MR/PR comment
47
47
  thub resources List resources and their status
48
+ thub reserve <client> [--for <dur>] Only your jobs run on this runner until then (default 1h)
49
+ thub release <client> End your reservation (maintainers: anyone's)
50
+ thub connect <client> [--target <name>] Your reserved board's UARTs and SWD (GDB) on local ports
51
+ thub console <client> [--uart <n>] A reserved board's UART in this terminal (Ctrl-] quits)
48
52
  thub jobs [--mine] [--state <s>] List recent jobs (a cli token: only its own; a ci token: all, or its own with --mine)
49
53
  thub config set <key> <value> Save coordinator URL / key / default user locally
50
54
  thub check-update Compare this Agent with the latest published version
@@ -173,6 +177,12 @@ thub run --type sw --download-file https://does-not-exist.invalid/app.bin \
173
177
 
174
178
  **Run in a resource group:** there's no `--group` option. An admin or maintainer gives your user (or a CI token) one group on the dashboard, and every job it submits runs only on that group's resources; `thub whoami` shows it.
175
179
 
180
+ **Reserve a board** to iterate on it without a pipeline taking it between your runs: `thub reserve lab-hw-01 --for 2h`. Until then the scheduler gives lab-hw-01 only your jobs (`thub run --client lab-hw-01 …`); everyone else's go to another runner or wait. `thub release lab-hw-01` gives it back early, otherwise it frees itself when the time is up — a job still running keeps running. `--for` takes whole minutes (`30m`, `2h`) up to the runner's own maximum (8 h unless a maintainer set another on its card); reserving it again extends it from now. `thub resources` shows who holds what. Needs a user's key — a CI token can't reserve.
181
+
182
+ **Its UARTs and SWD on your machine:** `thub connect lab-hw-01 --target stm32f4x` keeps a live connection to the reserved board and prints the link and a local port per device: UART 1 on `127.0.0.1:5001` (and up; `--uart-port`), ST-Link 1's GDB server on `127.0.0.1:3333` (and up; `--gdb-port`) — `arm-none-eabi-gdb -ex "target extended-remote 127.0.0.1:3333"`, `socat -,raw,echo=0 TCP:127.0.0.1:5001`, or pyserial's `socket://127.0.0.1:5001`. Each GDB connection starts its own OpenOCD on the Client with `target/<name>.cfg` (`--target`, else the probe's OpenOCD target set on the runner card). `thub console lab-hw-01 --uart 1` puts a UART right in your terminal (Ctrl-] quits). A dropped connection is made again by itself (a UART port stays connected through it, GDB has to reconnect); the session ends with the reservation. Each reservation has its own link (`thub reserve` prints it): reserve again later and `thub connect` again for the new one.
183
+
184
+ **Watch the board:** on a runner with a USB camera (label `video-stream`), its stream starts with your reservation, and `thub connect` prints a local link of that reservation's own — `http://127.0.0.1:8090/<reservation id>/video.ts` (`--video-port`) for VLC, ffplay or mpv; `--video-udp 5600` also sends it as UDP to `udp://127.0.0.1:5600`.
185
+
176
186
  **Who submitted a job** comes from your key: your username (a CI token: its name), shown on the dashboard, in `thub jobs`, on the Client's console and as `JOB_USER`. There's no `--user`.
177
187
 
178
188
  ## Environment variables and secrets
package/RELEASE.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  What changed in each release, newest first. `## Unreleased` collects the changes since the version on npm; `bin/publish` turns that heading into the version and date it releases.
4
4
 
5
+ ## 1.1.20 — 2026-10-15
6
+
7
+ ### Changed
8
+
9
+ - Requires thub-common 2.2.3.
10
+
5
11
  ## 1.1.19 — 2026-10-14
6
12
 
7
13
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andrian.yablonskyy/thub-agent",
3
- "version": "1.1.19",
3
+ "version": "1.1.20",
4
4
  "description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "Andrian Yablonskyy <andrian.yablonskyy@gmail.com>",
@@ -15,8 +15,9 @@
15
15
  "lint:fix": "eslint . --fix"
16
16
  },
17
17
  "dependencies": {
18
- "@andrian.yablonskyy/thub-common": "^2.2.2",
19
- "commander": "^13.1.0"
18
+ "@andrian.yablonskyy/thub-common": "^2.2.3",
19
+ "commander": "^13.1.0",
20
+ "ws": "8.21.3"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@stylistic/eslint-plugin": "^5.10.0",
package/src/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * @file packages/agent/src/cli.js
5
- * @description thub CLI entry point: run/status/cancel/power/report/resources/jobs/config commands (README §7)
5
+ * @description thub CLI entry point: run/status/cancel/power/report/resources/reserve/release/connect/console/jobs/config commands (README §7)
6
6
  *
7
7
  * @author Andrian Yablonskyy
8
8
  * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
@@ -18,8 +18,9 @@
18
18
  const { Command } = require('commander'),
19
19
  {
20
20
  ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
21
- parseEnvList, POWER_ACTIONS, DEFAULT_RESET_DELAY_SEC, MAX_RESET_DELAY_SEC, powerRequestErrors
21
+ parseEnvList, POWER_ACTIONS, DEFAULT_RESET_DELAY_SEC, MAX_RESET_DELAY_SEC, powerRequestErrors, OPENOCD_TARGET_RE
22
22
  } = require('@andrian.yablonskyy/thub-common'),
23
+ { RemoteSession, listenLocal, serveVideo } = require('./remote'),
23
24
  { resolveConnection, writeConfigFile, readConfigFile, CONFIG_PATH } = require('./config'),
24
25
  { parseDurationSec } = require('./duration'),
25
26
  { followJob } = require('./streaming'),
@@ -384,6 +385,7 @@ program
384
385
  ['STATUS', (r) => r.status],
385
386
  ['BUSY', (r) => r.busySource || '-'],
386
387
  ['LABELS', (r) => r.labels.join(',')],
388
+ ['RESERVED', (r) => (r.reservation ? `${r.reservation.by} until ${formatDateTime(r.reservation.until)}` : '-')],
387
389
  ['LAST HEARTBEAT', (r) => r.lastHeartbeatAt || 'never']
388
390
  ]);
389
391
  }
@@ -392,6 +394,269 @@ program
392
394
  }
393
395
  });
394
396
 
397
+ // README §5.5: hold a runner for your own jobs for a while — everyone
398
+ // else's go elsewhere or wait — and give it back, or let the time run out.
399
+ program
400
+ .command('reserve')
401
+ .description('Reserve a runner for your own jobs, for a while: until then only your jobs run on it')
402
+ .argument('<client>', 'The runner\'s name or id (thub resources)')
403
+ .option('--for <duration>', 'How long, e.g. 30m, 2h (default 1h; at most what the runner allows). Again on your own: extends it from now')
404
+ .option('--json', 'Machine-readable output', false)
405
+ .action(async (name, opts) => {
406
+ try {
407
+ let minutes;
408
+ if (opts.for !== undefined){
409
+ const sec = parseDurationSec(opts.for);
410
+ if (!sec || sec % 60){
411
+ throw usageError(`--for must be whole minutes, e.g. 30m or 2h (got "${opts.for}")`);
412
+ }
413
+ minutes = sec / 60;
414
+ }
415
+ const r = await client().post(`/resources/${encodeURIComponent(name)}/reserve`, minutes ? { minutes } : {});
416
+ if (opts.json){
417
+ return console.log(JSON.stringify(r));
418
+ }
419
+ console.log(`${r.name} is reserved for you until ${formatDateTime(r.reservation.until)}.\n` +
420
+ `Only your jobs run on it (thub run --client ${r.name} …); thub release ${r.name} when you're done.\n` +
421
+ `Its UARTs and SWD: thub connect ${r.name} (local ports), thub console ${r.name} (serial in this terminal).` +
422
+ (r.reservation.link ? `\nThis reservation's link: ${r.reservation.link}` : ''));
423
+ }
424
+ catch (err){
425
+ fail(err);
426
+ }
427
+ });
428
+
429
+ program
430
+ .command('release')
431
+ .description('End your reservation of a runner (maintainers: anyone\'s) — everyone\'s jobs can run on it again')
432
+ .argument('<client>', 'The runner\'s name or id')
433
+ .option('--json', 'Machine-readable output', false)
434
+ .action(async (name, opts) => {
435
+ try {
436
+ const r = await client().post(`/resources/${encodeURIComponent(name)}/release`);
437
+ if (opts.json){
438
+ return console.log(JSON.stringify(r));
439
+ }
440
+ console.log(r.released ? `${r.name} released.` : `${r.name} wasn't reserved.`);
441
+ }
442
+ catch (err){
443
+ fail(err);
444
+ }
445
+ });
446
+
447
+ // README §5.6: the reserved board's UARTs and SWD, live from the Client
448
+ // through the Coordinator — `thub connect` on local TCP ports for any tool
449
+ // (GDB, socat, pyserial), `thub console` right in this terminal. Both make
450
+ // a dropped connection again by themselves, until the reservation ends.
451
+ // Each reservation has its own link (README §5.6): the one you hold now.
452
+ // A later reservation of the same runner is a new link — run this again.
453
+ async function remoteSession(name){
454
+ const { token } = resolveConnection(program.opts()),
455
+ { resources } = await client().get('/resources'),
456
+ r = resources.find((x) => x.name === name || x.id === name);
457
+ if (!r){
458
+ throw usageError(`Unknown client "${name}" (thub resources lists them)`);
459
+ }
460
+ if (!r.reservation?.link){
461
+ throw usageError(r.reservation
462
+ ? `${r.name} is reserved by ${r.reservation.by} — only their reservation's link connects`
463
+ : `${r.name} isn't reserved — thub reserve ${r.name} first`);
464
+ }
465
+ return new RemoteSession({ url: r.reservation.link, token, client: r.name, userAgent: `thub-agent/${version}` });
466
+ }
467
+
468
+ function onRemoteTrouble(session, log, cleanup){
469
+ session.on('status', (s) => s.state === 'waiting' &&
470
+ log(`Waiting for ${s.client}'s Client to connect — it does within a heartbeat of the reservation…`));
471
+ session.on('disconnected', ({ retryMs, reason }) =>
472
+ log(`Connection lost${reason ? ` (${reason})` : ''} — reconnecting in ${Math.max(1, Math.round(retryMs / 1000))} s…`));
473
+ session.on('connected', ({ again }) => again && log('Reconnected.'));
474
+ session.on('fatal', (message) => {
475
+ cleanup();
476
+ fail(Object.assign(new Error(message), { status: EXIT_CODES.USAGE }));
477
+ });
478
+ }
479
+
480
+ program
481
+ .command('connect')
482
+ .description('Share your reserved board\'s UARTs and SWD (GDB via OpenOCD) on local TCP ports, over a live connection that reconnects by itself')
483
+ .argument('<client>', 'The runner you reserved (thub reserve)')
484
+ .option('--target <name>', 'OpenOCD target for SWD, e.g. stm32f4x (target/<name>.cfg; default: the probe\'s openocdTarget on the Client)')
485
+ .option('--uart-port <port>', 'Local port of UART 1 (UART n: this + n - 1)', (v) => Number(v), 5001)
486
+ .option('--gdb-port <port>', 'Local GDB port of ST-Link 1 (ST-Link n: this + n - 1)', (v) => Number(v), 3333)
487
+ .option('--bind <address>', 'Local address to listen on (another than loopback shares the board with your network)', '127.0.0.1')
488
+ .option('--video-port <port>', 'Local HTTP port of the board\'s USB camera stream, if it has one', (v) => Number(v), 8090)
489
+ .option('--video-udp <port>', 'Also send the camera stream as UDP (MPEG-TS) to this local port, e.g. 5600', (v) => Number(v))
490
+ .option('--json', 'Print the ports as JSON once connected', false)
491
+ .action(async (name, opts) => {
492
+ try {
493
+ if (opts.target !== undefined && !OPENOCD_TARGET_RE.test(opts.target)){
494
+ throw usageError(`--target must be an OpenOCD target script name such as stm32f4x (got "${opts.target}")`);
495
+ }
496
+ for (const [flag, v]of [['--uart-port', opts.uartPort], ['--gdb-port', opts.gdbPort], ['--video-port', opts.videoPort],
497
+ ...(opts.videoUdp !== undefined ? [['--video-udp', opts.videoUdp]] : [])]){
498
+ if (!Number.isInteger(v) || v < 0 || v > 65535){
499
+ throw usageError(`${flag} must be a port number`);
500
+ }
501
+ }
502
+ const session = await remoteSession(name),
503
+ log = (m) => console.error(m);
504
+ let listening = null,
505
+ until = null;
506
+ const cleanup = () => {
507
+ session.stop();
508
+ listening?.then(({ servers, video }) => {
509
+ servers.forEach((srv) => srv.close());
510
+ video?.close();
511
+ }).catch(() => {});
512
+ };
513
+ onRemoteTrouble(session, log, cleanup);
514
+ session.on('status', (s) => {
515
+ until = s.until;
516
+ });
517
+ session.on('devices', (devices) => {
518
+ if (listening){
519
+ return;
520
+ }
521
+ listening = (async () => {
522
+ const local = await listenLocal(session, devices, {
523
+ host: opts.bind, uartBase: opts.uartPort, gdbBase: opts.gdbPort, target: opts.target, log
524
+ }),
525
+ // The camera (README §5.7) on a URL with this reservation's own id in it.
526
+ video = devices.camera
527
+ ? await serveVideo(session, {
528
+ host: opts.bind, port: opts.videoPort, reservation: session.url.split('/').pop(), udpPort: opts.videoUdp, log
529
+ })
530
+ : null;
531
+ return { ...local, video };
532
+ })();
533
+ listening
534
+ .then(({ ports, video }) => printConnection(name, session.url, until, ports, devices, opts, video))
535
+ .catch((err) => {
536
+ cleanup();
537
+ fail(usageError(err.code === 'EADDRINUSE'
538
+ ? `Local port ${err.port} is in use — pick others with --uart-port / --gdb-port / --video-port`
539
+ : `Can't listen locally: ${err.message}`));
540
+ });
541
+ });
542
+ process.on('SIGINT', () => {
543
+ cleanup();
544
+ log(`Disconnected from ${name} (its reservation stays — thub release ${name} ends it).`);
545
+ process.exit(0);
546
+ });
547
+ session.start();
548
+ }
549
+ catch (err){
550
+ fail(err);
551
+ }
552
+ });
553
+
554
+ // The connection link and how to use each port.
555
+ function printConnection(name, link, until, ports, devices, opts, video){
556
+ if (opts.json){
557
+ return console.log(JSON.stringify({
558
+ client: name, link, until, ports: ports.map((p) => ({ kind: p.kind, ...p.device, host: opts.bind, port: p.port })),
559
+ video: video ? { camera: devices.camera.name, url: video.url, udp: video.udp } : null
560
+ }));
561
+ }
562
+ const host = opts.bind === '0.0.0.0' || opts.bind === '::' ? 'localhost' : opts.bind,
563
+ lines = [
564
+ `Connected to ${name}${until ? ` — reserved until ${formatDateTime(until)} (thub release ${name} ends it)` : ''}`,
565
+ `Link: ${link}`,
566
+ ''
567
+ ];
568
+ for (const { kind, device: d, port }of ports){
569
+ if (kind === 'uart'){
570
+ lines.push(`UART ${d.index} ${d.label} ${d.baudRate} 8N1 tcp://${host}:${port}`,
571
+ ` terminal: thub console ${name} --uart ${d.index}`,
572
+ ` or: socat -,raw,echo=0 TCP:${host}:${port} · pyserial: serial_for_url('socket://${host}:${port}')`);
573
+ }
574
+ else {
575
+ const target = opts.target || d.target;
576
+ lines.push(`SWD ${d.index} ${d.label}${d.serial ? ` (ST-Link ${d.serial})` : ''} ` +
577
+ `OpenOCD target ${target || '— none: add --target <name>'} GDB: ${host}:${port}`,
578
+ ` arm-none-eabi-gdb -ex "target extended-remote ${host}:${port}" (each connection starts its own OpenOCD)`);
579
+ }
580
+ }
581
+ if (video){
582
+ lines.push(`Video ${devices.camera.name} H.264 (MPEG-TS) ${video.url}`,
583
+ ` vlc ${video.url} · ffplay -fflags nobuffer ${video.url}` +
584
+ (video.udp ? `\n UDP: ${video.udp} (ffplay -fflags nobuffer ${video.udp})` : ' · UDP too: --video-udp 5600'));
585
+ }
586
+ if (!ports.length && !video){
587
+ lines.push(`${name} has no UARTs, ST-Links or camera.`);
588
+ }
589
+ if (devices.uarts.length && !devices.serial){
590
+ lines.push('', 'UARTs won\'t open: the \'serialport\' package isn\'t installed on the Client host.');
591
+ }
592
+ lines.push('', 'Ctrl-C disconnects; a dropped connection is made again by itself.');
593
+ console.log(lines.join('\n'));
594
+ }
595
+
596
+ program
597
+ .command('console')
598
+ .description('Your reserved board\'s UART in this terminal: its output here, your typing to the board. Ctrl-] quits')
599
+ .argument('<client>', 'The runner you reserved (thub reserve)')
600
+ .option('--uart <n>', 'Which UART (thub connect lists them)', (v) => Number(v), 1)
601
+ .action(async (name, opts) => {
602
+ try {
603
+ const session = await remoteSession(name),
604
+ log = (m) => process.stderr.write(`\r\n— ${m} —\r\n`),
605
+ tty = process.stdin.isTTY;
606
+ let ch = null;
607
+ const restore = () => {
608
+ if (tty){
609
+ process.stdin.setRawMode(false);
610
+ }
611
+ process.stdin.pause();
612
+ },
613
+ cleanup = () => {
614
+ session.stop();
615
+ restore();
616
+ },
617
+ quit = (code = 0) => {
618
+ cleanup();
619
+ process.stderr.write(`\r\nDisconnected from ${name}.\r\n`);
620
+ process.exit(code);
621
+ };
622
+ onRemoteTrouble(session, log, cleanup);
623
+ session.on('devices', (devices) => {
624
+ if (ch !== null){
625
+ return;
626
+ }
627
+ const uart = devices.uarts.find((u) => u.index === opts.uart);
628
+ if (!uart){
629
+ cleanup();
630
+ return fail(usageError(`${name} has no UART ${opts.uart} (it has ${devices.uarts.length})`));
631
+ }
632
+ process.stderr.write(`Connected to ${name} UART ${uart.index} (${uart.label}, ${uart.baudRate} 8N1). Ctrl-] quits.\r\n`);
633
+ ch = session.open('uart', uart.index, {
634
+ data: (buf) => process.stdout.write(buf),
635
+ note: log,
636
+ closed: (reason) => {
637
+ log(reason);
638
+ quit(EXIT_CODES.USAGE);
639
+ }
640
+ }, { persistent: true });
641
+ if (tty){
642
+ process.stdin.setRawMode(true);
643
+ }
644
+ process.stdin.on('data', (buf) => {
645
+ if (buf.includes(0x1d)){
646
+ return quit();
647
+ }
648
+ session.write(ch, buf);
649
+ });
650
+ process.stdin.resume();
651
+ });
652
+ process.on('SIGINT', () => quit());
653
+ session.start();
654
+ }
655
+ catch (err){
656
+ fail(err);
657
+ }
658
+ });
659
+
395
660
  program
396
661
  .command('jobs')
397
662
  .description('List recent jobs')
package/src/remote.js ADDED
@@ -0,0 +1,357 @@
1
+ /**
2
+ * @file packages/agent/src/remote.js
3
+ * @description Remote board access (README §5.6): the Agent's WebSocket session to a reserved runner, reconnecting
4
+ * by itself, and the local TCP ports (`thub connect`) and terminal (`thub console`) on top of it
5
+ *
6
+ * @author Andrian Yablonskyy
7
+ * @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
8
+ *
9
+ * This file is part of TestHub and is proprietary and confidential.
10
+ * Unauthorized copying, modification, distribution, or use of this file,
11
+ * via any medium, is strictly prohibited without prior written permission
12
+ * from AdSystem.PRO.
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const net = require('node:net'),
18
+ http = require('node:http'),
19
+ dgram = require('node:dgram'),
20
+ { EventEmitter } = require('node:events'),
21
+ { decodeData, dataMessages, parseControl, backoffMs, REMOTE_CLOSE } = require('@andrian.yablonskyy/thub-common');
22
+
23
+ // Without a ping from the Coordinator (every 30 s) for this long, the
24
+ // connection is taken for dead and made again.
25
+ const PING_TIMEOUT_MS = 75_000,
26
+ // HTTP answers to the upgrade that won't change by trying again.
27
+ FINAL_STATUSES = new Set([401, 403, 404, 409]);
28
+
29
+ // One WebSocket to the reservation's own link (/api/v1/remote/<reservation>,
30
+ // from GET /resources), made again whenever it drops (1 s, 2 s, 4 s … 30 s
31
+ // apart) until that reservation ends or stop(). Channels are opened with open(); a `persistent` one (a UART) is
32
+ // opened again on its own once the Client is back, a GDB one is closed —
33
+ // a debugger's state doesn't survive a dropped connection anyway.
34
+ //
35
+ // Events: status ({ state, client, until }), devices ({ uarts, probes }),
36
+ // disconnected ({ retryMs, reason }), connected, fatal (message).
37
+ class RemoteSession extends EventEmitter{
38
+ constructor({ url, token, client, userAgent, WebSocket = require('ws') }){
39
+ super();
40
+ Object.assign(this, { url, token, client, userAgent, WebSocket });
41
+ this.ws = null;
42
+ this.ready = false;
43
+ this.stopped = false;
44
+ this.attempt = 0;
45
+ this.everConnected = false;
46
+ this.channels = new Map(); // ch -> { kind, index, extra, persistent, handler, open, queue }
47
+ this.nextCh = 1;
48
+ this.devices = null;
49
+ }
50
+
51
+ start(){
52
+ this.stopped = false;
53
+ this._connect();
54
+ return this;
55
+ }
56
+
57
+ stop(){
58
+ this.stopped = true;
59
+ clearTimeout(this.retryTimer);
60
+ clearTimeout(this.pingTimer);
61
+ for (const ch of [...this.channels.keys()]){
62
+ this.close(ch);
63
+ }
64
+ this.ws?.close(1000, 'bye');
65
+ }
66
+
67
+ _connect(){
68
+ if (this.stopped){
69
+ return;
70
+ }
71
+ const ws = new this.WebSocket(this.url, {
72
+ headers: { Authorization: `Bearer ${this.token}`, ...(this.userAgent ? { 'User-Agent': this.userAgent } : {}) },
73
+ handshakeTimeout: 15_000
74
+ });
75
+ this.ws = ws;
76
+ let refused = null;
77
+ const alive = () => {
78
+ clearTimeout(this.pingTimer);
79
+ this.pingTimer = setTimeout(() => ws.terminate(), PING_TIMEOUT_MS);
80
+ this.pingTimer.unref?.();
81
+ };
82
+ ws.on('open', () => {
83
+ this.attempt = 0;
84
+ alive();
85
+ this.emit('connected', { again: this.everConnected });
86
+ this.everConnected = true;
87
+ });
88
+ ws.on('ping', alive);
89
+ ws.on('unexpected-response', (req, res) => {
90
+ let body = '';
91
+ res.on('data', (c) => {
92
+ body += c;
93
+ });
94
+ res.on('end', () => {
95
+ let error;
96
+ try {
97
+ error = JSON.parse(body).error;
98
+ }
99
+ catch {
100
+ error = null;
101
+ }
102
+ refused = { status: res.statusCode, error: error || `HTTP ${res.statusCode}` };
103
+ ws.terminate();
104
+ });
105
+ });
106
+ ws.on('message', (data, isBinary) => (isBinary ? this._onData(data) : this._onControl(parseControl(data))));
107
+ ws.on('error', () => {}); // 'close' follows, and says what to do
108
+ ws.on('close', (code, reasonBuf) => {
109
+ clearTimeout(this.pingTimer);
110
+ if (this.ws !== ws){
111
+ return;
112
+ }
113
+ this.ws = null;
114
+ this._remoteGone('the connection dropped');
115
+ const reason = String(reasonBuf || '') || refused?.error || null;
116
+ if (this.stopped){
117
+ return;
118
+ }
119
+ if ((refused && FINAL_STATUSES.has(refused.status)) || code === REMOTE_CLOSE.RESERVATION_ENDED || code === REMOTE_CLOSE.REPLACED){
120
+ this.stopped = true;
121
+ const message = reason || 'The Coordinator ended the connection';
122
+ for (const ch of [...this.channels.keys()]){
123
+ this._finish(ch, message);
124
+ }
125
+ return this.emit('fatal', message);
126
+ }
127
+ const retryMs = backoffMs(this.attempt++);
128
+ this.emit('disconnected', { retryMs, reason });
129
+ this.retryTimer = setTimeout(() => this._connect(), retryMs);
130
+ });
131
+ }
132
+
133
+ // Everything open on the Client side is gone (link or connection down).
134
+ _remoteGone(reason){
135
+ this.ready = false;
136
+ for (const [ch, c]of [...this.channels]){
137
+ c.open = false;
138
+ if (!c.persistent){
139
+ this._finish(ch, reason);
140
+ }
141
+ }
142
+ }
143
+
144
+ _onControl(msg){
145
+ if (!msg){
146
+ return;
147
+ }
148
+ if (msg.t === 'status'){
149
+ this.emit('status', msg);
150
+ if (msg.state === 'waiting'){
151
+ this._remoteGone(`${msg.client || 'the runner'}'s Client went away`);
152
+ }
153
+ return;
154
+ }
155
+ if (msg.t === 'devices'){
156
+ this.devices = { uarts: msg.uarts || [], probes: msg.probes || [], serial: msg.serial !== false, camera: msg.camera || null };
157
+ this.emit('devices', this.devices);
158
+ // Ready again: every channel still wanted is opened on the Client.
159
+ this.ready = true;
160
+ for (const [ch, c]of this.channels){
161
+ if (!c.open){
162
+ this._sendOpen(ch, c);
163
+ }
164
+ }
165
+ return;
166
+ }
167
+ const c = this.channels.get(msg.ch);
168
+ if (!c){
169
+ return;
170
+ }
171
+ if (msg.t === 'opened'){
172
+ c.open = true;
173
+ c.handler.opened?.(msg);
174
+ for (const data of c.queue.splice(0)){
175
+ this._sendData(msg.ch, data);
176
+ }
177
+ }
178
+ else if (msg.t === 'note'){
179
+ c.handler.note?.(String(msg.text || ''));
180
+ }
181
+ else if (msg.t === 'close'){
182
+ this._finish(msg.ch, msg.reason || 'closed by the Client');
183
+ }
184
+ }
185
+
186
+ _onData(buf){
187
+ const frame = decodeData(buf);
188
+ frame && this.channels.get(frame.ch)?.handler.data?.(frame.data);
189
+ }
190
+
191
+ _sendJson(msg){
192
+ if (this.ws?.readyState === this.WebSocket.OPEN){
193
+ this.ws.send(JSON.stringify(msg));
194
+ }
195
+ }
196
+
197
+ _sendData(ch, data){
198
+ if (this.ws?.readyState === this.WebSocket.OPEN){
199
+ for (const msg of dataMessages(ch, data)){
200
+ this.ws.send(msg, { binary: true });
201
+ }
202
+ }
203
+ }
204
+
205
+ _sendOpen(ch, c){
206
+ this._sendJson({ t: 'open', ch, kind: c.kind, index: c.index, ...c.extra });
207
+ }
208
+
209
+ // A new channel to UART/probe `index`. `handler`: { opened(msg), data(buf),
210
+ // note(text), closed(reason) }. Returns its id.
211
+ open(kind, index, handler, { persistent = false, extra = {} } = {}){
212
+ let ch = this.nextCh;
213
+ while (this.channels.has(ch)){
214
+ ch = ch >= 0xffff ? 1 : ch + 1;
215
+ }
216
+ this.nextCh = ch >= 0xffff ? 1 : ch + 1;
217
+ const c = { kind, index, extra, persistent, handler, open: false, queue: [] };
218
+ this.channels.set(ch, c);
219
+ if (this.ready){
220
+ this._sendOpen(ch, c);
221
+ }
222
+ return ch;
223
+ }
224
+
225
+ // Bytes for the channel's device; queued (GDB's first packets) until the
226
+ // Client has it open, dropped while a UART waits for the Client to return.
227
+ write(ch, data){
228
+ const c = this.channels.get(ch);
229
+ if (!c){
230
+ return;
231
+ }
232
+ if (c.open){
233
+ this._sendData(ch, data);
234
+ }
235
+ else if (!c.persistent){
236
+ c.queue.push(data);
237
+ }
238
+ }
239
+
240
+ close(ch){
241
+ if (this.channels.has(ch)){
242
+ this.channels.delete(ch);
243
+ this._sendJson({ t: 'close', ch });
244
+ }
245
+ }
246
+
247
+ _finish(ch, reason){
248
+ const c = this.channels.get(ch);
249
+ if (c){
250
+ this.channels.delete(ch);
251
+ c.handler.closed?.(reason);
252
+ }
253
+ }
254
+ }
255
+
256
+ // `thub connect`: a local TCP port per UART (every connection to it sees the
257
+ // board's output and types to it) and per ST-Link (a GDB server: each
258
+ // connection gets its own OpenOCD on the Client). Returns the servers.
259
+ function listenLocal(session, devices, { host, uartBase, gdbBase, target, log }){
260
+ const servers = [],
261
+ bridge = (kind, index, opts) => (sock) => {
262
+ sock.setNoDelay(true);
263
+ let ch = null;
264
+ const end = () => {
265
+ if (ch !== null){
266
+ session.close(ch);
267
+ ch = null;
268
+ }
269
+ };
270
+ ch = session.open(kind, index, {
271
+ data: (buf) => sock.write(buf),
272
+ note: (text) => log(`${kind === 'uart' ? 'UART' : 'SWD'} ${index}: ${text}`),
273
+ closed: (reason) => {
274
+ ch = null;
275
+ log(`${kind === 'uart' ? 'UART' : 'SWD'} ${index}: ${reason}`);
276
+ sock.destroy();
277
+ }
278
+ }, opts);
279
+ sock.on('data', (buf) => ch !== null && session.write(ch, buf));
280
+ sock.on('close', end);
281
+ sock.on('error', end);
282
+ },
283
+ listen = (port, onConn) => new Promise((resolve, reject) => {
284
+ const srv = net.createServer(onConn);
285
+ srv.once('error', reject);
286
+ srv.listen(port, host, () => {
287
+ servers.push(srv);
288
+ resolve(srv.address().port);
289
+ });
290
+ });
291
+ return Promise.all([
292
+ ...devices.uarts.map(async (u) => ({
293
+ kind: 'uart', device: u, port: await listen(uartBase ? uartBase + u.index - 1 : 0, bridge('uart', u.index, { persistent: true }))
294
+ })),
295
+ ...devices.probes.map(async (p) => ({
296
+ kind: 'swd', device: p,
297
+ port: await listen(gdbBase ? gdbBase + p.index - 1 : 0, bridge('swd', p.index, { extra: target ? { target } : {} }))
298
+ }))
299
+ ]).then((ports) => ({ servers, ports }));
300
+ }
301
+
302
+ // `thub connect`: the board's USB camera (README §5.7) — H.264 in MPEG-TS,
303
+ // streamed by the Client since the reservation started — on a local URL
304
+ // that's this reservation's own: http://<host>:<port>/<reservation>/video.ts
305
+ // (VLC, ffplay, mpv). Each viewer is a channel of its own. With `udpPort`,
306
+ // also re-sent as UDP datagrams to udp://<host>:<udpPort> (QGroundControl,
307
+ // GStreamer). Resolves to { server, url, udp, close() }.
308
+ function serveVideo(session, { host, port, reservation, udpPort = null, log }){
309
+ const target = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host,
310
+ videoPath = `/${reservation}/video.ts`,
311
+ server = http.createServer((req, res) => {
312
+ if (req.method !== 'GET' || req.url !== videoPath){
313
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
314
+ return res.end('Not this reservation\'s video — thub connect prints its current link.\n');
315
+ }
316
+ res.writeHead(200, { 'Content-Type': 'video/mp2t', 'Cache-Control': 'no-store' });
317
+ let ch = session.open('video', 1, {
318
+ data: (buf) => res.write(buf),
319
+ note: (text) => log(`Video: ${text}`),
320
+ closed: (reason) => {
321
+ ch = null;
322
+ log(`Video: ${reason}`);
323
+ res.end();
324
+ }
325
+ }, { persistent: true });
326
+ req.on('close', () => ch !== null && session.close(ch));
327
+ });
328
+ let udp = null,
329
+ udpCh = null;
330
+ if (udpPort){
331
+ udp = dgram.createSocket(target.includes(':') ? 'udp6' : 'udp4');
332
+ udp.on('error', (err) => log(`Video (UDP): ${err.message}`));
333
+ udpCh = session.open('video', 1, {
334
+ data: (buf) => udp.send(buf, udpPort, target),
335
+ note: (text) => log(`Video: ${text}`),
336
+ closed: (reason) => log(`Video (UDP): ${reason}`)
337
+ }, { persistent: true });
338
+ }
339
+ return new Promise((resolve, reject) => {
340
+ server.once('error', reject);
341
+ server.listen(port, host, () => resolve({
342
+ server,
343
+ url: `http://${target.includes(':') ? `[${target}]` : target}:${server.address().port}${videoPath}`,
344
+ udp: udpPort ? `udp://${target}:${udpPort}` : null,
345
+ close: () => {
346
+ server.close();
347
+ server.closeAllConnections?.();
348
+ if (udpCh !== null){
349
+ session.close(udpCh);
350
+ }
351
+ udp?.close();
352
+ }
353
+ }));
354
+ });
355
+ }
356
+
357
+ module.exports = { RemoteSession, listenLocal, serveVideo };