Repository navigation
Expand file tree
/
Copy pathmenu.cpp
More file actions
476 lines (446 loc) · 20.3 KB
/
Copy pathmenu.cpp
File metadata and controls
476 lines (446 loc) · 20.3 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
// SPDX-FileCopyrightText: 2026 John Ackermann N8UR
// SPDX-License-Identifier: MIT
// Portions of this file were written with assistance from Claude
// (Anthropic) and were reviewed and edited by the author.
//
// menu.cpp — see menu.h for the public API and design notes.
//
// Frequency entry (options 1-3) now previews the candidate frequency --
// achieved value, fractional error, and any impact on a PLLB-sharing
// sibling clock -- and requires explicit y/n confirmation before
// anything is actually written to the Si5351 or persisted to flash.
// Added after bench characterization showed a small set of individually
// identifiable frequencies can hit the Si5351's fundamental register
// resolution limit regardless of algorithm; the operator should see
// that before committing to one, not discover it later. See
// si5351PreviewClockFreq() in si5351.cpp and FREQ_ERROR_WARN_THRESHOLD
// in config.h.
//
// Also surfaces the proven exact-multiple-of-10-Hz guarantee (see
// si5351.h) both proactively (a hint shown before the prompt) and as a
// concrete remedy (computed nearest exact alternatives) whenever the
// warning threshold is exceeded.
//
// 20261008.1: added option 9, "Reboot into firmware-update (UF2) mode"
// (RP2040 builds only -- it relies on rp2040.rebootToBootloader(), and
// is simply absent on SAMD21/ESP32C3 builds). It reboots the XIAO into
// the chip's ROM UF2 bootloader so a new .uf2 can be copied onto the
// RPI-RP2 USB drive without reaching the BOOT button. Because stray
// bytes have woken this menu before (see the menuCheckWake() notes),
// the reboot is gated behind typing the word UPDATE (any case), not a
// single y/n keystroke. "Exit menu" moved from 9 to 99 so it stays the
// last entry however many features get added above it. Number 9, the
// old Exit, is now the update option -- harmless if typed out of habit,
// since it only asks for the UPDATE confirmation and cancels on anything
// else.
//
#include <Arduino.h>
#include <stdlib.h>
#include <strings.h> // strcasecmp()
#include "config.h"
#include "menu.h"
#include "si5351.h"
#include "status.h"
#include "gnss.h"
#include "cmd_output.h"
#include "nv_store.h"
enum class MenuState : uint8_t {
IDLE, ROOT, AWAIT_FREQ, AWAIT_FREQ_CONFIRM, AWAIT_DRIVE, AWAIT_CONTINUE,
AWAIT_PASSTHROUGH_PERSIST, AWAIT_UF2_CONFIRM
};
static MenuState s_state = MenuState::IDLE;
static uint8_t s_targetClk = 0;
static uint32_t s_pendingFreq = 0; // candidate frequency awaiting y/n confirmation
static uint32_t s_lastActivityMs = 0;
static char s_lineBuf[32];
static uint8_t s_lineLen = 0;
static void printRoot() {
CMD_SERIAL.println();
statusPrintVersion();
for (uint8_t c = 0; c < 3; c++) {
CMD_SERIAL.print(F(" "));
CMD_SERIAL.print(c + 1);
CMD_SERIAL.print(F(") Set CLK")); CMD_SERIAL.print(c);
CMD_SERIAL.print(F(" frequency (currently "));
if (si5351IsClockEnabled(c)) {
CMD_SERIAL.print(si5351GetClockFreq(c));
CMD_SERIAL.println(F(" Hz)"));
} else {
CMD_SERIAL.println(F("off)"));
}
}
CMD_SERIAL.print(F(" 4) Set drive strength, all channels (currently "));
CMD_SERIAL.print(si5351GetDrive());
CMD_SERIAL.println(F(" mA)"));
CMD_SERIAL.println(F(" 5) Show this menu again"));
CMD_SERIAL.println(F(" 6) Show lock history"));
CMD_SERIAL.println(F(" 7) Restore compile-time defaults (overwrites saved config)"));
CMD_SERIAL.print(F(" 8) Toggle raw NMEA passthrough (currently "));
CMD_SERIAL.print(gnssIsPassthroughEnabled() ? F("ON") : F("OFF"));
CMD_SERIAL.print(F("; boot default "));
bool persistedPassthrough = false;
nvStoreLoadPassthrough(persistedPassthrough); // leaves it false if nothing saved yet -- correct
CMD_SERIAL.print(persistedPassthrough ? F("ON") : F("OFF"));
CMD_SERIAL.println(F(")"));
#if defined(ARDUINO_ARCH_RP2040)
CMD_SERIAL.println(F(" 9) Reboot into firmware-update (UF2) mode"));
#endif
CMD_SERIAL.println(F(" 99) Exit menu"));
CMD_SERIAL.print(F("> "));
}
static void enterRoot() {
// The menu is always plain, unwrapped text -- turn wrapping off (if
// it was on) BEFORE printing anything, since this is also how the
// menu first opens (from menuCheckWake()) as well as how it redraws
// itself while already open. Safe/idempotent to call every time.
cmdOutput.setWrapEnabled(false);
s_state = MenuState::ROOT;
printRoot();
s_lastActivityMs = millis();
}
static void exitMenu() {
s_state = MenuState::IDLE;
if (gnssIsPassthroughEnabled()) {
CMD_SERIAL.println(F("Exiting menu, resuming NMEA passthrough.\n"));
} else {
CMD_SERIAL.println(F("Exiting menu, back to normal status output.\n"));
}
// Restore wrapping for whatever comes next, now that the exit message
// itself has already gone out plain.
cmdOutput.setWrapEnabled(gnssIsPassthroughEnabled());
}
bool menuIsActive() {
return s_state != MenuState::IDLE;
}
void menuCheckWake() {
if (s_state != MenuState::IDLE) return;
if (!CMD_SERIAL.available()) return;
// Peek before deciding this is really a human asking for the menu.
// While passthrough is streaming to an automated consumer, that
// consumer can itself write bytes back to this port -- gpsd in
// particular actively probes/configures u-blox receivers with UBX
// binary commands once it recognizes u-blox-style NMEA talker IDs,
// which ours genuinely are. A UBX frame starts with the non-
// printable sync byte 0xB5, which would otherwise satisfy
// CMD_SERIAL.available() and get misread here as a keypress --
// waking the menu, pausing the raw passthrough forward, and (since
// the menu is always unwrapped) silencing the $PMVR status output
// gpsd was correctly ignoring. A real human at a terminal always
// sends something printable (ASCII 0x20-0x7E) or CR/LF.
int first = CMD_SERIAL.peek();
bool looksHuman = (first >= 0x20 && first <= 0x7E) || first == '\r' || first == '\n';
if (!looksHuman) {
// Drain everything currently buffered, not just this one byte: a
// binary frame usually arrives as a contiguous burst, and later
// bytes within it (e.g. UBX's second sync byte, 0x62 'b') can
// individually look like plain printable ASCII even though the
// frame as a whole isn't something a human typed. This isn't a
// hard guarantee against every possible probe sequence, but covers
// the common case where a whole frame lands in one poll.
while (CMD_SERIAL.available()) CMD_SERIAL.read();
return;
}
// Looks human -- any keypress wakes the menu. Drain whatever's
// waiting -- usually just the keystroke(s) the user typed to get our
// attention -- so it doesn't get misread as a menu selection once the
// prompt is up.
while (CMD_SERIAL.available()) CMD_SERIAL.read();
enterRoot();
}
static void handleRootSelection(const char* line) {
int choice = atoi(line);
switch (choice) {
case 1: case 2: case 3:
s_targetClk = (uint8_t)(choice - 1);
CMD_SERIAL.println(F("(tip: frequencies that are exact multiples of 10 Hz are"));
CMD_SERIAL.println(F(" guaranteed exact synthesis -- see project notes)"));
CMD_SERIAL.print(F("Enter new frequency for CLK"));
CMD_SERIAL.print(s_targetClk);
CMD_SERIAL.print(F(" in Hz (2500-200000000), or 0 to power it down: "));
s_state = MenuState::AWAIT_FREQ;
break;
case 4:
CMD_SERIAL.print(F("Enter drive strength in mA (2, 4, 6, or 8): "));
s_state = MenuState::AWAIT_DRIVE;
break;
case 5:
printRoot();
break;
case 6:
statusPrintLockHistory();
CMD_SERIAL.println(F("(press Enter to return to the menu)"));
s_state = MenuState::AWAIT_CONTINUE;
break;
case 7:
si5351ResetToDefaults();
CMD_SERIAL.println(F("Restored compile-time defaults and saved."));
printRoot();
break;
case 8:
gnssSetPassthrough(!gnssIsPassthroughEnabled());
CMD_SERIAL.print(F("NMEA passthrough turned "));
CMD_SERIAL.print(gnssIsPassthroughEnabled() ? F("ON") : F("OFF"));
CMD_SERIAL.println(F(" for this session."));
CMD_SERIAL.print(F("Persist this as the power-on default? (y/n): "));
s_state = MenuState::AWAIT_PASSTHROUGH_PERSIST;
break;
#if defined(ARDUINO_ARCH_RP2040)
case 9:
CMD_SERIAL.println(F("This reboots the unit into firmware-update (UF2) mode. NMEA and"));
CMD_SERIAL.println(F("status output stop, and this serial port disconnects, until the"));
CMD_SERIAL.println(F("unit restarts (new firmware copied over, or power-cycle/RESET)."));
CMD_SERIAL.print(F("Type UPDATE to continue, anything else cancels: "));
s_state = MenuState::AWAIT_UF2_CONFIRM;
break;
#endif
case 99:
exitMenu();
break;
default:
CMD_SERIAL.println(F("Not a valid option."));
printRoot();
break;
}
}
// Parses the candidate frequency and, if it's a real (nonzero, in-range)
// value, computes and displays a preview -- achieved frequency,
// fractional error, an explicit warning if that error exceeds
// FREQ_ERROR_WARN_THRESHOLD, and any impact on a PLLB-sharing sibling
// clock -- then waits for y/n confirmation before anything is actually
// committed. Zero (power down) and out-of-range entries still act
// immediately, same as before, since there's no accuracy question to
// preview for either of those.
// True if line is one or more decimal digits and nothing else -- i.e.
// something atol() parsed as a number because it actually IS one, not
// because atol() silently returns 0 for non-numeric input too. Used to
// tell a deliberately-typed "0" (power the clock down) apart from
// garbage that doesn't start with a digit, which used to be treated
// identically -- see handleFreqEntry() and the wake-gate fix above for
// why that distinction matters now.
static bool isAllDigits(const char* s) {
if (*s == '\0') return false;
for (const char* p = s; *p; p++) {
if (*p < '0' || *p > '9') return false;
}
return true;
}
static void handleFreqEntry(const char* line) {
if (!isAllDigits(line)) {
// Not a number at all. Previously fell through to atol(), which
// returns 0 for non-numeric input exactly the same as it would for
// a deliberately-typed "0" -- silently powering the targeted clock
// down on any garbage line. Reject instead.
CMD_SERIAL.println(F("Rejected -- not a valid number."));
enterRoot();
return;
}
long freq = atol(line);
if (freq == 0) {
si5351DisableClock(s_targetClk);
CMD_SERIAL.print(F("CLK")); CMD_SERIAL.print(s_targetClk);
CMD_SERIAL.println(F(" powered down."));
enterRoot();
return;
}
if (freq < 0) {
// Unreachable now that isAllDigits() has already rejected anything
// containing a '-', but left in as a harmless belt-and-suspenders
// check rather than relying on that invariant holding forever.
CMD_SERIAL.println(F("Rejected -- out of range (2500-200000000 Hz)."));
enterRoot();
return;
}
ClockFreqPreview pv = si5351PreviewClockFreq(s_targetClk, (uint32_t)freq);
if (!pv.valid) {
CMD_SERIAL.println(F("Rejected -- out of range (2500-200000000 Hz)."));
enterRoot();
return;
}
if (!pv.dividerValid || (pv.otherClkAffected && !pv.otherDividerValid)) {
CMD_SERIAL.println();
CMD_SERIAL.println(F(" Rejected -- this frequency cannot be validly synthesized as"));
CMD_SERIAL.println(F(" configured. The Si5351's output divider only supports the exact"));
CMD_SERIAL.println(F(" values 4, 6, or >=8 (per AN619); the combination requested here"));
CMD_SERIAL.println(F(" would require something else, most likely because CLK1 and CLK2"));
CMD_SERIAL.println(F(" share PLLB and one of them needs a frequency above 112.5 MHz --"));
CMD_SERIAL.println(F(" above that point the output frequency fixes the shared VCO, and"));
CMD_SERIAL.println(F(" the other clock may no longer land on a supported divide ratio."));
CMD_SERIAL.println(F(" Try a different frequency, or don't run both clocks at once here."));
enterRoot();
return;
}
CMD_SERIAL.println();
CMD_SERIAL.print(F(" Requested: CLK")); CMD_SERIAL.print(pv.clk);
CMD_SERIAL.print(F(" = ")); CMD_SERIAL.print(pv.requestedHz); CMD_SERIAL.println(F(" Hz"));
CMD_SERIAL.print(F(" Achieved: ")); CMD_SERIAL.print(pv.achievedHz, 6); CMD_SERIAL.println(F(" Hz"));
CMD_SERIAL.print(F(" Fractional error: "));
CMD_SERIAL.print(pv.fractionalError, 13);
CMD_SERIAL.print(F(" ("));
CMD_SERIAL.print(pv.fractionalError * 1.0e9, 3);
CMD_SERIAL.println(F(" ppb)"));
if (pv.requestedHz > 112500000UL) {
CMD_SERIAL.println(F(" (Note: above 112.5 MHz, AN619 restricts the Si5351's output divider"));
CMD_SERIAL.println(F(" to a fixed set of values -- handled correctly here.)"));
}
bool exceeds = (pv.fractionalError > FREQ_ERROR_WARN_THRESHOLD) ||
(pv.fractionalError < -FREQ_ERROR_WARN_THRESHOLD);
if (exceeds) {
CMD_SERIAL.println(F(" *** WARNING: exceeds the accuracy warning threshold."));
CMD_SERIAL.println(F(" *** This frequency lands on one of the rare cases where the"));
CMD_SERIAL.println(F(" *** Si5351's register resolution can't do better. Frequencies"));
CMD_SERIAL.println(F(" *** that are exact multiples of 10 Hz are GUARANTEED exact --"));
CMD_SERIAL.println(F(" *** proven mathematically (see project notes), not just usually"));
CMD_SERIAL.println(F(" *** true. Nearest exact alternatives:"));
uint32_t lowerTen = (pv.requestedHz / 10UL) * 10UL;
uint32_t upperTen = lowerTen + 10UL;
CMD_SERIAL.print(F(" *** ")); CMD_SERIAL.print(lowerTen); CMD_SERIAL.println(F(" Hz"));
if (upperTen != lowerTen) {
CMD_SERIAL.print(F(" *** ")); CMD_SERIAL.print(upperTen); CMD_SERIAL.println(F(" Hz"));
}
}
if (pv.otherClkAffected) {
CMD_SERIAL.print(F(" NOTE: CLK")); CMD_SERIAL.print(pv.otherClk);
CMD_SERIAL.println(F(" shares PLLB with this clock -- its achieved frequency changes too:"));
CMD_SERIAL.print(F(" requested ")); CMD_SERIAL.print(pv.otherRequestedHz);
CMD_SERIAL.print(F(" Hz -> achieved ")); CMD_SERIAL.print(pv.otherAchievedHz, 6);
CMD_SERIAL.print(F(" Hz (error ")); CMD_SERIAL.print(pv.otherFractionalError * 1.0e9, 3);
CMD_SERIAL.println(F(" ppb)"));
}
CMD_SERIAL.print(F(" Accept this frequency? (y/n): "));
s_pendingFreq = (uint32_t)freq;
s_state = MenuState::AWAIT_FREQ_CONFIRM;
}
static void handleFreqConfirm(const char* line) {
if (line[0] == 'y' || line[0] == 'Y') {
if (si5351SetClockFreq(s_targetClk, s_pendingFreq)) {
CMD_SERIAL.print(F("CLK")); CMD_SERIAL.print(s_targetClk);
CMD_SERIAL.print(F(" set to ")); CMD_SERIAL.print(s_pendingFreq);
CMD_SERIAL.println(F(" Hz."));
} else {
CMD_SERIAL.println(F("FAILED to apply -- see I2C error messages above."));
}
} else {
CMD_SERIAL.println(F("Cancelled -- no change made."));
}
enterRoot();
}
// Follows the case-8 toggle above. The in-memory/current-session state
// has already been changed by the time this runs; this only decides
// whether that new state also becomes the boot-time default. 'n' (or
// anything but y/Y) leaves whatever was previously saved untouched --
// this is "persist THIS change", not "clear the saved default".
static void handlePassthroughPersistConfirm(const char* line) {
if (line[0] == 'y' || line[0] == 'Y') {
nvStoreSavePassthrough(gnssIsPassthroughEnabled());
CMD_SERIAL.print(F("Saved -- passthrough will start "));
CMD_SERIAL.print(gnssIsPassthroughEnabled() ? F("ON") : F("OFF"));
CMD_SERIAL.println(F(" at boot."));
} else {
CMD_SERIAL.println(F("Not persisted -- boot default unchanged."));
}
enterRoot();
}
#if defined(ARDUINO_ARCH_RP2040)
// Follows option 9. Only the whole word "update" (any case) proceeds;
// anything else -- including a stray keystroke or a lone y -- cancels
// and redraws the menu. On proceed this never returns: the RP2040
// restarts into its ROM UF2 bootloader and shows up as the RPI-RP2 USB
// drive. The ROM bootloader can't be overwritten, so a bad firmware
// image is always recoverable by holding BOOT while powering up.
//
// The Si5351 is a separate chip that keeps its register contents as
// long as it stays powered, so its outputs are expected to carry on
// through the reboot; setup() reconfigures it when firmware restarts.
static void handleUf2Confirm(const char* line) {
if (strcasecmp(line, "update") != 0) {
CMD_SERIAL.println(F("Cancelled -- not rebooting."));
enterRoot();
return;
}
CMD_SERIAL.println(F("Rebooting into firmware-update (UF2) mode..."));
CMD_SERIAL.println(F(" A USB drive named RPI-RP2 will appear on your computer."));
CMD_SERIAL.println(F(" Copy the new .uf2 firmware file onto it; the unit restarts by"));
CMD_SERIAL.println(F(" itself when the copy finishes. To abort instead, power-cycle the"));
CMD_SERIAL.println(F(" unit or press the XIAO's RESET button."));
CMD_SERIAL.println(F(" This serial port will disconnect now."));
statusIndicateUpdateMode();
Serial.flush(); // the physical USB port, directly: CmdOutput doesn't forward flush()
delay(250); // let the host actually read those lines before the port vanishes
rp2040.rebootToBootloader();
// not reached
}
#endif
static void handleDriveEntry(const char* line) {
int drive = atoi(line);
if (!si5351SetDrive((uint8_t)drive)) {
CMD_SERIAL.println(F("Rejected -- must be 2, 4, 6, or 8 mA."));
} else {
CMD_SERIAL.print(F("Drive strength set to ")); CMD_SERIAL.print(drive);
CMD_SERIAL.println(F(" mA on all channels."));
}
enterRoot();
}
void menuPoll() {
if (s_state == MenuState::IDLE) return;
if ((millis() - s_lastActivityMs) > MENU_IDLE_TIMEOUT_MS) {
if (gnssIsPassthroughEnabled()) {
CMD_SERIAL.println(F("\nMenu timed out, resuming NMEA passthrough.\n"));
} else {
CMD_SERIAL.println(F("\nMenu timed out, back to normal status output.\n"));
}
s_state = MenuState::IDLE;
// Same as exitMenu() -- restore wrapping only after the plain-text
// timeout message has already gone out.
cmdOutput.setWrapEnabled(gnssIsPassthroughEnabled());
return;
}
while (CMD_SERIAL.available()) {
char c = (char)CMD_SERIAL.read();
s_lastActivityMs = millis();
if (c == '\r' || c == '\n') {
CMD_SERIAL.println(); // move to a fresh line on the terminal, whichever line ending it sent
s_lineBuf[s_lineLen] = '\0';
if (s_state == MenuState::AWAIT_CONTINUE) {
if (s_lineLen > 0) {
// Typed a real selection instead of a bare Enter -- don't
// discard it, act on it as if it were entered at the root
// menu rather than forcing a second, separate submission.
s_state = MenuState::ROOT;
handleRootSelection(s_lineBuf);
} else {
enterRoot(); // bare Enter -- just redraw the menu
}
} else if (s_lineLen > 0) {
switch (s_state) {
case MenuState::ROOT: handleRootSelection(s_lineBuf); break;
case MenuState::AWAIT_FREQ: handleFreqEntry(s_lineBuf); break;
case MenuState::AWAIT_FREQ_CONFIRM: handleFreqConfirm(s_lineBuf); break;
case MenuState::AWAIT_DRIVE: handleDriveEntry(s_lineBuf); break;
case MenuState::AWAIT_PASSTHROUGH_PERSIST: handlePassthroughPersistConfirm(s_lineBuf); break;
#if defined(ARDUINO_ARCH_RP2040)
case MenuState::AWAIT_UF2_CONFIRM: handleUf2Confirm(s_lineBuf); break;
#endif
default: break;
}
}
s_lineLen = 0;
if (s_state == MenuState::IDLE) return; // exited mid-drain, stop here
} else if (c == '\b' || c == (char)0x7F) {
// Backspace (0x08) or Delete (0x7F) -- different terminals send
// different codes for the same key, so accept either. Erase the
// last buffered character and produce the matching visual erase
// (cursor back, overwrite with a space, cursor back again) --
// does nothing if the line is already empty, so this can't back
// up into the prompt text itself.
if (s_lineLen > 0) {
s_lineLen--;
CMD_SERIAL.print(F("\b \b"));
}
} else if (s_lineLen < sizeof(s_lineBuf) - 1) {
CMD_SERIAL.write(c); // echo what was typed -- no local echo can be assumed
s_lineBuf[s_lineLen++] = c;
} else {
s_lineLen = 0; // overflow guard: drop the line, resync on next '\r'/'\n'
}
}
}