build: the libavr pin advances past phase 6

The renames land (interrupt_guard, consume_reset_cause, set_duty), the
sampler binds its input in the new converter shape (the input pack plus
in<>::start() as free-running's one kick), and the console states
.allow_baud_error = true for the 115200-at-16-MHz this board has always
spoken - the receiver-tolerance table libavr now enforces is stricter
than the rate's own +2.1 %. The loader probe reads through
avr::flash_load instead of raw pgmspace, the terminal's line buffer is
std::array with backspace and delete named, the tree is reformatted
under InsertBraces, and the sources are ASCII.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:45:07 +02:00
parent 0e9060db69
commit b1caf49522
13 changed files with 161 additions and 113 deletions

View File

@@ -1,13 +1,15 @@
--- ---
BasedOnStyle: LLVM BasedOnStyle: LLVM
Standard: Latest
ColumnLimit: 120 ColumnLimit: 120
IndentWidth: 4 IndentWidth: 4
TabWidth: 4 TabWidth: 4
UseTab: ForIndentation UseTab: ForIndentation
AlignEscapedNewlines: DontAlign AlignEscapedNewlines: DontAlign
AllowShortFunctionsOnASingleLine: Empty AllowShortFunctionsOnASingleLine: Empty
AlwaysBreakTemplateDeclarations: true BreakTemplateDeclarations: Yes
BreakBeforeBraces: Custom BreakBeforeBraces: Custom
BraceWrapping: BraceWrapping:
AfterFunction: true AfterFunction: true
InsertBraces: true
... ...

View File

@@ -12,7 +12,7 @@ if(NOT LIBAVR_ROOT)
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr) set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr)
endif() endif()
if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt) if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt)
message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} run: git submodule update --init libavr") message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} - run: git submodule update --init libavr")
endif() endif()
add_subdirectory(${LIBAVR_ROOT} libavr-build) add_subdirectory(${LIBAVR_ROOT} libavr-build)

2
libavr

Submodule libavr updated: b719ed74d8...26109e172b

View File

