`(quarters + 2) / 4` is round-half-up only for positive values: C truncates a negative quotient toward zero, so -3.00 C read as -2, -1.00 C as 0 and -0.75 C as 0 - nine of fifteen negative quarter-values off by a whole degree, always toward zero. `(quarters + 2) >> 2` is an arithmetic shift, which floors, and is right across the whole range. It is also smaller: the shift skips the bias correction signed division needs. The formula was written three times - thermistor::celsius(), which nothing called, and inline at both live call sites - so the defect had three homes and so would its fix. One `thermistor::whole_degrees()` now, called from both, and the dead entry point is gone (rules 6, 27). test/consteval.cpp is new and is what should have caught it: whole_degrees across zero including both ties and both table limits, the curve's start and saturation points and its monotonicity, and the thermistor table anchored where the Beta equation fixes it - the count at which the divider reads the thermistor's nominal resistance must read the nominal temperature - plus both clamps and the fall across every step. Red-green: four assertions fire against the old division. Beside it: the cubic's three coefficients are named rather than inlined and restated in prose (rule 5), the consteval table builders take explicit 32-bit types (rule 25), the curve's clamp reads the table's own size (rule 36), and the serial override says what expects the rate rather than what the board has always done (rules 12, 13). And bootloader::handle_reset()'s watchdog diversion is gone. Its own comment called it a leftover "kept only because it is free and cannot hurt", and it did not: pureboot peeks WDRF without clearing it and hands back on purpose, so a watchdog reset arrives here with the flag still set and the diversion jumped into the loader with MCUSR already cleared - opening the activation window that policy exists to close. Clearing MCUSR is the whole job and stays. 8206 -> 8168 bytes, byte-identical between generated and reflect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fantemp
v2.2. Temperature-controlled fan firmware (ATmega328P, 16 MHz), rewritten on
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
histogram persisted to EEPROM, and a direct jump into a boot-section
bootloader at 0x7e00.
The EEPROM format is the legacy firmware's, unchanged: 100 little-endian
uint32 buckets at address 0, one per °C. A board carrying years of history
from FanTemp 1.8b keeps every count — verified on hardware, all 67 non-empty
buckets byte-identical across the conversion.
The console
Commands may be abbreviated to any unambiguous-by-order prefix, as the legacy
firmware allowed: up is uptime, st is statistics, sa is save. The
table order resolves ties, so s is show — and reset is deliberately the one
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:
- pureboot hands straight back on WDRF, by design — an unattended board that watchdog-resets in a loop must not sit in a loader. So the legacy watchdog-reset hand-over arrives and opens no window at all, and on a board with no reset line that is a board that cannot be reflashed.
- The address is
0x7e00, the top 512 bytes. The legacy firmware used0x7800, a 2 KB boot section's base, which on a board with a 512-byte boot section reads erased — so itsbootloadercommand silently never arrived anywhere. UCSR0Bis cleared first. WhileTXEN0is set the USART owns PD1, so a loader that bit-bangs the same pin receives perfectly and answers into nothing.
ctest reads all three back out of the emitted image (test/check_reachability.py),
because none of them is visible from the source alone and the failure mode is an
unreflashable board. Both the address and the watchdog checks are red-proven
against the legacy behaviour they exist to catch.
The Steinhart–Hart math of the legacy firmware (runtime doubles + libm log) is gone: the Beta equation and the cubic fan curve are evaluated consteval into flash tables — the firmware itself never touches floating point.
libavr rides as the libavr/ submodule, pinned to the commit this firmware
builds against; LIBAVR_ROOT (cache or environment) overrides it for
development against a working tree:
git submodule update --init libavr
cmake --preset atmega328p-generated
cmake --build --preset atmega328p-generated
Atmel Studio
master carries a Studio solution, so this branch does too: ide/fantemp.atsln
builds the same firmware — byte-identical .text and .data to the CMake
build — from the same sources, with the flags mirrored by hand.
Studio finds libavr in the submodule, at
$(MSBuildProjectDirectory)\..\libavr\include — correct by construction, and
anchored to the project rather than written relative to the generated makefile,
which runs from the configuration's output directory and would need a different
number of ... Unlike the CMake build there is no LIBAVR_ROOT to point
elsewhere: an environment variable set in a shell is not visible to Studio
launched from the Start menu — which is what the submodule answers.
It also needs a GCC 16.1 toolchain registered as flavour avr-g++-16.1.0;
nothing older can compile -std=c++26.
One generated file is required before the project will load, and one command
checks the flags have not drifted (both from libavr's tools/atmelstudio/):
python ../libavr/tools/atmelstudio/componentinfo.py \
ide/fantemp.componentinfo.xml --device ATmega328P
python ../libavr/tools/atmelstudio/check-flags.py --solution ide/fantemp.atsln \
--compile-commands build/atmega328p-generated/compile_commands.json \
--log build/atmelstudio.log
CMake remains the build system; the solution is there so the project opens in
Studio as its predecessor did. Only the Release configuration is gated against
CMake — the presets define no debug build — and Debug carries the -Og -gdwarf-4 pair libavr's own debug preset uses.
Legacy (yazoalfa submodules) stays on master.