Six things the port had dropped or got wrong, and the one that matters is the last. The help is a table again — name, dots, description, one command per line — instead of a single line of bare words that said nothing about what any of them did. The layout is the original's, colons at column 12, which `bootloader` at ten characters is what sets. Abbreviations are back, and they were a feature: any prefix resolves to the first command it matches, so `up` is uptime and `st` is statistics. Order does the disambiguating, which is why the table is in the original's dispatch order and new entries go on the end — appending cannot take an abbreviation that already meant something. `reset` keeps the original's exception and must be typed in full: `r` should not be able to clear the histogram. The histogram gets its resolution back. The bar was capped at 40 columns where the original scaled to 100, and on a distribution this narrow that threw away most of the difference between neighbouring buckets. Same normalisation as before: divide by whatever makes the tallest bucket fit. The sample count moves to a fixed ten-column field before the bar, so the numbers read as a table instead of trailing off the ragged right end. `version` exists again, and this is 2.1 — 2.0 being the port as it stood. Added while here: `save`, to force the writeback that otherwise waits up to thirty minutes; the resistance in `show`, which is the one number that says *why* a temperature is wrong and which the original printed; a report when a line overflows the buffer rather than silently acting on its head; "no data yet" where there is none; and a blank line after each command's output. And the way out. `bootloader` now jumps rather than resetting, because pureboot hands straight back on WDRF by design — so the legacy watchdog-reset hand-over reaches it and opens no window, which on a board with no reset line is a board that cannot be reflashed. Two more bugs in the same three lines: the target was 0x7800, a 2 KB boot section's base, which on this board's 512-byte section reads erased and made the check false and the command a no-op; and UCSR0B was left set, which mutes a loader that bit-bangs the pin the USART still owns. All three are now read back out of the emitted image by ctest, the address and the watchdog red-proven against exactly the legacy behaviour they exist to catch. libavr advances to 71cfb2f. Verified on the board: FanTemp v2.1, min 0 C / max 74 C matching what 1.8b reported off the same EEPROM, the fan curve within one percentage point of the legacy double-precision one at every 5 C from 15 to 60, and `bootloader` -> pureboot 7 -> back to a running application. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
360 lines
10 KiB
C++
360 lines
10 KiB
C++
#pragma once
|
|
|
|
#include <array>
|
|
#include <cstdint>
|
|
#include <string_view>
|
|
|
|
#include <libavr/libavr.hpp>
|
|
|
|
#include "board.hpp"
|
|
#include "bootloader.hpp"
|
|
#include "controller.hpp"
|
|
#include "curve.hpp"
|
|
#include "statistics.hpp"
|
|
#include "thermistor.hpp"
|
|
|
|
// The serial console: line-buffered commands over the hardware UART.
|
|
// `help` lists everything; `monitor` streams until any key.
|
|
namespace app {
|
|
|
|
class terminal {
|
|
static constexpr std::uint8_t line_max = 24;
|
|
static inline char line[line_max]{};
|
|
static inline std::uint8_t at = 0;
|
|
static inline bool overflowed = false;
|
|
static inline bool monitoring = false;
|
|
static inline std::uint64_t last_monitor = 0;
|
|
|
|
// Commands, in the order they are matched — which is the order the original
|
|
// firmware matched them in, and that order is load-bearing. An abbreviation
|
|
// resolves to the *first* entry it prefixes, so `s` is show (not statistics,
|
|
// not set) exactly as it always was, and anything appended to this list
|
|
// cannot steal an abbreviation that already meant something else.
|
|
struct command {
|
|
std::string_view name;
|
|
bool exact; // reset only: an abbreviation must not be able to wipe data
|
|
};
|
|
|
|
static constexpr std::array<command, 13> commands{{
|
|
{"help", false},
|
|
{"show", false},
|
|
{"curve", false},
|
|
{"monitor", false},
|
|
{"bootloader", false},
|
|
{"uptime", false},
|
|
{"statistics", false},
|
|
{"histogram", false},
|
|
{"reset", true},
|
|
{"set", false},
|
|
{"auto", false},
|
|
{"version", false},
|
|
{"save", false},
|
|
}};
|
|
|
|
// Column the descriptions' colons line up in, counted from the start of the
|
|
// name. The longest name is `bootloader` at 10, so 12 leaves it a space and
|
|
// one dot — the original's layout exactly.
|
|
static constexpr std::uint8_t help_column = 12;
|
|
|
|
static void prompt()
|
|
{
|
|
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)
|
|
{
|
|
serial << name << ' ';
|
|
for (auto i = name.size() + 1; i < help_column; ++i)
|
|
serial << '.';
|
|
serial << ": "_P;
|
|
}
|
|
|
|
static void help()
|
|
{
|
|
serial << "\r\nFanTemp "_P << version << " command overview\r\n"_P;
|
|
help_row(commands[0].name);
|
|
serial << "prints this help message\r\n"_P;
|
|
help_row(commands[1].name);
|
|
serial << "shows current temperature and fan speed\r\n"_P;
|
|
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;
|
|
help_row(commands[4].name);
|
|
serial << "enters the bootloader\r\n"_P;
|
|
help_row(commands[5].name);
|
|
serial << "shows system uptime\r\n"_P;
|
|
help_row(commands[6].name);
|
|
serial << "prints overall statistics like min and max temp\r\n"_P;
|
|
help_row(commands[7].name);
|
|
serial << "prints a histogram of the temperature\r\n"_P;
|
|
help_row(commands[8].name);
|
|
serial << "resets statistics to 0 in EEPROM and RAM (no abbreviation)\r\n"_P;
|
|
help_row(commands[9].name);
|
|
serial << "sets the fan speed to the provided value, 0-100\r\n"_P;
|
|
help_row(commands[10].name);
|
|
serial << "turns on automatic fan control\r\n"_P;
|
|
help_row(commands[11].name);
|
|
serial << "displays firmware version\r\n"_P;
|
|
help_row(commands[12].name);
|
|
serial << "writes the statistics to EEPROM now\r\n"_P;
|
|
serial << "commands may be abbreviated: 'up' is uptime\r\n"_P;
|
|
}
|
|
|
|
// The thermistor's resistance from the divider, in whole ohms. The original
|
|
// printed this beside the reading and it is the one number that says *why* a
|
|
// temperature is wrong: an open sensor rails the ADC and the resistance goes
|
|
// to the tens of megohms, a shorted one to zero.
|
|
static std::uint32_t resistance()
|
|
{
|
|
auto adc = controller::last_adc();
|
|
if (adc >= 1023)
|
|
return 0xffffffff; // open circuit: the divider has no solution
|
|
return static_cast<std::uint32_t>(thermistor::series_resistor) * adc / (1023u - adc);
|
|
}
|
|
|
|
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;
|
|
else
|
|
serial << ohms << " Ohm"_P;
|
|
serial << ", fan "_P << controller::fan_percent() << " %, "_P;
|
|
if (controller::automatic())
|
|
serial << "auto"_P;
|
|
else
|
|
serial << "manual"_P;
|
|
serial << "\r\n"_P;
|
|
}
|
|
|
|
static void print_curve()
|
|
{
|
|
for (std::int8_t t = 15; t <= 60; t += 5)
|
|
serial << t << " C -> "_P << curve::duty(t) << " %\r\n"_P;
|
|
}
|
|
|
|
static void print_uptime()
|
|
{
|
|
auto seconds = static_cast<std::uint32_t>(uptime::millis() / 1000);
|
|
serial << seconds / 86400 << "d "_P << (seconds / 3600) % 24 << "h "_P << (seconds / 60) % 60 << "m "_P
|
|
<< seconds % 60 << "s\r\n"_P;
|
|
}
|
|
|
|
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<std::uint32_t>(statistics::total_samples()) << "\r\n"_P;
|
|
}
|
|
|
|
static void print_histogram()
|
|
{
|
|
auto highest = statistics::highest_bucket();
|
|
if (highest == 0) {
|
|
serial << "no data yet\r\n"_P;
|
|
return;
|
|
}
|
|
// The original's normalisation, and its resolution: divide by whatever
|
|
// makes the tallest bucket fit in a hundred columns, not forty. A bar
|
|
// that tops out at 40 throws away most of the difference between
|
|
// neighbouring buckets, which on a distribution this narrow is the whole
|
|
// picture.
|
|
std::uint32_t factor = highest / bar_max > 1 ? highest / bar_max : 1;
|
|
while (highest / factor > bar_max)
|
|
++factor;
|
|
|
|
for (std::uint8_t t = statistics::min_temperature(); t <= statistics::max_temperature(); ++t) {
|
|
auto count = statistics::bucket(t);
|
|
// Count first, in a fixed column, so the numbers read as a table
|
|
// instead of trailing off the ragged right-hand end of the bars.
|
|
serial << avr::dec<{.width = 2, .fill = '0'}>(t) << " C : "_P << avr::dec<{.width = 10, .fill = ' '}>(count)
|
|
<< " |"_P;
|
|
for (std::uint32_t i = 0; i < count / factor; ++i)
|
|
serial << '#';
|
|
serial << "\r\n"_P;
|
|
}
|
|
}
|
|
|
|
// Abbreviations: the input matches a command when it is a non-empty prefix
|
|
// of it. `reset` is the exception and must be typed in full.
|
|
//
|
|
// starts_with, not substr: substr throws std::out_of_range, and one
|
|
// potentially-throwing call is enough to pull in std::terminate, which does
|
|
// not exist in a freestanding AVR build. The link fails rather than the
|
|
// firmware, so this is a build-time trap rather than a runtime one — but it
|
|
// is a trap, and the whole file avoids substr for that reason.
|
|
static bool matches(std::string_view input, const command &c)
|
|
{
|
|
if (input.empty())
|
|
return false;
|
|
if (c.exact)
|
|
return input == c.name;
|
|
return c.name.starts_with(input);
|
|
}
|
|
|
|
static void dispatch(std::string_view input)
|
|
{
|
|
// A line that overflowed the buffer is not a command — it is the tail of
|
|
// one. Acting on it is how a truncated `reset` becomes a surprise.
|
|
if (overflowed) {
|
|
serial << "input too long, ignored\r\n"_P;
|
|
overflowed = false;
|
|
return;
|
|
}
|
|
|
|
// Split on the first space with the (pointer, length) constructor rather
|
|
// than substr, which throws — see matches().
|
|
const auto space = input.find(' ');
|
|
const auto word = space == std::string_view::npos ? input : std::string_view{input.data(), space};
|
|
const auto rest = space == std::string_view::npos
|
|
? std::string_view{}
|
|
: std::string_view{input.data() + space + 1, input.size() - space - 1};
|
|
if (word.empty())
|
|
return;
|
|
|
|
std::uint8_t which = commands.size();
|
|
for (std::uint8_t i = 0; i < commands.size(); ++i)
|
|
if (matches(word, commands[i])) {
|
|
which = i;
|
|
break;
|
|
}
|
|
|
|
switch (which) {
|
|
case 0:
|
|
help();
|
|
return;
|
|
case 1:
|
|
show();
|
|
return;
|
|
case 2:
|
|
print_curve();
|
|
return;
|
|
case 3:
|
|
monitoring = true;
|
|
return;
|
|
case 4:
|
|
serial << "entering bootloader\r\n"_P;
|
|
statistics::save();
|
|
serial.drain();
|
|
bootloader::enter();
|
|
case 5:
|
|
print_uptime();
|
|
return;
|
|
case 6:
|
|
print_statistics();
|
|
return;
|
|
case 7:
|
|
print_histogram();
|
|
return;
|
|
case 8:
|
|
statistics::reset();
|
|
serial << "statistics cleared in EEPROM and RAM\r\n"_P;
|
|
return;
|
|
case 9: {
|
|
std::uint16_t percent = 0;
|
|
bool valid = !rest.empty();
|
|
for (char c : rest) {
|
|
if (c < '0' || c > '9') {
|
|
valid = false;
|
|
break;
|
|
}
|
|
percent = static_cast<std::uint16_t>(percent * 10 + (c - '0'));
|
|
if (percent > 100)
|
|
valid = false;
|
|
}
|
|
if (valid) {
|
|
controller::set_manual(static_cast<std::uint8_t>(percent));
|
|
serial << "fan "_P << percent << " %, manual\r\n"_P;
|
|
} else {
|
|
serial << "set 0..100\r\n"_P;
|
|
}
|
|
return;
|
|
}
|
|
case 10:
|
|
controller::set_automatic();
|
|
serial << "automatic fan control\r\n"_P;
|
|
return;
|
|
case 11:
|
|
serial << "FanTemp "_P << version << " on libavr\r\n"_P;
|
|
return;
|
|
case 12:
|
|
statistics::save();
|
|
serial << "statistics written to EEPROM\r\n"_P;
|
|
return;
|
|
default:
|
|
serial << '\'' << word << "' is not a command; 'help' for the list\r\n"_P;
|
|
return;
|
|
}
|
|
}
|
|
|
|
public:
|
|
static constexpr std::string_view version = "v2.1";
|
|
static constexpr std::uint8_t bar_max = 100;
|
|
|
|
static void init()
|
|
{
|
|
serial << "\r\nFanTemp "_P << version << " on libavr -- 'help' for commands\r\n"_P;
|
|
prompt();
|
|
}
|
|
|
|
static void poll()
|
|
{
|
|
if (monitoring) {
|
|
if (uptime::millis() >= last_monitor + 1000) {
|
|
show();
|
|
last_monitor = uptime::millis();
|
|
}
|
|
if (serial_t::read()) { // any key stops
|
|
monitoring = false;
|
|
prompt();
|
|
}
|
|
return;
|
|
}
|
|
while (auto in = serial_t::read()) {
|
|
char c = static_cast<char>(*in);
|
|
if (c == '\r' || c == '\n') {
|
|
serial << "\r\n"_P;
|
|
if (at == 0 && !overflowed) {
|
|
prompt(); // a bare Enter just reprompts, no gap needed
|
|
continue;
|
|
}
|
|
dispatch(std::string_view{line, at});
|
|
at = 0;
|
|
if (!monitoring) {
|
|
// A blank line between a command's output and the next
|
|
// prompt: without it the answer and the thing you type
|
|
// next run together and a screen of them is unreadable.
|
|
serial << "\r\n"_P;
|
|
prompt();
|
|
}
|
|
} else if (c == 0x7f || c == 0x08) {
|
|
if (at) {
|
|
--at;
|
|
serial << "\b \b"_P;
|
|
}
|
|
} else if (c >= ' ') {
|
|
if (at < line_max) {
|
|
line[at++] = c;
|
|
serial << c; // echo
|
|
} else {
|
|
overflowed = true; // reported when the line is submitted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
} // namespace app
|