@@ -3,7 +3,7 @@
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
// The board composition: every peripheral of the fan controller in one // The board composition: every peripheral of the fan controller in one
// place. ATmega328P at 16 MHz thermistor divider on ADC0 (PC0), fan on // place. ATmega328P at 16 MHz - thermistor divider on ADC0 (PC0), fan on
// OC0B (PD5) at 50 kHz, console on the hardware UART. // OC0B (PD5) at 50 kHz, console on the hardware UART.
namespace app { namespace app {
@@ -20,7 +20,7 @@ class uptime {
static std::uint64_t millis() static std::uint64_t millis()
{ {
avr::irq::atomic_guard lock; avr::irq::interrupt_guard lock;
return ms; return ms;
} }
}; };
@@ -35,25 +35,29 @@ class sampler {
static constexpr std::uint16_t samples = 1000; static constexpr std::uint16_t samples = 1000;
public: public:
using input = dev::adc<{.input = avr::adc::input_pin(0), using input = dev::adc<{.trigger = avr::adc::trigger::free_running,
.trigger = avr::adc::trigger::free_running, .on_conversion =
.on_conversion = [](std::uint16_t value) { [](std::uint16_t value) {
sum = sum + value; sum = sum + value;
count = count + 1; count = count + 1;
if (count >= samples) { if (count >= samples) {
window = static_cast<std::uint16_t>(sum / samples); window = static_cast<std::uint16_t>(sum / samples);
sum = 0; sum = 0;
count = 0; count = 0;
ready = true; ready = true;
} }
}}>; }},
avr::adc::input<avr::adc::input_pin(0)>>;
// The bound input, whose start() is free-running's one kick.
using thermistor = input::in<avr::adc::input_pin(0)>;
// The finished average (raw 10-bit), once per window. // The finished average (raw 10-bit), once per window.
static bool take(std::uint16_t &value) static bool take(std::uint16_t &value)
{ {
avr::irq::atomic_guard lock; avr::irq::interrupt_guard lock;
if (!ready) if (!ready) {
return false; return false;
}
value = window; value = window;
ready = false; ready = false;
return true; return true;
@@ -62,7 +66,10 @@ class sampler {
using fan = dev::pwm<avr::pd5, {.frequency = 50_kHz}>; using fan = dev::pwm<avr::pd5, {.frequency = 50_kHz}>;
using serial_t = dev::uart0<{.baud = 115200_Bd, .rx_buffer = 32, .max_baud_error = 2.5_pct}>; // 115200 at 16 MHz lands +2.1 % off, past the receiver-tolerance table the
// solver holds rates to - the rate this board has always spoken, so the
// override states that it is meant.
using serial_t = dev::uart0<{.baud = 115200_Bd, .rx_buffer = 32, .allow_baud_error = true}>;
inline constexpr serial_t serial{}; inline constexpr serial_t serial{};
} // namespace app } // namespace app

View File

@@ -1,7 +1,5 @@
#pragma once #pragma once
#include <avr/pgmspace.h>
#include <libavr/libavr.hpp> #include <libavr/libavr.hpp>
#include "board.hpp" #include "board.hpp"
@@ -11,7 +9,7 @@
// The legacy firmware did this with a watchdog reset: `bootloader` armed the // The legacy firmware did this with a watchdog reset: `bootloader` armed the
// watchdog and hung, and the next boot noticed WDRF and jumped to the boot // watchdog and hung, and the next boot noticed WDRF and jumped to the boot
// section. That works for TinySafeBoot and **does not work for pureboot**, which // section. That works for TinySafeBoot and **does not work for pureboot**, which
// deliberately hands straight back to the application on WDRF an unattended // deliberately hands straight back to the application on WDRF - an unattended
// board that watchdog-resets in a loop must not sit in a loader instead of // board that watchdog-resets in a loop must not sit in a loader instead of
// running. So a reset-based route into pureboot opens no window at all, and on a // running. So a reset-based route into pureboot opens no window at all, and on a
// board whose only way in is the firmware that is a lockout. // board whose only way in is the firmware that is a lockout.
@@ -22,7 +20,7 @@
// The address is this board's, and it is not the legacy one: the loader lives in // The address is this board's, and it is not the legacy one: the loader lives in
// the top 512 bytes at 0x7e00 (`hfuse d4` puts the boot section at 0x7c00 with // the top 512 bytes at 0x7e00 (`hfuse d4` puts the boot section at 0x7c00 with
// pureboot's staging slot below its own slot). The legacy firmware probed 0x7800 // pureboot's staging slot below its own slot). The legacy firmware probed 0x7800
// a 2 KB boot section's base which on this board reads erased, so its check // - a 2 KB boot section's base - which on this board reads erased, so its check
// was always false and its `bootloader` command never actually arrived anywhere. // was always false and its `bootloader` command never actually arrived anywhere.
namespace app { namespace app {
@@ -32,14 +30,14 @@ class bootloader {
using guard = dev::watchdog<{.timeout = 16_ms}>; using guard = dev::watchdog<{.timeout = 16_ms}>;
// The top 512 bytes. An erased slot reads 0xffff, which is not an // The top 512 bytes. An erased slot reads 0xffff, which is not an
// instruction any loader begins with so this asks "is a loader installed" // instruction any loader begins with - so this asks "is a loader installed"
// rather than "is it the one I expect", which is the check the legacy // rather than "is it the one I expect", which is the check the legacy
// firmware got wrong in the other direction by testing one specific byte. // firmware got wrong in the other direction by testing one specific byte.
static constexpr std::uint16_t base = 0x7e00; static constexpr std::uint16_t base = 0x7e00;
static bool present() static bool present()
{ {
return pgm_read_word(base) != 0xffff; return avr::flash_load(reinterpret_cast<const std::uint16_t *>(base)) != 0xffff;
} }
// A function pointer holds a word address on AVR, so the byte address // A function pointer holds a word address on AVR, so the byte address
@@ -52,24 +50,25 @@ class bootloader {
} }
public: public:
// Call first thing in main. reset_cause() reads *and clears* MCUSR, which // Call first thing in main. consume_reset_cause() reads *and clears* MCUSR, which
// matters on its own: a lingering WDRF forces the watchdog back on at its // matters on its own: a lingering WDRF forces the watchdog back on at its
// shortest timeout. The diversion below is a leftover of the legacy route // shortest timeout. The diversion below is a leftover of the legacy route
// and is kept only because it is free and cannot hurt with BOOTRST // and is kept only because it is free and cannot hurt - with BOOTRST
// programmed the loader has already run before this line, so nothing // programmed the loader has already run before this line, so nothing
// normally reaches it. // normally reaches it.
static void handle_reset() static void handle_reset()
{ {
auto cause = avr::power::reset_cause(); auto cause = avr::power::consume_reset_cause();
guard::disable(); guard::disable();
if (cause.watchdog && present()) if (cause.watchdog && present()) {
call(reinterpret_cast<jump_fn>(base / 2)); call(reinterpret_cast<jump_fn>(base / 2));
}
} }
// Hand over for real: no reset, so no WDRF for the loader to refuse. // Hand over for real: no reset, so no WDRF for the loader to refuse.
[[noreturn]] static void enter() [[noreturn]] static void enter()
{ {
// Interrupts first the receive vector and the timer live in this // Interrupts first - the receive vector and the timer live in this
// application's vector table, and once the loader is running there is no // application's vector table, and once the loader is running there is no
// application to vector into. // application to vector into.
avr::irq::disable(); avr::irq::disable();
@@ -77,7 +76,7 @@ class bootloader {
// Release the USART. While TXEN0 is set the peripheral owns PD1, not the // Release the USART. While TXEN0 is set the peripheral owns PD1, not the
// port register, so a loader that bit-bangs the same pin receives // port register, so a loader that bit-bangs the same pin receives
// perfectly and answers into nothing mute, not deaf, and unverifiable // perfectly and answers into nothing - mute, not deaf, and unverifiable
// from the host. pureboot clears this itself; TinySafeBoot, which is what // from the host. pureboot clears this itself; TinySafeBoot, which is what
// this board still carries, does not. Four bytes make the hand-over work // this board still carries, does not. Four bytes make the hand-over work
// for either one, which is the only reason this route can be tested // for either one, which is the only reason this route can be tested

View File

@@ -6,7 +6,7 @@
#include "curve.hpp" #include "curve.hpp"
#include "thermistor.hpp" #include "thermistor.hpp"
// Control loop: averaged thermistor samples temperature fan duty // Control loop: averaged thermistor samples -> temperature -> fan duty
// through the curve table (auto) or a console-set value (manual). // through the curve table (auto) or a console-set value (manual).
namespace app { namespace app {
@@ -20,27 +20,29 @@ class controller {
public: public:
static void init() static void init()
{ {
fan::duty(avr::percent_t{10000}); // full blast until the first reading fan::set_duty(avr::percent_t{10000}); // full blast until the first reading
} }
static void poll() static void poll()
{ {
std::uint16_t sample; std::uint16_t sample;
if (!sampler::take(sample)) if (!sampler::take(sample)) {
return; return;
}
adc_average = sample; adc_average = sample;
temp_quarters = thermistor::quarters(sample); temp_quarters = thermistor::quarters(sample);
have_data = true; have_data = true;
if (auto_mode) if (auto_mode) {
percent = curve::duty(static_cast<std::int8_t>((temp_quarters + 2) / 4)); percent = curve::duty(static_cast<std::int8_t>((temp_quarters + 2) / 4));
fan::duty(avr::percent_t{static_cast<std::uint16_t>(percent * 100)}); }
fan::set_duty(avr::percent_t{static_cast<std::uint16_t>(percent * 100)});
} }
static void set_manual(std::uint8_t p) static void set_manual(std::uint8_t p)
{ {
auto_mode = false; auto_mode = false;
percent = p; percent = p;
fan::duty(avr::percent_t{static_cast<std::uint16_t>(p * 100)}); fan::set_duty(avr::percent_t{static_cast<std::uint16_t>(p * 100)});
} }
static void set_automatic() static void set_automatic()

View File

@@ -5,9 +5,9 @@
#include <libavr/flash.hpp> #include <libavr/flash.hpp>
// The auto-mode fan curve, tabulated at compile time: a cubic in °C // The auto-mode fan curve, tabulated at compile time: a cubic in C
// (0.002246·x³ 0.09·x² + 0.91·x, zero below 20 °C) as a flash_table of // (0.002246*x^3 - 0.09*x^2 + 0.91*x, zero below 20 C) as a flash_table of
// duty percent per °C. // duty percent per C.
namespace app::curve { namespace app::curve {
namespace detail { namespace detail {
@@ -15,33 +15,39 @@ namespace detail {
consteval std::uint8_t duty_entry(int celsius) consteval std::uint8_t duty_entry(int celsius)
{ {
double x = celsius; double x = celsius;
if (x < 20) if (x < 20) {
return 0; return 0;
}
double duty = 0.002246 * x * x * x - 0.09 * x * x + 0.91 * x; double duty = 0.002246 * x * x * x - 0.09 * x * x + 0.91 * x;
if (duty < 0) if (duty < 0) {
duty = 0; duty = 0;
if (duty > 100) }
if (duty > 100) {
duty = 100; duty = 100;
}
return static_cast<std::uint8_t>(duty + 0.5); return static_cast<std::uint8_t>(duty + 0.5);
} }
inline constexpr avr::flash_table<[] { inline constexpr avr::flash_table<[] {
std::array<std::uint8_t, 100> out{}; std::array<std::uint8_t, 100> out{};
for (int t = 0; t < 100; ++t) for (int t = 0; t < 100; ++t) {
out[static_cast<std::size_t>(t)] = duty_entry(t); out[static_cast<std::size_t>(t)] = duty_entry(t);
}
return out; return out;
}()> }()>
table; table;
} // namespace detail } // namespace detail
// Duty percent for a temperature (clamped to the 0..99 °C table window). // Duty percent for a temperature (clamped to the 0..99 C table window).
inline std::uint8_t duty(std::int8_t celsius) inline std::uint8_t duty(std::int8_t celsius)
{ {
if (celsius < 0) if (celsius < 0) {
celsius = 0; celsius = 0;
if (celsius > 99) }
if (celsius > 99) {
celsius = 99; celsius = 99;
}
return detail::table[static_cast<std::uint8_t>(celsius)]; return detail::table[static_cast<std::uint8_t>(celsius)];
} }

View File

@@ -16,14 +16,15 @@ int main()
avr::init<uptime::ticker, sampler::input, fan, serial_t, statistics>(); avr::init<uptime::ticker, sampler::input, fan, serial_t, statistics>();
avr::irq::enable(); avr::irq::enable();
sampler::input::start(); sampler::thermistor::start();
controller::init(); controller::init();
terminal::init(); terminal::init();
while (true) { while (true) {
controller::poll(); controller::poll();
if (controller::data_available()) if (controller::data_available()) {
statistics::record(static_cast<std::int8_t>((controller::temperature_quarters() + 2) / 4)); statistics::record(static_cast<std::int8_t>((controller::temperature_quarters() + 2) / 4));
}
terminal::poll(); terminal::poll();
} }
} }

View File

@@ -7,9 +7,9 @@
#include "board.hpp" #include "board.hpp"
// Temperature histogram: one uint32 bucket per °C 0..99, sampled once a // Temperature histogram: one uint32 bucket per C 0..99, sampled once a
// second, written back to EEPROM every 30 minutes (update() only touches // second, written back to EEPROM every 30 minutes (update() only touches
// changed bytes). Erased EEPROM reads back as 0xffffffff treated as 0. // changed bytes). Erased EEPROM reads back as 0xffffffff - treated as 0.
namespace app { namespace app {
class statistics { class statistics {
@@ -34,9 +34,11 @@ class statistics {
static void init() static void init()
{ {
histogram = stored::read(); histogram = stored::read();
for (auto &bucket : histogram) for (auto &bucket : histogram) {
if (bucket == 0xffffffff) if (bucket == 0xffffffff) {
bucket = 0; bucket = 0;
}
}
} }
static void record(std::int8_t celsius) static void record(std::int8_t celsius)
@@ -65,34 +67,41 @@ class statistics {
static std::uint8_t min_temperature() static std::uint8_t min_temperature()
{ {
for (std::uint8_t i = 0; i < range; ++i) for (std::uint8_t i = 0; i < range; ++i) {
if (histogram[i]) if (histogram[i]) {
return i; return i;
}
}
return range; return range;
} }
static std::uint8_t max_temperature() static std::uint8_t max_temperature()
{ {
for (std::uint8_t i = range; i > 0; --i) for (std::uint8_t i = range; i > 0; --i) {
if (histogram[i - 1]) if (histogram[i - 1]) {
return i - 1; return i - 1;
}
}
return 0; return 0;
} }
static std::uint64_t total_samples() static std::uint64_t total_samples()
{ {
std::uint64_t total = 0; std::uint64_t total = 0;
for (auto bucket : histogram) for (auto bucket : histogram) {
total += bucket; total += bucket;
}
return total; return total;
} }
static std::uint32_t highest_bucket() static std::uint32_t highest_bucket()
{ {
std::uint32_t highest = 0; std::uint32_t highest = 0;
for (auto bucket : histogram) for (auto bucket : histogram) {
if (bucket > highest) if (bucket > highest) {
highest = bucket; highest = bucket;
}
}
return highest; return highest;
} }

View File

@@ -20,14 +20,15 @@ namespace app {
class terminal { class terminal {
static constexpr char ctrl_c = 0x03; static constexpr char ctrl_c = 0x03;
static constexpr std::uint8_t line_max = 24; static constexpr char backspace = 0x08;
static inline char line[line_max]{}; static constexpr char del = 0x7f;
static inline std::array<char, 24> line{};
static inline std::uint8_t at = 0; static inline std::uint8_t at = 0;
static inline bool overflowed = false; static inline bool overflowed = false;
static inline bool monitoring = false; static inline bool monitoring = false;
static inline std::uint64_t last_monitor = 0; static inline std::uint64_t last_monitor = 0;
// Commands, in the order they are matched which is the order the original // 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 // 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, // 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 // not set) exactly as it always was, and anything appended to this list
@@ -55,7 +56,7 @@ class terminal {
// Column the descriptions' colons line up in, counted from the start of the // 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 // name. The longest name is `bootloader` at 10, so 12 leaves it a space and
// one dot the original's layout exactly. // one dot - the original's layout exactly.
static constexpr std::uint8_t help_column = 12; static constexpr std::uint8_t help_column = 12;
static void prompt() static void prompt()
@@ -63,27 +64,29 @@ class terminal {
serial << "> "_P; serial << "> "_P;
} }
// `name ....: ` the dotted label the original used everywhere it printed a // `name ....: ` - the dotted label the original used everywhere it printed a
// list of things, which is what makes a column of values readable without // 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. // counting spaces. One renderer for all three users; only the column differs.
static void label(std::string_view text, std::uint8_t column) static void label(std::string_view text, std::uint8_t column)
{ {
serial << text << ' '; serial << text << ' ';
for (auto i = text.size() + 1; i < 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 // 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 // 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 // hand-counted constant. The command names cannot use this - they are
// string_views because they are matched at run time. // string_views because they are matched at run time.
template <typename Flash> template <typename Flash>
static void label(Flash text, std::uint8_t column) static void label(Flash text, std::uint8_t column)
{ {
serial << text << ' '; serial << text << ' ';
for (auto i = Flash::size + 1; i < column; ++i) for (auto i = Flash::size + 1; i < column; ++i) {
serial << '.'; serial << '.';
}
serial << ": "_P; serial << ": "_P;
} }
@@ -92,13 +95,14 @@ class terminal {
label(name, help_column); label(name, help_column);
} }
// Quarter-°C as a signed decimal with a two-digit fraction. The sign is taken // 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 // 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". // `(q % 4) * 25` on -40.25 C yields -25 and prints "-40.-25".
static void temperature(std::int16_t quarters) static void temperature(std::int16_t quarters)
{ {
if (quarters < 0) if (quarters < 0) {
serial << '-'; serial << '-';
}
auto magnitude = static_cast<std::uint16_t>(quarters < 0 ? -quarters : quarters); auto magnitude = static_cast<std::uint16_t>(quarters < 0 ? -quarters : quarters);
serial << magnitude / 4 << '.' << avr::dec<{.width = 2, .fill = '0'}>((magnitude % 4) * 25); serial << magnitude / 4 << '.' << avr::dec<{.width = 2, .fill = '0'}>((magnitude % 4) * 25);
} }
@@ -142,8 +146,9 @@ class terminal {
static std::uint32_t resistance() static std::uint32_t resistance()
{ {
auto adc = controller::last_adc(); auto adc = controller::last_adc();
if (adc >= 1023) if (adc >= 1023) {
return 0xffffffff; // open circuit: the divider has no solution return 0xffffffff; // open circuit: the divider has no solution
}
return static_cast<std::uint32_t>(thermistor::series_resistor) * adc / (1023u - adc); return static_cast<std::uint32_t>(thermistor::series_resistor) * adc / (1023u - adc);
} }
@@ -160,10 +165,11 @@ class terminal {
serial << controller::last_adc() << " / 1023\r\n"_P; serial << controller::last_adc() << " / 1023\r\n"_P;
label("Resistance"_P, reading_column); label("Resistance"_P, reading_column);
if (auto ohms = resistance(); ohms == open_circuit) if (auto ohms = resistance(); ohms == open_circuit) {
serial << "open circuit\r\n"_P; serial << "open circuit\r\n"_P;
else } else {
serial << ohms << " Ohm\r\n"_P; serial << ohms << " Ohm\r\n"_P;
}
label("Temperature"_P, reading_column); label("Temperature"_P, reading_column);
temperature(controller::temperature_quarters()); temperature(controller::temperature_quarters());
@@ -171,10 +177,11 @@ class terminal {
label("Fan speed"_P, reading_column); label("Fan speed"_P, reading_column);
serial << controller::fan_percent() << "% "_P; serial << controller::fan_percent() << "% "_P;
if (controller::automatic()) if (controller::automatic()) {
serial << "auto\r\n"_P; serial << "auto\r\n"_P;
else } else {
serial << "manual\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 // Every whole degree from 10 to 60 with a bar, which is the original's and is
@@ -187,8 +194,9 @@ class terminal {
auto duty = curve::duty(static_cast<std::int8_t>(t)); 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) serial << avr::dec<{.width = 2, .fill = '0'}>(t) << " C = "_P << avr::dec<{.width = 3, .fill = ' '}>(duty)
<< "% |"_P; << "% |"_P;
for (std::uint8_t i = 0; i < duty; ++i) for (std::uint8_t i = 0; i < duty; ++i) {
serial << '#'; serial << '#';
}
serial << "\r\n"_P; serial << "\r\n"_P;
} }
} }
@@ -204,16 +212,18 @@ class terminal {
{ {
auto empty = statistics::total_samples() == 0; auto empty = statistics::total_samples() == 0;
label("Minimum temperature"_P, stat_column); label("Minimum temperature"_P, stat_column);
if (empty) if (empty) {
serial << "not available\r\n"_P; serial << "not available\r\n"_P;
else } else {
serial << statistics::min_temperature() << " C\r\n"_P; serial << statistics::min_temperature() << " C\r\n"_P;
}
label("Maximum temperature"_P, stat_column); label("Maximum temperature"_P, stat_column);
if (empty) if (empty) {
serial << "not available\r\n"_P; serial << "not available\r\n"_P;
else } else {
serial << statistics::max_temperature() << " C\r\n"_P; serial << statistics::max_temperature() << " C\r\n"_P;
}
label("Total samples"_P, stat_column); label("Total samples"_P, stat_column);
serial << static_cast<std::uint32_t>(statistics::total_samples()) << "\r\n"_P; serial << static_cast<std::uint32_t>(statistics::total_samples()) << "\r\n"_P;
@@ -232,8 +242,9 @@ class terminal {
// neighbouring buckets, which on a distribution this narrow is the whole // neighbouring buckets, which on a distribution this narrow is the whole
// picture. // picture.
std::uint32_t factor = highest / bar_max > 1 ? highest / bar_max : 1; std::uint32_t factor = highest / bar_max > 1 ? highest / bar_max : 1;
while (highest / factor > bar_max) while (highest / factor > bar_max) {
++factor; ++factor;
}
for (std::uint8_t t = statistics::min_temperature(); t <= statistics::max_temperature(); ++t) { for (std::uint8_t t = statistics::min_temperature(); t <= statistics::max_temperature(); ++t) {
auto count = statistics::bucket(t); auto count = statistics::bucket(t);
@@ -241,8 +252,9 @@ class terminal {
// instead of trailing off the ragged right-hand end of the bars. // 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) serial << avr::dec<{.width = 2, .fill = '0'}>(t) << " C : "_P << avr::dec<{.width = 10, .fill = ' '}>(count)
<< " |"_P; << " |"_P;
for (std::uint32_t i = 0; i < count / factor; ++i) for (std::uint32_t i = 0; i < count / factor; ++i) {
serial << '#'; serial << '#';
}
serial << "\r\n"_P; serial << "\r\n"_P;
} }
} }
@@ -253,20 +265,22 @@ class terminal {
// starts_with, not substr: substr throws std::out_of_range, and one // starts_with, not substr: substr throws std::out_of_range, and one
// potentially-throwing call is enough to pull in std::terminate, which does // 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 // 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 // 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. // is a trap, and the whole file avoids substr for that reason.
static bool matches(std::string_view input, const command &c) static bool matches(std::string_view input, const command &c)
{ {
if (input.empty()) if (input.empty()) {
return false; return false;
if (c.exact) }
if (c.exact) {
return input == c.name; return input == c.name;
}
return c.name.starts_with(input); return c.name.starts_with(input);
} }
static void dispatch(std::string_view input) static void dispatch(std::string_view input)
{ {
// A line that overflowed the buffer is not a command it is the tail of // 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. // one. Acting on it is how a truncated `reset` becomes a surprise.
if (overflowed) { if (overflowed) {
serial << "input too long, ignored\r\n"_P; serial << "input too long, ignored\r\n"_P;
@@ -275,21 +289,23 @@ class terminal {
} }
// Split on the first space with the (pointer, length) constructor rather // Split on the first space with the (pointer, length) constructor rather
// than substr, which throws see matches(). // than substr, which throws - see matches().
const auto space = input.find(' '); const auto space = input.find(' ');
const auto word = space == std::string_view::npos ? input : std::string_view{input.data(), space}; const auto word = space == std::string_view::npos ? input : std::string_view{input.data(), space};
const auto rest = space == std::string_view::npos const auto rest = space == std::string_view::npos
? std::string_view{} ? std::string_view{}
: std::string_view{input.data() + space + 1, input.size() - space - 1}; : std::string_view{input.data() + space + 1, input.size() - space - 1};
if (word.empty()) if (word.empty()) {
return; return;
}
std::uint8_t which = commands.size(); std::uint8_t which = commands.size();
for (std::uint8_t i = 0; i < commands.size(); ++i) for (std::uint8_t i = 0; i < commands.size(); ++i) {
if (matches(word, commands[i])) { if (matches(word, commands[i])) {
which = i; which = i;
break; break;
} }
}
switch (which) { switch (which) {
case 0: case 0:
@@ -331,8 +347,9 @@ class terminal {
break; break;
} }
percent = static_cast<std::uint16_t>(percent * 10 + (c - '0')); percent = static_cast<std::uint16_t>(percent * 10 + (c - '0'));
if (percent > 100) if (percent > 100) {
valid = false; valid = false;
}
} }
if (valid) { if (valid) {
controller::set_manual(static_cast<std::uint8_t>(percent)); controller::set_manual(static_cast<std::uint8_t>(percent));
@@ -411,7 +428,7 @@ class terminal {
prompt(); // a bare Enter just reprompts, no gap needed prompt(); // a bare Enter just reprompts, no gap needed
continue; continue;
} }
dispatch(std::string_view{line, at}); dispatch(std::string_view{line.data(), at});
at = 0; at = 0;
if (!monitoring) { if (!monitoring) {
// A blank line between a command's output and the next // A blank line between a command's output and the next
@@ -420,13 +437,13 @@ class terminal {
serial << "\r\n"_P; serial << "\r\n"_P;
prompt(); prompt();
} }
} else if (c == 0x7f || c == 0x08) { } else if (c == del || c == backspace) {
if (at) { if (at) {
--at; --at;
serial << "\b \b"_P; serial << "\b \b"_P;
} }
} else if (c >= ' ') { } else if (c >= ' ') {
if (at < line_max) { if (at < line.size()) {
line[at++] = c; line[at++] = c;
serial << c; // echo serial << c; // echo
} else { } else {

View File

@@ -7,8 +7,8 @@
// NTC thermistor on a series divider, solved entirely at compile time: // NTC thermistor on a series divider, solved entirely at compile time:
// the Beta equation (logarithm and all) runs consteval into a libavr // the Beta equation (logarithm and all) runs consteval into a libavr
// flash_table the firmware never does floating point. Raw 10-bit ADC // flash_table - the firmware never does floating point. Raw 10-bit ADC
// counts map to quarter-°C with linear interpolation between table steps. // counts map to quarter- C with linear interpolation between table steps.
namespace app::thermistor { namespace app::thermistor {
inline constexpr double series_resistor = 9951; inline constexpr double series_resistor = 9951;
@@ -27,35 +27,40 @@ consteval double temperature_of(double adc)
return 1.0 / steinhart - 273.15; return 1.0 / steinhart - 273.15;
} }
// 256 entries over the 10-bit range (steps of 4 counts), quarter-°C, // 256 entries over the 10-bit range (steps of 4 counts), quarter- C,
// clamped to a sane sensor window; entry 256 mirrors 255 so interpolation // clamped to a sane sensor window; entry 256 mirrors 255 so interpolation
// at full scale has a right neighbour. // at full scale has a right neighbour.
consteval std::int16_t quarters_entry(int index) consteval std::int16_t quarters_entry(int index)
{ {
double adc = index * 4.0; double adc = index * 4.0;
if (adc < 4) if (adc < 4) {
adc = 4; adc = 4;
if (adc > 1019) }
if (adc > 1019) {
adc = 1019; adc = 1019;
}
double t = temperature_of(adc) * 4.0; double t = temperature_of(adc) * 4.0;
if (t < -40 * 4) if (t < -40 * 4) {
t = -40 * 4; t = -40 * 4;
if (t > 125 * 4) }
if (t > 125 * 4) {
t = 125 * 4; t = 125 * 4;
}
return static_cast<std::int16_t>(t < 0 ? t - 0.5 : t + 0.5); return static_cast<std::int16_t>(t < 0 ? t - 0.5 : t + 0.5);
} }
inline constexpr avr::flash_table<[] { inline constexpr avr::flash_table<[] {
std::array<std::int16_t, 257> out{}; std::array<std::int16_t, 257> out{};
for (int i = 0; i < 257; ++i) for (int i = 0; i < 257; ++i) {
out[static_cast<std::size_t>(i)] = quarters_entry(i < 256 ? i : 255); out[static_cast<std::size_t>(i)] = quarters_entry(i < 256 ? i : 255);
}
return out; return out;
}()> }()>
table; table;
} // namespace detail } // namespace detail
// Temperature in quarter-°C from a raw (or averaged) 10-bit sample. // Temperature in quarter- C from a raw (or averaged) 10-bit sample.
inline std::int16_t quarters(std::uint16_t adc) inline std::int16_t quarters(std::uint16_t adc)
{ {
std::uint16_t index = adc >> 2; // the 257th entry backs index+1 at full scale std::uint16_t index = adc >> 2; // the 257th entry backs index+1 at full scale

View File

@@ -1,6 +1,6 @@
# The board has no reset line and no programming header, so the loader-entry # The board has no reset line and no programming header, so the loader-entry
# route in the emitted image is the only thing standing between a firmware change # route in the emitted image is the only thing standing between a firmware change
# and an unreflashable board. It has been wrong before see the script. # and an unreflashable board. It has been wrong before - see the script.
find_package(Python3 COMPONENTS Interpreter) find_package(Python3 COMPONENTS Interpreter)
if(Python3_FOUND) if(Python3_FOUND)
add_test(NAME fantemp.reachability add_test(NAME fantemp.reachability
@@ -8,5 +8,5 @@ if(Python3_FOUND)
--objdump ${CMAKE_OBJDUMP} --elf $<TARGET_FILE:fantemp> --objdump ${CMAKE_OBJDUMP} --elf $<TARGET_FILE:fantemp>
--image $<TARGET_FILE_DIR:fantemp>/fantemp.bin) --image $<TARGET_FILE_DIR:fantemp>/fantemp.bin)
else() else()
message(STATUS "Python not found the reachability check is skipped") message(STATUS "Python not found - the reachability check is skipped")
endif() endif()

View File

@@ -3,7 +3,7 @@
This board has no reset line and no programming header. The single route to the This board has no reset line and no programming header. The single route to the
bootloader is the running firmware's `bootloader` command, so a firmware that bootloader is the running firmware's `bootloader` command, so a firmware that
gets that route wrong is a board that cannot be reflashed and the failure is gets that route wrong is a board that cannot be reflashed - and the failure is
silent, because everything else still works. silent, because everything else still works.
It has been wrong before. The firmware this one replaces probed and jumped to It has been wrong before. The firmware this one replaces probed and jumped to
@@ -15,7 +15,7 @@ hardware, which is what this replaces.
Three properties, all read out of the disassembly rather than the source: Three properties, all read out of the disassembly rather than the source:
1. The image ends below the boot section. `hfuse d4` puts that at 0x7c00, so an 1. The image ends below the boot section. `hfuse d4` puts that at 0x7c00, so an
application reaching into it would be overwritten by the loader or worse, application reaching into it would be overwritten by the loader - or worse,
executed at reset, since BOOTRST points there. executed at reset, since BOOTRST points there.
2. The hand-over targets the loader base. A word address of 0x3f00 is byte 2. The hand-over targets the loader base. A word address of 0x3f00 is byte
0x7e00; anything else is the 0x7800 bug again. 0x7e00; anything else is the 0x7800 bug again.
@@ -60,7 +60,7 @@ def main() -> int:
text = subprocess.run([args.objdump, "-d", str(args.elf)], text = subprocess.run([args.objdump, "-d", str(args.elf)],
capture_output=True, text=True, check=True).stdout capture_output=True, text=True, check=True).stdout
# The address the hand-over actually targets, read at its call sites not # The address the hand-over actually targets, read at its call sites - not
# "does the image contain this byte somewhere", which proves nothing: 0x3f is # "does the image contain this byte somewhere", which proves nothing: 0x3f is
# an ordinary constant that appears in the curve tables, so a check like that # an ordinary constant that appears in the curve tables, so a check like that
# passes just as happily on the 0x7800 bug it is supposed to catch. # passes just as happily on the 0x7800 bug it is supposed to catch.
@@ -83,7 +83,7 @@ def main() -> int:
want = LOADER_BASE // 2 want = LOADER_BASE // 2
if not sites: if not sites:
failures.append("no call to bootloader::call with a loaded target the " failures.append("no call to bootloader::call with a loaded target - the "
"hand-over could not be read out of the image") "hand-over could not be read out of the image")
elif wrong := [a for a in sites if a != want]: elif wrong := [a for a in sites if a != want]:
failures.append(f"the hand-over targets word {[hex(a) for a in wrong]} " failures.append(f"the hand-over targets word {[hex(a) for a in wrong]} "
@@ -95,13 +95,13 @@ def main() -> int:
# An icall/ijmp has to exist for that address to be jumped to indirectly. # An icall/ijmp has to exist for that address to be jumped to indirectly.
if not re.search(r"\b(icall|ijmp)\b", text): if not re.search(r"\b(icall|ijmp)\b", text):
failures.append("no icall/ijmp the hand-over cannot reach across flash") failures.append("no icall/ijmp - the hand-over cannot reach across flash")
else: else:
print(" ok an indirect call exists (a relative one cannot reach)") print(" ok an indirect call exists (a relative one cannot reach)")
# What actually reaches WDTCSR, not what the image happens to load somewhere. # What actually reaches WDTCSR, not what the image happens to load somewhere.
# A timed disable writes WDCE|WDE (0x18) and then zero. Arming writes WDE # A timed disable writes WDCE|WDE (0x18) and then zero. Arming writes WDE
# *without* WDCE including 0x08, a 16 ms timeout with every prescaler bit # *without* WDCE - including 0x08, a 16 ms timeout with every prescaler bit
# clear, which is precisely what the legacy route used and is why this cannot # clear, which is precisely what the legacy route used and is why this cannot
# be a check for "a prescaler is present". # be a check for "a prescaler is present".
WDCE, WDE = 0x10, 0x08 WDCE, WDE = 0x10, 0x08
@@ -114,14 +114,14 @@ def main() -> int:
values.append(0 if reg == "r1" else held.get(reg)) values.append(0 if reg == "r1" else held.get(reg))
armed = [v for v in values if v is not None and (v & WDE) and not (v & WDCE)] armed = [v for v in values if v is not None and (v & WDE) and not (v & WDCE)]
if armed: if armed:
failures.append(f"WDTCSR is written {[hex(v) for v in armed]} WDE without " failures.append(f"WDTCSR is written {[hex(v) for v in armed]} - WDE without "
f"WDCE is arming the watchdog, and a reset-based hand-over " f"WDCE is arming the watchdog, and a reset-based hand-over "
f"opens no pureboot window") f"opens no pureboot window")
elif not values: elif not values:
print(" ok the watchdog is never written") print(" ok the watchdog is never written")
else: else:
print(f" ok WDTCSR writes are {[hex(v) if v is not None else '?' for v in values]}" print(f" ok WDTCSR writes are {[hex(v) if v is not None else '?' for v in values]}"
f" unlock and clear, never an arm") f" - unlock and clear, never an arm")
for line in failures: for line in failures:
print(f" FAIL {line}") print(f" FAIL {line}")