console: values in a column, the curve as a graph, and Ctrl+C

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 02:34:02 +02:00
parent c01e583597
commit 6abf0b5563
2 changed files with 121 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
# fantemp # 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 [libavr](https://git.blackmark.me/avr/libavr): thermistor on ADC0 sampled
free-running and averaged over 1000 conversions, fan on OC0B at 50 kHz, free-running and averaged over 1000 conversions, fan on OC0B at 50 kHz,
115200 Bd serial console (`help` lists the commands), temperature 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 histogram. `save` (new) forces a writeback, which otherwise happens every 30
minutes and on the way into the bootloader. 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 ## Reaching the bootloader
`bootloader` **jumps**; it does not reset. That is not a style choice: `bootloader` **jumps**; it does not reset. That is not a style choice:

View File

@@ -14,10 +14,12 @@
#include "thermistor.hpp" #include "thermistor.hpp"
// The serial console: line-buffered commands over the hardware UART. // 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 { namespace app {
class terminal { class terminal {
static constexpr char ctrl_c = 0x03;
static constexpr std::uint8_t line_max = 24; static constexpr std::uint8_t line_max = 24;
static inline char line[line_max]{}; static inline char line[line_max]{};
static inline std::uint8_t at = 0; static inline std::uint8_t at = 0;
@@ -61,16 +63,46 @@ class terminal {
serial << "> "_P; serial << "> "_P;
} }
// `name ....: ` — the dots are what make a dozen descriptions readable in a // `name ....: ` — the dotted label the original used everywhere it printed a
// terminal, and they cost nothing but a loop. // list of things, which is what makes a column of values readable without
static void help_row(std::string_view name) // 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 << ' '; serial << text << ' ';
for (auto i = name.size() + 1; i < help_column; ++i) for (auto i = text.size() + 1; i < column; ++i)
serial << '.'; serial << '.';
serial << ": "_P; 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 <typename Flash>
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<std::uint16_t>(quarters < 0 ? -quarters : quarters);
serial << magnitude / 4 << '.' << avr::dec<{.width = 2, .fill = '0'}>((magnitude % 4) * 25);
}
static void help() static void help()
{ {
serial << "\r\nFanTemp "_P << version << " command overview\r\n"_P; serial << "\r\nFanTemp "_P << version << " command overview\r\n"_P;
@@ -81,7 +113,7 @@ class terminal {
help_row(commands[2].name); help_row(commands[2].name);
serial << "shows mapping from temperature to fan speed\r\n"_P; serial << "shows mapping from temperature to fan speed\r\n"_P;
help_row(commands[3].name); 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); help_row(commands[4].name);
serial << "enters the bootloader\r\n"_P; serial << "enters the bootloader\r\n"_P;
help_row(commands[5].name); help_row(commands[5].name);
@@ -115,31 +147,50 @@ class terminal {
return static_cast<std::uint32_t>(thermistor::series_resistor) * adc / (1023u - adc); return static_cast<std::uint32_t>(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() static void show()
{ {
if (!controller::data_available()) { if (!controller::data_available()) {
serial << "no data yet\r\n"_P; serial << "no data yet\r\n"_P;
return; return;
} }
auto quarters = controller::temperature_quarters(); label("ADC value"_P, reading_column);
serial << "temperature "_P << quarters / 4 << '.' << (quarters % 4) * 25 << " C, adc "_P serial << controller::last_adc() << " / 1023\r\n"_P;
<< controller::last_adc() << ", resistance "_P;
if (auto ohms = resistance(); ohms == 0xffffffff) label("Resistance"_P, reading_column);
serial << "open"_P; if (auto ohms = resistance(); ohms == open_circuit)
serial << "open circuit\r\n"_P;
else else
serial << ohms << " Ohm"_P; serial << ohms << " Ohm\r\n"_P;
serial << ", fan "_P << controller::fan_percent() << " %, "_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()) if (controller::automatic())
serial << "auto"_P; serial << "auto\r\n"_P;
else else
serial << "manual"_P; serial << "manual\r\n"_P;
serial << "\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() static void print_curve()
{ {
for (std::int8_t t = 15; t <= 60; t += 5) for (std::uint8_t t = curve_low; t <= curve_high; ++t) {
serial << t << " C -> "_P << curve::duty(t) << " %\r\n"_P; auto duty = curve::duty(static_cast<std::int8_t>(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() static void print_uptime()
@@ -151,12 +202,21 @@ class terminal {
static void print_statistics() static void print_statistics()
{ {
if (statistics::total_samples() == 0) { auto empty = statistics::total_samples() == 0;
serial << "no data yet\r\n"_P; label("Minimum temperature"_P, stat_column);
return; if (empty)
} serial << "not available\r\n"_P;
serial << "min "_P << statistics::min_temperature() << " C, max "_P << statistics::max_temperature() else
<< " C, samples "_P << static_cast<std::uint32_t>(statistics::total_samples()) << "\r\n"_P; 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<std::uint32_t>(statistics::total_samples()) << "\r\n"_P;
} }
static void print_histogram() static void print_histogram()
@@ -300,9 +360,19 @@ class terminal {
} }
public: 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; 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() static void init()
{ {
serial << "\r\nFanTemp "_P << version << " on libavr -- 'help' for commands\r\n"_P; serial << "\r\nFanTemp "_P << version << " on libavr -- 'help' for commands\r\n"_P;
@@ -316,7 +386,11 @@ class terminal {
show(); show();
last_monitor = uptime::millis(); 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; monitoring = false;
prompt(); prompt();
} }
@@ -324,7 +398,14 @@ class terminal {
} }
while (auto in = serial_t::read()) { while (auto in = serial_t::read()) {
char c = static_cast<char>(*in); char c = static_cast<char>(*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; serial << "\r\n"_P;
if (at == 0 && !overflowed) { if (at == 0 && !overflowed) {
prompt(); // a bare Enter just reprompts, no gap needed prompt(); // a bare Enter just reprompts, no gap needed