gameglue 4.0.0 → 4.0.2
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 +21 -21
- package/README.md +275 -275
- package/babel.config.cjs +5 -5
- package/coverage/auth.js.html +525 -525
- package/coverage/base.css +224 -224
- package/coverage/block-navigation.js +87 -87
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +175 -175
- package/coverage/index.js.html +309 -309
- package/coverage/lcov-report/auth.js.html +525 -525
- package/coverage/lcov-report/base.css +224 -224
- package/coverage/lcov-report/block-navigation.js +87 -87
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +175 -175
- package/coverage/lcov-report/index.js.html +309 -309
- package/coverage/lcov-report/listener.js.html +528 -528
- package/coverage/lcov-report/prettify.css +1 -1
- package/coverage/lcov-report/prettify.js +2 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -210
- package/coverage/lcov-report/user.js.html +117 -117
- package/coverage/lcov-report/utils.js.html +117 -117
- package/coverage/lcov.info +391 -391
- package/coverage/listener.js.html +528 -528
- package/coverage/prettify.css +1 -1
- package/coverage/prettify.js +2 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -210
- package/coverage/user.js.html +117 -117
- package/coverage/utils.js.html +117 -117
- package/dist/gg.cjs.js +1 -1
- package/dist/gg.cjs.js.map +1 -1
- package/dist/gg.esm.js +1 -1
- package/dist/gg.esm.js.map +1 -1
- package/dist/gg.umd.js +1 -1
- package/dist/gg.umd.js.map +1 -1
- package/examples/certs/cert.pem +19 -19
- package/examples/certs/key.pem +28 -28
- package/examples/flight-dashboard.html +431 -431
- package/examples/server.js +99 -99
- package/examples/telemetry-validator.html +1410 -1410
- package/jest.config.cjs +33 -33
- package/package.json +56 -56
- package/rollup.config.js +57 -57
- package/src/auth.js +255 -255
- package/src/auth.spec.js +481 -481
- package/src/index.js +168 -168
- package/src/listener.js +196 -193
- package/src/listener.spec.js +598 -598
- package/src/presence_listener.js +112 -112
- package/src/test/fixtures.js +106 -106
- package/src/test/setup.js +51 -51
- package/src/utils.js +63 -63
- package/src/utils.spec.js +78 -78
- package/types/index.d.ts +338 -338
- package/webpack.config.js +15 -15
|
@@ -1,1410 +1,1410 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>GameGlue Telemetry Validator</title>
|
|
7
|
-
<style>
|
|
8
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
-
body {
|
|
10
|
-
font-family: 'SF Mono', 'Consolas', monospace;
|
|
11
|
-
background: #0a0a0a;
|
|
12
|
-
color: #e2e8f0;
|
|
13
|
-
min-height: 100vh;
|
|
14
|
-
padding: 1.5rem;
|
|
15
|
-
}
|
|
16
|
-
.container { max-width: 1400px; margin: 0 auto; }
|
|
17
|
-
h1 { font-size: 1.25rem; margin-bottom: 0.25rem; color: #00ff88; }
|
|
18
|
-
.subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.875rem; }
|
|
19
|
-
.card {
|
|
20
|
-
background: #111;
|
|
21
|
-
border: 1px solid #222;
|
|
22
|
-
border-radius: 0.5rem;
|
|
23
|
-
padding: 1rem;
|
|
24
|
-
margin-bottom: 1rem;
|
|
25
|
-
}
|
|
26
|
-
.card h2 {
|
|
27
|
-
font-size: 0.75rem;
|
|
28
|
-
color: #666;
|
|
29
|
-
text-transform: uppercase;
|
|
30
|
-
letter-spacing: 0.1em;
|
|
31
|
-
margin-bottom: 0.75rem;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/* Stats */
|
|
35
|
-
.stats-grid {
|
|
36
|
-
display: grid;
|
|
37
|
-
grid-template-columns: repeat(6, 1fr);
|
|
38
|
-
gap: 1rem;
|
|
39
|
-
margin-bottom: 1rem;
|
|
40
|
-
}
|
|
41
|
-
.stat-card {
|
|
42
|
-
background: #111;
|
|
43
|
-
border: 1px solid #222;
|
|
44
|
-
border-radius: 0.5rem;
|
|
45
|
-
padding: 1rem;
|
|
46
|
-
text-align: center;
|
|
47
|
-
}
|
|
48
|
-
.stat-value { font-size: 1.5rem; font-weight: bold; color: #fff; }
|
|
49
|
-
.stat-value.success { color: #00ff88; }
|
|
50
|
-
.stat-value.warning { color: #fbbf24; }
|
|
51
|
-
.stat-value.error { color: #ef4444; }
|
|
52
|
-
.stat-label { font-size: 0.7rem; color: #666; text-transform: uppercase; margin-top: 0.25rem; }
|
|
53
|
-
|
|
54
|
-
/* Form */
|
|
55
|
-
.form-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
|
|
56
|
-
.form-group { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
57
|
-
.form-group label { font-size: 0.7rem; color: #666; text-transform: uppercase; }
|
|
58
|
-
.form-group input, .form-group select {
|
|
59
|
-
background: #0a0a0a;
|
|
60
|
-
border: 1px solid #333;
|
|
61
|
-
color: #fff;
|
|
62
|
-
padding: 0.5rem 0.75rem;
|
|
63
|
-
border-radius: 0.25rem;
|
|
64
|
-
font-family: inherit;
|
|
65
|
-
font-size: 0.875rem;
|
|
66
|
-
}
|
|
67
|
-
.form-group input:focus, .form-group select:focus { outline: none; border-color: #00ff88; }
|
|
68
|
-
.btn {
|
|
69
|
-
padding: 0.5rem 1rem;
|
|
70
|
-
border: none;
|
|
71
|
-
border-radius: 0.25rem;
|
|
72
|
-
font-family: inherit;
|
|
73
|
-
font-size: 0.875rem;
|
|
74
|
-
cursor: pointer;
|
|
75
|
-
}
|
|
76
|
-
.btn-primary { background: #00ff88; color: #0a0a0a; }
|
|
77
|
-
.btn-secondary { background: #333; color: #fff; border: 1px solid #444; }
|
|
78
|
-
.btn-danger { background: #ef4444; color: #fff; }
|
|
79
|
-
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
80
|
-
.btn-group { display: flex; gap: 0.5rem; }
|
|
81
|
-
|
|
82
|
-
/* Status */
|
|
83
|
-
.status-badge {
|
|
84
|
-
display: inline-block;
|
|
85
|
-
padding: 0.25rem 0.75rem;
|
|
86
|
-
border-radius: 9999px;
|
|
87
|
-
font-size: 0.75rem;
|
|
88
|
-
text-transform: uppercase;
|
|
89
|
-
}
|
|
90
|
-
.status-badge.disconnected { background: #7f1d1d; color: #fca5a5; }
|
|
91
|
-
.status-badge.connected { background: #14532d; color: #86efac; }
|
|
92
|
-
.status-badge.listening { background: #1e3a5f; color: #93c5fd; }
|
|
93
|
-
|
|
94
|
-
/* Field Table */
|
|
95
|
-
.field-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
|
96
|
-
.field-table th {
|
|
97
|
-
text-align: left;
|
|
98
|
-
padding: 0.5rem;
|
|
99
|
-
border-bottom: 1px solid #333;
|
|
100
|
-
color: #666;
|
|
101
|
-
font-weight: normal;
|
|
102
|
-
text-transform: uppercase;
|
|
103
|
-
font-size: 0.7rem;
|
|
104
|
-
}
|
|
105
|
-
.field-table td { padding: 0.5rem; border-bottom: 1px solid #222; }
|
|
106
|
-
.field-table tr.ok { background: rgba(0, 255, 136, 0.03); }
|
|
107
|
-
.field-table tr.missing { background: rgba(239, 68, 68, 0.05); }
|
|
108
|
-
.field-table tr.warning { background: rgba(251, 191, 36, 0.05); }
|
|
109
|
-
.canonical { color: #00ff88; }
|
|
110
|
-
.raw { color: #888; font-size: 0.75rem; }
|
|
111
|
-
.arrow { color: #444; margin: 0 0.5rem; }
|
|
112
|
-
.required { color: #ef4444; margin-left: 0.25rem; }
|
|
113
|
-
.field-value { text-align: right; color: #fff; }
|
|
114
|
-
.field-unit { color: #666; margin-left: 0.25rem; }
|
|
115
|
-
.badge {
|
|
116
|
-
display: inline-block;
|
|
117
|
-
padding: 0.125rem 0.5rem;
|
|
118
|
-
border-radius: 0.25rem;
|
|
119
|
-
font-size: 0.65rem;
|
|
120
|
-
text-transform: uppercase;
|
|
121
|
-
border: 1px solid;
|
|
122
|
-
}
|
|
123
|
-
.badge.ok { border-color: #00ff88; color: #00ff88; }
|
|
124
|
-
.badge.missing { border-color: #ef4444; color: #ef4444; }
|
|
125
|
-
.badge.null { border-color: #fbbf24; color: #fbbf24; }
|
|
126
|
-
.badge.new { border-color: #3b82f6; color: #3b82f6; }
|
|
127
|
-
|
|
128
|
-
/* Category */
|
|
129
|
-
.category-header {
|
|
130
|
-
display: flex;
|
|
131
|
-
justify-content: space-between;
|
|
132
|
-
align-items: center;
|
|
133
|
-
padding: 0.75rem 0.5rem;
|
|
134
|
-
background: #0a0a0a;
|
|
135
|
-
margin: 1rem 0 0.5rem 0;
|
|
136
|
-
border-radius: 0.25rem;
|
|
137
|
-
}
|
|
138
|
-
.category-name { color: #fff; font-size: 0.875rem; }
|
|
139
|
-
.category-count { font-size: 0.75rem; }
|
|
140
|
-
.category-count.complete { color: #00ff88; }
|
|
141
|
-
.category-count.incomplete { color: #fbbf24; }
|
|
142
|
-
|
|
143
|
-
/* Unexpected */
|
|
144
|
-
.unexpected-fields { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
145
|
-
.unexpected-field {
|
|
146
|
-
padding: 0.25rem 0.5rem;
|
|
147
|
-
background: #1e3a5f;
|
|
148
|
-
border: 1px solid #3b82f6;
|
|
149
|
-
border-radius: 0.25rem;
|
|
150
|
-
font-size: 0.75rem;
|
|
151
|
-
color: #93c5fd;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/* Auth */
|
|
155
|
-
.auth-section { text-align: center; padding: 3rem; }
|
|
156
|
-
.auth-btn {
|
|
157
|
-
background: #00ff88;
|
|
158
|
-
color: #0a0a0a;
|
|
159
|
-
border: none;
|
|
160
|
-
padding: 0.75rem 2rem;
|
|
161
|
-
border-radius: 0.5rem;
|
|
162
|
-
font-size: 1rem;
|
|
163
|
-
font-weight: 600;
|
|
164
|
-
cursor: pointer;
|
|
165
|
-
font-family: inherit;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.hidden { display: none !important; }
|
|
169
|
-
|
|
170
|
-
/* Toast notification */
|
|
171
|
-
.toast {
|
|
172
|
-
position: fixed;
|
|
173
|
-
bottom: 2rem;
|
|
174
|
-
right: 2rem;
|
|
175
|
-
background: #14532d;
|
|
176
|
-
color: #86efac;
|
|
177
|
-
padding: 0.75rem 1.25rem;
|
|
178
|
-
border-radius: 0.5rem;
|
|
179
|
-
font-size: 0.875rem;
|
|
180
|
-
opacity: 0;
|
|
181
|
-
transform: translateY(1rem);
|
|
182
|
-
transition: all 0.3s ease;
|
|
183
|
-
z-index: 1000;
|
|
184
|
-
}
|
|
185
|
-
.toast.show {
|
|
186
|
-
opacity: 1;
|
|
187
|
-
transform: translateY(0);
|
|
188
|
-
}
|
|
189
|
-
.waiting { text-align: center; padding: 2rem; color: #666; }
|
|
190
|
-
.spinner {
|
|
191
|
-
display: inline-block;
|
|
192
|
-
width: 24px;
|
|
193
|
-
height: 24px;
|
|
194
|
-
border: 2px solid #333;
|
|
195
|
-
border-top-color: #00ff88;
|
|
196
|
-
border-radius: 50%;
|
|
197
|
-
animation: spin 1s linear infinite;
|
|
198
|
-
margin-bottom: 1rem;
|
|
199
|
-
}
|
|
200
|
-
@keyframes spin { to { transform: rotate(360deg); } }
|
|
201
|
-
|
|
202
|
-
.instructions {
|
|
203
|
-
background: #0a0a0a;
|
|
204
|
-
border: 1px solid #222;
|
|
205
|
-
border-radius: 0.5rem;
|
|
206
|
-
padding: 1rem;
|
|
207
|
-
margin-top: 1rem;
|
|
208
|
-
}
|
|
209
|
-
.instructions h3 {
|
|
210
|
-
font-size: 0.75rem;
|
|
211
|
-
color: #666;
|
|
212
|
-
text-transform: uppercase;
|
|
213
|
-
margin-bottom: 0.5rem;
|
|
214
|
-
}
|
|
215
|
-
.instructions ol { margin-left: 1.25rem; font-size: 0.8rem; color: #888; }
|
|
216
|
-
.instructions li { margin-bottom: 0.25rem; }
|
|
217
|
-
|
|
218
|
-
/* Key Events */
|
|
219
|
-
.key-events-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; }
|
|
220
|
-
.key-event {
|
|
221
|
-
display: flex;
|
|
222
|
-
align-items: flex-start;
|
|
223
|
-
gap: 0.75rem;
|
|
224
|
-
padding: 0.75rem;
|
|
225
|
-
background: #0a0a0a;
|
|
226
|
-
border: 1px solid #222;
|
|
227
|
-
border-radius: 0.375rem;
|
|
228
|
-
border-left: 3px solid;
|
|
229
|
-
}
|
|
230
|
-
.key-event.landing { border-left-color: #00ff88; }
|
|
231
|
-
.key-event.takeoff { border-left-color: #3b82f6; }
|
|
232
|
-
.key-event.flight_phase { border-left-color: #fbbf24; }
|
|
233
|
-
.key-event-icon {
|
|
234
|
-
width: 32px;
|
|
235
|
-
height: 32px;
|
|
236
|
-
display: flex;
|
|
237
|
-
align-items: center;
|
|
238
|
-
justify-content: center;
|
|
239
|
-
border-radius: 0.25rem;
|
|
240
|
-
font-size: 1rem;
|
|
241
|
-
flex-shrink: 0;
|
|
242
|
-
}
|
|
243
|
-
.key-event.landing .key-event-icon { background: rgba(0, 255, 136, 0.15); }
|
|
244
|
-
.key-event.takeoff .key-event-icon { background: rgba(59, 130, 246, 0.15); }
|
|
245
|
-
.key-event.flight_phase .key-event-icon { background: rgba(251, 191, 36, 0.15); }
|
|
246
|
-
.key-event-content { flex: 1; min-width: 0; }
|
|
247
|
-
.key-event-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
|
|
248
|
-
.key-event-type { font-weight: 600; font-size: 0.875rem; text-transform: capitalize; }
|
|
249
|
-
.key-event.landing .key-event-type { color: #00ff88; }
|
|
250
|
-
.key-event.takeoff .key-event-type { color: #3b82f6; }
|
|
251
|
-
.key-event.flight_phase .key-event-type { color: #fbbf24; }
|
|
252
|
-
.key-event-time { font-size: 0.7rem; color: #666; }
|
|
253
|
-
.key-event-details { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.75rem; }
|
|
254
|
-
.key-event-detail { display: flex; gap: 0.25rem; }
|
|
255
|
-
.key-event-detail-label { color: #666; }
|
|
256
|
-
.key-event-detail-value { color: #fff; }
|
|
257
|
-
.key-event-quality { padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.65rem; text-transform: uppercase; font-weight: 600; }
|
|
258
|
-
.key-event-quality.butter { background: #14532d; color: #86efac; }
|
|
259
|
-
.key-event-quality.smooth { background: #1e3a5f; color: #93c5fd; }
|
|
260
|
-
.key-event-quality.normal { background: #3f3f46; color: #d4d4d8; }
|
|
261
|
-
.key-event-quality.firm { background: #78350f; color: #fcd34d; }
|
|
262
|
-
.key-event-quality.hard { background: #7c2d12; color: #fdba74; }
|
|
263
|
-
.key-event-quality.crash { background: #7f1d1d; color: #fca5a5; }
|
|
264
|
-
.key-events-empty { text-align: center; padding: 2rem; color: #666; font-size: 0.875rem; }
|
|
265
|
-
|
|
266
|
-
/* Tab Navigation */
|
|
267
|
-
.tabs {
|
|
268
|
-
display: flex;
|
|
269
|
-
gap: 0.5rem;
|
|
270
|
-
margin-bottom: 1rem;
|
|
271
|
-
border-bottom: 1px solid #222;
|
|
272
|
-
padding-bottom: 0.5rem;
|
|
273
|
-
}
|
|
274
|
-
.tab {
|
|
275
|
-
padding: 0.5rem 1rem;
|
|
276
|
-
background: transparent;
|
|
277
|
-
border: 1px solid transparent;
|
|
278
|
-
border-radius: 0.25rem 0.25rem 0 0;
|
|
279
|
-
color: #888;
|
|
280
|
-
cursor: pointer;
|
|
281
|
-
font-family: inherit;
|
|
282
|
-
font-size: 0.875rem;
|
|
283
|
-
transition: all 0.2s;
|
|
284
|
-
}
|
|
285
|
-
.tab:hover { color: #fff; }
|
|
286
|
-
.tab.active {
|
|
287
|
-
background: #111;
|
|
288
|
-
border-color: #222;
|
|
289
|
-
border-bottom-color: #111;
|
|
290
|
-
color: #00ff88;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/* Commands Section */
|
|
294
|
-
.commands-grid {
|
|
295
|
-
display: grid;
|
|
296
|
-
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
297
|
-
gap: 1rem;
|
|
298
|
-
}
|
|
299
|
-
.command-category {
|
|
300
|
-
background: #0a0a0a;
|
|
301
|
-
border: 1px solid #222;
|
|
302
|
-
border-radius: 0.5rem;
|
|
303
|
-
padding: 1rem;
|
|
304
|
-
}
|
|
305
|
-
.command-category h3 {
|
|
306
|
-
font-size: 0.75rem;
|
|
307
|
-
color: #00ff88;
|
|
308
|
-
text-transform: uppercase;
|
|
309
|
-
letter-spacing: 0.05em;
|
|
310
|
-
margin-bottom: 0.75rem;
|
|
311
|
-
padding-bottom: 0.5rem;
|
|
312
|
-
border-bottom: 1px solid #222;
|
|
313
|
-
}
|
|
314
|
-
.command-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
315
|
-
.command-item {
|
|
316
|
-
display: flex;
|
|
317
|
-
align-items: center;
|
|
318
|
-
gap: 0.5rem;
|
|
319
|
-
}
|
|
320
|
-
.command-btn {
|
|
321
|
-
flex: 1;
|
|
322
|
-
padding: 0.375rem 0.75rem;
|
|
323
|
-
background: #1a1a1a;
|
|
324
|
-
border: 1px solid #333;
|
|
325
|
-
border-radius: 0.25rem;
|
|
326
|
-
color: #e2e8f0;
|
|
327
|
-
cursor: pointer;
|
|
328
|
-
font-family: inherit;
|
|
329
|
-
font-size: 0.75rem;
|
|
330
|
-
text-align: left;
|
|
331
|
-
transition: all 0.15s;
|
|
332
|
-
}
|
|
333
|
-
.command-btn:hover {
|
|
334
|
-
background: #252525;
|
|
335
|
-
border-color: #444;
|
|
336
|
-
}
|
|
337
|
-
.command-btn:active {
|
|
338
|
-
background: #00ff88;
|
|
339
|
-
color: #0a0a0a;
|
|
340
|
-
}
|
|
341
|
-
.command-btn:disabled {
|
|
342
|
-
opacity: 0.4;
|
|
343
|
-
cursor: not-allowed;
|
|
344
|
-
}
|
|
345
|
-
.command-input {
|
|
346
|
-
width: 80px;
|
|
347
|
-
padding: 0.375rem 0.5rem;
|
|
348
|
-
background: #0a0a0a;
|
|
349
|
-
border: 1px solid #333;
|
|
350
|
-
border-radius: 0.25rem;
|
|
351
|
-
color: #fff;
|
|
352
|
-
font-family: inherit;
|
|
353
|
-
font-size: 0.75rem;
|
|
354
|
-
text-align: right;
|
|
355
|
-
}
|
|
356
|
-
.command-input:focus {
|
|
357
|
-
outline: none;
|
|
358
|
-
border-color: #00ff88;
|
|
359
|
-
}
|
|
360
|
-
.command-unit {
|
|
361
|
-
font-size: 0.65rem;
|
|
362
|
-
color: #666;
|
|
363
|
-
min-width: 30px;
|
|
364
|
-
}
|
|
365
|
-
.command-log {
|
|
366
|
-
max-height: 200px;
|
|
367
|
-
overflow-y: auto;
|
|
368
|
-
font-size: 0.75rem;
|
|
369
|
-
background: #0a0a0a;
|
|
370
|
-
border: 1px solid #222;
|
|
371
|
-
border-radius: 0.25rem;
|
|
372
|
-
padding: 0.5rem;
|
|
373
|
-
margin-top: 1rem;
|
|
374
|
-
}
|
|
375
|
-
.command-log-entry {
|
|
376
|
-
padding: 0.25rem 0;
|
|
377
|
-
border-bottom: 1px solid #1a1a1a;
|
|
378
|
-
display: flex;
|
|
379
|
-
justify-content: space-between;
|
|
380
|
-
}
|
|
381
|
-
.command-log-entry:last-child { border-bottom: none; }
|
|
382
|
-
.command-log-time { color: #666; }
|
|
383
|
-
.command-log-cmd { color: #00ff88; }
|
|
384
|
-
.command-log-value { color: #fff; }
|
|
385
|
-
.command-log-empty { color: #666; text-align: center; padding: 1rem; }
|
|
386
|
-
</style>
|
|
387
|
-
</head>
|
|
388
|
-
<body>
|
|
389
|
-
<div class="container">
|
|
390
|
-
<h1>Telemetry Validator</h1>
|
|
391
|
-
<p class="subtitle">Validate real-time telemetry and see field normalization</p>
|
|
392
|
-
|
|
393
|
-
<!-- Auth -->
|
|
394
|
-
<div id="auth-section" class="card auth-section">
|
|
395
|
-
<p style="margin-bottom: 1rem; color: #888;">Connect to validate telemetry from your broadcaster</p>
|
|
396
|
-
<button class="auth-btn" onclick="doLogin()">Connect with GameGlue</button>
|
|
397
|
-
</div>
|
|
398
|
-
|
|
399
|
-
<!-- Dashboard -->
|
|
400
|
-
<div id="dashboard" class="hidden">
|
|
401
|
-
<!-- Connection -->
|
|
402
|
-
<div class="card">
|
|
403
|
-
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
|
404
|
-
<h2 style="margin: 0;">Connection</h2>
|
|
405
|
-
<span id="status-badge" class="status-badge disconnected">Disconnected</span>
|
|
406
|
-
</div>
|
|
407
|
-
<div class="form-row">
|
|
408
|
-
<div class="form-group">
|
|
409
|
-
<label>Game</label>
|
|
410
|
-
<select id="game-select" onchange="onGameChange()">
|
|
411
|
-
<option value="msfs">Microsoft Flight Simulator</option>
|
|
412
|
-
<option value="xplane">X-Plane 12</option>
|
|
413
|
-
</select>
|
|
414
|
-
</div>
|
|
415
|
-
<div class="form-group">
|
|
416
|
-
<label>User ID (Broadcaster)</label>
|
|
417
|
-
<input type="text" id="user-id" placeholder="Your user ID">
|
|
418
|
-
</div>
|
|
419
|
-
<div class="btn-group">
|
|
420
|
-
<button id="connect-btn" class="btn btn-primary" onclick="startValidation()">Start</button>
|
|
421
|
-
<button id="stop-btn" class="btn btn-danger hidden" onclick="stopValidation()">Stop</button>
|
|
422
|
-
<button id="reset-btn" class="btn btn-secondary hidden" onclick="resetStats()">Reset</button>
|
|
423
|
-
<button id="export-btn" class="btn btn-secondary hidden" onclick="exportReport()">Export</button>
|
|
424
|
-
<button class="btn btn-secondary" onclick="doLogout()">Logout</button>
|
|
425
|
-
</div>
|
|
426
|
-
</div>
|
|
427
|
-
<div id="error-msg" class="hidden" style="color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem;"></div>
|
|
428
|
-
</div>
|
|
429
|
-
|
|
430
|
-
<!-- Tabs -->
|
|
431
|
-
<div id="tabs-section" class="hidden">
|
|
432
|
-
<div class="tabs">
|
|
433
|
-
<button class="tab active" onclick="switchTab('telemetry')">Telemetry</button>
|
|
434
|
-
<button class="tab" onclick="switchTab('commands')">Commands</button>
|
|
435
|
-
</div>
|
|
436
|
-
</div>
|
|
437
|
-
|
|
438
|
-
<!-- Stats -->
|
|
439
|
-
<div id="stats-section" class="hidden">
|
|
440
|
-
<div class="stats-grid">
|
|
441
|
-
<div class="stat-card">
|
|
442
|
-
<div id="stat-rate" class="stat-value">0</div>
|
|
443
|
-
<div class="stat-label">Hz</div>
|
|
444
|
-
</div>
|
|
445
|
-
<div class="stat-card">
|
|
446
|
-
<div id="stat-total" class="stat-value">0</div>
|
|
447
|
-
<div class="stat-label">Updates</div>
|
|
448
|
-
</div>
|
|
449
|
-
<div class="stat-card">
|
|
450
|
-
<div id="stat-fields" class="stat-value">0/0</div>
|
|
451
|
-
<div class="stat-label">Fields</div>
|
|
452
|
-
</div>
|
|
453
|
-
<div class="stat-card">
|
|
454
|
-
<div id="stat-required" class="stat-value">0/0</div>
|
|
455
|
-
<div class="stat-label">Required</div>
|
|
456
|
-
</div>
|
|
457
|
-
<div class="stat-card">
|
|
458
|
-
<div id="stat-key-events" class="stat-value">0</div>
|
|
459
|
-
<div class="stat-label">Key Events</div>
|
|
460
|
-
</div>
|
|
461
|
-
<div class="stat-card">
|
|
462
|
-
<div id="stat-issues" class="stat-value success">0</div>
|
|
463
|
-
<div class="stat-label">Issues</div>
|
|
464
|
-
</div>
|
|
465
|
-
</div>
|
|
466
|
-
</div>
|
|
467
|
-
|
|
468
|
-
<!-- Key Events -->
|
|
469
|
-
<div id="key-events-section" class="hidden">
|
|
470
|
-
<div class="card">
|
|
471
|
-
<h2>Key Events</h2>
|
|
472
|
-
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
473
|
-
Computed events from gg-client: landings, takeoffs, and flight phase changes.
|
|
474
|
-
</p>
|
|
475
|
-
<div id="key-events-list" class="key-events-list">
|
|
476
|
-
<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger events.</div>
|
|
477
|
-
</div>
|
|
478
|
-
</div>
|
|
479
|
-
</div>
|
|
480
|
-
|
|
481
|
-
<!-- Waiting -->
|
|
482
|
-
<div id="waiting-section" class="hidden">
|
|
483
|
-
<div class="card waiting">
|
|
484
|
-
<div class="spinner"></div>
|
|
485
|
-
<p>Waiting for telemetry...</p>
|
|
486
|
-
<p style="font-size: 0.75rem; margin-top: 0.5rem;">Make sure gg-client is broadcasting.</p>
|
|
487
|
-
</div>
|
|
488
|
-
</div>
|
|
489
|
-
|
|
490
|
-
<!-- Unexpected Fields -->
|
|
491
|
-
<div id="unexpected-section" class="hidden">
|
|
492
|
-
<div class="card">
|
|
493
|
-
<h2>Unmapped Fields</h2>
|
|
494
|
-
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
495
|
-
These raw fields aren't mapped to canonical names yet.
|
|
496
|
-
</p>
|
|
497
|
-
<div id="unexpected-fields" class="unexpected-fields"></div>
|
|
498
|
-
</div>
|
|
499
|
-
</div>
|
|
500
|
-
|
|
501
|
-
<!-- Coverage -->
|
|
502
|
-
<div id="coverage-section" class="hidden">
|
|
503
|
-
<div class="card">
|
|
504
|
-
<h2>Field Coverage</h2>
|
|
505
|
-
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
506
|
-
<span class="canonical">Canonical</span> <span class="arrow">←</span> <span class="raw">raw_field</span>
|
|
507
|
-
shows how game fields map to normalized names.
|
|
508
|
-
</p>
|
|
509
|
-
<div id="categories"></div>
|
|
510
|
-
</div>
|
|
511
|
-
</div>
|
|
512
|
-
|
|
513
|
-
<!-- Commands Section -->
|
|
514
|
-
<div id="commands-section" class="hidden">
|
|
515
|
-
<div class="card">
|
|
516
|
-
<h2>Send Commands</h2>
|
|
517
|
-
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
518
|
-
Send canonical commands to the simulator. Commands work across both MSFS and X-Plane.
|
|
519
|
-
</p>
|
|
520
|
-
<div id="commands-grid" class="commands-grid"></div>
|
|
521
|
-
<div class="command-log">
|
|
522
|
-
<div id="command-log-entries">
|
|
523
|
-
<div class="command-log-empty">No commands sent yet</div>
|
|
524
|
-
</div>
|
|
525
|
-
</div>
|
|
526
|
-
</div>
|
|
527
|
-
</div>
|
|
528
|
-
|
|
529
|
-
<!-- Not Listening -->
|
|
530
|
-
<div id="not-listening-section">
|
|
531
|
-
<div class="card" style="text-align: center; padding: 2rem;">
|
|
532
|
-
<p style="color: #fff; margin-bottom: 0.5rem;">Ready to validate</p>
|
|
533
|
-
<p style="color: #666; font-size: 0.8rem;">Select a game and click Start to begin.</p>
|
|
534
|
-
<div class="instructions">
|
|
535
|
-
<h3>About Normalization</h3>
|
|
536
|
-
<ol>
|
|
537
|
-
<li>Each sim uses different field names (e.g., MSFS: <code>indicated_airspeed</code>, X-Plane: <code>ias</code>)</li>
|
|
538
|
-
<li>GameGlue normalizes these to canonical names (e.g., <code>indicated_airspeed</code>)</li>
|
|
539
|
-
<li>This lets you write code once that works across all supported sims</li>
|
|
540
|
-
</ol>
|
|
541
|
-
</div>
|
|
542
|
-
</div>
|
|
543
|
-
</div>
|
|
544
|
-
</div>
|
|
545
|
-
</div>
|
|
546
|
-
|
|
547
|
-
<!-- Toast notification -->
|
|
548
|
-
<div id="toast" class="toast">Report copied to clipboard!</div>
|
|
549
|
-
|
|
550
|
-
<script src="../dist/gg.umd.js"></script>
|
|
551
|
-
<script>
|
|
552
|
-
// ===========================================
|
|
553
|
-
// CANONICAL SCHEMA (loaded from @gameglue/schemas)
|
|
554
|
-
// ===========================================
|
|
555
|
-
// Fields that are 0-1 ratios but displayed as percentages
|
|
556
|
-
const RATIO_FIELDS = ['throttle_0', 'throttle_1', 'throttle_2', 'throttle_3', 'flaps', 'spoiler'];
|
|
557
|
-
|
|
558
|
-
// Unit display mapping (schema units -> display symbols)
|
|
559
|
-
const UNIT_DISPLAY = {
|
|
560
|
-
'degrees': '°',
|
|
561
|
-
'deg': '°',
|
|
562
|
-
'ft': 'ft',
|
|
563
|
-
'kts': 'kts',
|
|
564
|
-
'fpm': 'fpm',
|
|
565
|
-
'lbs': 'lbs',
|
|
566
|
-
'lbs/hr': 'lbs/hr',
|
|
567
|
-
'rpm': 'RPM',
|
|
568
|
-
'%': '%',
|
|
569
|
-
'ratio': '%',
|
|
570
|
-
'°C': '°C',
|
|
571
|
-
'G': 'G',
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
// Build CANONICAL from category schema
|
|
575
|
-
function buildCanonicalFromSchema() {
|
|
576
|
-
const categorySchema = window.GameGlueSchemas?.getCategorySchema('flight_sim');
|
|
577
|
-
if (!categorySchema) {
|
|
578
|
-
console.warn('Category schema not available, using fallback');
|
|
579
|
-
return { requiredFields: [], fields: {} };
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const fields = {};
|
|
583
|
-
for (const [fieldName, fieldDef] of Object.entries(categorySchema.fields)) {
|
|
584
|
-
fields[fieldName] = {
|
|
585
|
-
group: fieldDef.group || 'Other',
|
|
586
|
-
desc: fieldDef.description || fieldName,
|
|
587
|
-
unit: UNIT_DISPLAY[fieldDef.unit] || fieldDef.unit || '',
|
|
588
|
-
type: fieldDef.type,
|
|
589
|
-
ratio: RATIO_FIELDS.includes(fieldName),
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
return {
|
|
594
|
-
requiredFields: categorySchema.requiredFields || [],
|
|
595
|
-
fields
|
|
596
|
-
};
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Initialize after SDK loads
|
|
600
|
-
let CANONICAL = { requiredFields: [], fields: {} };
|
|
601
|
-
|
|
602
|
-
// ===========================================
|
|
603
|
-
// GAME SELECTION (schemas from @gameglue/schemas via SDK)
|
|
604
|
-
// ===========================================
|
|
605
|
-
const GAME_OPTIONS = [
|
|
606
|
-
{ gameId: 'msfs', name: 'Microsoft Flight Simulator' },
|
|
607
|
-
{ gameId: 'xplane', name: 'X-Plane 12' }
|
|
608
|
-
];
|
|
609
|
-
|
|
610
|
-
// Build reverse mappings (canonical -> raw) from schema
|
|
611
|
-
function buildReverseMappings(gameId) {
|
|
612
|
-
const schema = window.GameGlueSchemas?.getGameSchema(gameId);
|
|
613
|
-
if (!schema?.fieldMappings) return {};
|
|
614
|
-
|
|
615
|
-
const reverse = {};
|
|
616
|
-
for (const [rawField, mapping] of Object.entries(schema.fieldMappings)) {
|
|
617
|
-
const canonical = mapping.canonical;
|
|
618
|
-
if (!reverse[canonical]) {
|
|
619
|
-
reverse[canonical] = rawField;
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
return reverse;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
// ===========================================
|
|
626
|
-
// STATE
|
|
627
|
-
// ===========================================
|
|
628
|
-
const CLIENT_ID = 'gameglue-sdk-examples';
|
|
629
|
-
const REDIRECT_URI = window.location.href.split('?')[0].split('#')[0];
|
|
630
|
-
// For local development, add socketUrl: 'http://localhost:3031'
|
|
631
|
-
const ggClient = new GameGlue({ clientId: CLIENT_ID, redirect_uri: REDIRECT_URI, scopes: ['msfs:read', 'msfs:write', 'xplane:read', 'xplane:write'] });
|
|
632
|
-
|
|
633
|
-
let listener = null;
|
|
634
|
-
let currentGameId = 'msfs';
|
|
635
|
-
let reverseMappings = {}; // canonical -> raw field name
|
|
636
|
-
let state = {
|
|
637
|
-
totalUpdates: 0,
|
|
638
|
-
timestamps: [],
|
|
639
|
-
fieldStats: {}, // canonical -> { rawField, lastValue, updateCount }
|
|
640
|
-
unmappedFields: new Set(),
|
|
641
|
-
keyEvents: [] // Array of { eventType, data, receivedAt }
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
// ===========================================
|
|
645
|
-
// PROCESS TELEMETRY (using SDK's normalized data)
|
|
646
|
-
// ===========================================
|
|
647
|
-
function processUpdate(evt) {
|
|
648
|
-
const now = Date.now();
|
|
649
|
-
state.totalUpdates++;
|
|
650
|
-
state.timestamps.push(now);
|
|
651
|
-
if (state.timestamps.length > 100) state.timestamps = state.timestamps.slice(-100);
|
|
652
|
-
|
|
653
|
-
const normalizedData = evt.data || {};
|
|
654
|
-
const rawData = evt.raw || {};
|
|
655
|
-
|
|
656
|
-
// Track canonical fields (already normalized by SDK via @gameglue/schemas)
|
|
657
|
-
for (const [canonical, value] of Object.entries(normalizedData)) {
|
|
658
|
-
// Find which raw field produced this canonical field
|
|
659
|
-
const rawField = reverseMappings[canonical] || canonical;
|
|
660
|
-
const existing = state.fieldStats[canonical] || { rawField, updateCount: 0 };
|
|
661
|
-
state.fieldStats[canonical] = {
|
|
662
|
-
rawField,
|
|
663
|
-
lastValue: value,
|
|
664
|
-
updateCount: existing.updateCount + 1
|
|
665
|
-
};
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// Track unmapped raw fields (not in schema)
|
|
669
|
-
for (const rawField of Object.keys(rawData)) {
|
|
670
|
-
const schema = window.GameGlueSchemas?.getGameSchema(currentGameId);
|
|
671
|
-
if (schema?.fieldMappings && !schema.fieldMappings[rawField]) {
|
|
672
|
-
state.unmappedFields.add(rawField);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
updateUI();
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
function getRate() {
|
|
680
|
-
const now = Date.now();
|
|
681
|
-
return state.timestamps.filter(t => now - t < 1000).length;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// ===========================================
|
|
685
|
-
// KEY EVENTS
|
|
686
|
-
// ===========================================
|
|
687
|
-
function processKeyEvent(eventType, data) {
|
|
688
|
-
state.keyEvents.unshift({
|
|
689
|
-
eventType,
|
|
690
|
-
data,
|
|
691
|
-
receivedAt: Date.now()
|
|
692
|
-
});
|
|
693
|
-
// Keep last 50 events
|
|
694
|
-
if (state.keyEvents.length > 50) {
|
|
695
|
-
state.keyEvents.pop();
|
|
696
|
-
}
|
|
697
|
-
updateKeyEventsUI();
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
function updateKeyEventsUI() {
|
|
701
|
-
const list = document.getElementById('key-events-list');
|
|
702
|
-
const stat = document.getElementById('stat-key-events');
|
|
703
|
-
|
|
704
|
-
stat.textContent = state.keyEvents.length;
|
|
705
|
-
stat.className = 'stat-value ' + (state.keyEvents.length > 0 ? 'success' : '');
|
|
706
|
-
|
|
707
|
-
if (state.keyEvents.length === 0) {
|
|
708
|
-
list.innerHTML = '<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger landings, takeoffs, and phase changes.</div>';
|
|
709
|
-
return;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
list.innerHTML = state.keyEvents.map(evt => renderKeyEvent(evt)).join('');
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function renderKeyEvent(evt) {
|
|
716
|
-
const { eventType, data, receivedAt } = evt;
|
|
717
|
-
const time = new Date(receivedAt).toLocaleTimeString();
|
|
718
|
-
const icon = getEventIcon(eventType);
|
|
719
|
-
const details = getEventDetails(eventType, data);
|
|
720
|
-
|
|
721
|
-
return `
|
|
722
|
-
<div class="key-event ${eventType}">
|
|
723
|
-
<div class="key-event-icon">${icon}</div>
|
|
724
|
-
<div class="key-event-content">
|
|
725
|
-
<div class="key-event-header">
|
|
726
|
-
<span class="key-event-type">${formatEventType(eventType)}</span>
|
|
727
|
-
<span class="key-event-time">${time}</span>
|
|
728
|
-
</div>
|
|
729
|
-
<div class="key-event-details">${details}</div>
|
|
730
|
-
</div>
|
|
731
|
-
</div>
|
|
732
|
-
`;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function getEventIcon(eventType) {
|
|
736
|
-
switch (eventType) {
|
|
737
|
-
case 'landing': return '✈'; // airplane
|
|
738
|
-
case 'takeoff': return '🚀'; // rocket
|
|
739
|
-
case 'flight_phase': return '🗼'; // round pushpin
|
|
740
|
-
default: return '●'; // bullet
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
function formatEventType(eventType) {
|
|
745
|
-
switch (eventType) {
|
|
746
|
-
case 'landing': return 'Landing';
|
|
747
|
-
case 'takeoff': return 'Takeoff';
|
|
748
|
-
case 'flight_phase': return 'Phase Change';
|
|
749
|
-
default: return eventType;
|
|
750
|
-
}
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
function getEventDetails(eventType, data) {
|
|
754
|
-
switch (eventType) {
|
|
755
|
-
case 'landing':
|
|
756
|
-
const bounceCount = data.bounce_count || 0;
|
|
757
|
-
const bounceInfo = bounceCount > 0
|
|
758
|
-
? `<span class="key-event-detail"><span class="key-event-detail-label">Bounces:</span><span class="key-event-detail-value" style="color:#fbbf24">${bounceCount}</span></span>`
|
|
759
|
-
: '';
|
|
760
|
-
return `
|
|
761
|
-
<span class="key-event-quality ${data.quality || 'normal'}">${data.quality || 'unknown'}</span>
|
|
762
|
-
<span class="key-event-detail">
|
|
763
|
-
<span class="key-event-detail-label">Rate:</span>
|
|
764
|
-
<span class="key-event-detail-value">${Math.abs(data.landing_rate || 0).toFixed(0)} fpm</span>
|
|
765
|
-
</span>
|
|
766
|
-
<span class="key-event-detail">
|
|
767
|
-
<span class="key-event-detail-label">Speed:</span>
|
|
768
|
-
<span class="key-event-detail-value">${(data.speed_at_touchdown || 0).toFixed(0)} kts</span>
|
|
769
|
-
</span>
|
|
770
|
-
<span class="key-event-detail">
|
|
771
|
-
<span class="key-event-detail-label">Pitch:</span>
|
|
772
|
-
<span class="key-event-detail-value">${(data.pitch_at_touchdown || 0).toFixed(1)}°</span>
|
|
773
|
-
</span>
|
|
774
|
-
${bounceInfo}
|
|
775
|
-
`;
|
|
776
|
-
case 'takeoff':
|
|
777
|
-
return `
|
|
778
|
-
<span class="key-event-detail">
|
|
779
|
-
<span class="key-event-detail-label">Rotation:</span>
|
|
780
|
-
<span class="key-event-detail-value">${(data.rotation_speed || 0).toFixed(0)} kts</span>
|
|
781
|
-
</span>
|
|
782
|
-
<span class="key-event-detail">
|
|
783
|
-
<span class="key-event-detail-label">Pitch:</span>
|
|
784
|
-
<span class="key-event-detail-value">${(data.pitch_at_liftoff || 0).toFixed(1)}°</span>
|
|
785
|
-
</span>
|
|
786
|
-
<span class="key-event-detail">
|
|
787
|
-
<span class="key-event-detail-label">Flaps:</span>
|
|
788
|
-
<span class="key-event-detail-value">${((data.flaps_setting || 0) * 100).toFixed(0)}%</span>
|
|
789
|
-
</span>
|
|
790
|
-
`;
|
|
791
|
-
case 'flight_phase':
|
|
792
|
-
return `
|
|
793
|
-
<span class="key-event-detail">
|
|
794
|
-
<span class="key-event-detail-label">Phase:</span>
|
|
795
|
-
<span class="key-event-detail-value">${formatPhase(data.phase)}</span>
|
|
796
|
-
</span>
|
|
797
|
-
<span class="key-event-detail">
|
|
798
|
-
<span class="key-event-detail-label">From:</span>
|
|
799
|
-
<span class="key-event-detail-value">${formatPhase(data.previous_phase)}</span>
|
|
800
|
-
</span>
|
|
801
|
-
<span class="key-event-detail">
|
|
802
|
-
<span class="key-event-detail-label">MSL:</span>
|
|
803
|
-
<span class="key-event-detail-value">${(data.altitude_msl || 0).toFixed(0)} ft</span>
|
|
804
|
-
</span>
|
|
805
|
-
<span class="key-event-detail">
|
|
806
|
-
<span class="key-event-detail-label">AGL:</span>
|
|
807
|
-
<span class="key-event-detail-value">${(data.altitude_agl || 0).toFixed(0)} ft</span>
|
|
808
|
-
</span>
|
|
809
|
-
<span class="key-event-detail">
|
|
810
|
-
<span class="key-event-detail-label">Speed:</span>
|
|
811
|
-
<span class="key-event-detail-value">${(data.speed || 0).toFixed(0)} kts</span>
|
|
812
|
-
</span>
|
|
813
|
-
`;
|
|
814
|
-
default:
|
|
815
|
-
return `<span class="key-event-detail"><span class="key-event-detail-value">${JSON.stringify(data)}</span></span>`;
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
function formatPhase(phase) {
|
|
820
|
-
if (!phase) return 'unknown';
|
|
821
|
-
return phase.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// ===========================================
|
|
825
|
-
// UI
|
|
826
|
-
// ===========================================
|
|
827
|
-
function updateUI() {
|
|
828
|
-
const rate = getRate();
|
|
829
|
-
const totalCanonical = Object.keys(CANONICAL.fields).length;
|
|
830
|
-
const received = Object.keys(state.fieldStats).length;
|
|
831
|
-
const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
|
|
832
|
-
const totalRequired = CANONICAL.requiredFields.length;
|
|
833
|
-
|
|
834
|
-
// Update stats (always visible in stats-section)
|
|
835
|
-
document.getElementById('stat-rate').textContent = rate;
|
|
836
|
-
document.getElementById('stat-rate').className = 'stat-value ' + (rate >= 10 ? 'success' : rate > 0 ? 'warning' : '');
|
|
837
|
-
document.getElementById('stat-total').textContent = state.totalUpdates;
|
|
838
|
-
document.getElementById('stat-fields').textContent = `${received}/${totalCanonical}`;
|
|
839
|
-
document.getElementById('stat-fields').className = 'stat-value ' + (received === totalCanonical ? 'success' : 'warning');
|
|
840
|
-
document.getElementById('stat-required').textContent = `${requiredReceived}/${totalRequired}`;
|
|
841
|
-
document.getElementById('stat-required').className = 'stat-value ' + (requiredReceived === totalRequired ? 'success' : 'error');
|
|
842
|
-
document.getElementById('stat-issues').textContent = state.unmappedFields.size;
|
|
843
|
-
document.getElementById('stat-issues').className = 'stat-value ' + (state.unmappedFields.size === 0 ? 'success' : 'warning');
|
|
844
|
-
|
|
845
|
-
// Only update section visibility if on telemetry tab
|
|
846
|
-
if (currentTab !== 'telemetry') return;
|
|
847
|
-
|
|
848
|
-
if (state.totalUpdates === 0) {
|
|
849
|
-
document.getElementById('waiting-section').classList.remove('hidden');
|
|
850
|
-
document.getElementById('coverage-section').classList.add('hidden');
|
|
851
|
-
} else {
|
|
852
|
-
document.getElementById('waiting-section').classList.add('hidden');
|
|
853
|
-
document.getElementById('coverage-section').classList.remove('hidden');
|
|
854
|
-
renderCategories();
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
if (state.unmappedFields.size > 0) {
|
|
858
|
-
document.getElementById('unexpected-section').classList.remove('hidden');
|
|
859
|
-
document.getElementById('unexpected-fields').innerHTML = Array.from(state.unmappedFields)
|
|
860
|
-
.map(f => `<span class="unexpected-field">${f}</span>`).join('');
|
|
861
|
-
} else {
|
|
862
|
-
document.getElementById('unexpected-section').classList.add('hidden');
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
function renderCategories() {
|
|
867
|
-
const groups = {};
|
|
868
|
-
for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
|
|
869
|
-
const g = def.group || 'Other';
|
|
870
|
-
if (!groups[g]) groups[g] = [];
|
|
871
|
-
groups[g].push({ canonical, ...def });
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
let html = '';
|
|
875
|
-
for (const [group, fields] of Object.entries(groups)) {
|
|
876
|
-
const received = fields.filter(f => state.fieldStats[f.canonical]).length;
|
|
877
|
-
const complete = received === fields.length;
|
|
878
|
-
|
|
879
|
-
html += `<div class="category-header">
|
|
880
|
-
<span class="category-name">${group}</span>
|
|
881
|
-
<span class="category-count ${complete ? 'complete' : 'incomplete'}">${received}/${fields.length}</span>
|
|
882
|
-
</div>`;
|
|
883
|
-
html += '<table class="field-table"><thead><tr><th>Canonical Field</th><th>Raw Field</th><th style="text-align:right">Value</th><th style="text-align:right">Status</th></tr></thead><tbody>';
|
|
884
|
-
|
|
885
|
-
for (const field of fields) {
|
|
886
|
-
const stats = state.fieldStats[field.canonical];
|
|
887
|
-
const isRequired = CANONICAL.requiredFields.includes(field.canonical);
|
|
888
|
-
let status = 'missing';
|
|
889
|
-
let rawField = '—';
|
|
890
|
-
let value = '—';
|
|
891
|
-
|
|
892
|
-
if (stats) {
|
|
893
|
-
status = 'ok';
|
|
894
|
-
rawField = stats.rawField;
|
|
895
|
-
let displayVal = stats.lastValue;
|
|
896
|
-
// Convert ratio (0-1) to percentage (0-100) for fields marked as ratio
|
|
897
|
-
if (typeof displayVal === 'number' && field.ratio) {
|
|
898
|
-
displayVal = displayVal * 100;
|
|
899
|
-
}
|
|
900
|
-
value = typeof displayVal === 'number' ? displayVal.toFixed(2) : String(displayVal);
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
html += `<tr class="${status}">
|
|
904
|
-
<td>
|
|
905
|
-
<span class="canonical">${field.canonical}</span>
|
|
906
|
-
${isRequired ? '<span class="required">*</span>' : ''}
|
|
907
|
-
<span style="color:#666;font-size:0.7rem;margin-left:0.5rem">${field.desc}</span>
|
|
908
|
-
</td>
|
|
909
|
-
<td><span class="raw">${rawField}</span></td>
|
|
910
|
-
<td class="field-value">${value}${field.unit ? `<span class="field-unit">${field.unit}</span>` : ''}</td>
|
|
911
|
-
<td style="text-align:right"><span class="badge ${status}">${status}</span></td>
|
|
912
|
-
</tr>`;
|
|
913
|
-
}
|
|
914
|
-
html += '</tbody></table>';
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
document.getElementById('categories').innerHTML = html;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// ===========================================
|
|
921
|
-
// CONNECTION
|
|
922
|
-
// ===========================================
|
|
923
|
-
async function startValidation() {
|
|
924
|
-
const userId = document.getElementById('user-id').value.trim();
|
|
925
|
-
if (!userId) { showError('Enter user ID'); return; }
|
|
926
|
-
|
|
927
|
-
try {
|
|
928
|
-
showError('');
|
|
929
|
-
setStatus('listening', 'Connecting...');
|
|
930
|
-
|
|
931
|
-
// Build reverse mappings from schema (canonical -> raw)
|
|
932
|
-
reverseMappings = buildReverseMappings(currentGameId);
|
|
933
|
-
|
|
934
|
-
listener = await ggClient.createListener({ userId, gameId: currentGameId });
|
|
935
|
-
// Use full event - SDK provides both evt.raw and evt.data (normalized via @gameglue/schemas)
|
|
936
|
-
listener.on('update', evt => processUpdate(evt));
|
|
937
|
-
|
|
938
|
-
// Key events (landing, takeoff, flight phase changes)
|
|
939
|
-
listener.on('landing', data => processKeyEvent('landing', data));
|
|
940
|
-
listener.on('takeoff', data => processKeyEvent('takeoff', data));
|
|
941
|
-
listener.on('flight_phase', data => processKeyEvent('flight_phase', data));
|
|
942
|
-
|
|
943
|
-
setStatus('listening', 'Listening');
|
|
944
|
-
document.getElementById('connect-btn').classList.add('hidden');
|
|
945
|
-
document.getElementById('stop-btn').classList.remove('hidden');
|
|
946
|
-
document.getElementById('reset-btn').classList.remove('hidden');
|
|
947
|
-
document.getElementById('export-btn').classList.remove('hidden');
|
|
948
|
-
document.getElementById('tabs-section').classList.remove('hidden');
|
|
949
|
-
document.getElementById('stats-section').classList.remove('hidden');
|
|
950
|
-
document.getElementById('key-events-section').classList.remove('hidden');
|
|
951
|
-
document.getElementById('waiting-section').classList.remove('hidden');
|
|
952
|
-
document.getElementById('not-listening-section').classList.add('hidden');
|
|
953
|
-
document.getElementById('game-select').disabled = true;
|
|
954
|
-
document.getElementById('user-id').disabled = true;
|
|
955
|
-
renderCommands();
|
|
956
|
-
} catch (err) {
|
|
957
|
-
showError(err.message);
|
|
958
|
-
setStatus('disconnected', 'Disconnected');
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function stopValidation() {
|
|
963
|
-
listener = null;
|
|
964
|
-
setStatus('disconnected', 'Stopped');
|
|
965
|
-
document.getElementById('connect-btn').classList.remove('hidden');
|
|
966
|
-
document.getElementById('stop-btn').classList.add('hidden');
|
|
967
|
-
document.getElementById('reset-btn').classList.add('hidden');
|
|
968
|
-
document.getElementById('export-btn').classList.add('hidden');
|
|
969
|
-
document.getElementById('tabs-section').classList.add('hidden');
|
|
970
|
-
document.getElementById('key-events-section').classList.add('hidden');
|
|
971
|
-
document.getElementById('commands-section').classList.add('hidden');
|
|
972
|
-
document.getElementById('game-select').disabled = false;
|
|
973
|
-
document.getElementById('user-id').disabled = false;
|
|
974
|
-
// Reset to telemetry tab
|
|
975
|
-
currentTab = 'telemetry';
|
|
976
|
-
document.querySelectorAll('.tab').forEach(el => {
|
|
977
|
-
el.classList.toggle('active', el.textContent.toLowerCase() === 'telemetry');
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function resetStats() {
|
|
982
|
-
state.totalUpdates = 0;
|
|
983
|
-
state.timestamps = [];
|
|
984
|
-
state.fieldStats = {};
|
|
985
|
-
state.unmappedFields = new Set();
|
|
986
|
-
state.keyEvents = [];
|
|
987
|
-
commandLog = [];
|
|
988
|
-
updateUI();
|
|
989
|
-
updateKeyEventsUI();
|
|
990
|
-
renderCommandLog();
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
function onGameChange() {
|
|
994
|
-
currentGameId = document.getElementById('game-select').value;
|
|
995
|
-
reverseMappings = buildReverseMappings(currentGameId);
|
|
996
|
-
resetStats();
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
function getGameName() {
|
|
1000
|
-
const opt = GAME_OPTIONS.find(g => g.gameId === currentGameId);
|
|
1001
|
-
return opt?.name || currentGameId;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function setStatus(s, text) {
|
|
1005
|
-
const el = document.getElementById('status-badge');
|
|
1006
|
-
el.className = 'status-badge ' + s;
|
|
1007
|
-
el.textContent = text;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function showError(msg) {
|
|
1011
|
-
const el = document.getElementById('error-msg');
|
|
1012
|
-
el.textContent = msg;
|
|
1013
|
-
el.classList.toggle('hidden', !msg);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
function showToast(message) {
|
|
1017
|
-
const toast = document.getElementById('toast');
|
|
1018
|
-
toast.textContent = message;
|
|
1019
|
-
toast.classList.add('show');
|
|
1020
|
-
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// ===========================================
|
|
1024
|
-
// EXPORT REPORT
|
|
1025
|
-
// ===========================================
|
|
1026
|
-
function exportReport() {
|
|
1027
|
-
const rate = getRate();
|
|
1028
|
-
const totalCanonical = Object.keys(CANONICAL.fields).length;
|
|
1029
|
-
const received = Object.keys(state.fieldStats).length;
|
|
1030
|
-
const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
|
|
1031
|
-
const totalRequired = CANONICAL.requiredFields.length;
|
|
1032
|
-
|
|
1033
|
-
// Group fields by category
|
|
1034
|
-
const groups = {};
|
|
1035
|
-
for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
|
|
1036
|
-
const g = def.group || 'Other';
|
|
1037
|
-
if (!groups[g]) groups[g] = { total: 0, received: 0, fields: [] };
|
|
1038
|
-
groups[g].total++;
|
|
1039
|
-
const stats = state.fieldStats[canonical];
|
|
1040
|
-
if (stats) {
|
|
1041
|
-
groups[g].received++;
|
|
1042
|
-
// Convert ratio (0-1) to percentage (0-100) for display
|
|
1043
|
-
let displayValue = stats.lastValue;
|
|
1044
|
-
if (typeof displayValue === 'number' && def.ratio) {
|
|
1045
|
-
displayValue = displayValue * 100;
|
|
1046
|
-
}
|
|
1047
|
-
groups[g].fields.push({
|
|
1048
|
-
canonical,
|
|
1049
|
-
raw: stats.rawField,
|
|
1050
|
-
value: displayValue,
|
|
1051
|
-
unit: def.unit || ''
|
|
1052
|
-
});
|
|
1053
|
-
} else {
|
|
1054
|
-
groups[g].fields.push({ canonical, raw: null, value: null, unit: def.unit || '' });
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
// Build report
|
|
1059
|
-
let report = `# Telemetry Validator Report\n\n`;
|
|
1060
|
-
report += `**Game:** ${getGameName()} (\`${currentGameId}\`)\n`;
|
|
1061
|
-
report += `**Timestamp:** ${new Date().toISOString()}\n\n`;
|
|
1062
|
-
|
|
1063
|
-
report += `## Summary\n\n`;
|
|
1064
|
-
report += `| Metric | Value |\n`;
|
|
1065
|
-
report += `|--------|-------|\n`;
|
|
1066
|
-
report += `| Update Rate | ${rate} Hz |\n`;
|
|
1067
|
-
report += `| Total Updates | ${state.totalUpdates} |\n`;
|
|
1068
|
-
report += `| Field Coverage | ${received}/${totalCanonical} (${Math.round(received/totalCanonical*100)}%) |\n`;
|
|
1069
|
-
report += `| Required Fields | ${requiredReceived}/${totalRequired} (${requiredReceived === totalRequired ? 'PASS' : 'FAIL'}) |\n`;
|
|
1070
|
-
report += `| Key Events | ${state.keyEvents.length} |\n`;
|
|
1071
|
-
report += `| Unmapped Fields | ${state.unmappedFields.size} |\n\n`;
|
|
1072
|
-
|
|
1073
|
-
// Required fields status
|
|
1074
|
-
report += `## Required Fields\n\n`;
|
|
1075
|
-
report += `| Field | Status | Raw Name | Value |\n`;
|
|
1076
|
-
report += `|-------|--------|----------|-------|\n`;
|
|
1077
|
-
for (const field of CANONICAL.requiredFields) {
|
|
1078
|
-
const stats = state.fieldStats[field];
|
|
1079
|
-
const status = stats ? 'OK' : 'MISSING';
|
|
1080
|
-
const raw = stats?.rawField || '-';
|
|
1081
|
-
const value = stats ? formatValue(stats.lastValue) : '-';
|
|
1082
|
-
report += `| \`${field}\` | ${status} | \`${raw}\` | ${value} |\n`;
|
|
1083
|
-
}
|
|
1084
|
-
report += '\n';
|
|
1085
|
-
|
|
1086
|
-
// Coverage by category
|
|
1087
|
-
report += `## Field Coverage by Category\n\n`;
|
|
1088
|
-
for (const [group, data] of Object.entries(groups)) {
|
|
1089
|
-
const pct = Math.round(data.received / data.total * 100);
|
|
1090
|
-
report += `### ${group} (${data.received}/${data.total} - ${pct}%)\n\n`;
|
|
1091
|
-
report += `| Canonical | Raw | Value |\n`;
|
|
1092
|
-
report += `|-----------|-----|-------|\n`;
|
|
1093
|
-
for (const f of data.fields) {
|
|
1094
|
-
const raw = f.raw ? `\`${f.raw}\`` : '-';
|
|
1095
|
-
const val = f.value !== null ? formatValue(f.value) + (f.unit ? ` ${f.unit}` : '') : '-';
|
|
1096
|
-
report += `| \`${f.canonical}\` | ${raw} | ${val} |\n`;
|
|
1097
|
-
}
|
|
1098
|
-
report += '\n';
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
// Key Events
|
|
1102
|
-
if (state.keyEvents.length > 0) {
|
|
1103
|
-
report += `## Key Events\n\n`;
|
|
1104
|
-
report += `| Time | Type | Details |\n`;
|
|
1105
|
-
report += `|------|------|--------|\n`;
|
|
1106
|
-
for (const evt of state.keyEvents) {
|
|
1107
|
-
const time = new Date(evt.receivedAt).toLocaleTimeString();
|
|
1108
|
-
const type = formatEventType(evt.eventType);
|
|
1109
|
-
let details = '';
|
|
1110
|
-
switch (evt.eventType) {
|
|
1111
|
-
case 'landing':
|
|
1112
|
-
const bounces = evt.data.bounce_count || 0;
|
|
1113
|
-
details = `${evt.data.quality || 'unknown'} (${Math.abs(evt.data.landing_rate || 0).toFixed(0)} fpm)${bounces > 0 ? `, ${bounces} bounce${bounces > 1 ? 's' : ''}` : ''}`;
|
|
1114
|
-
break;
|
|
1115
|
-
case 'takeoff':
|
|
1116
|
-
details = `Vr: ${(evt.data.rotation_speed || 0).toFixed(0)} kts, Pitch: ${(evt.data.pitch_at_liftoff || 0).toFixed(1)}°`;
|
|
1117
|
-
break;
|
|
1118
|
-
case 'flight_phase':
|
|
1119
|
-
details = `${formatPhase(evt.data.previous_phase)} → ${formatPhase(evt.data.phase)}`;
|
|
1120
|
-
break;
|
|
1121
|
-
default:
|
|
1122
|
-
details = JSON.stringify(evt.data);
|
|
1123
|
-
}
|
|
1124
|
-
report += `| ${time} | ${type} | ${details} |\n`;
|
|
1125
|
-
}
|
|
1126
|
-
report += '\n';
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// Unmapped fields
|
|
1130
|
-
if (state.unmappedFields.size > 0) {
|
|
1131
|
-
report += `## Unmapped Raw Fields\n\n`;
|
|
1132
|
-
report += `These fields are being sent but don't have canonical mappings:\n\n`;
|
|
1133
|
-
report += `\`\`\`\n${Array.from(state.unmappedFields).join(', ')}\n\`\`\`\n`;
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
// Copy to clipboard
|
|
1137
|
-
navigator.clipboard.writeText(report).then(() => {
|
|
1138
|
-
showToast('Report copied to clipboard!');
|
|
1139
|
-
}).catch(err => {
|
|
1140
|
-
console.error('Failed to copy:', err);
|
|
1141
|
-
showToast('Failed to copy report');
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
function formatValue(val) {
|
|
1146
|
-
if (typeof val === 'number') {
|
|
1147
|
-
return Number.isInteger(val) ? val.toString() : val.toFixed(2);
|
|
1148
|
-
}
|
|
1149
|
-
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
|
1150
|
-
return String(val);
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
// ===========================================
|
|
1154
|
-
// COMMANDS
|
|
1155
|
-
// ===========================================
|
|
1156
|
-
const COMMAND_CATEGORIES = {
|
|
1157
|
-
'Autopilot': [
|
|
1158
|
-
{ cmd: 'autopilot_on', label: 'AP On' },
|
|
1159
|
-
{ cmd: 'autopilot_off', label: 'AP Off' },
|
|
1160
|
-
{ cmd: 'autopilot_toggle', label: 'AP Toggle' },
|
|
1161
|
-
{ cmd: 'autopilot_altitude_hold_on', label: 'ALT Hold On' },
|
|
1162
|
-
{ cmd: 'autopilot_altitude_hold_off', label: 'ALT Hold Off' },
|
|
1163
|
-
{ cmd: 'autopilot_heading_hold_on', label: 'HDG Hold On' },
|
|
1164
|
-
{ cmd: 'autopilot_heading_hold_off', label: 'HDG Hold Off' },
|
|
1165
|
-
{ cmd: 'autopilot_vs_hold_on', label: 'VS Hold On' },
|
|
1166
|
-
{ cmd: 'autopilot_vs_hold_off', label: 'VS Hold Off' },
|
|
1167
|
-
{ cmd: 'autopilot_nav_on', label: 'NAV On' },
|
|
1168
|
-
{ cmd: 'autopilot_nav_off', label: 'NAV Off' },
|
|
1169
|
-
{ cmd: 'autopilot_approach_on', label: 'APR On' },
|
|
1170
|
-
{ cmd: 'autopilot_approach_off', label: 'APR Off' },
|
|
1171
|
-
{ cmd: 'flight_director_on', label: 'FD On' },
|
|
1172
|
-
{ cmd: 'flight_director_off', label: 'FD Off' },
|
|
1173
|
-
{ cmd: 'autopilot_flc_on', label: 'FLC On' },
|
|
1174
|
-
{ cmd: 'autopilot_flc_off', label: 'FLC Off' },
|
|
1175
|
-
{ cmd: 'autopilot_flc_toggle', label: 'FLC Toggle' },
|
|
1176
|
-
],
|
|
1177
|
-
'AP Targets': [
|
|
1178
|
-
{ cmd: 'set_autopilot_altitude', label: 'Set Altitude', input: true, unit: 'ft', default: 10000 },
|
|
1179
|
-
{ cmd: 'set_autopilot_heading', label: 'Set Heading', input: true, unit: '°', default: 360 },
|
|
1180
|
-
{ cmd: 'set_autopilot_vs', label: 'Set VS', input: true, unit: 'fpm', default: 1000 },
|
|
1181
|
-
{ cmd: 'set_autopilot_speed', label: 'Set Speed', input: true, unit: 'kts', default: 250 },
|
|
1182
|
-
],
|
|
1183
|
-
'Gear & Flaps': [
|
|
1184
|
-
{ cmd: 'gear_up', label: 'Gear Up' },
|
|
1185
|
-
{ cmd: 'gear_down', label: 'Gear Down' },
|
|
1186
|
-
{ cmd: 'gear_toggle', label: 'Gear Toggle' },
|
|
1187
|
-
{ cmd: 'flaps_up', label: 'Flaps Up' },
|
|
1188
|
-
{ cmd: 'flaps_down', label: 'Flaps Down' },
|
|
1189
|
-
{ cmd: 'flaps_full', label: 'Flaps Full' },
|
|
1190
|
-
{ cmd: 'flaps_retract', label: 'Flaps Retract' },
|
|
1191
|
-
{ cmd: 'set_flaps', label: 'Set Flaps', input: true, unit: '%', default: 50 },
|
|
1192
|
-
],
|
|
1193
|
-
'Spoilers': [
|
|
1194
|
-
{ cmd: 'spoilers_arm', label: 'Arm' },
|
|
1195
|
-
{ cmd: 'spoilers_deploy', label: 'Deploy' },
|
|
1196
|
-
{ cmd: 'spoilers_retract', label: 'Retract' },
|
|
1197
|
-
{ cmd: 'spoilers_toggle', label: 'Toggle' },
|
|
1198
|
-
],
|
|
1199
|
-
'Parking Brake': [
|
|
1200
|
-
{ cmd: 'parking_brake_on', label: 'Set' },
|
|
1201
|
-
{ cmd: 'parking_brake_off', label: 'Release' },
|
|
1202
|
-
{ cmd: 'parking_brake_toggle', label: 'Toggle' },
|
|
1203
|
-
],
|
|
1204
|
-
'Throttle': [
|
|
1205
|
-
{ cmd: 'throttle_full', label: 'Full' },
|
|
1206
|
-
{ cmd: 'throttle_idle', label: 'Idle' },
|
|
1207
|
-
{ cmd: 'throttle_cutoff', label: 'Cutoff' },
|
|
1208
|
-
{ cmd: 'set_throttle', label: 'Set All', input: true, unit: '%', default: 50 },
|
|
1209
|
-
{ cmd: 'set_throttle_0', label: 'Set Eng 1', input: true, unit: '%', default: 50 },
|
|
1210
|
-
{ cmd: 'set_throttle_1', label: 'Set Eng 2', input: true, unit: '%', default: 50 },
|
|
1211
|
-
],
|
|
1212
|
-
'Landing Lights': [
|
|
1213
|
-
{ cmd: 'landing_lights_on', label: 'On' },
|
|
1214
|
-
{ cmd: 'landing_lights_off', label: 'Off' },
|
|
1215
|
-
{ cmd: 'landing_lights_toggle', label: 'Toggle' },
|
|
1216
|
-
],
|
|
1217
|
-
'Taxi Lights': [
|
|
1218
|
-
{ cmd: 'taxi_lights_on', label: 'On' },
|
|
1219
|
-
{ cmd: 'taxi_lights_off', label: 'Off' },
|
|
1220
|
-
{ cmd: 'taxi_lights_toggle', label: 'Toggle' },
|
|
1221
|
-
],
|
|
1222
|
-
'Nav Lights': [
|
|
1223
|
-
{ cmd: 'nav_lights_on', label: 'On' },
|
|
1224
|
-
{ cmd: 'nav_lights_off', label: 'Off' },
|
|
1225
|
-
{ cmd: 'nav_lights_toggle', label: 'Toggle' },
|
|
1226
|
-
],
|
|
1227
|
-
'Beacon': [
|
|
1228
|
-
{ cmd: 'beacon_lights_on', label: 'On' },
|
|
1229
|
-
{ cmd: 'beacon_lights_off', label: 'Off' },
|
|
1230
|
-
{ cmd: 'beacon_lights_toggle', label: 'Toggle' },
|
|
1231
|
-
],
|
|
1232
|
-
'Strobes': [
|
|
1233
|
-
{ cmd: 'strobe_lights_on', label: 'On' },
|
|
1234
|
-
{ cmd: 'strobe_lights_off', label: 'Off' },
|
|
1235
|
-
{ cmd: 'strobe_lights_toggle', label: 'Toggle' },
|
|
1236
|
-
],
|
|
1237
|
-
'Simulation': [
|
|
1238
|
-
{ cmd: 'pause', label: 'Pause' },
|
|
1239
|
-
{ cmd: 'unpause', label: 'Unpause' },
|
|
1240
|
-
{ cmd: 'pause_toggle', label: 'Toggle Pause' },
|
|
1241
|
-
],
|
|
1242
|
-
};
|
|
1243
|
-
|
|
1244
|
-
let commandLog = [];
|
|
1245
|
-
|
|
1246
|
-
function renderCommands() {
|
|
1247
|
-
const grid = document.getElementById('commands-grid');
|
|
1248
|
-
const isConnected = listener !== null;
|
|
1249
|
-
|
|
1250
|
-
let html = '';
|
|
1251
|
-
for (const [category, commands] of Object.entries(COMMAND_CATEGORIES)) {
|
|
1252
|
-
html += `<div class="command-category"><h3>${category}</h3><div class="command-list">`;
|
|
1253
|
-
for (const cmd of commands) {
|
|
1254
|
-
if (cmd.input) {
|
|
1255
|
-
html += `
|
|
1256
|
-
<div class="command-item">
|
|
1257
|
-
<button class="command-btn" onclick="sendCommandWithInput('${cmd.cmd}', '${cmd.cmd}-input')" ${!isConnected ? 'disabled' : ''}>
|
|
1258
|
-
${cmd.label}
|
|
1259
|
-
</button>
|
|
1260
|
-
<input type="number" id="${cmd.cmd}-input" class="command-input" value="${cmd.default || 0}" ${!isConnected ? 'disabled' : ''}>
|
|
1261
|
-
<span class="command-unit">${cmd.unit || ''}</span>
|
|
1262
|
-
</div>
|
|
1263
|
-
`;
|
|
1264
|
-
} else {
|
|
1265
|
-
html += `
|
|
1266
|
-
<div class="command-item">
|
|
1267
|
-
<button class="command-btn" onclick="sendCommand('${cmd.cmd}')" ${!isConnected ? 'disabled' : ''}>
|
|
1268
|
-
${cmd.label}
|
|
1269
|
-
</button>
|
|
1270
|
-
</div>
|
|
1271
|
-
`;
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
html += '</div></div>';
|
|
1275
|
-
}
|
|
1276
|
-
grid.innerHTML = html;
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
async function sendCommand(cmd) {
|
|
1280
|
-
if (!listener) {
|
|
1281
|
-
showToast('Not connected');
|
|
1282
|
-
return;
|
|
1283
|
-
}
|
|
1284
|
-
try {
|
|
1285
|
-
await listener.sendCommand(cmd, true);
|
|
1286
|
-
logCommand(cmd, true);
|
|
1287
|
-
showToast(`Sent: ${cmd}`);
|
|
1288
|
-
} catch (err) {
|
|
1289
|
-
console.error('Command failed:', err);
|
|
1290
|
-
showToast(`Failed: ${err.message}`);
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
async function sendCommandWithInput(cmd, inputId) {
|
|
1295
|
-
if (!listener) {
|
|
1296
|
-
showToast('Not connected');
|
|
1297
|
-
return;
|
|
1298
|
-
}
|
|
1299
|
-
const input = document.getElementById(inputId);
|
|
1300
|
-
let value = parseFloat(input.value);
|
|
1301
|
-
|
|
1302
|
-
// Convert percentage to ratio for throttle/flaps commands
|
|
1303
|
-
if (cmd.startsWith('set_throttle') || cmd === 'set_flaps') {
|
|
1304
|
-
value = value / 100;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
try {
|
|
1308
|
-
await listener.sendCommand(cmd, value);
|
|
1309
|
-
logCommand(cmd, value);
|
|
1310
|
-
showToast(`Sent: ${cmd} = ${input.value}`);
|
|
1311
|
-
} catch (err) {
|
|
1312
|
-
console.error('Command failed:', err);
|
|
1313
|
-
showToast(`Failed: ${err.message}`);
|
|
1314
|
-
}
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
function logCommand(cmd, value) {
|
|
1318
|
-
commandLog.unshift({
|
|
1319
|
-
cmd,
|
|
1320
|
-
value,
|
|
1321
|
-
time: new Date().toLocaleTimeString()
|
|
1322
|
-
});
|
|
1323
|
-
if (commandLog.length > 20) commandLog.pop();
|
|
1324
|
-
renderCommandLog();
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
function renderCommandLog() {
|
|
1328
|
-
const container = document.getElementById('command-log-entries');
|
|
1329
|
-
if (commandLog.length === 0) {
|
|
1330
|
-
container.innerHTML = '<div class="command-log-empty">No commands sent yet</div>';
|
|
1331
|
-
return;
|
|
1332
|
-
}
|
|
1333
|
-
container.innerHTML = commandLog.map(entry => `
|
|
1334
|
-
<div class="command-log-entry">
|
|
1335
|
-
<span class="command-log-time">${entry.time}</span>
|
|
1336
|
-
<span class="command-log-cmd">${entry.cmd}</span>
|
|
1337
|
-
<span class="command-log-value">${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}</span>
|
|
1338
|
-
</div>
|
|
1339
|
-
`).join('');
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// ===========================================
|
|
1343
|
-
// TABS
|
|
1344
|
-
// ===========================================
|
|
1345
|
-
let currentTab = 'telemetry';
|
|
1346
|
-
|
|
1347
|
-
function switchTab(tab) {
|
|
1348
|
-
currentTab = tab;
|
|
1349
|
-
|
|
1350
|
-
// Update tab buttons
|
|
1351
|
-
document.querySelectorAll('.tab').forEach(el => {
|
|
1352
|
-
el.classList.toggle('active', el.textContent.trim().toLowerCase() === tab);
|
|
1353
|
-
});
|
|
1354
|
-
|
|
1355
|
-
// Show/hide tab content
|
|
1356
|
-
const telemetrySections = ['stats-section', 'key-events-section', 'waiting-section', 'unexpected-section', 'coverage-section'];
|
|
1357
|
-
|
|
1358
|
-
if (tab === 'telemetry') {
|
|
1359
|
-
// Show telemetry sections based on state
|
|
1360
|
-
telemetrySections.forEach(id => {
|
|
1361
|
-
const el = document.getElementById(id);
|
|
1362
|
-
if (!el) return;
|
|
1363
|
-
if (id === 'stats-section') el.classList.remove('hidden');
|
|
1364
|
-
else if (id === 'key-events-section') el.classList.remove('hidden');
|
|
1365
|
-
else if (id === 'waiting-section' && state.totalUpdates === 0) el.classList.remove('hidden');
|
|
1366
|
-
else if (id === 'coverage-section' && state.totalUpdates > 0) el.classList.remove('hidden');
|
|
1367
|
-
else if (id === 'unexpected-section' && state.unmappedFields.size > 0) el.classList.remove('hidden');
|
|
1368
|
-
else el.classList.add('hidden');
|
|
1369
|
-
});
|
|
1370
|
-
document.getElementById('commands-section').classList.add('hidden');
|
|
1371
|
-
} else if (tab === 'commands') {
|
|
1372
|
-
// Hide ALL telemetry sections
|
|
1373
|
-
telemetrySections.forEach(id => {
|
|
1374
|
-
const el = document.getElementById(id);
|
|
1375
|
-
if (el) el.classList.add('hidden');
|
|
1376
|
-
});
|
|
1377
|
-
// Show commands section
|
|
1378
|
-
document.getElementById('commands-section').classList.remove('hidden');
|
|
1379
|
-
renderCommands();
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
// ===========================================
|
|
1384
|
-
// AUTH
|
|
1385
|
-
// ===========================================
|
|
1386
|
-
function doLogin() { ggClient.login(); }
|
|
1387
|
-
function doLogout() {
|
|
1388
|
-
ggClient.logout({ redirect: false });
|
|
1389
|
-
document.getElementById('auth-section').classList.remove('hidden');
|
|
1390
|
-
document.getElementById('dashboard').classList.add('hidden');
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
async function init() {
|
|
1394
|
-
// Build CANONICAL from @gameglue/schemas
|
|
1395
|
-
CANONICAL = buildCanonicalFromSchema();
|
|
1396
|
-
console.log(`Loaded ${Object.keys(CANONICAL.fields).length} canonical fields from schema`);
|
|
1397
|
-
|
|
1398
|
-
try {
|
|
1399
|
-
const isAuthed = await ggClient.isAuthenticated();
|
|
1400
|
-
if (!isAuthed) return;
|
|
1401
|
-
document.getElementById('user-id').value = ggClient.getUser();
|
|
1402
|
-
document.getElementById('auth-section').classList.add('hidden');
|
|
1403
|
-
document.getElementById('dashboard').classList.remove('hidden');
|
|
1404
|
-
} catch (err) { console.error(err); }
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
window.onload = init;
|
|
1408
|
-
</script>
|
|
1409
|
-
</body>
|
|
1410
|
-
</html>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>GameGlue Telemetry Validator</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
body {
|
|
10
|
+
font-family: 'SF Mono', 'Consolas', monospace;
|
|
11
|
+
background: #0a0a0a;
|
|
12
|
+
color: #e2e8f0;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
padding: 1.5rem;
|
|
15
|
+
}
|
|
16
|
+
.container { max-width: 1400px; margin: 0 auto; }
|
|
17
|
+
h1 { font-size: 1.25rem; margin-bottom: 0.25rem; color: #00ff88; }
|
|
18
|
+
.subtitle { color: #666; margin-bottom: 1.5rem; font-size: 0.875rem; }
|
|
19
|
+
.card {
|
|
20
|
+
background: #111;
|
|
21
|
+
border: 1px solid #222;
|
|
22
|
+
border-radius: 0.5rem;
|
|
23
|
+
padding: 1rem;
|
|
24
|
+
margin-bottom: 1rem;
|
|
25
|
+
}
|
|
26
|
+
.card h2 {
|
|
27
|
+
font-size: 0.75rem;
|
|
28
|
+
color: #666;
|
|
29
|
+
text-transform: uppercase;
|
|
30
|
+
letter-spacing: 0.1em;
|
|
31
|
+
margin-bottom: 0.75rem;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Stats */
|
|
35
|
+
.stats-grid {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: repeat(6, 1fr);
|
|
38
|
+
gap: 1rem;
|
|
39
|
+
margin-bottom: 1rem;
|
|
40
|
+
}
|
|
41
|
+
.stat-card {
|
|
42
|
+
background: #111;
|
|
43
|
+
border: 1px solid #222;
|
|
44
|
+
border-radius: 0.5rem;
|
|
45
|
+
padding: 1rem;
|
|
46
|
+
text-align: center;
|
|
47
|
+
}
|
|
48
|
+
.stat-value { font-size: 1.5rem; font-weight: bold; color: #fff; }
|
|
49
|
+
.stat-value.success { color: #00ff88; }
|
|
50
|
+
.stat-value.warning { color: #fbbf24; }
|
|
51
|
+
.stat-value.error { color: #ef4444; }
|
|
52
|
+
.stat-label { font-size: 0.7rem; color: #666; text-transform: uppercase; margin-top: 0.25rem; }
|
|
53
|
+
|
|
54
|
+
/* Form */
|
|
55
|
+
.form-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 1rem; align-items: end; }
|
|
56
|
+
.form-group { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
57
|
+
.form-group label { font-size: 0.7rem; color: #666; text-transform: uppercase; }
|
|
58
|
+
.form-group input, .form-group select {
|
|
59
|
+
background: #0a0a0a;
|
|
60
|
+
border: 1px solid #333;
|
|
61
|
+
color: #fff;
|
|
62
|
+
padding: 0.5rem 0.75rem;
|
|
63
|
+
border-radius: 0.25rem;
|
|
64
|
+
font-family: inherit;
|
|
65
|
+
font-size: 0.875rem;
|
|
66
|
+
}
|
|
67
|
+
.form-group input:focus, .form-group select:focus { outline: none; border-color: #00ff88; }
|
|
68
|
+
.btn {
|
|
69
|
+
padding: 0.5rem 1rem;
|
|
70
|
+
border: none;
|
|
71
|
+
border-radius: 0.25rem;
|
|
72
|
+
font-family: inherit;
|
|
73
|
+
font-size: 0.875rem;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
.btn-primary { background: #00ff88; color: #0a0a0a; }
|
|
77
|
+
.btn-secondary { background: #333; color: #fff; border: 1px solid #444; }
|
|
78
|
+
.btn-danger { background: #ef4444; color: #fff; }
|
|
79
|
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
80
|
+
.btn-group { display: flex; gap: 0.5rem; }
|
|
81
|
+
|
|
82
|
+
/* Status */
|
|
83
|
+
.status-badge {
|
|
84
|
+
display: inline-block;
|
|
85
|
+
padding: 0.25rem 0.75rem;
|
|
86
|
+
border-radius: 9999px;
|
|
87
|
+
font-size: 0.75rem;
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
}
|
|
90
|
+
.status-badge.disconnected { background: #7f1d1d; color: #fca5a5; }
|
|
91
|
+
.status-badge.connected { background: #14532d; color: #86efac; }
|
|
92
|
+
.status-badge.listening { background: #1e3a5f; color: #93c5fd; }
|
|
93
|
+
|
|
94
|
+
/* Field Table */
|
|
95
|
+
.field-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
|
96
|
+
.field-table th {
|
|
97
|
+
text-align: left;
|
|
98
|
+
padding: 0.5rem;
|
|
99
|
+
border-bottom: 1px solid #333;
|
|
100
|
+
color: #666;
|
|
101
|
+
font-weight: normal;
|
|
102
|
+
text-transform: uppercase;
|
|
103
|
+
font-size: 0.7rem;
|
|
104
|
+
}
|
|
105
|
+
.field-table td { padding: 0.5rem; border-bottom: 1px solid #222; }
|
|
106
|
+
.field-table tr.ok { background: rgba(0, 255, 136, 0.03); }
|
|
107
|
+
.field-table tr.missing { background: rgba(239, 68, 68, 0.05); }
|
|
108
|
+
.field-table tr.warning { background: rgba(251, 191, 36, 0.05); }
|
|
109
|
+
.canonical { color: #00ff88; }
|
|
110
|
+
.raw { color: #888; font-size: 0.75rem; }
|
|
111
|
+
.arrow { color: #444; margin: 0 0.5rem; }
|
|
112
|
+
.required { color: #ef4444; margin-left: 0.25rem; }
|
|
113
|
+
.field-value { text-align: right; color: #fff; }
|
|
114
|
+
.field-unit { color: #666; margin-left: 0.25rem; }
|
|
115
|
+
.badge {
|
|
116
|
+
display: inline-block;
|
|
117
|
+
padding: 0.125rem 0.5rem;
|
|
118
|
+
border-radius: 0.25rem;
|
|
119
|
+
font-size: 0.65rem;
|
|
120
|
+
text-transform: uppercase;
|
|
121
|
+
border: 1px solid;
|
|
122
|
+
}
|
|
123
|
+
.badge.ok { border-color: #00ff88; color: #00ff88; }
|
|
124
|
+
.badge.missing { border-color: #ef4444; color: #ef4444; }
|
|
125
|
+
.badge.null { border-color: #fbbf24; color: #fbbf24; }
|
|
126
|
+
.badge.new { border-color: #3b82f6; color: #3b82f6; }
|
|
127
|
+
|
|
128
|
+
/* Category */
|
|
129
|
+
.category-header {
|
|
130
|
+
display: flex;
|
|
131
|
+
justify-content: space-between;
|
|
132
|
+
align-items: center;
|
|
133
|
+
padding: 0.75rem 0.5rem;
|
|
134
|
+
background: #0a0a0a;
|
|
135
|
+
margin: 1rem 0 0.5rem 0;
|
|
136
|
+
border-radius: 0.25rem;
|
|
137
|
+
}
|
|
138
|
+
.category-name { color: #fff; font-size: 0.875rem; }
|
|
139
|
+
.category-count { font-size: 0.75rem; }
|
|
140
|
+
.category-count.complete { color: #00ff88; }
|
|
141
|
+
.category-count.incomplete { color: #fbbf24; }
|
|
142
|
+
|
|
143
|
+
/* Unexpected */
|
|
144
|
+
.unexpected-fields { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
|
145
|
+
.unexpected-field {
|
|
146
|
+
padding: 0.25rem 0.5rem;
|
|
147
|
+
background: #1e3a5f;
|
|
148
|
+
border: 1px solid #3b82f6;
|
|
149
|
+
border-radius: 0.25rem;
|
|
150
|
+
font-size: 0.75rem;
|
|
151
|
+
color: #93c5fd;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Auth */
|
|
155
|
+
.auth-section { text-align: center; padding: 3rem; }
|
|
156
|
+
.auth-btn {
|
|
157
|
+
background: #00ff88;
|
|
158
|
+
color: #0a0a0a;
|
|
159
|
+
border: none;
|
|
160
|
+
padding: 0.75rem 2rem;
|
|
161
|
+
border-radius: 0.5rem;
|
|
162
|
+
font-size: 1rem;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
font-family: inherit;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.hidden { display: none !important; }
|
|
169
|
+
|
|
170
|
+
/* Toast notification */
|
|
171
|
+
.toast {
|
|
172
|
+
position: fixed;
|
|
173
|
+
bottom: 2rem;
|
|
174
|
+
right: 2rem;
|
|
175
|
+
background: #14532d;
|
|
176
|
+
color: #86efac;
|
|
177
|
+
padding: 0.75rem 1.25rem;
|
|
178
|
+
border-radius: 0.5rem;
|
|
179
|
+
font-size: 0.875rem;
|
|
180
|
+
opacity: 0;
|
|
181
|
+
transform: translateY(1rem);
|
|
182
|
+
transition: all 0.3s ease;
|
|
183
|
+
z-index: 1000;
|
|
184
|
+
}
|
|
185
|
+
.toast.show {
|
|
186
|
+
opacity: 1;
|
|
187
|
+
transform: translateY(0);
|
|
188
|
+
}
|
|
189
|
+
.waiting { text-align: center; padding: 2rem; color: #666; }
|
|
190
|
+
.spinner {
|
|
191
|
+
display: inline-block;
|
|
192
|
+
width: 24px;
|
|
193
|
+
height: 24px;
|
|
194
|
+
border: 2px solid #333;
|
|
195
|
+
border-top-color: #00ff88;
|
|
196
|
+
border-radius: 50%;
|
|
197
|
+
animation: spin 1s linear infinite;
|
|
198
|
+
margin-bottom: 1rem;
|
|
199
|
+
}
|
|
200
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
201
|
+
|
|
202
|
+
.instructions {
|
|
203
|
+
background: #0a0a0a;
|
|
204
|
+
border: 1px solid #222;
|
|
205
|
+
border-radius: 0.5rem;
|
|
206
|
+
padding: 1rem;
|
|
207
|
+
margin-top: 1rem;
|
|
208
|
+
}
|
|
209
|
+
.instructions h3 {
|
|
210
|
+
font-size: 0.75rem;
|
|
211
|
+
color: #666;
|
|
212
|
+
text-transform: uppercase;
|
|
213
|
+
margin-bottom: 0.5rem;
|
|
214
|
+
}
|
|
215
|
+
.instructions ol { margin-left: 1.25rem; font-size: 0.8rem; color: #888; }
|
|
216
|
+
.instructions li { margin-bottom: 0.25rem; }
|
|
217
|
+
|
|
218
|
+
/* Key Events */
|
|
219
|
+
.key-events-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 400px; overflow-y: auto; }
|
|
220
|
+
.key-event {
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: flex-start;
|
|
223
|
+
gap: 0.75rem;
|
|
224
|
+
padding: 0.75rem;
|
|
225
|
+
background: #0a0a0a;
|
|
226
|
+
border: 1px solid #222;
|
|
227
|
+
border-radius: 0.375rem;
|
|
228
|
+
border-left: 3px solid;
|
|
229
|
+
}
|
|
230
|
+
.key-event.landing { border-left-color: #00ff88; }
|
|
231
|
+
.key-event.takeoff { border-left-color: #3b82f6; }
|
|
232
|
+
.key-event.flight_phase { border-left-color: #fbbf24; }
|
|
233
|
+
.key-event-icon {
|
|
234
|
+
width: 32px;
|
|
235
|
+
height: 32px;
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
justify-content: center;
|
|
239
|
+
border-radius: 0.25rem;
|
|
240
|
+
font-size: 1rem;
|
|
241
|
+
flex-shrink: 0;
|
|
242
|
+
}
|
|
243
|
+
.key-event.landing .key-event-icon { background: rgba(0, 255, 136, 0.15); }
|
|
244
|
+
.key-event.takeoff .key-event-icon { background: rgba(59, 130, 246, 0.15); }
|
|
245
|
+
.key-event.flight_phase .key-event-icon { background: rgba(251, 191, 36, 0.15); }
|
|
246
|
+
.key-event-content { flex: 1; min-width: 0; }
|
|
247
|
+
.key-event-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.25rem; }
|
|
248
|
+
.key-event-type { font-weight: 600; font-size: 0.875rem; text-transform: capitalize; }
|
|
249
|
+
.key-event.landing .key-event-type { color: #00ff88; }
|
|
250
|
+
.key-event.takeoff .key-event-type { color: #3b82f6; }
|
|
251
|
+
.key-event.flight_phase .key-event-type { color: #fbbf24; }
|
|
252
|
+
.key-event-time { font-size: 0.7rem; color: #666; }
|
|
253
|
+
.key-event-details { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.75rem; }
|
|
254
|
+
.key-event-detail { display: flex; gap: 0.25rem; }
|
|
255
|
+
.key-event-detail-label { color: #666; }
|
|
256
|
+
.key-event-detail-value { color: #fff; }
|
|
257
|
+
.key-event-quality { padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-size: 0.65rem; text-transform: uppercase; font-weight: 600; }
|
|
258
|
+
.key-event-quality.butter { background: #14532d; color: #86efac; }
|
|
259
|
+
.key-event-quality.smooth { background: #1e3a5f; color: #93c5fd; }
|
|
260
|
+
.key-event-quality.normal { background: #3f3f46; color: #d4d4d8; }
|
|
261
|
+
.key-event-quality.firm { background: #78350f; color: #fcd34d; }
|
|
262
|
+
.key-event-quality.hard { background: #7c2d12; color: #fdba74; }
|
|
263
|
+
.key-event-quality.crash { background: #7f1d1d; color: #fca5a5; }
|
|
264
|
+
.key-events-empty { text-align: center; padding: 2rem; color: #666; font-size: 0.875rem; }
|
|
265
|
+
|
|
266
|
+
/* Tab Navigation */
|
|
267
|
+
.tabs {
|
|
268
|
+
display: flex;
|
|
269
|
+
gap: 0.5rem;
|
|
270
|
+
margin-bottom: 1rem;
|
|
271
|
+
border-bottom: 1px solid #222;
|
|
272
|
+
padding-bottom: 0.5rem;
|
|
273
|
+
}
|
|
274
|
+
.tab {
|
|
275
|
+
padding: 0.5rem 1rem;
|
|
276
|
+
background: transparent;
|
|
277
|
+
border: 1px solid transparent;
|
|
278
|
+
border-radius: 0.25rem 0.25rem 0 0;
|
|
279
|
+
color: #888;
|
|
280
|
+
cursor: pointer;
|
|
281
|
+
font-family: inherit;
|
|
282
|
+
font-size: 0.875rem;
|
|
283
|
+
transition: all 0.2s;
|
|
284
|
+
}
|
|
285
|
+
.tab:hover { color: #fff; }
|
|
286
|
+
.tab.active {
|
|
287
|
+
background: #111;
|
|
288
|
+
border-color: #222;
|
|
289
|
+
border-bottom-color: #111;
|
|
290
|
+
color: #00ff88;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* Commands Section */
|
|
294
|
+
.commands-grid {
|
|
295
|
+
display: grid;
|
|
296
|
+
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
297
|
+
gap: 1rem;
|
|
298
|
+
}
|
|
299
|
+
.command-category {
|
|
300
|
+
background: #0a0a0a;
|
|
301
|
+
border: 1px solid #222;
|
|
302
|
+
border-radius: 0.5rem;
|
|
303
|
+
padding: 1rem;
|
|
304
|
+
}
|
|
305
|
+
.command-category h3 {
|
|
306
|
+
font-size: 0.75rem;
|
|
307
|
+
color: #00ff88;
|
|
308
|
+
text-transform: uppercase;
|
|
309
|
+
letter-spacing: 0.05em;
|
|
310
|
+
margin-bottom: 0.75rem;
|
|
311
|
+
padding-bottom: 0.5rem;
|
|
312
|
+
border-bottom: 1px solid #222;
|
|
313
|
+
}
|
|
314
|
+
.command-list { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
315
|
+
.command-item {
|
|
316
|
+
display: flex;
|
|
317
|
+
align-items: center;
|
|
318
|
+
gap: 0.5rem;
|
|
319
|
+
}
|
|
320
|
+
.command-btn {
|
|
321
|
+
flex: 1;
|
|
322
|
+
padding: 0.375rem 0.75rem;
|
|
323
|
+
background: #1a1a1a;
|
|
324
|
+
border: 1px solid #333;
|
|
325
|
+
border-radius: 0.25rem;
|
|
326
|
+
color: #e2e8f0;
|
|
327
|
+
cursor: pointer;
|
|
328
|
+
font-family: inherit;
|
|
329
|
+
font-size: 0.75rem;
|
|
330
|
+
text-align: left;
|
|
331
|
+
transition: all 0.15s;
|
|
332
|
+
}
|
|
333
|
+
.command-btn:hover {
|
|
334
|
+
background: #252525;
|
|
335
|
+
border-color: #444;
|
|
336
|
+
}
|
|
337
|
+
.command-btn:active {
|
|
338
|
+
background: #00ff88;
|
|
339
|
+
color: #0a0a0a;
|
|
340
|
+
}
|
|
341
|
+
.command-btn:disabled {
|
|
342
|
+
opacity: 0.4;
|
|
343
|
+
cursor: not-allowed;
|
|
344
|
+
}
|
|
345
|
+
.command-input {
|
|
346
|
+
width: 80px;
|
|
347
|
+
padding: 0.375rem 0.5rem;
|
|
348
|
+
background: #0a0a0a;
|
|
349
|
+
border: 1px solid #333;
|
|
350
|
+
border-radius: 0.25rem;
|
|
351
|
+
color: #fff;
|
|
352
|
+
font-family: inherit;
|
|
353
|
+
font-size: 0.75rem;
|
|
354
|
+
text-align: right;
|
|
355
|
+
}
|
|
356
|
+
.command-input:focus {
|
|
357
|
+
outline: none;
|
|
358
|
+
border-color: #00ff88;
|
|
359
|
+
}
|
|
360
|
+
.command-unit {
|
|
361
|
+
font-size: 0.65rem;
|
|
362
|
+
color: #666;
|
|
363
|
+
min-width: 30px;
|
|
364
|
+
}
|
|
365
|
+
.command-log {
|
|
366
|
+
max-height: 200px;
|
|
367
|
+
overflow-y: auto;
|
|
368
|
+
font-size: 0.75rem;
|
|
369
|
+
background: #0a0a0a;
|
|
370
|
+
border: 1px solid #222;
|
|
371
|
+
border-radius: 0.25rem;
|
|
372
|
+
padding: 0.5rem;
|
|
373
|
+
margin-top: 1rem;
|
|
374
|
+
}
|
|
375
|
+
.command-log-entry {
|
|
376
|
+
padding: 0.25rem 0;
|
|
377
|
+
border-bottom: 1px solid #1a1a1a;
|
|
378
|
+
display: flex;
|
|
379
|
+
justify-content: space-between;
|
|
380
|
+
}
|
|
381
|
+
.command-log-entry:last-child { border-bottom: none; }
|
|
382
|
+
.command-log-time { color: #666; }
|
|
383
|
+
.command-log-cmd { color: #00ff88; }
|
|
384
|
+
.command-log-value { color: #fff; }
|
|
385
|
+
.command-log-empty { color: #666; text-align: center; padding: 1rem; }
|
|
386
|
+
</style>
|
|
387
|
+
</head>
|
|
388
|
+
<body>
|
|
389
|
+
<div class="container">
|
|
390
|
+
<h1>Telemetry Validator</h1>
|
|
391
|
+
<p class="subtitle">Validate real-time telemetry and see field normalization</p>
|
|
392
|
+
|
|
393
|
+
<!-- Auth -->
|
|
394
|
+
<div id="auth-section" class="card auth-section">
|
|
395
|
+
<p style="margin-bottom: 1rem; color: #888;">Connect to validate telemetry from your broadcaster</p>
|
|
396
|
+
<button class="auth-btn" onclick="doLogin()">Connect with GameGlue</button>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<!-- Dashboard -->
|
|
400
|
+
<div id="dashboard" class="hidden">
|
|
401
|
+
<!-- Connection -->
|
|
402
|
+
<div class="card">
|
|
403
|
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem;">
|
|
404
|
+
<h2 style="margin: 0;">Connection</h2>
|
|
405
|
+
<span id="status-badge" class="status-badge disconnected">Disconnected</span>
|
|
406
|
+
</div>
|
|
407
|
+
<div class="form-row">
|
|
408
|
+
<div class="form-group">
|
|
409
|
+
<label>Game</label>
|
|
410
|
+
<select id="game-select" onchange="onGameChange()">
|
|
411
|
+
<option value="msfs">Microsoft Flight Simulator</option>
|
|
412
|
+
<option value="xplane">X-Plane 12</option>
|
|
413
|
+
</select>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="form-group">
|
|
416
|
+
<label>User ID (Broadcaster)</label>
|
|
417
|
+
<input type="text" id="user-id" placeholder="Your user ID">
|
|
418
|
+
</div>
|
|
419
|
+
<div class="btn-group">
|
|
420
|
+
<button id="connect-btn" class="btn btn-primary" onclick="startValidation()">Start</button>
|
|
421
|
+
<button id="stop-btn" class="btn btn-danger hidden" onclick="stopValidation()">Stop</button>
|
|
422
|
+
<button id="reset-btn" class="btn btn-secondary hidden" onclick="resetStats()">Reset</button>
|
|
423
|
+
<button id="export-btn" class="btn btn-secondary hidden" onclick="exportReport()">Export</button>
|
|
424
|
+
<button class="btn btn-secondary" onclick="doLogout()">Logout</button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
<div id="error-msg" class="hidden" style="color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem;"></div>
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
<!-- Tabs -->
|
|
431
|
+
<div id="tabs-section" class="hidden">
|
|
432
|
+
<div class="tabs">
|
|
433
|
+
<button class="tab active" onclick="switchTab('telemetry')">Telemetry</button>
|
|
434
|
+
<button class="tab" onclick="switchTab('commands')">Commands</button>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
|
|
438
|
+
<!-- Stats -->
|
|
439
|
+
<div id="stats-section" class="hidden">
|
|
440
|
+
<div class="stats-grid">
|
|
441
|
+
<div class="stat-card">
|
|
442
|
+
<div id="stat-rate" class="stat-value">0</div>
|
|
443
|
+
<div class="stat-label">Hz</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="stat-card">
|
|
446
|
+
<div id="stat-total" class="stat-value">0</div>
|
|
447
|
+
<div class="stat-label">Updates</div>
|
|
448
|
+
</div>
|
|
449
|
+
<div class="stat-card">
|
|
450
|
+
<div id="stat-fields" class="stat-value">0/0</div>
|
|
451
|
+
<div class="stat-label">Fields</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div class="stat-card">
|
|
454
|
+
<div id="stat-required" class="stat-value">0/0</div>
|
|
455
|
+
<div class="stat-label">Required</div>
|
|
456
|
+
</div>
|
|
457
|
+
<div class="stat-card">
|
|
458
|
+
<div id="stat-key-events" class="stat-value">0</div>
|
|
459
|
+
<div class="stat-label">Key Events</div>
|
|
460
|
+
</div>
|
|
461
|
+
<div class="stat-card">
|
|
462
|
+
<div id="stat-issues" class="stat-value success">0</div>
|
|
463
|
+
<div class="stat-label">Issues</div>
|
|
464
|
+
</div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<!-- Key Events -->
|
|
469
|
+
<div id="key-events-section" class="hidden">
|
|
470
|
+
<div class="card">
|
|
471
|
+
<h2>Key Events</h2>
|
|
472
|
+
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
473
|
+
Computed events from gg-client: landings, takeoffs, and flight phase changes.
|
|
474
|
+
</p>
|
|
475
|
+
<div id="key-events-list" class="key-events-list">
|
|
476
|
+
<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger events.</div>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
|
|
481
|
+
<!-- Waiting -->
|
|
482
|
+
<div id="waiting-section" class="hidden">
|
|
483
|
+
<div class="card waiting">
|
|
484
|
+
<div class="spinner"></div>
|
|
485
|
+
<p>Waiting for telemetry...</p>
|
|
486
|
+
<p style="font-size: 0.75rem; margin-top: 0.5rem;">Make sure gg-client is broadcasting.</p>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
<!-- Unexpected Fields -->
|
|
491
|
+
<div id="unexpected-section" class="hidden">
|
|
492
|
+
<div class="card">
|
|
493
|
+
<h2>Unmapped Fields</h2>
|
|
494
|
+
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
495
|
+
These raw fields aren't mapped to canonical names yet.
|
|
496
|
+
</p>
|
|
497
|
+
<div id="unexpected-fields" class="unexpected-fields"></div>
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
|
|
501
|
+
<!-- Coverage -->
|
|
502
|
+
<div id="coverage-section" class="hidden">
|
|
503
|
+
<div class="card">
|
|
504
|
+
<h2>Field Coverage</h2>
|
|
505
|
+
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
506
|
+
<span class="canonical">Canonical</span> <span class="arrow">←</span> <span class="raw">raw_field</span>
|
|
507
|
+
shows how game fields map to normalized names.
|
|
508
|
+
</p>
|
|
509
|
+
<div id="categories"></div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
|
|
513
|
+
<!-- Commands Section -->
|
|
514
|
+
<div id="commands-section" class="hidden">
|
|
515
|
+
<div class="card">
|
|
516
|
+
<h2>Send Commands</h2>
|
|
517
|
+
<p style="font-size: 0.75rem; color: #666; margin-bottom: 0.75rem;">
|
|
518
|
+
Send canonical commands to the simulator. Commands work across both MSFS and X-Plane.
|
|
519
|
+
</p>
|
|
520
|
+
<div id="commands-grid" class="commands-grid"></div>
|
|
521
|
+
<div class="command-log">
|
|
522
|
+
<div id="command-log-entries">
|
|
523
|
+
<div class="command-log-empty">No commands sent yet</div>
|
|
524
|
+
</div>
|
|
525
|
+
</div>
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
|
|
529
|
+
<!-- Not Listening -->
|
|
530
|
+
<div id="not-listening-section">
|
|
531
|
+
<div class="card" style="text-align: center; padding: 2rem;">
|
|
532
|
+
<p style="color: #fff; margin-bottom: 0.5rem;">Ready to validate</p>
|
|
533
|
+
<p style="color: #666; font-size: 0.8rem;">Select a game and click Start to begin.</p>
|
|
534
|
+
<div class="instructions">
|
|
535
|
+
<h3>About Normalization</h3>
|
|
536
|
+
<ol>
|
|
537
|
+
<li>Each sim uses different field names (e.g., MSFS: <code>indicated_airspeed</code>, X-Plane: <code>ias</code>)</li>
|
|
538
|
+
<li>GameGlue normalizes these to canonical names (e.g., <code>indicated_airspeed</code>)</li>
|
|
539
|
+
<li>This lets you write code once that works across all supported sims</li>
|
|
540
|
+
</ol>
|
|
541
|
+
</div>
|
|
542
|
+
</div>
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<!-- Toast notification -->
|
|
548
|
+
<div id="toast" class="toast">Report copied to clipboard!</div>
|
|
549
|
+
|
|
550
|
+
<script src="../dist/gg.umd.js"></script>
|
|
551
|
+
<script>
|
|
552
|
+
// ===========================================
|
|
553
|
+
// CANONICAL SCHEMA (loaded from @gameglue/schemas)
|
|
554
|
+
// ===========================================
|
|
555
|
+
// Fields that are 0-1 ratios but displayed as percentages
|
|
556
|
+
const RATIO_FIELDS = ['throttle_0', 'throttle_1', 'throttle_2', 'throttle_3', 'flaps', 'spoiler'];
|
|
557
|
+
|
|
558
|
+
// Unit display mapping (schema units -> display symbols)
|
|
559
|
+
const UNIT_DISPLAY = {
|
|
560
|
+
'degrees': '°',
|
|
561
|
+
'deg': '°',
|
|
562
|
+
'ft': 'ft',
|
|
563
|
+
'kts': 'kts',
|
|
564
|
+
'fpm': 'fpm',
|
|
565
|
+
'lbs': 'lbs',
|
|
566
|
+
'lbs/hr': 'lbs/hr',
|
|
567
|
+
'rpm': 'RPM',
|
|
568
|
+
'%': '%',
|
|
569
|
+
'ratio': '%',
|
|
570
|
+
'°C': '°C',
|
|
571
|
+
'G': 'G',
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// Build CANONICAL from category schema
|
|
575
|
+
function buildCanonicalFromSchema() {
|
|
576
|
+
const categorySchema = window.GameGlueSchemas?.getCategorySchema('flight_sim');
|
|
577
|
+
if (!categorySchema) {
|
|
578
|
+
console.warn('Category schema not available, using fallback');
|
|
579
|
+
return { requiredFields: [], fields: {} };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const fields = {};
|
|
583
|
+
for (const [fieldName, fieldDef] of Object.entries(categorySchema.fields)) {
|
|
584
|
+
fields[fieldName] = {
|
|
585
|
+
group: fieldDef.group || 'Other',
|
|
586
|
+
desc: fieldDef.description || fieldName,
|
|
587
|
+
unit: UNIT_DISPLAY[fieldDef.unit] || fieldDef.unit || '',
|
|
588
|
+
type: fieldDef.type,
|
|
589
|
+
ratio: RATIO_FIELDS.includes(fieldName),
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
requiredFields: categorySchema.requiredFields || [],
|
|
595
|
+
fields
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Initialize after SDK loads
|
|
600
|
+
let CANONICAL = { requiredFields: [], fields: {} };
|
|
601
|
+
|
|
602
|
+
// ===========================================
|
|
603
|
+
// GAME SELECTION (schemas from @gameglue/schemas via SDK)
|
|
604
|
+
// ===========================================
|
|
605
|
+
const GAME_OPTIONS = [
|
|
606
|
+
{ gameId: 'msfs', name: 'Microsoft Flight Simulator' },
|
|
607
|
+
{ gameId: 'xplane', name: 'X-Plane 12' }
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
// Build reverse mappings (canonical -> raw) from schema
|
|
611
|
+
function buildReverseMappings(gameId) {
|
|
612
|
+
const schema = window.GameGlueSchemas?.getGameSchema(gameId);
|
|
613
|
+
if (!schema?.fieldMappings) return {};
|
|
614
|
+
|
|
615
|
+
const reverse = {};
|
|
616
|
+
for (const [rawField, mapping] of Object.entries(schema.fieldMappings)) {
|
|
617
|
+
const canonical = mapping.canonical;
|
|
618
|
+
if (!reverse[canonical]) {
|
|
619
|
+
reverse[canonical] = rawField;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return reverse;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ===========================================
|
|
626
|
+
// STATE
|
|
627
|
+
// ===========================================
|
|
628
|
+
const CLIENT_ID = 'gameglue-sdk-examples';
|
|
629
|
+
const REDIRECT_URI = window.location.href.split('?')[0].split('#')[0];
|
|
630
|
+
// For local development, add socketUrl: 'http://localhost:3031'
|
|
631
|
+
const ggClient = new GameGlue({ clientId: CLIENT_ID, redirect_uri: REDIRECT_URI, scopes: ['msfs:read', 'msfs:write', 'xplane:read', 'xplane:write'] });
|
|
632
|
+
|
|
633
|
+
let listener = null;
|
|
634
|
+
let currentGameId = 'msfs';
|
|
635
|
+
let reverseMappings = {}; // canonical -> raw field name
|
|
636
|
+
let state = {
|
|
637
|
+
totalUpdates: 0,
|
|
638
|
+
timestamps: [],
|
|
639
|
+
fieldStats: {}, // canonical -> { rawField, lastValue, updateCount }
|
|
640
|
+
unmappedFields: new Set(),
|
|
641
|
+
keyEvents: [] // Array of { eventType, data, receivedAt }
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// ===========================================
|
|
645
|
+
// PROCESS TELEMETRY (using SDK's normalized data)
|
|
646
|
+
// ===========================================
|
|
647
|
+
function processUpdate(evt) {
|
|
648
|
+
const now = Date.now();
|
|
649
|
+
state.totalUpdates++;
|
|
650
|
+
state.timestamps.push(now);
|
|
651
|
+
if (state.timestamps.length > 100) state.timestamps = state.timestamps.slice(-100);
|
|
652
|
+
|
|
653
|
+
const normalizedData = evt.data || {};
|
|
654
|
+
const rawData = evt.raw || {};
|
|
655
|
+
|
|
656
|
+
// Track canonical fields (already normalized by SDK via @gameglue/schemas)
|
|
657
|
+
for (const [canonical, value] of Object.entries(normalizedData)) {
|
|
658
|
+
// Find which raw field produced this canonical field
|
|
659
|
+
const rawField = reverseMappings[canonical] || canonical;
|
|
660
|
+
const existing = state.fieldStats[canonical] || { rawField, updateCount: 0 };
|
|
661
|
+
state.fieldStats[canonical] = {
|
|
662
|
+
rawField,
|
|
663
|
+
lastValue: value,
|
|
664
|
+
updateCount: existing.updateCount + 1
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Track unmapped raw fields (not in schema)
|
|
669
|
+
for (const rawField of Object.keys(rawData)) {
|
|
670
|
+
const schema = window.GameGlueSchemas?.getGameSchema(currentGameId);
|
|
671
|
+
if (schema?.fieldMappings && !schema.fieldMappings[rawField]) {
|
|
672
|
+
state.unmappedFields.add(rawField);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
updateUI();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function getRate() {
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
return state.timestamps.filter(t => now - t < 1000).length;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ===========================================
|
|
685
|
+
// KEY EVENTS
|
|
686
|
+
// ===========================================
|
|
687
|
+
function processKeyEvent(eventType, data) {
|
|
688
|
+
state.keyEvents.unshift({
|
|
689
|
+
eventType,
|
|
690
|
+
data,
|
|
691
|
+
receivedAt: Date.now()
|
|
692
|
+
});
|
|
693
|
+
// Keep last 50 events
|
|
694
|
+
if (state.keyEvents.length > 50) {
|
|
695
|
+
state.keyEvents.pop();
|
|
696
|
+
}
|
|
697
|
+
updateKeyEventsUI();
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function updateKeyEventsUI() {
|
|
701
|
+
const list = document.getElementById('key-events-list');
|
|
702
|
+
const stat = document.getElementById('stat-key-events');
|
|
703
|
+
|
|
704
|
+
stat.textContent = state.keyEvents.length;
|
|
705
|
+
stat.className = 'stat-value ' + (state.keyEvents.length > 0 ? 'success' : '');
|
|
706
|
+
|
|
707
|
+
if (state.keyEvents.length === 0) {
|
|
708
|
+
list.innerHTML = '<div class="key-events-empty">No key events received yet. Fly the aircraft to trigger landings, takeoffs, and phase changes.</div>';
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
list.innerHTML = state.keyEvents.map(evt => renderKeyEvent(evt)).join('');
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function renderKeyEvent(evt) {
|
|
716
|
+
const { eventType, data, receivedAt } = evt;
|
|
717
|
+
const time = new Date(receivedAt).toLocaleTimeString();
|
|
718
|
+
const icon = getEventIcon(eventType);
|
|
719
|
+
const details = getEventDetails(eventType, data);
|
|
720
|
+
|
|
721
|
+
return `
|
|
722
|
+
<div class="key-event ${eventType}">
|
|
723
|
+
<div class="key-event-icon">${icon}</div>
|
|
724
|
+
<div class="key-event-content">
|
|
725
|
+
<div class="key-event-header">
|
|
726
|
+
<span class="key-event-type">${formatEventType(eventType)}</span>
|
|
727
|
+
<span class="key-event-time">${time}</span>
|
|
728
|
+
</div>
|
|
729
|
+
<div class="key-event-details">${details}</div>
|
|
730
|
+
</div>
|
|
731
|
+
</div>
|
|
732
|
+
`;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function getEventIcon(eventType) {
|
|
736
|
+
switch (eventType) {
|
|
737
|
+
case 'landing': return '✈'; // airplane
|
|
738
|
+
case 'takeoff': return '🚀'; // rocket
|
|
739
|
+
case 'flight_phase': return '🗼'; // round pushpin
|
|
740
|
+
default: return '●'; // bullet
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function formatEventType(eventType) {
|
|
745
|
+
switch (eventType) {
|
|
746
|
+
case 'landing': return 'Landing';
|
|
747
|
+
case 'takeoff': return 'Takeoff';
|
|
748
|
+
case 'flight_phase': return 'Phase Change';
|
|
749
|
+
default: return eventType;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function getEventDetails(eventType, data) {
|
|
754
|
+
switch (eventType) {
|
|
755
|
+
case 'landing':
|
|
756
|
+
const bounceCount = data.bounce_count || 0;
|
|
757
|
+
const bounceInfo = bounceCount > 0
|
|
758
|
+
? `<span class="key-event-detail"><span class="key-event-detail-label">Bounces:</span><span class="key-event-detail-value" style="color:#fbbf24">${bounceCount}</span></span>`
|
|
759
|
+
: '';
|
|
760
|
+
return `
|
|
761
|
+
<span class="key-event-quality ${data.quality || 'normal'}">${data.quality || 'unknown'}</span>
|
|
762
|
+
<span class="key-event-detail">
|
|
763
|
+
<span class="key-event-detail-label">Rate:</span>
|
|
764
|
+
<span class="key-event-detail-value">${Math.abs(data.landing_rate || 0).toFixed(0)} fpm</span>
|
|
765
|
+
</span>
|
|
766
|
+
<span class="key-event-detail">
|
|
767
|
+
<span class="key-event-detail-label">Speed:</span>
|
|
768
|
+
<span class="key-event-detail-value">${(data.speed_at_touchdown || 0).toFixed(0)} kts</span>
|
|
769
|
+
</span>
|
|
770
|
+
<span class="key-event-detail">
|
|
771
|
+
<span class="key-event-detail-label">Pitch:</span>
|
|
772
|
+
<span class="key-event-detail-value">${(data.pitch_at_touchdown || 0).toFixed(1)}°</span>
|
|
773
|
+
</span>
|
|
774
|
+
${bounceInfo}
|
|
775
|
+
`;
|
|
776
|
+
case 'takeoff':
|
|
777
|
+
return `
|
|
778
|
+
<span class="key-event-detail">
|
|
779
|
+
<span class="key-event-detail-label">Rotation:</span>
|
|
780
|
+
<span class="key-event-detail-value">${(data.rotation_speed || 0).toFixed(0)} kts</span>
|
|
781
|
+
</span>
|
|
782
|
+
<span class="key-event-detail">
|
|
783
|
+
<span class="key-event-detail-label">Pitch:</span>
|
|
784
|
+
<span class="key-event-detail-value">${(data.pitch_at_liftoff || 0).toFixed(1)}°</span>
|
|
785
|
+
</span>
|
|
786
|
+
<span class="key-event-detail">
|
|
787
|
+
<span class="key-event-detail-label">Flaps:</span>
|
|
788
|
+
<span class="key-event-detail-value">${((data.flaps_setting || 0) * 100).toFixed(0)}%</span>
|
|
789
|
+
</span>
|
|
790
|
+
`;
|
|
791
|
+
case 'flight_phase':
|
|
792
|
+
return `
|
|
793
|
+
<span class="key-event-detail">
|
|
794
|
+
<span class="key-event-detail-label">Phase:</span>
|
|
795
|
+
<span class="key-event-detail-value">${formatPhase(data.phase)}</span>
|
|
796
|
+
</span>
|
|
797
|
+
<span class="key-event-detail">
|
|
798
|
+
<span class="key-event-detail-label">From:</span>
|
|
799
|
+
<span class="key-event-detail-value">${formatPhase(data.previous_phase)}</span>
|
|
800
|
+
</span>
|
|
801
|
+
<span class="key-event-detail">
|
|
802
|
+
<span class="key-event-detail-label">MSL:</span>
|
|
803
|
+
<span class="key-event-detail-value">${(data.altitude_msl || 0).toFixed(0)} ft</span>
|
|
804
|
+
</span>
|
|
805
|
+
<span class="key-event-detail">
|
|
806
|
+
<span class="key-event-detail-label">AGL:</span>
|
|
807
|
+
<span class="key-event-detail-value">${(data.altitude_agl || 0).toFixed(0)} ft</span>
|
|
808
|
+
</span>
|
|
809
|
+
<span class="key-event-detail">
|
|
810
|
+
<span class="key-event-detail-label">Speed:</span>
|
|
811
|
+
<span class="key-event-detail-value">${(data.speed || 0).toFixed(0)} kts</span>
|
|
812
|
+
</span>
|
|
813
|
+
`;
|
|
814
|
+
default:
|
|
815
|
+
return `<span class="key-event-detail"><span class="key-event-detail-value">${JSON.stringify(data)}</span></span>`;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function formatPhase(phase) {
|
|
820
|
+
if (!phase) return 'unknown';
|
|
821
|
+
return phase.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ===========================================
|
|
825
|
+
// UI
|
|
826
|
+
// ===========================================
|
|
827
|
+
function updateUI() {
|
|
828
|
+
const rate = getRate();
|
|
829
|
+
const totalCanonical = Object.keys(CANONICAL.fields).length;
|
|
830
|
+
const received = Object.keys(state.fieldStats).length;
|
|
831
|
+
const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
|
|
832
|
+
const totalRequired = CANONICAL.requiredFields.length;
|
|
833
|
+
|
|
834
|
+
// Update stats (always visible in stats-section)
|
|
835
|
+
document.getElementById('stat-rate').textContent = rate;
|
|
836
|
+
document.getElementById('stat-rate').className = 'stat-value ' + (rate >= 10 ? 'success' : rate > 0 ? 'warning' : '');
|
|
837
|
+
document.getElementById('stat-total').textContent = state.totalUpdates;
|
|
838
|
+
document.getElementById('stat-fields').textContent = `${received}/${totalCanonical}`;
|
|
839
|
+
document.getElementById('stat-fields').className = 'stat-value ' + (received === totalCanonical ? 'success' : 'warning');
|
|
840
|
+
document.getElementById('stat-required').textContent = `${requiredReceived}/${totalRequired}`;
|
|
841
|
+
document.getElementById('stat-required').className = 'stat-value ' + (requiredReceived === totalRequired ? 'success' : 'error');
|
|
842
|
+
document.getElementById('stat-issues').textContent = state.unmappedFields.size;
|
|
843
|
+
document.getElementById('stat-issues').className = 'stat-value ' + (state.unmappedFields.size === 0 ? 'success' : 'warning');
|
|
844
|
+
|
|
845
|
+
// Only update section visibility if on telemetry tab
|
|
846
|
+
if (currentTab !== 'telemetry') return;
|
|
847
|
+
|
|
848
|
+
if (state.totalUpdates === 0) {
|
|
849
|
+
document.getElementById('waiting-section').classList.remove('hidden');
|
|
850
|
+
document.getElementById('coverage-section').classList.add('hidden');
|
|
851
|
+
} else {
|
|
852
|
+
document.getElementById('waiting-section').classList.add('hidden');
|
|
853
|
+
document.getElementById('coverage-section').classList.remove('hidden');
|
|
854
|
+
renderCategories();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (state.unmappedFields.size > 0) {
|
|
858
|
+
document.getElementById('unexpected-section').classList.remove('hidden');
|
|
859
|
+
document.getElementById('unexpected-fields').innerHTML = Array.from(state.unmappedFields)
|
|
860
|
+
.map(f => `<span class="unexpected-field">${f}</span>`).join('');
|
|
861
|
+
} else {
|
|
862
|
+
document.getElementById('unexpected-section').classList.add('hidden');
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function renderCategories() {
|
|
867
|
+
const groups = {};
|
|
868
|
+
for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
|
|
869
|
+
const g = def.group || 'Other';
|
|
870
|
+
if (!groups[g]) groups[g] = [];
|
|
871
|
+
groups[g].push({ canonical, ...def });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
let html = '';
|
|
875
|
+
for (const [group, fields] of Object.entries(groups)) {
|
|
876
|
+
const received = fields.filter(f => state.fieldStats[f.canonical]).length;
|
|
877
|
+
const complete = received === fields.length;
|
|
878
|
+
|
|
879
|
+
html += `<div class="category-header">
|
|
880
|
+
<span class="category-name">${group}</span>
|
|
881
|
+
<span class="category-count ${complete ? 'complete' : 'incomplete'}">${received}/${fields.length}</span>
|
|
882
|
+
</div>`;
|
|
883
|
+
html += '<table class="field-table"><thead><tr><th>Canonical Field</th><th>Raw Field</th><th style="text-align:right">Value</th><th style="text-align:right">Status</th></tr></thead><tbody>';
|
|
884
|
+
|
|
885
|
+
for (const field of fields) {
|
|
886
|
+
const stats = state.fieldStats[field.canonical];
|
|
887
|
+
const isRequired = CANONICAL.requiredFields.includes(field.canonical);
|
|
888
|
+
let status = 'missing';
|
|
889
|
+
let rawField = '—';
|
|
890
|
+
let value = '—';
|
|
891
|
+
|
|
892
|
+
if (stats) {
|
|
893
|
+
status = 'ok';
|
|
894
|
+
rawField = stats.rawField;
|
|
895
|
+
let displayVal = stats.lastValue;
|
|
896
|
+
// Convert ratio (0-1) to percentage (0-100) for fields marked as ratio
|
|
897
|
+
if (typeof displayVal === 'number' && field.ratio) {
|
|
898
|
+
displayVal = displayVal * 100;
|
|
899
|
+
}
|
|
900
|
+
value = typeof displayVal === 'number' ? displayVal.toFixed(2) : String(displayVal);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
html += `<tr class="${status}">
|
|
904
|
+
<td>
|
|
905
|
+
<span class="canonical">${field.canonical}</span>
|
|
906
|
+
${isRequired ? '<span class="required">*</span>' : ''}
|
|
907
|
+
<span style="color:#666;font-size:0.7rem;margin-left:0.5rem">${field.desc}</span>
|
|
908
|
+
</td>
|
|
909
|
+
<td><span class="raw">${rawField}</span></td>
|
|
910
|
+
<td class="field-value">${value}${field.unit ? `<span class="field-unit">${field.unit}</span>` : ''}</td>
|
|
911
|
+
<td style="text-align:right"><span class="badge ${status}">${status}</span></td>
|
|
912
|
+
</tr>`;
|
|
913
|
+
}
|
|
914
|
+
html += '</tbody></table>';
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
document.getElementById('categories').innerHTML = html;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ===========================================
|
|
921
|
+
// CONNECTION
|
|
922
|
+
// ===========================================
|
|
923
|
+
async function startValidation() {
|
|
924
|
+
const userId = document.getElementById('user-id').value.trim();
|
|
925
|
+
if (!userId) { showError('Enter user ID'); return; }
|
|
926
|
+
|
|
927
|
+
try {
|
|
928
|
+
showError('');
|
|
929
|
+
setStatus('listening', 'Connecting...');
|
|
930
|
+
|
|
931
|
+
// Build reverse mappings from schema (canonical -> raw)
|
|
932
|
+
reverseMappings = buildReverseMappings(currentGameId);
|
|
933
|
+
|
|
934
|
+
listener = await ggClient.createListener({ userId, gameId: currentGameId });
|
|
935
|
+
// Use full event - SDK provides both evt.raw and evt.data (normalized via @gameglue/schemas)
|
|
936
|
+
listener.on('update', evt => processUpdate(evt));
|
|
937
|
+
|
|
938
|
+
// Key events (landing, takeoff, flight phase changes)
|
|
939
|
+
listener.on('landing', data => processKeyEvent('landing', data));
|
|
940
|
+
listener.on('takeoff', data => processKeyEvent('takeoff', data));
|
|
941
|
+
listener.on('flight_phase', data => processKeyEvent('flight_phase', data));
|
|
942
|
+
|
|
943
|
+
setStatus('listening', 'Listening');
|
|
944
|
+
document.getElementById('connect-btn').classList.add('hidden');
|
|
945
|
+
document.getElementById('stop-btn').classList.remove('hidden');
|
|
946
|
+
document.getElementById('reset-btn').classList.remove('hidden');
|
|
947
|
+
document.getElementById('export-btn').classList.remove('hidden');
|
|
948
|
+
document.getElementById('tabs-section').classList.remove('hidden');
|
|
949
|
+
document.getElementById('stats-section').classList.remove('hidden');
|
|
950
|
+
document.getElementById('key-events-section').classList.remove('hidden');
|
|
951
|
+
document.getElementById('waiting-section').classList.remove('hidden');
|
|
952
|
+
document.getElementById('not-listening-section').classList.add('hidden');
|
|
953
|
+
document.getElementById('game-select').disabled = true;
|
|
954
|
+
document.getElementById('user-id').disabled = true;
|
|
955
|
+
renderCommands();
|
|
956
|
+
} catch (err) {
|
|
957
|
+
showError(err.message);
|
|
958
|
+
setStatus('disconnected', 'Disconnected');
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function stopValidation() {
|
|
963
|
+
listener = null;
|
|
964
|
+
setStatus('disconnected', 'Stopped');
|
|
965
|
+
document.getElementById('connect-btn').classList.remove('hidden');
|
|
966
|
+
document.getElementById('stop-btn').classList.add('hidden');
|
|
967
|
+
document.getElementById('reset-btn').classList.add('hidden');
|
|
968
|
+
document.getElementById('export-btn').classList.add('hidden');
|
|
969
|
+
document.getElementById('tabs-section').classList.add('hidden');
|
|
970
|
+
document.getElementById('key-events-section').classList.add('hidden');
|
|
971
|
+
document.getElementById('commands-section').classList.add('hidden');
|
|
972
|
+
document.getElementById('game-select').disabled = false;
|
|
973
|
+
document.getElementById('user-id').disabled = false;
|
|
974
|
+
// Reset to telemetry tab
|
|
975
|
+
currentTab = 'telemetry';
|
|
976
|
+
document.querySelectorAll('.tab').forEach(el => {
|
|
977
|
+
el.classList.toggle('active', el.textContent.toLowerCase() === 'telemetry');
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function resetStats() {
|
|
982
|
+
state.totalUpdates = 0;
|
|
983
|
+
state.timestamps = [];
|
|
984
|
+
state.fieldStats = {};
|
|
985
|
+
state.unmappedFields = new Set();
|
|
986
|
+
state.keyEvents = [];
|
|
987
|
+
commandLog = [];
|
|
988
|
+
updateUI();
|
|
989
|
+
updateKeyEventsUI();
|
|
990
|
+
renderCommandLog();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function onGameChange() {
|
|
994
|
+
currentGameId = document.getElementById('game-select').value;
|
|
995
|
+
reverseMappings = buildReverseMappings(currentGameId);
|
|
996
|
+
resetStats();
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function getGameName() {
|
|
1000
|
+
const opt = GAME_OPTIONS.find(g => g.gameId === currentGameId);
|
|
1001
|
+
return opt?.name || currentGameId;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function setStatus(s, text) {
|
|
1005
|
+
const el = document.getElementById('status-badge');
|
|
1006
|
+
el.className = 'status-badge ' + s;
|
|
1007
|
+
el.textContent = text;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function showError(msg) {
|
|
1011
|
+
const el = document.getElementById('error-msg');
|
|
1012
|
+
el.textContent = msg;
|
|
1013
|
+
el.classList.toggle('hidden', !msg);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function showToast(message) {
|
|
1017
|
+
const toast = document.getElementById('toast');
|
|
1018
|
+
toast.textContent = message;
|
|
1019
|
+
toast.classList.add('show');
|
|
1020
|
+
setTimeout(() => toast.classList.remove('show'), 2500);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// ===========================================
|
|
1024
|
+
// EXPORT REPORT
|
|
1025
|
+
// ===========================================
|
|
1026
|
+
function exportReport() {
|
|
1027
|
+
const rate = getRate();
|
|
1028
|
+
const totalCanonical = Object.keys(CANONICAL.fields).length;
|
|
1029
|
+
const received = Object.keys(state.fieldStats).length;
|
|
1030
|
+
const requiredReceived = CANONICAL.requiredFields.filter(f => state.fieldStats[f]).length;
|
|
1031
|
+
const totalRequired = CANONICAL.requiredFields.length;
|
|
1032
|
+
|
|
1033
|
+
// Group fields by category
|
|
1034
|
+
const groups = {};
|
|
1035
|
+
for (const [canonical, def] of Object.entries(CANONICAL.fields)) {
|
|
1036
|
+
const g = def.group || 'Other';
|
|
1037
|
+
if (!groups[g]) groups[g] = { total: 0, received: 0, fields: [] };
|
|
1038
|
+
groups[g].total++;
|
|
1039
|
+
const stats = state.fieldStats[canonical];
|
|
1040
|
+
if (stats) {
|
|
1041
|
+
groups[g].received++;
|
|
1042
|
+
// Convert ratio (0-1) to percentage (0-100) for display
|
|
1043
|
+
let displayValue = stats.lastValue;
|
|
1044
|
+
if (typeof displayValue === 'number' && def.ratio) {
|
|
1045
|
+
displayValue = displayValue * 100;
|
|
1046
|
+
}
|
|
1047
|
+
groups[g].fields.push({
|
|
1048
|
+
canonical,
|
|
1049
|
+
raw: stats.rawField,
|
|
1050
|
+
value: displayValue,
|
|
1051
|
+
unit: def.unit || ''
|
|
1052
|
+
});
|
|
1053
|
+
} else {
|
|
1054
|
+
groups[g].fields.push({ canonical, raw: null, value: null, unit: def.unit || '' });
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Build report
|
|
1059
|
+
let report = `# Telemetry Validator Report\n\n`;
|
|
1060
|
+
report += `**Game:** ${getGameName()} (\`${currentGameId}\`)\n`;
|
|
1061
|
+
report += `**Timestamp:** ${new Date().toISOString()}\n\n`;
|
|
1062
|
+
|
|
1063
|
+
report += `## Summary\n\n`;
|
|
1064
|
+
report += `| Metric | Value |\n`;
|
|
1065
|
+
report += `|--------|-------|\n`;
|
|
1066
|
+
report += `| Update Rate | ${rate} Hz |\n`;
|
|
1067
|
+
report += `| Total Updates | ${state.totalUpdates} |\n`;
|
|
1068
|
+
report += `| Field Coverage | ${received}/${totalCanonical} (${Math.round(received/totalCanonical*100)}%) |\n`;
|
|
1069
|
+
report += `| Required Fields | ${requiredReceived}/${totalRequired} (${requiredReceived === totalRequired ? 'PASS' : 'FAIL'}) |\n`;
|
|
1070
|
+
report += `| Key Events | ${state.keyEvents.length} |\n`;
|
|
1071
|
+
report += `| Unmapped Fields | ${state.unmappedFields.size} |\n\n`;
|
|
1072
|
+
|
|
1073
|
+
// Required fields status
|
|
1074
|
+
report += `## Required Fields\n\n`;
|
|
1075
|
+
report += `| Field | Status | Raw Name | Value |\n`;
|
|
1076
|
+
report += `|-------|--------|----------|-------|\n`;
|
|
1077
|
+
for (const field of CANONICAL.requiredFields) {
|
|
1078
|
+
const stats = state.fieldStats[field];
|
|
1079
|
+
const status = stats ? 'OK' : 'MISSING';
|
|
1080
|
+
const raw = stats?.rawField || '-';
|
|
1081
|
+
const value = stats ? formatValue(stats.lastValue) : '-';
|
|
1082
|
+
report += `| \`${field}\` | ${status} | \`${raw}\` | ${value} |\n`;
|
|
1083
|
+
}
|
|
1084
|
+
report += '\n';
|
|
1085
|
+
|
|
1086
|
+
// Coverage by category
|
|
1087
|
+
report += `## Field Coverage by Category\n\n`;
|
|
1088
|
+
for (const [group, data] of Object.entries(groups)) {
|
|
1089
|
+
const pct = Math.round(data.received / data.total * 100);
|
|
1090
|
+
report += `### ${group} (${data.received}/${data.total} - ${pct}%)\n\n`;
|
|
1091
|
+
report += `| Canonical | Raw | Value |\n`;
|
|
1092
|
+
report += `|-----------|-----|-------|\n`;
|
|
1093
|
+
for (const f of data.fields) {
|
|
1094
|
+
const raw = f.raw ? `\`${f.raw}\`` : '-';
|
|
1095
|
+
const val = f.value !== null ? formatValue(f.value) + (f.unit ? ` ${f.unit}` : '') : '-';
|
|
1096
|
+
report += `| \`${f.canonical}\` | ${raw} | ${val} |\n`;
|
|
1097
|
+
}
|
|
1098
|
+
report += '\n';
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Key Events
|
|
1102
|
+
if (state.keyEvents.length > 0) {
|
|
1103
|
+
report += `## Key Events\n\n`;
|
|
1104
|
+
report += `| Time | Type | Details |\n`;
|
|
1105
|
+
report += `|------|------|--------|\n`;
|
|
1106
|
+
for (const evt of state.keyEvents) {
|
|
1107
|
+
const time = new Date(evt.receivedAt).toLocaleTimeString();
|
|
1108
|
+
const type = formatEventType(evt.eventType);
|
|
1109
|
+
let details = '';
|
|
1110
|
+
switch (evt.eventType) {
|
|
1111
|
+
case 'landing':
|
|
1112
|
+
const bounces = evt.data.bounce_count || 0;
|
|
1113
|
+
details = `${evt.data.quality || 'unknown'} (${Math.abs(evt.data.landing_rate || 0).toFixed(0)} fpm)${bounces > 0 ? `, ${bounces} bounce${bounces > 1 ? 's' : ''}` : ''}`;
|
|
1114
|
+
break;
|
|
1115
|
+
case 'takeoff':
|
|
1116
|
+
details = `Vr: ${(evt.data.rotation_speed || 0).toFixed(0)} kts, Pitch: ${(evt.data.pitch_at_liftoff || 0).toFixed(1)}°`;
|
|
1117
|
+
break;
|
|
1118
|
+
case 'flight_phase':
|
|
1119
|
+
details = `${formatPhase(evt.data.previous_phase)} → ${formatPhase(evt.data.phase)}`;
|
|
1120
|
+
break;
|
|
1121
|
+
default:
|
|
1122
|
+
details = JSON.stringify(evt.data);
|
|
1123
|
+
}
|
|
1124
|
+
report += `| ${time} | ${type} | ${details} |\n`;
|
|
1125
|
+
}
|
|
1126
|
+
report += '\n';
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Unmapped fields
|
|
1130
|
+
if (state.unmappedFields.size > 0) {
|
|
1131
|
+
report += `## Unmapped Raw Fields\n\n`;
|
|
1132
|
+
report += `These fields are being sent but don't have canonical mappings:\n\n`;
|
|
1133
|
+
report += `\`\`\`\n${Array.from(state.unmappedFields).join(', ')}\n\`\`\`\n`;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Copy to clipboard
|
|
1137
|
+
navigator.clipboard.writeText(report).then(() => {
|
|
1138
|
+
showToast('Report copied to clipboard!');
|
|
1139
|
+
}).catch(err => {
|
|
1140
|
+
console.error('Failed to copy:', err);
|
|
1141
|
+
showToast('Failed to copy report');
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function formatValue(val) {
|
|
1146
|
+
if (typeof val === 'number') {
|
|
1147
|
+
return Number.isInteger(val) ? val.toString() : val.toFixed(2);
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
|
1150
|
+
return String(val);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// ===========================================
|
|
1154
|
+
// COMMANDS
|
|
1155
|
+
// ===========================================
|
|
1156
|
+
const COMMAND_CATEGORIES = {
|
|
1157
|
+
'Autopilot': [
|
|
1158
|
+
{ cmd: 'autopilot_on', label: 'AP On' },
|
|
1159
|
+
{ cmd: 'autopilot_off', label: 'AP Off' },
|
|
1160
|
+
{ cmd: 'autopilot_toggle', label: 'AP Toggle' },
|
|
1161
|
+
{ cmd: 'autopilot_altitude_hold_on', label: 'ALT Hold On' },
|
|
1162
|
+
{ cmd: 'autopilot_altitude_hold_off', label: 'ALT Hold Off' },
|
|
1163
|
+
{ cmd: 'autopilot_heading_hold_on', label: 'HDG Hold On' },
|
|
1164
|
+
{ cmd: 'autopilot_heading_hold_off', label: 'HDG Hold Off' },
|
|
1165
|
+
{ cmd: 'autopilot_vs_hold_on', label: 'VS Hold On' },
|
|
1166
|
+
{ cmd: 'autopilot_vs_hold_off', label: 'VS Hold Off' },
|
|
1167
|
+
{ cmd: 'autopilot_nav_on', label: 'NAV On' },
|
|
1168
|
+
{ cmd: 'autopilot_nav_off', label: 'NAV Off' },
|
|
1169
|
+
{ cmd: 'autopilot_approach_on', label: 'APR On' },
|
|
1170
|
+
{ cmd: 'autopilot_approach_off', label: 'APR Off' },
|
|
1171
|
+
{ cmd: 'flight_director_on', label: 'FD On' },
|
|
1172
|
+
{ cmd: 'flight_director_off', label: 'FD Off' },
|
|
1173
|
+
{ cmd: 'autopilot_flc_on', label: 'FLC On' },
|
|
1174
|
+
{ cmd: 'autopilot_flc_off', label: 'FLC Off' },
|
|
1175
|
+
{ cmd: 'autopilot_flc_toggle', label: 'FLC Toggle' },
|
|
1176
|
+
],
|
|
1177
|
+
'AP Targets': [
|
|
1178
|
+
{ cmd: 'set_autopilot_altitude', label: 'Set Altitude', input: true, unit: 'ft', default: 10000 },
|
|
1179
|
+
{ cmd: 'set_autopilot_heading', label: 'Set Heading', input: true, unit: '°', default: 360 },
|
|
1180
|
+
{ cmd: 'set_autopilot_vs', label: 'Set VS', input: true, unit: 'fpm', default: 1000 },
|
|
1181
|
+
{ cmd: 'set_autopilot_speed', label: 'Set Speed', input: true, unit: 'kts', default: 250 },
|
|
1182
|
+
],
|
|
1183
|
+
'Gear & Flaps': [
|
|
1184
|
+
{ cmd: 'gear_up', label: 'Gear Up' },
|
|
1185
|
+
{ cmd: 'gear_down', label: 'Gear Down' },
|
|
1186
|
+
{ cmd: 'gear_toggle', label: 'Gear Toggle' },
|
|
1187
|
+
{ cmd: 'flaps_up', label: 'Flaps Up' },
|
|
1188
|
+
{ cmd: 'flaps_down', label: 'Flaps Down' },
|
|
1189
|
+
{ cmd: 'flaps_full', label: 'Flaps Full' },
|
|
1190
|
+
{ cmd: 'flaps_retract', label: 'Flaps Retract' },
|
|
1191
|
+
{ cmd: 'set_flaps', label: 'Set Flaps', input: true, unit: '%', default: 50 },
|
|
1192
|
+
],
|
|
1193
|
+
'Spoilers': [
|
|
1194
|
+
{ cmd: 'spoilers_arm', label: 'Arm' },
|
|
1195
|
+
{ cmd: 'spoilers_deploy', label: 'Deploy' },
|
|
1196
|
+
{ cmd: 'spoilers_retract', label: 'Retract' },
|
|
1197
|
+
{ cmd: 'spoilers_toggle', label: 'Toggle' },
|
|
1198
|
+
],
|
|
1199
|
+
'Parking Brake': [
|
|
1200
|
+
{ cmd: 'parking_brake_on', label: 'Set' },
|
|
1201
|
+
{ cmd: 'parking_brake_off', label: 'Release' },
|
|
1202
|
+
{ cmd: 'parking_brake_toggle', label: 'Toggle' },
|
|
1203
|
+
],
|
|
1204
|
+
'Throttle': [
|
|
1205
|
+
{ cmd: 'throttle_full', label: 'Full' },
|
|
1206
|
+
{ cmd: 'throttle_idle', label: 'Idle' },
|
|
1207
|
+
{ cmd: 'throttle_cutoff', label: 'Cutoff' },
|
|
1208
|
+
{ cmd: 'set_throttle', label: 'Set All', input: true, unit: '%', default: 50 },
|
|
1209
|
+
{ cmd: 'set_throttle_0', label: 'Set Eng 1', input: true, unit: '%', default: 50 },
|
|
1210
|
+
{ cmd: 'set_throttle_1', label: 'Set Eng 2', input: true, unit: '%', default: 50 },
|
|
1211
|
+
],
|
|
1212
|
+
'Landing Lights': [
|
|
1213
|
+
{ cmd: 'landing_lights_on', label: 'On' },
|
|
1214
|
+
{ cmd: 'landing_lights_off', label: 'Off' },
|
|
1215
|
+
{ cmd: 'landing_lights_toggle', label: 'Toggle' },
|
|
1216
|
+
],
|
|
1217
|
+
'Taxi Lights': [
|
|
1218
|
+
{ cmd: 'taxi_lights_on', label: 'On' },
|
|
1219
|
+
{ cmd: 'taxi_lights_off', label: 'Off' },
|
|
1220
|
+
{ cmd: 'taxi_lights_toggle', label: 'Toggle' },
|
|
1221
|
+
],
|
|
1222
|
+
'Nav Lights': [
|
|
1223
|
+
{ cmd: 'nav_lights_on', label: 'On' },
|
|
1224
|
+
{ cmd: 'nav_lights_off', label: 'Off' },
|
|
1225
|
+
{ cmd: 'nav_lights_toggle', label: 'Toggle' },
|
|
1226
|
+
],
|
|
1227
|
+
'Beacon': [
|
|
1228
|
+
{ cmd: 'beacon_lights_on', label: 'On' },
|
|
1229
|
+
{ cmd: 'beacon_lights_off', label: 'Off' },
|
|
1230
|
+
{ cmd: 'beacon_lights_toggle', label: 'Toggle' },
|
|
1231
|
+
],
|
|
1232
|
+
'Strobes': [
|
|
1233
|
+
{ cmd: 'strobe_lights_on', label: 'On' },
|
|
1234
|
+
{ cmd: 'strobe_lights_off', label: 'Off' },
|
|
1235
|
+
{ cmd: 'strobe_lights_toggle', label: 'Toggle' },
|
|
1236
|
+
],
|
|
1237
|
+
'Simulation': [
|
|
1238
|
+
{ cmd: 'pause', label: 'Pause' },
|
|
1239
|
+
{ cmd: 'unpause', label: 'Unpause' },
|
|
1240
|
+
{ cmd: 'pause_toggle', label: 'Toggle Pause' },
|
|
1241
|
+
],
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
let commandLog = [];
|
|
1245
|
+
|
|
1246
|
+
function renderCommands() {
|
|
1247
|
+
const grid = document.getElementById('commands-grid');
|
|
1248
|
+
const isConnected = listener !== null;
|
|
1249
|
+
|
|
1250
|
+
let html = '';
|
|
1251
|
+
for (const [category, commands] of Object.entries(COMMAND_CATEGORIES)) {
|
|
1252
|
+
html += `<div class="command-category"><h3>${category}</h3><div class="command-list">`;
|
|
1253
|
+
for (const cmd of commands) {
|
|
1254
|
+
if (cmd.input) {
|
|
1255
|
+
html += `
|
|
1256
|
+
<div class="command-item">
|
|
1257
|
+
<button class="command-btn" onclick="sendCommandWithInput('${cmd.cmd}', '${cmd.cmd}-input')" ${!isConnected ? 'disabled' : ''}>
|
|
1258
|
+
${cmd.label}
|
|
1259
|
+
</button>
|
|
1260
|
+
<input type="number" id="${cmd.cmd}-input" class="command-input" value="${cmd.default || 0}" ${!isConnected ? 'disabled' : ''}>
|
|
1261
|
+
<span class="command-unit">${cmd.unit || ''}</span>
|
|
1262
|
+
</div>
|
|
1263
|
+
`;
|
|
1264
|
+
} else {
|
|
1265
|
+
html += `
|
|
1266
|
+
<div class="command-item">
|
|
1267
|
+
<button class="command-btn" onclick="sendCommand('${cmd.cmd}')" ${!isConnected ? 'disabled' : ''}>
|
|
1268
|
+
${cmd.label}
|
|
1269
|
+
</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
`;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
html += '</div></div>';
|
|
1275
|
+
}
|
|
1276
|
+
grid.innerHTML = html;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
async function sendCommand(cmd) {
|
|
1280
|
+
if (!listener) {
|
|
1281
|
+
showToast('Not connected');
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
await listener.sendCommand(cmd, true);
|
|
1286
|
+
logCommand(cmd, true);
|
|
1287
|
+
showToast(`Sent: ${cmd}`);
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
console.error('Command failed:', err);
|
|
1290
|
+
showToast(`Failed: ${err.message}`);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async function sendCommandWithInput(cmd, inputId) {
|
|
1295
|
+
if (!listener) {
|
|
1296
|
+
showToast('Not connected');
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const input = document.getElementById(inputId);
|
|
1300
|
+
let value = parseFloat(input.value);
|
|
1301
|
+
|
|
1302
|
+
// Convert percentage to ratio for throttle/flaps commands
|
|
1303
|
+
if (cmd.startsWith('set_throttle') || cmd === 'set_flaps') {
|
|
1304
|
+
value = value / 100;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
try {
|
|
1308
|
+
await listener.sendCommand(cmd, value);
|
|
1309
|
+
logCommand(cmd, value);
|
|
1310
|
+
showToast(`Sent: ${cmd} = ${input.value}`);
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
console.error('Command failed:', err);
|
|
1313
|
+
showToast(`Failed: ${err.message}`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function logCommand(cmd, value) {
|
|
1318
|
+
commandLog.unshift({
|
|
1319
|
+
cmd,
|
|
1320
|
+
value,
|
|
1321
|
+
time: new Date().toLocaleTimeString()
|
|
1322
|
+
});
|
|
1323
|
+
if (commandLog.length > 20) commandLog.pop();
|
|
1324
|
+
renderCommandLog();
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function renderCommandLog() {
|
|
1328
|
+
const container = document.getElementById('command-log-entries');
|
|
1329
|
+
if (commandLog.length === 0) {
|
|
1330
|
+
container.innerHTML = '<div class="command-log-empty">No commands sent yet</div>';
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
container.innerHTML = commandLog.map(entry => `
|
|
1334
|
+
<div class="command-log-entry">
|
|
1335
|
+
<span class="command-log-time">${entry.time}</span>
|
|
1336
|
+
<span class="command-log-cmd">${entry.cmd}</span>
|
|
1337
|
+
<span class="command-log-value">${typeof entry.value === 'number' ? entry.value.toFixed(2) : entry.value}</span>
|
|
1338
|
+
</div>
|
|
1339
|
+
`).join('');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// ===========================================
|
|
1343
|
+
// TABS
|
|
1344
|
+
// ===========================================
|
|
1345
|
+
let currentTab = 'telemetry';
|
|
1346
|
+
|
|
1347
|
+
function switchTab(tab) {
|
|
1348
|
+
currentTab = tab;
|
|
1349
|
+
|
|
1350
|
+
// Update tab buttons
|
|
1351
|
+
document.querySelectorAll('.tab').forEach(el => {
|
|
1352
|
+
el.classList.toggle('active', el.textContent.trim().toLowerCase() === tab);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
// Show/hide tab content
|
|
1356
|
+
const telemetrySections = ['stats-section', 'key-events-section', 'waiting-section', 'unexpected-section', 'coverage-section'];
|
|
1357
|
+
|
|
1358
|
+
if (tab === 'telemetry') {
|
|
1359
|
+
// Show telemetry sections based on state
|
|
1360
|
+
telemetrySections.forEach(id => {
|
|
1361
|
+
const el = document.getElementById(id);
|
|
1362
|
+
if (!el) return;
|
|
1363
|
+
if (id === 'stats-section') el.classList.remove('hidden');
|
|
1364
|
+
else if (id === 'key-events-section') el.classList.remove('hidden');
|
|
1365
|
+
else if (id === 'waiting-section' && state.totalUpdates === 0) el.classList.remove('hidden');
|
|
1366
|
+
else if (id === 'coverage-section' && state.totalUpdates > 0) el.classList.remove('hidden');
|
|
1367
|
+
else if (id === 'unexpected-section' && state.unmappedFields.size > 0) el.classList.remove('hidden');
|
|
1368
|
+
else el.classList.add('hidden');
|
|
1369
|
+
});
|
|
1370
|
+
document.getElementById('commands-section').classList.add('hidden');
|
|
1371
|
+
} else if (tab === 'commands') {
|
|
1372
|
+
// Hide ALL telemetry sections
|
|
1373
|
+
telemetrySections.forEach(id => {
|
|
1374
|
+
const el = document.getElementById(id);
|
|
1375
|
+
if (el) el.classList.add('hidden');
|
|
1376
|
+
});
|
|
1377
|
+
// Show commands section
|
|
1378
|
+
document.getElementById('commands-section').classList.remove('hidden');
|
|
1379
|
+
renderCommands();
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// ===========================================
|
|
1384
|
+
// AUTH
|
|
1385
|
+
// ===========================================
|
|
1386
|
+
function doLogin() { ggClient.login(); }
|
|
1387
|
+
function doLogout() {
|
|
1388
|
+
ggClient.logout({ redirect: false });
|
|
1389
|
+
document.getElementById('auth-section').classList.remove('hidden');
|
|
1390
|
+
document.getElementById('dashboard').classList.add('hidden');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
async function init() {
|
|
1394
|
+
// Build CANONICAL from @gameglue/schemas
|
|
1395
|
+
CANONICAL = buildCanonicalFromSchema();
|
|
1396
|
+
console.log(`Loaded ${Object.keys(CANONICAL.fields).length} canonical fields from schema`);
|
|
1397
|
+
|
|
1398
|
+
try {
|
|
1399
|
+
const isAuthed = await ggClient.isAuthenticated();
|
|
1400
|
+
if (!isAuthed) return;
|
|
1401
|
+
document.getElementById('user-id').value = ggClient.getUser();
|
|
1402
|
+
document.getElementById('auth-section').classList.add('hidden');
|
|
1403
|
+
document.getElementById('dashboard').classList.remove('hidden');
|
|
1404
|
+
} catch (err) { console.error(err); }
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
window.onload = init;
|
|
1408
|
+
</script>
|
|
1409
|
+
</body>
|
|
1410
|
+
</html>
|