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/LICENSE +674 -0
- package/README.md +218 -2
- package/package.json +40 -4
- package/src/index.js +16 -0
- package/src/laptimer.js +395 -0
- package/src/types.js +276 -0
- package/types/index.d.ts +13 -0
- package/types/laptimer.d.ts +253 -0
- package/types/types.d.ts +368 -0
package/README.md
CHANGED
|
@@ -1,3 +1,219 @@
|
|
|
1
|
-
#
|
|
1
|
+
# fpv-laptimer-client (JavaScript)
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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 */
|
package/src/laptimer.js
ADDED
|
@@ -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
|
+
}
|