fpv-laptimer-client 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,219 @@
1
- # Temporary Holding Version
1
+ # fpv-laptimer-client (JavaScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Client für die API des [FPV-Laptimers](https://github.com/bli-blu-bla/fpv-laptimer) (`/api/v1`,
4
+ ausführlich in der [Doku](https://bli-blu-bla.github.io/fpv-laptimer/api/)):
5
+ jeder Befehl als Methode, Events live über den WebSocket, mit automatischem Neuverbinden.
6
+
7
+ - läuft in **Node ≥ 22** und im **Browser** (nutzt das eingebaute `fetch` und `WebSocket`)
8
+ - **keine Abhängigkeiten**, reines JavaScript mit JSDoc-Typen (`// @ts-check`), der Editor
9
+ ergänzt also Befehle, Argumente und Event-Felder
10
+
11
+ ## Einbinden
12
+
13
+ ```sh
14
+ npm install fpv-laptimer-client
15
+ # oder aus dem Repo: npm install github:bli-blu-bla/fpv-laptimer-client-js
16
+ ```
17
+
18
+ ```js
19
+ import { Laptimer, LaptimerError } from 'fpv-laptimer-client'
20
+ // oder ohne npm, mit dem Pfad zum Klon: import { Laptimer } from './fpv-laptimer-client-js/src/index.js'
21
+
22
+ const lt = new Laptimer({ url: 'http://pi:5800', key: 'lt_…' })
23
+ ```
24
+
25
+ Den Schlüssel legst du auf der Seite unter *API & Hooks → Schlüssel* an.
26
+
27
+ Optionen: `url`, `key`, `transport` (`'auto'` oder `'http'`, siehe unten), `timeout_ms`
28
+ (pro Befehl, Standard 15 000), `max_backoff_ms` (längste Pause beim Neuverbinden, Standard
29
+ 10 000).
30
+
31
+ ## Beispiele
32
+
33
+ **Rennen über 3 Runden starten:**
34
+
35
+ ```js
36
+ await lt.channels.assign(['NITRO', 'ACE', null, 'BOB'], { create: true })
37
+ const session = await lt.session.start({ race: true, laps: 3, countdown_s: 5 })
38
+ console.log('Rennen', session.id, session.phase) // "countdown"
39
+ ```
40
+
41
+ **Jede Runde ansagen** (hier: ausgeben):
42
+
43
+ ```js
44
+ lt.on('pilot_pass', (d) => {
45
+ if (d.kind === 'lap') console.log(`${d.pilot}: Runde ${d.lap} in ${d.lap_time_s?.toFixed(2)} s, Platz ${d.position}`)
46
+ })
47
+ lt.on('pilot_finished', (d) => console.log(`${d.pilot} im Ziel, Platz ${d.position}`))
48
+ await lt.connect()
49
+ ```
50
+
51
+ **Ergebnis bestätigen**, sobald das Rennen vorbei ist (alle im Ziel oder gestoppt):
52
+
53
+ ```js
54
+ lt.on('session_stopped', async (d) => {
55
+ if (!d.needs_confirmation) return
56
+ console.log('Sieger:', d.winner)
57
+ await lt.session.confirm(d.session_id)
58
+ })
59
+ ```
60
+
61
+ Oder der Reihe nach mit `waitFor`:
62
+
63
+ ```js
64
+ const stopped = await lt.waitFor('session_stopped', { filter: (d) => d.session_id === session.id, timeout_ms: 30 * 60_000 })
65
+ await lt.session.confirm(stopped.session_id)
66
+ ```
67
+
68
+ **Fehler** kommen als `LaptimerError` mit der Meldung des Servers (deutsch, zum direkten
69
+ Anzeigen):
70
+
71
+ ```js
72
+ try {
73
+ await lt.session.start()
74
+ } catch (e) {
75
+ if (e instanceof LaptimerError) console.log(e.message, e.status) // "Es läuft schon eine Session – erst stoppen." 400
76
+ else throw e
77
+ }
78
+ ```
79
+
80
+ `status`: HTTP-Status (400 falsche Argumente oder nicht erlaubt, 401 Schlüssel fehlt oder ist
81
+ falsch, 404 unbekannter Befehl; über den WebSocket dieselben Zahlen); `null`, wenn der Server
82
+ nicht erreichbar ist. `command`: der Befehl.
83
+
84
+ Zum Ausprobieren in [`examples/`](examples/):
85
+
86
+ ```sh
87
+ LAPTIMER_URL=http://pi:5800 LAPTIMER_KEY=lt_… node examples/events.js # alle Events anzeigen
88
+ LAPTIMER_URL=http://pi:5800 LAPTIMER_KEY=lt_… node examples/race.js NITRO ACE # Rennen, Runden, bestätigen
89
+ ```
90
+
91
+ ## Methoden
92
+
93
+ Alle geben ein Promise zurück. Kanäle zählen ab 0 (Slot 1 auf der Seite = Kanal 0), Zeiten
94
+ sind µs seit 1970.
95
+
96
+ | Methode | Befehl | Ergebnis |
97
+ |---|---|---|
98
+ | `ping()` | `ping` | `{pong, now_us}` |
99
+ | `state()` | `state` | Gesamtzustand: `session`, `unconfirmed`, `channels`, `pilots`, `boards` |
100
+ | `stats()` | `stats` | Bestzeiten, Rennen, Siege pro Pilot (`all`, `today`) |
101
+ | `channels.list()` | `channels.list` | Kanäle |
102
+ | `channels.set(index, {pilot, pilot_id, create, label, enabled, frequency})` | `channels.set` | der Kanal; nur Angegebenes ändert sich |
103
+ | `channels.assign(pilots, {create})` | `channels.assign` | alle Kanäle; `pilots` = Callsign, ID oder `null` pro Kanal |
104
+ | `pilots.list()` | `pilots.list` | Piloten |
105
+ | `pilots.get(id)` | `pilots.get` | Pilot |
106
+ | `pilots.create(name, full_name?)` | `pilots.create` | Pilot |
107
+ | `pilots.update(id, {name, full_name})` | `pilots.update` | Pilot |
108
+ | `pilots.delete(id)` | `pilots.delete` | `{}` |
109
+ | `session.start({race, laps, seconds, countdown_s, start_mode})` | `session.start` | die Session; ohne Argumente ein Training |
110
+ | `session.stop()` | `session.stop` | `{session_id}` |
111
+ | `session.cancel()` | `session.cancel` | stoppt und löscht die laufende Session |
112
+ | `session.confirm(id?)` | `session.confirm` | `{session_id}`; ohne `id` das Rennen, das auf Bestätigung wartet |
113
+ | `session.discard(id?)` | `session.discard` | löscht eine beendete Session |
114
+ | `sessions.list({limit})` | `sessions.list` | die letzten Sessions (Standard 20) |
115
+ | `sessions.get(id)` | `sessions.get` | Session mit allen Durchflügen (`passes`) |
116
+ | `passes.add(channel, {time_us, session_id})` | `passes.add` | `{pass_id, session_id, time_us}`; Standard: jetzt, laufende Session |
117
+ | `passes.delete(id)` | `passes.delete` | erkannte werden nur markiert, von Hand eingefügte entfernt |
118
+ | `passes.restore(id)` | `passes.restore` | einen markierten wiederherstellen |
119
+ | `command(name, args)` | jeder | für Befehle, die neuer sind als dieser Client |
120
+
121
+ Was die Felder bedeuten, steht in der Doku unter [Events](https://bli-blu-bla.github.io/fpv-laptimer/api/events/) und [Datenformate](https://bli-blu-bla.github.io/fpv-laptimer/api/datenformate/); die Typen in
122
+ [`src/types.js`](src/types.js).
123
+
124
+ ## Events
125
+
126
+ ```js
127
+ const off = lt.on('pilot_pass', (data, msg) => { … }) // msg: {event, seq, time_us, data}
128
+ lt.on('*', (data, msg) => console.log(msg.event, data)) // jedes Event
129
+ off() // oder lt.off('pilot_pass', fn)
130
+ await lt.connect() // liefert das hello mit state
131
+ lt.close()
132
+ ```
133
+
134
+ Events des Servers: `session_started`, `countdown`, `start_signal`, `pilot_pass`,
135
+ `pilot_finished`, `session_stopped`, `race_confirmed`, `session_deleted`, `sessions_reset`, `passes_changed`,
136
+ `channels_changed`, `pilot_added`, `pilot_updated`, `pilot_deleted`.
137
+
138
+ Dazu drei vom Client selbst:
139
+
140
+ | Name | wann |
141
+ |---|---|
142
+ | `hello` | verbunden (auch nach jedem Neuverbinden), `data.state` ist der aktuelle Zustand |
143
+ | `disconnected` | Verbindung weg; der Client verbindet sich wieder |
144
+ | `lagged` | der Client war zu langsam, `data.missed` Events fehlen: `state()` neu holen |
145
+
146
+ `waitFor(name, {filter, timeout_ms})` wartet auf das nächste passende Event und liefert seine
147
+ Daten (Standard-Zeitlimit 30 s, dann ein Fehler).
148
+
149
+ **Neuverbinden:** Bricht die Verbindung ab (Pi neu gestartet, WLAN weg), verbindet sich der
150
+ Client von selbst wieder, mit wachsenden Pausen (0,5 s, 1 s, 2 s … bis `max_backoff_ms`), bis
151
+ `close()`. Events, die in der Pause passiert sind, kommen nicht nach: nach `hello` den Zustand
152
+ aus `data.state` nehmen. Klappt schon der **erste** `connect()` nicht (falsche Adresse,
153
+ falscher Schlüssel), lehnt das Promise mit der Meldung des Servers ab, und es wird nichts
154
+ wiederholt.
155
+
156
+ ## HTTP oder WebSocket
157
+
158
+ Befehle gehen per HTTP (`POST /api/v1/cmd/<befehl>`, Schlüssel im Header
159
+ `Authorization: Bearer`). Solange der WebSocket verbunden ist, gehen sie über ihn (kein neuer
160
+ Verbindungsaufbau pro Befehl). Mit `transport: 'http'` bleiben sie immer bei HTTP. Bricht der
161
+ WebSocket mitten in einem Befehl ab, schlägt dieser mit einem `LaptimerError` fehl; er wird
162
+ nicht automatisch wiederholt (er könnte schon ausgeführt sein).
163
+
164
+ Der WebSocket bekommt den Schlüssel als `?key=…`, weil Browser bei WebSockets keine Header
165
+ setzen können. Im Browser gilt außerdem: eine Seite über `https://` darf keine Verbindung zu
166
+ `http://pi:5800` aufbauen; der Laptimer selbst läuft ohne HTTPS.
167
+
168
+ ## Tests
169
+
170
+ Gegen einen laufenden Server ohne Boards, am besten mit eigenem Datenordner (die Tests legen
171
+ Piloten an, starten Trainings und Rennen und ändern das gespeicherte Rennformat):
172
+
173
+ ```sh
174
+ # im Laptimer-Repo:
175
+ cd server && ./target/debug/laptimer serve --port 5801 --data /tmp/lt-test --sounds /tmp/leer
176
+ curl -s -H 'content-type: application/json' -d '{"name":"test"}' http://localhost:5801/api/keys # {"key": "lt_…"}
177
+ # hier:
178
+ LAPTIMER_URL=http://localhost:5801 LAPTIMER_KEY=lt_… npm test
179
+ ```
180
+
181
+ Ohne die beiden Variablen werden die Tests übersprungen. Typprüfung: `npm run check` (lädt
182
+ TypeScript nur als Prüfer).
183
+
184
+ ## Veröffentlichen (npm)
185
+
186
+ Die Version kommt aus dem Git-Tag und ist unabhängig von Server und Python-Client. Im Repo
187
+ steht in `package.json` nur der Platzhalter `0.0.0-dev`; der Workflow schreibt vor dem Packen
188
+ die echte Version hinein. Ablauf:
189
+
190
+ 1. Tag `v<version>` pushen, z. B. `git tag v0.2.0 && git push origin v0.2.0`
191
+ (SemVer, Vorabversionen wie `0.2.0-rc.1`).
192
+ 2. Der Workflow `.github/workflows/npm.yml` setzt die Version, prüft die Typen, packt das Paket
193
+ (`prepack` erzeugt dabei `types/*.d.ts` aus dem JSDoc, damit auch TypeScript-Projekte die
194
+ Typen bekommen), installiert es testweise und lädt es per Trusted Publishing (ohne Token,
195
+ mit Herkunftsnachweis) auf npm. Vorabversionen bekommen das Dist-Tag `next`, damit
196
+ `npm install` weiter die letzte richtige Version holt.
197
+
198
+ Von Hand starten (*Actions → npm → Run workflow*) packt und prüft nur
199
+ (`npm publish --dry-run`); npm hat kein Test-Register wie TestPyPI.
200
+
201
+ Einmal einrichten: npm bietet Trusted Publishing nur für ein Paket an, das es schon gibt. Die
202
+ erste Version deshalb von Hand:
203
+
204
+ ```sh
205
+ npm login
206
+ npm version 0.1.0 --no-git-tag-version
207
+ npm publish --access public
208
+ git checkout package.json # zurück auf den Platzhalter
209
+ ```
210
+
211
+ Diese erste Version bekommt keinen Tag (der Workflow würde sie noch einmal hochladen wollen und
212
+ scheitern); die nächste kommt dann über `v0.2.0`.
213
+
214
+ Danach auf npmjs.com unter *Package → Settings → Trusted Publisher* GitHub Actions wählen:
215
+ Owner `bli-blu-bla`, Repo `fpv-laptimer-client-js`, Workflow `npm.yml`, Environment `npm`; im
216
+ GitHub-Repo unter *Settings → Environments* das Environment `npm` anlegen (am besten mit
217
+ Freigabe durch dich). Ab dann gehen alle Versionen über den Tag.
218
+
219
+ Lokal packen und ansehen: `npm pack --dry-run` (bzw. `npm pack` für die Datei).
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "fpv-laptimer-client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "JavaScript-Client für die API des FPV-Laptimers (/api/v1): Befehle per HTTP oder WebSocket, Events live",
5
+ "keywords": [
6
+ "fpv",
7
+ "drone",
8
+ "racing",
9
+ "laptimer",
10
+ "lap timer"
11
+ ],
12
+ "type": "module",
13
+ "main": "./src/index.js",
14
+ "types": "./types/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./types/index.d.ts",
18
+ "default": "./src/index.js"
19
+ }
20
+ },
21
+ "files": [
22
+ "src",
23
+ "types"
24
+ ],
25
+ "engines": {
26
+ "node": ">=22"
27
+ },
28
+ "scripts": {
29
+ "test": "node --test",
30
+ "check": "npx -y -p typescript@5.9.3 tsc -p jsconfig.json",
31
+ "types": "npx -y -p typescript@5.9.3 tsc -p jsconfig.json --noEmit false --declaration --emitDeclarationOnly --outDir types",
32
+ "prepack": "npm run types"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/bli-blu-bla/fpv-laptimer-client-js.git"
37
+ },
38
+ "homepage": "https://bli-blu-bla.github.io/fpv-laptimer/api/clients/",
39
+ "bugs": "https://github.com/bli-blu-bla/fpv-laptimer-client-js/issues",
40
+ "author": "bli-blu-bla",
41
+ "license": "GPL-3.0-or-later"
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+ // fpv-laptimer-client: everything in one import.
3
+ export { Laptimer, LaptimerError } from './laptimer.js'
4
+
5
+ /** @typedef {import('./types.js').Channel} Channel */
6
+ /** @typedef {import('./types.js').ChannelChange} ChannelChange */
7
+ /** @typedef {import('./types.js').Pilot} Pilot */
8
+ /** @typedef {import('./types.js').Session} Session */
9
+ /** @typedef {import('./types.js').SeatResult} SeatResult */
10
+ /** @typedef {import('./types.js').Pass} Pass */
11
+ /** @typedef {import('./types.js').State} State */
12
+ /** @typedef {import('./types.js').Stats} Stats */
13
+ /** @typedef {import('./types.js').StartOptions} StartOptions */
14
+ /** @typedef {import('./types.js').EventMap} EventMap */
15
+ /** @typedef {import('./types.js').PilotPassData} PilotPassData */
16
+ /** @typedef {import('./types.js').SessionStoppedData} SessionStoppedData */
@@ -0,0 +1,395 @@
1
+ // @ts-check
2
+ // Client for the lap timer's public API (/api/v1, https://bli-blu-bla.github.io/fpv-laptimer/api/).
3
+ // No dependencies: global fetch and WebSocket (Node >= 22, browsers).
4
+
5
+ /** @import { Channel, ChannelChange, EventMap, EventMessage, Hello, Pilot, Session, StartOptions, State, Stats } from './types.js' */
6
+
7
+ /**
8
+ * An error from the server (its message is German, meant to be shown as is)
9
+ * or from the connection.
10
+ */
11
+ export class LaptimerError extends Error {
12
+ /**
13
+ * @param {string} message
14
+ * @param {object} [info]
15
+ * @param {number | null} [info.status] HTTP status (400 bad arguments, 401 key, 404 unknown command), the same over the WebSocket; null without an answer
16
+ * @param {string} [info.command]
17
+ * @param {unknown} [info.cause]
18
+ */
19
+ constructor(message, { status = null, command, cause } = {}) {
20
+ super(message, { cause })
21
+ this.name = 'LaptimerError'
22
+ /** @type {number | null} */
23
+ this.status = status
24
+ /** @type {string | undefined} */
25
+ this.command = command
26
+ }
27
+ }
28
+
29
+ /**
30
+ * What `on` can listen to: the server's events, `'*'` (every event) and
31
+ * three of the client's own: `hello` (connected, with the state),
32
+ * `lagged` (events were lost: fetch the state again) and `disconnected`.
33
+ * @typedef {EventMap & {
34
+ * '*': any,
35
+ * hello: Hello,
36
+ * lagged: {missed: number},
37
+ * disconnected: {code: number, reason: string},
38
+ * }} ClientEvents
39
+ */
40
+
41
+ /**
42
+ * @template {string} K
43
+ * @typedef {(data: K extends keyof ClientEvents ? ClientEvents[K] : any, msg: EventMessage<any> | any) => void} Listener
44
+ */
45
+
46
+ /**
47
+ * @typedef {object} LaptimerOptions
48
+ * @property {string} url e.g. "http://pi:5800"
49
+ * @property {string} key API key "lt_…"
50
+ * @property {'auto' | 'http'} [transport] auto (default): commands over the WebSocket while it is connected, else HTTP
51
+ * @property {number} [timeout_ms] per command, default 15000
52
+ * @property {number} [max_backoff_ms] longest wait between reconnects, default 10000
53
+ */
54
+
55
+ export class Laptimer {
56
+ /** @type {WebSocket | null} */
57
+ #ws = null
58
+ /** true between connect() and close(): reconnect when the socket drops. */
59
+ #wanted = false
60
+ /** The socket got its `hello` (commands may go over it). */
61
+ #ready = false
62
+ #attempt = 0
63
+ /** @type {ReturnType<typeof setTimeout> | null} */
64
+ #retryTimer = null
65
+ /** @type {Promise<Hello> | null} */
66
+ #connecting = null
67
+ #nextId = 1
68
+ /** Commands sent over the WebSocket, waiting for their reply (by id). @type {Map<number, {resolve: (v: any) => void, reject: (e: Error) => void, timer: ReturnType<typeof setTimeout>, cmd: string}>} */
69
+ #pending = new Map()
70
+ /** @type {Map<string, Set<Function>>} */
71
+ #listeners = new Map()
72
+
73
+ /** @param {LaptimerOptions} options */
74
+ constructor({ url, key, transport = 'auto', timeout_ms = 15000, max_backoff_ms = 10000 }) {
75
+ if (!url) throw new TypeError("Laptimer: 'url' fehlt, z. B. http://pi:5800")
76
+ if (!key) throw new TypeError("Laptimer: 'key' fehlt (API-Schlüssel lt_…)")
77
+ this.url = url.replace(/\/+$/, '')
78
+ this.key = key
79
+ this.transport = transport
80
+ this.timeout_ms = timeout_ms
81
+ this.max_backoff_ms = max_backoff_ms
82
+
83
+ // The commands by group, as in the API (channels.set → client.channels.set).
84
+ this.channels = {
85
+ /** @returns {Promise<Channel[]>} */
86
+ list: () => this.command('channels.list'),
87
+ /**
88
+ * One channel; only the given fields change. A pilot or frequency another
89
+ * channel has is swapped with it.
90
+ * @param {number} index 0 = first channel
91
+ * @param {ChannelChange} change
92
+ * @returns {Promise<Channel>}
93
+ */
94
+ set: (index, change) => this.command('channels.set', { ...change, index }),
95
+ /**
96
+ * All channels at once, in order: callsign, pilot id or null per channel.
97
+ * @param {(string | number | null)[]} pilots
98
+ * @param {{create?: boolean}} [options] create: make unknown callsigns pilots
99
+ * @returns {Promise<Channel[]>}
100
+ */
101
+ assign: (pilots, { create } = {}) => this.command('channels.assign', { pilots, create }),
102
+ }
103
+
104
+ this.pilots = {
105
+ /** @returns {Promise<Pilot[]>} */
106
+ list: () => this.command('pilots.list'),
107
+ /** @param {number} id @returns {Promise<Pilot>} */
108
+ get: (id) => this.command('pilots.get', { id }),
109
+ /** @param {string} name callsign @param {string} [full_name] @returns {Promise<Pilot>} */
110
+ create: (name, full_name) => this.command('pilots.create', { name, full_name }),
111
+ /** @param {number} id @param {{name?: string, full_name?: string}} change @returns {Promise<Pilot>} */
112
+ update: (id, change) => this.command('pilots.update', { ...change, id }),
113
+ /** @param {number} id @returns {Promise<{}>} */
114
+ delete: (id) => this.command('pilots.delete', { id }),
115
+ }
116
+
117
+ this.session = {
118
+ /**
119
+ * Starts a training (default) or a race. Refused while a session runs or
120
+ * a race waits for confirmation.
121
+ * @param {StartOptions} [options]
122
+ * @returns {Promise<Session>}
123
+ */
124
+ start: (options = {}) => this.command('session.start', options),
125
+ /** @returns {Promise<{session_id: number}>} */
126
+ stop: () => this.command('session.stop'),
127
+ /** Stops and deletes the running session. @returns {Promise<{session_id: number}>} */
128
+ cancel: () => this.command('session.cancel'),
129
+ /** @param {number} [id] default: the race waiting for confirmation @returns {Promise<{session_id: number}>} */
130
+ confirm: (id) => this.command('session.confirm', { id }),
131
+ /** Deletes an ended session. @param {number} [id] default: the race waiting for confirmation @returns {Promise<{session_id: number}>} */
132
+ discard: (id) => this.command('session.discard', { id }),
133
+ }
134
+
135
+ this.sessions = {
136
+ /** @param {{limit?: number}} [options] default 20 @returns {Promise<Session[]>} */
137
+ list: ({ limit } = {}) => this.command('sessions.list', { limit }),
138
+ /** With every pass (`passes`). @param {number} id @returns {Promise<Session>} */
139
+ get: (id) => this.command('sessions.get', { id }),
140
+ }
141
+
142
+ this.passes = {
143
+ /**
144
+ * A pass by hand: counts like a detected one (announcement, `pilot_pass`).
145
+ * @param {number} channel index
146
+ * @param {{time_us?: number, session_id?: number}} [options] default: now, the running session
147
+ * @returns {Promise<{pass_id: number, session_id: number, time_us: number}>}
148
+ */
149
+ add: (channel, { time_us, session_id } = {}) => this.command('passes.add', { channel, time_us, session_id }),
150
+ /** Detected passes are only marked (restorable). @param {number} id @returns {Promise<{session_id: number}>} */
151
+ delete: (id) => this.command('passes.delete', { id }),
152
+ /** @param {number} id @returns {Promise<{session_id: number}>} */
153
+ restore: (id) => this.command('passes.restore', { id }),
154
+ }
155
+ }
156
+
157
+ /** @returns {Promise<{pong: true, now_us: number}>} */
158
+ ping() {
159
+ return this.command('ping')
160
+ }
161
+
162
+ /** @returns {Promise<State>} */
163
+ state() {
164
+ return this.command('state')
165
+ }
166
+
167
+ /** @returns {Promise<Stats>} */
168
+ stats() {
169
+ return this.command('stats')
170
+ }
171
+
172
+ /** True while the WebSocket is connected (and commands go over it with transport "auto"). */
173
+ get connected() {
174
+ return this.#ready
175
+ }
176
+
177
+ // --- commands ---
178
+
179
+ /**
180
+ * Any command by name, e.g. `command('session.start', {race: true})`.
181
+ * Arguments that are `undefined` are left out.
182
+ * @param {string} name
183
+ * @param {Record<string, unknown>} [args]
184
+ * @returns {Promise<any>}
185
+ */
186
+ command(name, args = {}) {
187
+ // JSON.stringify drops undefined fields; the server treats missing as default.
188
+ const clean = JSON.parse(JSON.stringify(args))
189
+ if (this.transport === 'auto' && this.#ready) return this.#wsCommand(name, clean)
190
+ return this.#httpCommand(name, clean)
191
+ }
192
+
193
+ /** `POST /api/v1/cmd/<name>`: one route for every command (same as the REST routes). @param {string} name @param {object} args */
194
+ async #httpCommand(name, args) {
195
+ let res
196
+ try {
197
+ res = await fetch(`${this.url}/api/v1/cmd/${encodeURIComponent(name)}`, {
198
+ method: 'POST',
199
+ headers: { authorization: `Bearer ${this.key}`, 'content-type': 'application/json' },
200
+ body: JSON.stringify(args),
201
+ signal: AbortSignal.timeout(this.timeout_ms),
202
+ })
203
+ } catch (e) {
204
+ throw new LaptimerError(`Laptimer nicht erreichbar (${this.url}): ${e instanceof Error ? e.message : e}`, { command: name, cause: e })
205
+ }
206
+ const text = await res.text()
207
+ /** @type {any} */
208
+ let body = null
209
+ try {
210
+ body = text ? JSON.parse(text) : null
211
+ } catch {
212
+ // not JSON (e.g. a proxy's error page): the text is the message
213
+ }
214
+ if (!res.ok) {
215
+ const message = body?.error ?? (text || `HTTP ${res.status}`)
216
+ throw new LaptimerError(message, { status: res.status, command: name })
217
+ }
218
+ return body
219
+ }
220
+
221
+ /** @param {string} name @param {object} args @returns {Promise<any>} */
222
+ #wsCommand(name, args) {
223
+ const id = this.#nextId++
224
+ return new Promise((resolve, reject) => {
225
+ const timer = setTimeout(() => {
226
+ this.#pending.delete(id)
227
+ reject(new LaptimerError(`Keine Antwort auf '${name}' nach ${this.timeout_ms} ms.`, { command: name }))
228
+ }, this.timeout_ms)
229
+ this.#pending.set(id, { resolve, reject, timer, cmd: name })
230
+ this.#ws?.send(JSON.stringify({ id, cmd: name, args }))
231
+ })
232
+ }
233
+
234
+ // --- events ---
235
+
236
+ /**
237
+ * Listens to an event (`pilot_pass`, …), to `'*'` (every server event) or
238
+ * to `hello`, `lagged`, `disconnected`. The listener gets `data` and the
239
+ * whole message. Returns a function that removes the listener.
240
+ * @template {string} K
241
+ * @param {K} name
242
+ * @param {Listener<K>} fn
243
+ * @returns {() => void}
244
+ */
245
+ on(name, fn) {
246
+ let set = this.#listeners.get(name)
247
+ if (!set) this.#listeners.set(name, (set = new Set()))
248
+ set.add(fn)
249
+ return () => this.off(name, fn)
250
+ }
251
+
252
+ /** @template {string} K @param {K} name @param {Listener<K>} fn */
253
+ off(name, fn) {
254
+ this.#listeners.get(name)?.delete(fn)
255
+ }
256
+
257
+ /**
258
+ * Waits for the next event `name` for which `filter` is true (or for any,
259
+ * without a filter); resolves with its data. Rejects after `timeout_ms`.
260
+ * Needs a connected WebSocket (`connect()`).
261
+ * @template {string} K
262
+ * @param {K} name
263
+ * @param {{filter?: (data: K extends keyof ClientEvents ? ClientEvents[K] : any) => boolean, timeout_ms?: number}} [options]
264
+ * @returns {Promise<K extends keyof ClientEvents ? ClientEvents[K] : any>}
265
+ */
266
+ waitFor(name, { filter, timeout_ms = 30000 } = {}) {
267
+ return new Promise((resolve, reject) => {
268
+ const timer = setTimeout(() => {
269
+ off()
270
+ reject(new LaptimerError(`Event '${name}' kam nicht innerhalb von ${timeout_ms} ms.`))
271
+ }, timeout_ms)
272
+ const off = this.on(name, (data) => {
273
+ if (filter && !filter(data)) return
274
+ clearTimeout(timer)
275
+ off()
276
+ resolve(data)
277
+ })
278
+ })
279
+ }
280
+
281
+ /** @param {string} name @param {unknown} data @param {unknown} msg */
282
+ #emit(name, data, msg) {
283
+ for (const fn of [...(this.#listeners.get(name) ?? [])]) {
284
+ try {
285
+ fn(data, msg)
286
+ } catch (e) {
287
+ // One broken listener must not stop the others or the connection.
288
+ console.error(`laptimer: Fehler im Listener für '${name}':`, e)
289
+ }
290
+ }
291
+ }
292
+
293
+ // --- WebSocket ---
294
+
295
+ /**
296
+ * Opens the WebSocket; resolves with the `hello` (contains `state`).
297
+ * Once connected, a dropped connection is opened again by itself (waiting
298
+ * 0.5 s, 1 s, 2 s, … up to `max_backoff_ms`) until `close()`. If the very
299
+ * first attempt fails (wrong address or key), the promise rejects and
300
+ * nothing is retried.
301
+ * @returns {Promise<Hello>}
302
+ */
303
+ connect() {
304
+ if (this.#connecting) return this.#connecting
305
+ this.#wanted = true
306
+ this.#connecting = this.#open(true)
307
+ return this.#connecting
308
+ }
309
+
310
+ /** Closes the WebSocket for good (no reconnect). Commands go over HTTP again. */
311
+ close() {
312
+ this.#wanted = false
313
+ this.#connecting = null
314
+ if (this.#retryTimer) clearTimeout(this.#retryTimer)
315
+ this.#retryTimer = null
316
+ this.#ws?.close()
317
+ }
318
+
319
+ /** @param {boolean} first the first attempt of connect() @returns {Promise<Hello>} */
320
+ #open(first) {
321
+ return new Promise((resolve, reject) => {
322
+ // Browsers can't set headers on a WebSocket: the key goes into the query.
323
+ const wsUrl = this.url.replace(/^http/, 'ws') + `/api/v1/ws?key=${encodeURIComponent(this.key)}`
324
+ const ws = new WebSocket(wsUrl)
325
+ this.#ws = ws
326
+ let gotHello = false
327
+
328
+ ws.addEventListener('message', (ev) => {
329
+ /** @type {any} */
330
+ let msg
331
+ try {
332
+ msg = JSON.parse(String(ev.data))
333
+ } catch {
334
+ return
335
+ }
336
+ if (msg.type === 'hello') {
337
+ gotHello = true
338
+ this.#ready = true
339
+ this.#attempt = 0
340
+ resolve(msg)
341
+ this.#emit('hello', msg, msg)
342
+ } else if (msg.type === 'event') {
343
+ this.#emit(msg.event, msg.data, msg)
344
+ this.#emit('*', msg.data, msg)
345
+ } else if (msg.type === 'reply') {
346
+ const p = this.#pending.get(msg.id)
347
+ if (!p) return
348
+ this.#pending.delete(msg.id)
349
+ clearTimeout(p.timer)
350
+ if (msg.ok) p.resolve(msg.data)
351
+ else p.reject(new LaptimerError(msg.error, { status: msg.status ?? null, command: p.cmd }))
352
+ } else if (msg.type === 'lagged') {
353
+ this.#emit('lagged', { missed: msg.missed }, msg)
354
+ }
355
+ })
356
+
357
+ ws.addEventListener('close', (ev) => {
358
+ if (this.#ws !== ws) return // an old socket
359
+ this.#ws = null
360
+ this.#ready = false
361
+ for (const [id, p] of this.#pending) {
362
+ clearTimeout(p.timer)
363
+ p.reject(new LaptimerError(`Verbindung getrennt, bevor '${p.cmd}' beantwortet wurde.`, { command: p.cmd }))
364
+ this.#pending.delete(id)
365
+ }
366
+ if (gotHello) this.#emit('disconnected', { code: ev.code, reason: ev.reason }, null)
367
+ if (!gotHello && first) {
368
+ // Never connected: no retry. Ask over HTTP why (401 says "key"), so
369
+ // the error is the server's own message.
370
+ this.#wanted = false
371
+ this.#connecting = null
372
+ this.ping().then(
373
+ () => reject(new LaptimerError(`WebSocket ${wsUrl.replace(/key=[^&]*/, 'key=…')} ließ sich nicht öffnen.`)),
374
+ (e) => reject(e),
375
+ )
376
+ return
377
+ }
378
+ if (this.#wanted) this.#scheduleReconnect()
379
+ })
380
+ // 'error' is always followed by 'close', which handles it.
381
+ ws.addEventListener('error', () => {})
382
+ })
383
+ }
384
+
385
+ #scheduleReconnect() {
386
+ const delay = Math.min(500 * 2 ** this.#attempt, this.max_backoff_ms)
387
+ this.#attempt++
388
+ this.#retryTimer = setTimeout(() => {
389
+ this.#retryTimer = null
390
+ if (!this.#wanted) return
391
+ // Failed attempts end in 'close' again and come back here.
392
+ this.#open(false).catch(() => {})
393
+ }, delay)
394
+ }
395
+ }