From 6abf0b556398e30ee7b4b0f7a80a7b279268fe21 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 31 Jul 2026 02:34:02 +0200 Subject: [PATCH] console: values in a column, the curve as a graph, and Ctrl+C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more things the original did better, and two bugs found doing them. `curve` walks every whole degree from 10 to 60 with a bar, which is the original's. The port sampled it every five degrees and printed a bare percentage — ten numbers for a cubic, showing none of its shape. The bar is the duty itself, so it needs no scale. `show` and `statistics` print one value per line behind a dotted label instead of a run-on line. That reads the same either way for a single reading and is the whole difference when `monitor` emits one a second forever. The label renderer is now shared with the help, since it is the same thing three times; the flash overload takes its width from the string's type, so the padding needs no hand-counted constant and the labels stay out of SRAM. `statistics` gains the sample total, and says "not available" rather than a zero it never measured. Ctrl+C echoes `^C` and gives a fresh prompt, abandoning whatever was half-typed, and it is what stops `monitor` now. Stopping on *any* byte was the port's own invention and it reads fine until a host sends a line ending: `monitor\r\n` stopped itself on the `\n` it arrived with, one reading in, which is why monitoring looked broken from a script and fine by hand. The other bug is arithmetic. A temperature's fraction came from `(quarters % 4) * 25`, and C++ gives a negative remainder for a negative dividend — so -40.25 C printed as "-40.-25". The sign comes off first now, and the fraction is two digits, so the column lines up: -40.00, -40.25. Verified against v1.8b on the board, which was flashed back to compare against directly: same 51 curve rows over the same span with the same 100-column bars, agreeing within the one percentage point the consteval table costs against the legacy runtime doubles. Co-Authored-By: Claude Opus 5 --- README.md | 13 ++++- src/terminal.hpp | 137 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 121 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 5164a64..8155285 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # fantemp -**v2.1.** Temperature-controlled fan firmware (ATmega328P, 16 MHz), rewritten on +**v2.2.** Temperature-controlled fan firmware (ATmega328P, 16 MHz), rewritten on [libavr](https://git.blackmark.me/avr/libavr): thermistor on ADC0 sampled free-running and averaged over 1000 conversions, fan on OC0B at 50 kHz, 115200 Bd serial console (`help` lists the commands), temperature @@ -21,6 +21,17 @@ command that cannot be abbreviated, because `r` should not be able to wipe the histogram. `save` (new) forces a writeback, which otherwise happens every 30 minutes and on the way into the bootloader. +`show`, `statistics` and the histogram print one value per line behind a dotted +label, the way the original did — a run-on line is fine for one reading and +unreadable when `monitor` emits one a second. `curve` walks every whole degree +from 10 to 60 with a bar, because the curve is a cubic and five-degree samples +without a graph show none of its shape. + +**Ctrl+C** abandons a half-typed line and gives a fresh prompt, echoing `^C`, and +it is what stops `monitor`. Stopping on *any* byte, which is what the port did +first, reads well right up until a host sends a line ending: `monitor\r\n` then +stopped itself on the `\n` it arrived with, one reading in. + ## Reaching the bootloader `bootloader` **jumps**; it does not reset. That is not a style choice: diff --git a/src/terminal.hpp b/src/terminal.hpp index f6c0edb..41c27ea 100644 --- a/src/terminal.hpp +++ b/src/terminal.hpp @@ -14,10 +14,12 @@ #include "thermistor.hpp" // The serial console: line-buffered commands over the hardware UART. -// `help` lists everything; `monitor` streams until any key. +// `help` lists everything, command names may be abbreviated, `monitor` streams +// until Ctrl+C, and Ctrl+C abandons a half-typed line anywhere else. namespace app { class terminal { + static constexpr char ctrl_c = 0x03; static constexpr std::uint8_t line_max = 24; static inline char line[line_max]{}; static inline std::uint8_t at = 0; @@ -61,16 +63,46 @@ class terminal { serial << "> "_P; } - // `name ....: ` — the dots are what make a dozen descriptions readable in a - // terminal, and they cost nothing but a loop. - static void help_row(std::string_view name) + // `name ....: ` — the dotted label the original used everywhere it printed a + // list of things, which is what makes a column of values readable without + // counting spaces. One renderer for all three users; only the column differs. + static void label(std::string_view text, std::uint8_t column) { - serial << name << ' '; - for (auto i = name.size() + 1; i < help_column; ++i) + serial << text << ' '; + for (auto i = text.size() + 1; i < column; ++i) serial << '.'; serial << ": "_P; } + // The same, for a label that is a literal rather than a command name: it + // stays in flash, and its width comes from the type, so the padding needs no + // hand-counted constant. The command names cannot use this — they are + // string_views because they are matched at run time. + template + static void label(Flash text, std::uint8_t column) + { + serial << text << ' '; + for (auto i = Flash::size + 1; i < column; ++i) + serial << '.'; + serial << ": "_P; + } + + static void help_row(std::string_view name) + { + label(name, help_column); + } + + // Quarter-°C as a signed decimal with a two-digit fraction. The sign is taken + // off first: C++ gives a negative remainder for a negative dividend, so + // `(q % 4) * 25` on -40.25 C yields -25 and prints "-40.-25". + static void temperature(std::int16_t quarters) + { + if (quarters < 0) + serial << '-'; + auto magnitude = static_cast(quarters < 0 ? -quarters : quarters); + serial << magnitude / 4 << '.' << avr::dec<{.width = 2, .fill = '0'}>((magnitude % 4) * 25); + } + static void help() { serial << "\r\nFanTemp "_P << version << " command overview\r\n"_P; @@ -81,7 +113,7 @@ class terminal { help_row(commands[2].name); serial << "shows mapping from temperature to fan speed\r\n"_P; help_row(commands[3].name); - serial << "loops the show command until a key is pressed\r\n"_P; + serial << "loops the show command until Ctrl+C is pressed\r\n"_P; help_row(commands[4].name); serial << "enters the bootloader\r\n"_P; help_row(commands[5].name); @@ -115,31 +147,50 @@ class terminal { return static_cast(thermistor::series_resistor) * adc / (1023u - adc); } + // One value per line behind a dotted label, as the original had it. A single + // run-on line is fine for one reading and unreadable when `monitor` prints + // one a second forever. static void show() { if (!controller::data_available()) { serial << "no data yet\r\n"_P; return; } - auto quarters = controller::temperature_quarters(); - serial << "temperature "_P << quarters / 4 << '.' << (quarters % 4) * 25 << " C, adc "_P - << controller::last_adc() << ", resistance "_P; - if (auto ohms = resistance(); ohms == 0xffffffff) - serial << "open"_P; + label("ADC value"_P, reading_column); + serial << controller::last_adc() << " / 1023\r\n"_P; + + label("Resistance"_P, reading_column); + if (auto ohms = resistance(); ohms == open_circuit) + serial << "open circuit\r\n"_P; else - serial << ohms << " Ohm"_P; - serial << ", fan "_P << controller::fan_percent() << " %, "_P; + serial << ohms << " Ohm\r\n"_P; + + label("Temperature"_P, reading_column); + temperature(controller::temperature_quarters()); + serial << " C\r\n"_P; + + label("Fan speed"_P, reading_column); + serial << controller::fan_percent() << "% "_P; if (controller::automatic()) - serial << "auto"_P; + serial << "auto\r\n"_P; else - serial << "manual"_P; - serial << "\r\n"_P; + serial << "manual\r\n"_P; } + // Every whole degree from 10 to 60 with a bar, which is the original's and is + // the point of the command: the curve is a cubic, and five-degree samples + // without a graph show none of its shape. The bar is the duty itself, so it + // reads as a percentage without needing a scale. static void print_curve() { - for (std::int8_t t = 15; t <= 60; t += 5) - serial << t << " C -> "_P << curve::duty(t) << " %\r\n"_P; + for (std::uint8_t t = curve_low; t <= curve_high; ++t) { + auto duty = curve::duty(static_cast(t)); + serial << avr::dec<{.width = 2, .fill = '0'}>(t) << " C = "_P << avr::dec<{.width = 3, .fill = ' '}>(duty) + << "% |"_P; + for (std::uint8_t i = 0; i < duty; ++i) + serial << '#'; + serial << "\r\n"_P; + } } static void print_uptime() @@ -151,12 +202,21 @@ class terminal { static void print_statistics() { - if (statistics::total_samples() == 0) { - serial << "no data yet\r\n"_P; - return; - } - serial << "min "_P << statistics::min_temperature() << " C, max "_P << statistics::max_temperature() - << " C, samples "_P << static_cast(statistics::total_samples()) << "\r\n"_P; + auto empty = statistics::total_samples() == 0; + label("Minimum temperature"_P, stat_column); + if (empty) + serial << "not available\r\n"_P; + else + serial << statistics::min_temperature() << " C\r\n"_P; + + label("Maximum temperature"_P, stat_column); + if (empty) + serial << "not available\r\n"_P; + else + serial << statistics::max_temperature() << " C\r\n"_P; + + label("Total samples"_P, stat_column); + serial << static_cast(statistics::total_samples()) << "\r\n"_P; } static void print_histogram() @@ -300,9 +360,19 @@ class terminal { } public: - static constexpr std::string_view version = "v2.1"; + static constexpr std::string_view version = "v2.2"; static constexpr std::uint8_t bar_max = 100; + // Columns the dotted labels' colons land in, and the curve's span. All four + // are the original's. + static constexpr std::uint8_t reading_column = 13; + static constexpr std::uint8_t stat_column = 21; + static constexpr std::uint8_t curve_low = 10; + static constexpr std::uint8_t curve_high = 60; + + // A railed ADC leaves the divider with no solution rather than a huge one. + static constexpr std::uint32_t open_circuit = 0xffffffff; + static void init() { serial << "\r\nFanTemp "_P << version << " on libavr -- 'help' for commands\r\n"_P; @@ -316,7 +386,11 @@ class terminal { show(); last_monitor = uptime::millis(); } - if (serial_t::read()) { // any key stops + // Ctrl+C only, as the original had it. Stopping on *any* byte reads + // well until a host sends a line ending: `monitor\r\n` then stops + // itself on the `\n` it arrived with, one reading in. + if (auto in = serial_t::read(); in && *in == ctrl_c) { + serial << "^C\r\n"_P; monitoring = false; prompt(); } @@ -324,7 +398,14 @@ class terminal { } while (auto in = serial_t::read()) { char c = static_cast(*in); - if (c == '\r' || c == '\n') { + if (c == ctrl_c) { + // Abandon whatever was typed and start a fresh line, which is + // what Ctrl+C means at every other prompt in the world. + serial << "^C\r\n"_P; + at = 0; + overflowed = false; + prompt(); + } else if (c == '\r' || c == '\n') { serial << "\r\n"_P; if (at == 0 && !overflowed) { prompt(); // a bare Enter just reprompts, no gap needed