Repository navigation
Expand file tree
/
Copy pathconsole.html
More file actions
1191 lines (1098 loc) · 44.6 KB
/
Copy pathconsole.html
File metadata and controls
1191 lines (1098 loc) · 44.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>rsc-host console</title>
<style>
:root {
--bg: #0e0e12;
--panel: #1a1a22;
--panel-hi: #24242e;
--fg: #e6e6ea;
--fg-dim: #8a8a99;
--accent: #6a9dff;
--ok: #7ed491;
--err: #ff8080;
--warn: #ffb266;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 14px/1.4 system-ui, -apple-system, sans-serif;
display: grid;
grid-template-columns: 380px 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"controls log";
height: 100vh;
}
header {
grid-area: header;
padding: 12px 20px;
border-bottom: 1px solid var(--panel-hi);
display: flex;
gap: 12px;
align-items: center;
}
header input, header button {
padding: 6px 10px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--panel-hi);
border-radius: 6px;
font: inherit;
}
header input:focus { outline: 2px solid var(--accent); border-color: var(--accent); }
header button { cursor: pointer; }
header button:hover { background: var(--panel-hi); }
header .status {
margin-left: auto;
padding: 4px 10px;
border-radius: 12px;
font-family: var(--mono);
font-size: 12px;
}
header .status.disconnected { background: rgba(255, 128, 128, 0.15); color: var(--err); }
header .status.connected { background: rgba(126, 212, 145, 0.15); color: var(--ok); }
header .status.connecting { background: rgba(255, 178, 102, 0.15); color: var(--warn); }
header .ready {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 12px;
font-family: var(--mono);
font-size: 12px;
background: var(--panel);
color: var(--fg-dim);
cursor: default;
}
header .ready .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-dim);
flex: none;
}
header .ready.live .dot { background: var(--ok); }
header .ready.live { color: var(--ok); }
header .ready.degraded .dot { background: var(--warn); }
header .ready.degraded { color: var(--warn); }
header .ready.simulated .dot { background: var(--accent); }
header .ready.simulated { color: var(--accent); }
header #powerBtn {
padding: 6px 12px;
font-size: 15px;
line-height: 1;
}
header #powerBtn:not([disabled]):hover {
border-color: var(--err);
color: var(--err);
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--panel);
border: 1px solid var(--panel-hi);
border-radius: 10px;
padding: 20px;
width: 340px;
max-width: 90vw;
}
.modal h3 { margin: 0 0 8px; font-size: 15px; }
.modal p {
margin: 0 0 16px;
font-size: 13px;
color: var(--fg-dim);
line-height: 1.5;
}
.modal .row { display: flex; gap: 8px; }
.modal button {
flex: 1;
padding: 10px;
background: var(--panel-hi);
color: var(--fg);
border: 1px solid var(--panel-hi);
border-radius: 6px;
font: inherit;
cursor: pointer;
}
.modal button:hover { border-color: var(--accent); }
.modal button.danger:hover { border-color: var(--err); color: var(--err); }
.controls {
grid-area: controls;
padding: 16px 20px;
overflow-y: auto;
border-right: 1px solid var(--panel-hi);
}
.controls h2 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-dim);
margin: 20px 0 8px;
}
.controls h2:first-child { margin-top: 0; }
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
button.action {
padding: 10px 12px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--panel-hi);
border-radius: 6px;
font: inherit;
cursor: pointer;
text-align: left;
}
button.action:hover { background: var(--panel-hi); border-color: var(--accent); }
button.action:active { transform: translateY(1px); }
button.action[disabled] { opacity: 0.4; cursor: not-allowed; }
button.action.wide { grid-column: 1 / -1; }
.slider-row {
display: grid;
grid-template-columns: 52px 1fr 56px;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.slider-row label {
font-size: 12px;
color: var(--fg-dim);
}
.slider-row output {
font: 12px var(--mono);
text-align: right;
color: var(--fg);
}
.slider-row input[type=range] {
width: 100%;
accent-color: var(--accent);
background: transparent;
}
.hint {
font-size: 12px;
color: var(--fg-dim);
margin: 6px 0 0;
line-height: 1.5;
}
.verb-row {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.verb-row input {
flex: 1;
padding: 8px 10px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--panel-hi);
border-radius: 6px;
font: 13px var(--mono);
}
.verb-row button { padding: 8px 12px; }
.log {
grid-area: log;
padding: 16px 20px;
overflow-y: auto;
font: 13px var(--mono);
}
.log .entry {
padding: 4px 8px;
border-left: 3px solid transparent;
margin-bottom: 2px;
white-space: pre-wrap;
word-break: break-all;
}
.log .entry.sent { border-left-color: var(--accent); color: var(--fg-dim); }
.log .entry.ack { border-left-color: var(--ok); color: var(--fg); }
.log .entry.err { border-left-color: var(--err); color: var(--err); }
.log .entry.event { border-left-color: var(--warn); color: var(--fg); }
.log .entry.info { border-left-color: var(--fg-dim); color: var(--fg-dim); }
.log .entry.level { border-left-color: var(--ok); color: var(--ok); }
.log .ts { color: var(--fg-dim); margin-right: 8px; }
.clear-btn button {
padding: 4px 10px;
margin-left: 6px;
background: var(--panel);
color: var(--fg);
border: 1px solid var(--panel-hi);
border-radius: 6px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.clear-btn button:hover { background: var(--panel-hi); border-color: var(--accent); }
.clear-btn {
position: sticky;
top: 0;
background: var(--bg);
padding-bottom: 8px;
margin: -16px -20px 8px;
padding: 8px 20px;
border-bottom: 1px solid var(--panel-hi);
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
</head>
<body>
<header>
<input id="host" list="known-robots" placeholder="your-robot.local" value="" size="20" autocomplete="off">
<datalist id="known-robots"></datalist>
<input id="port" placeholder="port" value="8765" size="6">
<input id="token" placeholder="token" value="" size="12" type="password">
<button id="connectBtn">Connect</button>
<button id="scanBtn" title="Try to reach common RSC hostnames on the LAN">Scan</button>
<button id="disconnectBtn" disabled>Disconnect</button>
<button id="powerBtn" disabled title="power — connect to a real robot first">⏻</button>
<span id="ready" class="ready" title="peripheral readiness"><span class="dot"></span><span id="readyText">unknown</span></span>
<span id="status" class="status disconnected">disconnected</span>
</header>
<div class="controls">
<h2>Baseline</h2>
<div class="grid">
<button class="action" data-verb="ping">ping</button>
<button class="action" data-verb="status">status</button>
<button class="action" data-verb="peripherals.status">peripherals</button>
<button class="action" data-verb="ring.status">ring status</button>
</div>
<h2>Flippers</h2>
<div class="grid">
<button class="action" data-verb="flipper.left" data-args='{"speed":0.5,"ramp_ms":300}'>left fwd 0.5</button>
<button class="action" data-verb="flipper.left" data-args='{"speed":-0.5,"ramp_ms":300}'>left rev 0.5</button>
<button class="action" data-verb="flipper.left.stop">left stop</button>
<button class="action" data-verb="flipper.right" data-args='{"speed":0.5,"ramp_ms":300}'>right fwd 0.5</button>
<button class="action" data-verb="flipper.right" data-args='{"speed":-0.5,"ramp_ms":300}'>right rev 0.5</button>
<button class="action" data-verb="flipper.right.stop">right stop</button>
<button class="action wide" data-verb="flipper.stop_all">stop all</button>
</div>
<h2>Flipper speed</h2>
<div class="slider-row">
<label for="spdLeft">left</label>
<input type="range" id="spdLeft" min="-100" max="100" value="0" step="1">
<output id="spdLeftOut">0</output>
</div>
<div class="slider-row">
<label for="spdRight">right</label>
<input type="range" id="spdRight" min="-100" max="100" value="0" step="1">
<output id="spdRightOut">0</output>
</div>
<div class="grid">
<button class="action" id="spdCentre">centre both</button>
<button class="action" data-verb="flipper.stop_all">stop all</button>
</div>
<p class="hint">
Drag to set speed directly, −100 to +100. Sends at 10 Hz while
dragging and always sends the value you released on, so the robot
cannot be left running at a speed the slider is not showing.
</p>
<h2>Servo calibration</h2>
<div class="grid">
<button class="action" id="calTargetLeft">tune left</button>
<button class="action" id="calTargetRight">tune right</button>
</div>
<div class="slider-row">
<label for="calNull">pulse µs</label>
<input type="range" id="calNull" min="1400" max="1600" value="1500" step="1" disabled>
<output id="calNullOut">—</output>
</div>
<div class="grid">
<button class="action wide" id="calAccept" disabled>this is the null — save it</button>
<button class="action wide" id="calRelease" disabled>release (stop pulsing)</button>
<button class="action" data-verb="servo.calibration">read values</button>
<button class="action" data-verb="servo.calibration.reset">reset to shipped</button>
</div>
<p class="hint">
Pick a side to start pulsing it at its stored null; the slider is dead
until you do, because a servo that is not being driven cannot be judged.
Then drag and watch that flipper arm: below the true null it creeps one
way, above it the other, and at the null it sits still. Land on the
middle of the still range, save, and release.
</p>
<p class="hint">
Release when finished. A held servo keeps pulsing, and pulsing at
neutral is the single loudest thing on the microphone — about
17 dB above 8 kHz. Normal operation ceases pulses within 120 ms of
stopping for exactly that reason.
</p>
<p class="hint">
<b>span µs</b> is a different quantity and is not set here: it is
the deflection meaning full speed, where a speed mismatch between the
two physical servos gets corrected. Judge it by driving both at the
same speed, then set it from the custom box with
<code>servo.calibrate</code>.
</p>
<h2>Ring</h2>
<div class="grid">
<button class="action" data-verb="ring.mode" data-args='{"mode":"off"}'>off</button>
<button class="action" data-verb="ring.mode" data-args='{"mode":"solid","params":{"r":0,"g":80,"b":255}}'>solid blue</button>
<button class="action" data-verb="ring.mode" data-args='{"mode":"pulse","params":{"r":80,"g":0,"b":80,"period_ms":1500}}'>pulse purple</button>
<button class="action" data-verb="ring.mode" data-args='{"mode":"spin","params":{"r":0,"g":80,"b":255,"period_ms":1500}}'>spin blue</button>
<button class="action" data-verb="ring.modes">list modes</button>
</div>
<h2>Arcade LED</h2>
<div class="grid">
<button class="action" data-verb="button_led" data-args='{"mode":"off"}'>off</button>
<button class="action" data-verb="button_led" data-args='{"mode":"on"}'>on</button>
<button class="action" data-verb="button_led" data-args='{"mode":"pulse"}'>pulse</button>
<button class="action" data-verb="button_led" data-args='{"mode":"breathe"}'>breathe</button>
</div>
<h2>CYD front panel</h2>
<div class="grid">
<button class="action" data-verb="cyd.mood" data-args='{"value":"HAPPY"}'>mood: HAPPY</button>
<button class="action" data-verb="cyd.mood" data-args='{"value":"SAD"}'>mood: SAD</button>
<button class="action" data-verb="cyd.blink">blink</button>
<button class="action" data-verb="cyd.mood_cycle">mood_cycle</button>
</div>
<h2>Audio — test</h2>
<div class="grid">
<button class="action wide" data-verb="audio.selftest" data-args='{"seconds":3,"playback":true}'>record 3s + play back</button>
<button class="action" data-verb="audio.selftest" data-args='{"seconds":3,"playback":true,"normalise":false}'>play back raw</button>
<button class="action" data-verb="audio.selftest" data-args='{"seconds":3,"playback":false}'>record only</button>
<button class="action wide" data-verb="audio.stop_play">stop play</button>
</div>
<p class="hint">
Speak while it records. Peak and RMS come back with the result: speech
at a normal distance lands near −30 dBFS RMS with peaks around
−10. Below −60 means the mic is not hearing you, which is a
mixer problem rather than a code one.
</p>
<p class="hint">
Playback is normalised to −3 dBFS so you can hear it — a
good recording peaks near −14, and the small speaker at 0 dB
makes that barely audible. The file on disk stays raw, and the gain
applied is reported, so a loud playback is never mistaken for a hot
recording. <b>Play back raw</b> skips it.
</p>
<h2>Audio — volume</h2>
<div class="grid">
<button class="action" data-verb="audio.volume" data-args='{"percent":25}'>25%</button>
<button class="action" data-verb="audio.volume" data-args='{"percent":50}'>50%</button>
<button class="action" data-verb="audio.volume" data-args='{"percent":75}'>75%</button>
<button class="action" data-verb="audio.volume" data-args='{"percent":100}'>100%</button>
<button class="action" data-verb="audio.mute" data-args='{"muted":true}'>mute</button>
<button class="action" data-verb="audio.mute" data-args='{"muted":false}'>unmute</button>
<button class="action" data-verb="audio.mic_mute" data-args='{"muted":true}'>mic off</button>
<button class="action" data-verb="audio.mic_mute" data-args='{"muted":false}'>mic on</button>
<button class="action wide" data-verb="audio.volume.get">read volume</button>
</div>
<p class="hint">
Volume attenuates the analogue output, leaving the DAC at full scale,
so 100% is 0 dB rather than the amplifier's ceiling. Add
<code>{"persist":true}</code> in the custom box to keep a level across
restarts; the CYD knob does not persist, since turning a dial is not
the same as choosing a setting. Mute never persists — a robot
that booted silent would give you nothing to go on. Mic off disconnects
the input in hardware.
</p>
<h2>Audio — capture chain</h2>
<div class="grid">
<button class="action" data-verb="audio.capture.config">config</button>
<button class="action" data-verb="audio.devices">devices</button>
<button class="action" data-verb="audio.capture.stats">stats</button>
<button class="action" data-verb="audio.capture.stats.reset">reset stats</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"stream_rate":16000}'>rate 16k</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"stream_rate":48000}'>rate 48k</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"channel_mode":"left"}'>channel left</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"channel_mode":"right"}'>channel right</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"hpf_hz":80}'>hpf 80 Hz</button>
<button class="action" data-verb="audio.capture.tune" data-args='{"hpf_mode":"off"}'>hpf off</button>
</div>
<p class="hint">
Capture is fixed at 48 kHz stereo — asking ALSA for 16k directly
warbles on this driver. The wire rate is reached by integer decimation,
which also removes the aliased converter tones above 6 kHz.
</p>
<h2>Audio — mixer</h2>
<div class="grid">
<button class="action" data-verb="audio.mixer.get">get all</button>
<button class="action" data-verb="audio.mixer.set" data-args='{"name":"Left Input Boost Mixer LINPUT1","value":"2"}'>boost L = 2</button>
<button class="action" data-verb="audio.mixer.set" data-args='{"name":"Left Input Boost Mixer LINPUT1","value":"3"}'>boost L = 3</button>
<button class="action" data-verb="audio.mixer.set" data-args='{"name":"Right Input Boost Mixer RINPUT1","value":"3"}'>boost R = 3</button>
<button class="action" data-verb="audio.mixer.set" data-args='{"name":"Capture","value":"28"}'>capture 28</button>
<button class="action" data-verb="audio.mixer.set" data-args='{"name":"Capture","value":"35"}'>capture 35</button>
<button class="action wide" data-verb="audio.mixer.store">keep these settings</button>
<button class="action wide" data-verb="audio.mixer.reset">reset to shipped default</button>
</div>
<p class="hint">
Gain belongs in the analogue boost ahead of the ADC, not in the digital
Capture control behind it. Boost lifts signal relative to the noise
floor; Capture lifts both equally.
</p>
<p class="hint">
Changes apply immediately but are forgotten on restart until you press
<b>keep these settings</b>, which writes them to the robot. <b>Reset</b>
throws them away and restores the recipe the robot shipped with —
the way back from a tuning session that went wrong.
</p>
<h2>Custom verb</h2>
<div class="verb-row">
<input id="customVerb" placeholder="verb (e.g. servo.calibrate)">
<button id="customSend">send</button>
</div>
<div class="verb-row">
<input id="customArgs" placeholder='args JSON (e.g. {"id":"right","null_us":1510})'>
</div>
<h2>Events</h2>
<div class="grid">
<button class="action wide" id="historyBtn">history (last 20)</button>
</div>
</div>
<div class="modal-backdrop" id="powerModal" hidden>
<div class="modal">
<h3 id="powerTitle">Power</h3>
<p id="powerBody"></p>
<div class="row">
<button id="powerReboot" class="danger">Reboot</button>
<button id="powerOff" class="danger">Shut down</button>
</div>
<div class="row" style="margin-top:8px">
<button id="powerCancel">Cancel</button>
</div>
</div>
</div>
<div class="log" id="log">
<div class="clear-btn">
<span>event log</span>
<span>
<button id="copyBtn">copy</button>
<button id="saveBtn">save</button>
<button id="clearBtn">clear</button>
</span>
</div>
</div>
<script>
let ws = null;
let cmdCounter = 0;
// Which verb each outstanding command id belongs to. The ack carries only the
// id, and some replies need to be handled by verb — re-asking for readiness
// after a ring command, for instance.
const inflight = new Map();
const $ = (id) => document.getElementById(id);
const statusEl = $("status");
const logEl = $("log");
// ---- Known-robots persistence ----
const STORAGE_KEY = "rsc.known_robots";
const TOKEN_KEY_PREFIX = "rsc.token."; // one entry per host
function loadKnownRobots() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
} catch {
return [];
}
}
function saveKnownRobot(host) {
const known = new Set(loadKnownRobots());
known.add(host);
localStorage.setItem(STORAGE_KEY, JSON.stringify([...known].sort()));
populateDatalist();
}
function loadTokenFor(host) {
return localStorage.getItem(TOKEN_KEY_PREFIX + host) || "";
}
function saveTokenFor(host, token) {
if (token) localStorage.setItem(TOKEN_KEY_PREFIX + host, token);
}
function populateDatalist() {
const list = $("known-robots");
list.innerHTML = "";
for (const host of loadKnownRobots()) {
const opt = document.createElement("option");
opt.value = host;
list.appendChild(opt);
}
}
function setStatus(state, text) {
statusEl.className = "status " + state;
statusEl.textContent = text;
$("connectBtn").disabled = (state === "connected" || state === "connecting");
$("disconnectBtn").disabled = (state !== "connected");
}
function log(kind, text) {
const entry = document.createElement("div");
entry.className = "entry " + kind;
const ts = new Date().toLocaleTimeString();
entry.innerHTML = `<span class="ts">${ts}</span>${text.replace(/</g, "<")}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
}
function send(verb, args) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log("err", "not connected");
return;
}
const id = "c" + (++cmdCounter);
const msg = { type: "cmd", id, verb, args: args || {} };
inflight.set(id, verb);
ws.send(JSON.stringify(msg));
log("sent", `→ ${verb} ${JSON.stringify(args || {})}`);
// audio.selftest blocks for the record duration, then again for playback.
// Without a note the console looks hung for six seconds.
if (verb === "audio.selftest") {
const secs = (args && args.seconds) || 3;
const total = (args && args.playback === false) ? secs : secs * 2;
log("info", `recording ${secs}s — speak now (result in ~${total}s)`);
}
}
// Levels are the point of the selftest, so pull them out of the result rather
// than leaving them buried in a JSON blob. Peak, RMS and crest each say
// something the others hide: a healthy RMS conceals clipping, and a single
// transient spike makes peak meaningless on its own.
function reportLevels(result) {
if (!result || result.rms_dbfs === undefined) return;
const rms = result.rms_dbfs;
const peak = result.peak_dbfs;
const crest = result.crest_db;
const clipped = result.clipped_samples;
const verdict =
rms < -60 ? "SILENT — check the mixer, the mic is not hearing you" :
rms < -45 ? "quiet — speak closer, or raise the analogue boost" :
rms > -12 ? "very hot — lower Capture" :
"healthy";
let line = ` levels: rms ${rms} dBFS, peak ${peak} dBFS`;
if (crest !== undefined) line += `, crest ${crest} dB`;
log("level", `${line} → ${verdict}`);
if (clipped > 0) {
log("err", ` CLIPPING — ${clipped} samples at the rail. Lower Capture and retest.`);
} else if (crest !== undefined && crest > 24) {
log("info", ` crest ${crest} dB is high for speech (12–18 typical). One spike `
+ "dominates — a knock, or a transient the settle trim missed.");
}
if (result.path) {
log("level", ` written to ${result.path} (${result.bytes} bytes, ${result.seconds}s)`);
}
}
$("connectBtn").addEventListener("click", () => {
const host = $("host").value.trim();
const port = $("port").value.trim() || "8765";
const token = $("token").value;
if (!host) {
log("err", "enter a hostname (e.g. rsc-shiny.local)");
$("host").focus();
return;
}
if (!token) {
log("err", "enter your token (from the Pi's install-systemd.sh output)");
$("token").focus();
return;
}
const url = `ws://${host}:${port}/`;
setStatus("connecting", `connecting to ${host}...`);
log("info", `opening ${url}`);
try {
ws = new WebSocket(url, ["bearer", token]);
} catch (exc) {
log("err", `WebSocket construction failed: ${exc.message}`);
setStatus("disconnected", "disconnected");
return;
}
ws.onopen = () => {
setStatus("connected", "connected");
log("info", `connected (subprotocol: ${ws.protocol})`);
saveKnownRobot(host);
saveTokenFor(host, token);
refreshReadiness();
};
ws.onmessage = (evt) => {
let msg;
try {
msg = JSON.parse(evt.data);
} catch {
log("err", `bad JSON: ${evt.data}`);
return;
}
if (msg.type === "ack") {
if (msg.id === "powerstatus") {
powerAllowed = !!(msg.ok && msg.result && msg.result.allowed);
if (!powerAllowed && msg.ok && msg.result) {
log("info", `power control unavailable: ${msg.result.reason}`);
}
return;
}
if (msg.id === "readiness") {
if (msg.ok) renderReadiness(msg.result);
else {
$("ready").className = "ready degraded";
$("readyText").textContent = "status failed";
}
return;
}
if (msg.ok) {
log("ack", `← [${msg.id}] ok ${JSON.stringify(msg.result ?? {})}`);
reportLevels(msg.result);
syncCalSlider(msg.result);
// Ring and state availability can change under us — a helper
// restart, a full disk. Cheap to re-ask after a ring command.
const verb = inflight.get(msg.id);
if (verb && verb.startsWith("ring.")) refreshReadiness();
} else {
log("err", `← [${msg.id}] ${msg.code}: ${msg.message}`);
}
inflight.delete(msg.id);
} else if (msg.type === "event") {
const seq = msg.seq != null ? `#${msg.seq} ` : "";
log("event", `⇢ ${seq}${msg.source}:${msg.topic} ${JSON.stringify(msg.data)}`);
} else {
log("info", `unknown: ${evt.data}`);
}
};
ws.onerror = () => {
log("err", "socket error");
};
ws.onclose = (evt) => {
setStatus("disconnected", "disconnected");
renderReadiness(null);
log("info", `closed (code=${evt.code}, reason=${evt.reason || "-"})`);
ws = null;
};
});
$("disconnectBtn").addEventListener("click", () => {
if (ws) ws.close();
});
$("clearBtn").addEventListener("click", () => {
logEl.querySelectorAll(".entry").forEach(e => e.remove());
});
// The log is the only record of a session, and these sessions are how
// hardware behaviour gets established. textContent rather than innerHTML so
// what lands on the clipboard is what is on screen, not markup.
function logAsText() {
return [...logEl.querySelectorAll(".entry")]
.map(e => e.textContent)
.join("\n");
}
$("copyBtn").addEventListener("click", async () => {
const text = logAsText();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
log("info", `copied ${text.split("\n").length} lines to the clipboard`);
} catch {
// Clipboard access needs a secure context, and the console is served
// over plain http on a LAN. Fall back to a selectable textarea rather
// than failing silently.
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;top:10%;left:10%;width:80%;height:70%;z-index:9999";
document.body.appendChild(ta);
ta.select();
log("info", "clipboard blocked (needs https) — select and copy, then press Escape");
ta.addEventListener("keydown", (e) => {
if (e.key === "Escape") ta.remove();
});
}
});
$("saveBtn").addEventListener("click", () => {
const text = logAsText();
if (!text) return;
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const url = URL.createObjectURL(new Blob([text], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = `rsc-console-${stamp}.log`;
a.click();
URL.revokeObjectURL(url);
log("info", `saved ${a.download}`);
});
// ---- Scan for RSCs on the LAN ----
//
// Browsers can't do real mDNS, so we probe candidate hostnames by opening
// a WebSocket without a token — a running rsc-host replies with 401
// (which the browser reports as a specific close code) very quickly.
// A dead host times out or refuses. Either signal identifies it.
const SCAN_CANDIDATES = [
// Common RSC names
"rsc-shiny", "rsc-pinky", "rsc-minion",
// Numbered fleet convention
"rsc-01", "rsc-02", "rsc-03",
// Un-prefixed friendly names
"shiny", "pinky", "minion",
];
async function probe(host, port, timeoutMs = 1500) {
// Try a WebSocket handshake; if the daemon is there it either
// - accepts (unlikely without a token, but count as "found") or
// - rejects with 401 fast (the definitive "yes, something is here").
// Dead hosts hang until browser timeout; we short-circuit at timeoutMs.
return new Promise((resolve) => {
let done = false;
const url = `ws://${host}:${port}/`;
let sock;
try {
sock = new WebSocket(url, ["bearer", "probe"]);
} catch {
resolve(false);
return;
}
const finish = (found) => {
if (done) return;
done = true;
try { sock.close(); } catch {}
resolve(found);
};
// The 401 close arrives fast; any close within timeout with a
// reason-code >= 1000 means we found *something* HTTP-responding.
sock.onopen = () => finish(true);
sock.onclose = (evt) => {
// Any clean close from the peer suggests a real HTTP server
// (as opposed to a timeout / connection refused).
finish(evt.code >= 1000 && evt.code < 5000 && evt.wasClean !== undefined);
};
sock.onerror = () => {}; // errors are expected for unreachable hosts
setTimeout(() => finish(false), timeoutMs);
});
}
$("scanBtn").addEventListener("click", async () => {
const port = $("port").value.trim() || "8765";
const candidates = new Set([
...loadKnownRobots().map(h => h.replace(/\.local$/, "")),
...SCAN_CANDIDATES,
]);
log("info", `scanning ${candidates.size} candidate hostnames on port ${port}...`);
$("scanBtn").disabled = true;
const found = [];
// Fire probes in parallel — the browser handles the concurrency fine.
await Promise.all(
[...candidates].map(async (name) => {
const host = `${name}.local`;
const hit = await probe(host, port);
if (hit) {
found.push(host);
log("ack", `found: ${host}`);
saveKnownRobot(host);
}
})
);
if (found.length === 0) {
log("err", "no RSCs found. Try typing the hostname manually.");
} else if (found.length === 1) {
log("info", `found ${found[0]}. Type your token and hit Connect.`);
$("host").value = found[0];
} else {
log("info", `found ${found.length} robots — pick one from the dropdown`);
}
$("scanBtn").disabled = false;
});
// Preset verb buttons
document.querySelectorAll("button.action[data-verb]").forEach(btn => {
btn.addEventListener("click", () => {
const verb = btn.dataset.verb;
let args = {};
if (btn.dataset.args) {
try { args = JSON.parse(btn.dataset.args); }
catch (exc) { log("err", `bad preset args: ${exc.message}`); return; }
}
send(verb, args);
});
});
// Custom verb
$("customSend").addEventListener("click", () => {
const verb = $("customVerb").value.trim();
const argsRaw = $("customArgs").value.trim();
if (!verb) return;
let args = {};
if (argsRaw) {
try { args = JSON.parse(argsRaw); }
catch (exc) { log("err", `bad JSON args: ${exc.message}`); return; }
}
send(verb, args);
});
// History replay
$("historyBtn").addEventListener("click", () => {
send("events.history", { limit: 20 });
});
// ---- Power ----
//
// Enabled only against a real backend. On fake the verbs would either refuse
// or, worse, shut down whichever machine is hosting the fake daemon — which
// during development is the developer's own laptop.
//
// Confirmation is a dialog rather than a second click, because the two actions
// differ in how recoverable they are and the wording should say so. Shut down
// on a robot with no physical power button means finding the plug.
let powerAllowed = false;
function setPowerEnabled(on, reason) {
const btn = $("powerBtn");
btn.disabled = !on;
btn.title = on ? "power — reboot or shut down" : `power — ${reason}`;
}
function openPowerModal() {
$("powerTitle").textContent = powerAllowed
? "Power"
: "Power — not permitted";
$("powerBody").innerHTML = powerAllowed
? "<b>Reboot</b> brings the robot back on its own in about a minute. "
+ "<b>Shut down</b> does not — someone has to reach the power "
+ "supply. Either way the flippers stop and the ring goes dark first."
: "The daemon is not allowed to power this machine down. Install the "
+ "polkit rule described in the apply notes, then reconnect. "
+ "<code>system.power_status</code> in the custom box reports why.";
$("powerReboot").disabled = !powerAllowed;
$("powerOff").disabled = !powerAllowed;
$("powerModal").hidden = false;
}
function closePowerModal() { $("powerModal").hidden = true; }
$("powerBtn").addEventListener("click", openPowerModal);
$("powerCancel").addEventListener("click", closePowerModal);
$("powerModal").addEventListener("click", (e) => {
if (e.target === $("powerModal")) closePowerModal();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("powerModal").hidden) closePowerModal();
});
$("powerReboot").addEventListener("click", () => {
closePowerModal();
send("system.reboot", { confirm: true });
log("info", "reboot requested — the connection will drop shortly");
});
$("powerOff").addEventListener("click", () => {
closePowerModal();
send("system.poweroff", { confirm: true });
log("info", "shutdown requested — the connection will drop shortly");
});
// Asked once per connect, alongside readiness. The answer depends on polkit,
// not on anything the console can see, so it has to come from the daemon.
function refreshPowerStatus() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({
type: "cmd", id: "powerstatus", verb: "system.power_status", args: {},
}));
}
// ---- Readiness ----
//
// "Connected" and "working" are different claims. A daemon on the fake backend
// answers every verb cheerfully and moves nothing; a real one with the ring
// helper down looks identical until you ask for a colour. The badge separates
// the two so the question "is this robot actually doing anything" has an
// answer on screen rather than in somebody's head.
//
// Read from peripherals.status, which the daemon already reports — no special
// endpoint, and it stays honest because it is the same snapshot used for
// diagnosis.
function renderReadiness(st) {
const badge = $("ready");
const text = $("readyText");
if (!st || typeof st !== "object") {
badge.className = "ready";
setPowerEnabled(false, "connect to a real robot first");
text.textContent = "unknown";
badge.title = "peripheral readiness — no status yet";
return;
}
const simulated = st.backend === "fake";
const ringOk = !!(st.ring && st.ring.available);
const cydOk = !!(st.cyd && st.cyd.available);
const stateOk = !!(st.state && st.state.available);
// Missing pieces are listed rather than summarised: "degraded" alone sends
// you hunting, and the whole point is to save that hunt.
const missing = [];
if (!ringOk) missing.push("ring");
if (!cydOk) missing.push("cyd");
if (!stateOk) missing.push("no saved settings");
// Real backend gates the button; polkit gates what happens when pressed.
// Both matter, and they fail for different reasons, so they are reported
// separately rather than collapsed into one "can I" flag.
if (simulated) {
setPowerEnabled(false, "simulated backend — nothing to power down");
} else {
setPowerEnabled(true, "");
refreshPowerStatus();
}
if (simulated) {
badge.className = "ready simulated";
text.textContent = "simulated";
badge.title = "backend=fake — every verb answers, no hardware moves";
} else if (missing.length === 0) {
badge.className = "ready live";
text.textContent = "ready";
badge.title = "backend=pi, ring, cyd and saved settings all available";
} else {
badge.className = "ready degraded";
text.textContent = missing.join(", ");
badge.title = `backend=pi, unavailable: ${missing.join(", ")}`;
}
}