Compare commits

..

50 Commits

Author SHA1 Message Date
78c9071f0c deps: the pins advance to the identity check that reads its own sibling
libavr 93d8b0e, pureboot 26a8621. The check is handed the sibling build tree now and asks its build system whether the image is current, so a comparison against a tree the run did not build skips rather than passing. Gated at the pin. Its own gate builds both modes, so the comparison still happens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:23:59 +02:00
d6673db131 deps: the pins advance, and this repository gets a committed gate
libavr moves to its USI I2C rate accounting, the two override surfaces the
fan-controller port filed - a PWM solve that can be pinned, a capture edge that
is a value - and the shared preset loop's own fix. pureboot moves with it. This
firmware names none of them and no image moved.

`tools/check.sh` is new, and its absence was the defect: there was no committed
gate at all, so "run the suite" was a snippet somebody remembered - and the
order in a snippet is the one thing nobody re-derives. Every preset built
before any is tested is subtle and load-bearing, because the mode-identity
check reads the sibling mode's tree and a build-then-test-per-preset run holds
a fresh image against a stale sibling. The loop is libavr's
`tools/check-presets.sh`.

Two defaults stop being the editing machine's. `.vscode/settings.json` named
`D:/dev/libavr/local/toolchain/avr-gcc-16.1.0-mingw`, which is a path true of
one machine (libavr guidance rule 50) and, since the in-repo toolchain copies
went, true of none - `local/machine.cmake` is where a checkout says that and it
already did. And `.gitattributes` gains `* text=auto eol=lf`: naming the source
extensions left Markdown, Python, shell and CMake to whatever wrote them, which
on the Windows side is CRLF, and a rewrite there buries the change it made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 03:53:43 +02:00
fb82f0f7a9 deps: the pins advance, and a missing interpreter skips rather than vanishes
libavr and pureboot move to the tips that register their checks everywhere.
fantemp.reachability stops being wrapped in `if(Python3_FOUND)`: it is
registered on every host now and skips where the interpreter is absent, so what
`ctest -N` counts is a fact about this repository rather than about the machine
that configured it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:37:44 +02:00
88df1c1ada deps: libavr and pureboot to their tips, both still matching the board
libavr's commit is the compare-debug tool's Windows command line and its test;
pureboot's is that pin advancing. Re-checked against the hardware rather than
only against the previous build: the loader this repo produces is still
byte-for-byte the 386 B the board's slot holds, and the firmware still the
8004 B it is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:48:38 +02:00
fc65457de3 deps: pureboot to its tip, whose own libavr pin is what moved
Nothing in pureboot's device code changed - the commit is its libavr pin
advancing eleven, measured across its twenty-one loader variants as
byte-identical. Re-checked from this side too: the loader this repo builds is
still byte-for-byte the 386 B the board's slot holds, and the firmware still
the 8004 B it is running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:18:50 +02:00
11da387ffa deps, build: the pins catch up, and the loader is built here now
Fifty-two libavr commits and twenty-seven of pureboot behind, which is far
enough that "it still builds" is not the interesting part. It builds, both
modes, byte-identical across them, five checks green - and the image is
**byte-for-byte the 8004 B the board is running**, so the catch-up costs this
deployment nothing and a redeploy was ruled out by comparison rather than
skipped by assumption.

The loader is the gap that mattered. pureboot rode as a submodule for its
geometry alone, so the commit that pinned this firmware did not build the one
image this board cannot be recovered without - the same hole tempmon had.
`pureboot_add_loader(pureboot)` closes it on nothing but pureboot's own
defaults for the chip, and the 386 B it produces is byte-for-byte what the
board's slot reads back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 09:33:48 +02:00
491ff76447 build: pureboot rides as a pinned submodule, and the libavr pin advances
pureboot moved to its own repo, so the loader this board hands over to stopped
being reachable through a sibling checkout of the bootloader repo. It is a
submodule here now: this board has no reset line and no programming header, so
the resident loader is the only way in, and the commit naming the firmware
should name the loader it has to reach.

The reachability check stops carrying its own copy of where that loader is.
0x7e00 was a literal beside pureboot's own geometry, which the submodule
exports as PUREBOOT_BASE_HEX - one source for the fact now, and the check reads
whichever slot the pinned loader actually has. The boot-section bound stays a
literal, being a fuse fact rather than a loader one.

Built and tested at both pins on the bench: 5/5, cross-mode identity included,
and the image deployed to the board verifies byte-for-byte through its loader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 22:38:19 +02:00
e56e8b977f docs: the millisecond clock stays 64 bits, owner-refused
608 B of flash, 7.6 % of the image, and it is not available: this board runs
continuously and an uptime that restarts every 49.7 days is not acceptable,
which is what a 32-bit millisecond counter wraps at.

The tick split goes with it. It only ever made sense beside the narrowing, and
on its own it costs 66 B to buy about 0.4 % of a CPU nothing here is competing
for.

Both are recorded rather than deleted, and that is the point of the change: the
numbers are real and re-measurable, so someone will find 608 B again and read
it as an opportunity. What they need with it is the reason it was refused.

Docs only; five tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:31:54 +02:00
8f43584c5c build: the libavr pin advances to the i2c fix
e697920. The change is confined to the i2c thunks' argument list, which this
firmware does not reach - 8004 B either way, and the suite is green in both modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:23:49 +02:00
996453c2a1 docs: what the 64-bit millisecond clock costs, measured
Two findings from the flash sweep, both measured and neither taken, because
each is a decision rather than a defect.

Narrowing uptime::millis() and its three timestamps to 32 bits is 608 B, 7.6 %
of the image, and 16 B of RAM. The reason it is not a free win is not the
display: a 32-bit millisecond counter wraps every 49.7 days on a board that
runs continuously, and the interval tests would have to become subtractions,
since `now - last >= interval` survives a wrap where `now >= last + interval`
does not - which is how terminal.hpp's monitor tick is written today. The
64-bit counter is what puts the wrap out of reach, so this is a question about
a wrapping uptime display, and the answer decides 608 B.

The 1 kHz tick's own prologue is the smaller one. The handler increments 64
bits, so it calls __adddi3_s8, and a call in a signal handler decides the
prologue - twelve push/pop pairs for what the helper might clobber. Two 32-bit
halves remove the call and keep the range, taking the handler from ~47
instructions to ~26 with the carry running once every 49.7 days. Not taken
either: it costs 66 B of flash to buy about 0.4 % of the CPU, and nothing here
is timing-critical.

This repo had nowhere to record work, so it has a tracker now.

Docs only; the image is unchanged at 8004 B and the suite is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:33:52 +02:00
0f5e40510c perf: the console's constants move to flash, -378 B and -214 B of RAM
8382 -> 8004 B of flash, 725 -> 511 B of RAM on a part that has 2048.

The command names were a std::to_array of string_view, and on a Harvard
machine that is the worst of both: the characters land in .data and so does
the table's own pointer-and-length pair for each of them, so the firmware
carried 216 B of RAM for thirteen words that never change - and paid for them
in flash too, since .data is copied out of an initialiser image at startup.

They are one NUL-separated blob in flash now, walked with lpm. Separators
rather than an offset table, because an offset table is the RAM this exists to
give back; the names sit in it in match order, so the walk that finds a name
is the same walk that compares it and measures it. Flash falls further than
RAM does: the initialiser image and the two tables were 216 B of it, and the
blob is 87.

The header said the names "cannot" be in flash because they are matched at run
time. Being matched at run time is not a reason to be in RAM on a machine with
two address spaces - only being *written* is, and nothing writes these.

Two smaller things came with it. `reset`'s exact-match rule was a bool on
every entry to protect one; it is an index found by searching the list, so
reordering the commands cannot move the protection onto a different one. And
`version` was the last string_view left, holding its own characters and a
pointer to them.

The matching is now pinned rather than assumed: lookup() is constexpr and the
battery asserts the load-bearing order the README documents - `s` is show and
not statistics, `st` is statistics, no abbreviation of `reset` resolves, and
`helpful` is not `help`. Red-checked by claiming `s` is statistics.

Both modes byte-identical, ten tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 05:29:01 +02:00
bf18f99635 build: the libavr pin advances, and one adiw appears in the ring
43fc479 -> aec9955, 8380 -> 8382 B. Two bytes, and they are all of e226340:
the uart ring now declares its indices before its storage.

The library measured that reorder at 0 B and it is +2 here, so the difference
is worth stating. The reorder exists for the 0..63 displacement window, and at
32 entries the indices were never outside it - ldd Z+32 and ld Z are both two
bytes, so moving them to the front buys nothing. What it does do is take
storage off offset zero, and storage is the member reached by a computed
index: pop() loaded storage[tail] as X = Z + tail with the base free, and now
adds the base with an adiw.

So it is free where the indices were out of the window and a loss where they
were in it, and which of those a consumer gets depends on its ring size and on
whether pop() is out of line - fantemp's is. Filed upstream with the
disassembly; nothing to work around here, and 8382 of 32768 is not a budget
question.

Everything else crossed is inert for this firmware: no i2c, no eeprom writer,
no spare vectors, and percent_t already reached through ::of().

Five tests green in both modes, cross-mode identity held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 02:20:57 +02:00
5244953654 test: the README's cross-mode claim is now checked, not just stated
The pin advances to the commit that gives consumers the gate's own image
comparison, and the claim written into this README a few commits ago - 8380 B,
byte-identical between the two modes - is registered against it. It compares
clean over every loadable section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:50:00 +02:00
cde7e60170 test: the image's size stops being a number nobody holds
Every other port in the fleet states its image size and is held to it; this
one stated none, so a library advance could move the firmware and no check
would say a word. 8380 B is what the presets build today, byte-identical
between the two modes, and the README now says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:21:29 +02:00
4c351c6c9d build: the libavr pin advances over the inlined vector
The ADC vector's handler was a lambda reached through a pointer, so it sat
out of line as a 140 B thunk; it is now inlined and the vector grows 136 B,
which is the whole of the image's 8384 -> 8380 B. The layout shift that
follows costs `statistics::record` an rcall widened to a call and refunds
the same two bytes at another site.

The prologue does not shrink here: 15 push/pop pairs before and after,
because the inlined body still calls `__udivmodsi4` and that clobbers the
caller-saved set the call used to. What this board gets is the removed
rcall and ret on every conversion, not a shorter frame.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:12:51 +02:00
5974a5aa90 chore: machine-local state has a home here too
Guidance rule 15 puts every machine-local artefact under a gitignored local/,
and this repo could only have committed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 03:32:53 +02:00
ba37ecea04 test: the format and ASCII rules stop being a habit, and the stated sizes stop drifting
libavr's guidance binds this repo too, and until now nothing here checked it -
`ctest` runs `libavr_format_test()` over this tree's own sources now (rules 11
and 33), skipping rather than passing where clang-format is absent. It caught
drift on its first run: a file written this week and edited after formatting.

Where the README states a measured size, `libavr_size_claim_test()` holds it to
the image and holds the image to the prose: advancing the library pin moved
three of these across the fleet with nothing saying so, and re-recording one
now requires the sentence that quotes it to move too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 23:02:40 +02:00
0f99b7787c build: the libavr pin advances to the sweep's own record
Documentation only - the guideline sweep's condensed entry, the three measured
facts about class-type constants it produced, and the port filings it left
open. No header, tool or generated input moves, so every image is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:02:56 +02:00
3e95160b00 fix: the quarter-degree rounding was wrong below zero, in three copies
`(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>
2026-08-12 16:23:05 +02:00
837b832bc7 build: the libavr pin advances past the audit sweep, and the numbers get names
The pin crosses libavr's phase-6 close and the guideline sweep behind it;
the image is byte-identical in both modes at 8206 bytes.

The port's own sweep, against the same rules. Every mutable `static inline`
takes `m_` - uptime's counter, the sampler's window, the controller's five,
the statistics histogram and the terminal's line state (rule 46; a private
`static constexpr` is a constant rather than state and keeps its bare name).
The command table is `std::to_array` and the serial config breaks one member
per line (rules 36, 40). And three numbers get the name they already had
somewhere: duty goes through `percent_t::of()` rather than a hand-built
basis-point count, the ADC's top count is `thermistor::adc_full_scale`
instead of 1023 in four places, and the two `0xffffffff` are `open_circuit`
- which was already declared five lines away - and `never_written`, which
replaces a comment explaining the literal (rules 5, 6, 41).

Measured, not assumed: rendering `adc_full_scale` into the `show` line
instead of leaving it in the message string cost 6 bytes, so the display
text stays text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:40:33 +02:00
1489f4c3a0 editor: clangd and cmake work from a committed vscode workspace
The three files libavr's consumers carry, in the leaf-application shape:
nothing vendors this repo, so .clangd names the one build tree an editor
should read (build/atmega328p-generated) beside the stand-ins clang needs
for GCC's AVR dialect; .vscode/settings.json passes --query-driver and names
the toolchain prefix for the window that opens this folder. The libavr pin
advances to the editor-audit fixes. One residue stays red and is the
documented frontend divergence, not a defect: thermistor.hpp's consteval
Beta-equation table folds __builtin_log under GCC, which clang refuses to
fold in a constant expression -- the same class as libavr's breathe example
and its __builtin_cos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 16:48:46 +02:00
b1caf49522 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>
2026-08-09 11:45:07 +02:00
0e9060db69 build: the libavr pin advances past the consumer-report fixes
timer::engine gains stop()/start() and a runtime TOP, adc gains
disable()/enable(), and libavr_programming_targets() stops leaving .fuse bytes
in the flash HEX. Every one of them is additive, and this port adopts none of
them yet: its 3 built images come out byte-identical across the pin change,
which is what the advance is here to keep true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:18:55 +02:00
3fbbcdade9 build: the libavr pin advances to the v9 era
Built and tested against it in a clean checkout of this port, through its own
submodule rather than a working-tree override, so the pin is what was proved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:46:36 +02:00
6abf0b5563 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>
2026-07-31 02:34:02 +02:00
c01e583597 console: the terminal is the original's again, and the way out is a jump
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>
2026-07-31 01:58:09 +02:00
e5a9a38bba build: the libavr pin advances over the delay and format contracts
The console's decimals render arithmetically now: the to_chars digit
table leaves SRAM (-202 B of data) and the streaming frames shrink with
it (-664 B of text, 7094 to 6430). Cross-mode .text stays
byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:18:41 +02:00
d77fe8ea9e build: the libavr pin advances over the trait projection
The de-string-2 pass upstream: every peripheral block behind generated
instance traits, the string layer gone. The firmware rebuilds
byte-identically across modes at its recorded size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 19:59:43 +02:00
949dc125cd build: the libavr pin advances to current main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 07:24:10 +02:00
ba2cae8f8f build: the libavr pin advances to current main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 06:48:36 +02:00
5afe29359c build: libavr rides as the pinned submodule
The submodule replaces FetchContent and the unpinned forge fallback;
LIBAVR_ROOT stays as the tandem-development override, the presets take the
toolchain file from the submodule, and the Studio project's include path
anchors there — correct by construction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 00:29:38 +02:00
2cdf56f8d6 ide: the whole project in Solution Explorer, and a path Studio can resolve
Three things the solution got wrong, all found by opening it in the GUI rather
than building it headlessly.

The project listed only main.cpp, so none of the headers could be opened from
Solution Explorer. Every source and header is listed now, with <Link> mirroring
the on-disk src/ layout, which is what master's project did.

Neither configuration compiled in the GUI: libavr was found through
$(LIBAVR_ROOT), and a variable exported in a shell is not visible to Studio
launched from the Start menu. Release only looked healthy because its objects
were already up to date from a headless build. The path is now anchored to the
project directory, which also side-steps a second trap: a plain relative include
is resolved against the generated makefile's own directory, the configuration's
output directory, not the project's. Pinning libavr as a submodule would remove
the assumption that the two sit side by side, and is on libavr's task list.

Both configurations verified with LIBAVR_ROOT deliberately unset, Release still
byte-identical to the CMake build and the flag gate still green. Debug's own
translation unit carries DWARF-4 as intended.

Studio's per-user state under ide/.vs/ and the build logs are ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 19:19:20 +02:00
00849d25d5 ide: the Atmel Studio solution master has, on the libavr port
master opens in Studio, so this branch should too. One project, the port's own
chip, flags mirrored by hand against the CMake build — and the acceptance is
not that it builds but that it builds the same firmware: .text (7094 B) and
.data (336 B) come out byte-identical to the CMake output from the same
sources.

libavr is found through $(LIBAVR_ROOT), the variable the CMake build already
uses, so no machine path is committed; Studio expands it from the environment.
The toolchain is named by flavour only, since nothing Studio ships can compile
-std=c++26 and the path to one that can is per-machine state.

The componentinfo file the project cannot load without is generated by
libavr's tools/atmelstudio/componentinfo.py and ignored here, as are Studio's
output directories. Release is what the flag gate compares, the presets
defining no debug build; Debug carries the -Og -gdwarf-4 pair libavr's own
debug preset uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 18:37:56 +02:00
b7a2e50506 Keep the curve and thermistor comments timeless
Both headers described the port transformation ("legacy cubic becomes",
"no hand-rolled series needed") rather than what the code is. State the
cubic and the __builtin_log rationale directly.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 10:10:47 +02:00
f17ebd17e5 Use libavr flash_table and __builtin_log
The thermistor Beta curve and the fan cubic move onto avr::flash_table
instead of hand-rolled [[gnu::progmem]] arrays with raw pgm_read, and
the compile-time logarithm uses __builtin_log (which constant-folds on
the AVR backend) instead of a hand-rolled series. Same 11284 B, still
byte-identical across libavr modes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 08:03:42 +02:00
ab78d94872 Rewrite on libavr
Same controller: thermistor on ADC0 averaged over 1000 free-running
conversions, 50 kHz fan PWM on OC0B, 115200 Bd console with the full
command set, EEPROM temperature histogram, watchdog-reset path into the
boot section. The Steinhart-Hart math and the libm log are gone — the
Beta equation and the cubic fan curve are consteval-evaluated into
flash tables; the firmware never does floating point. Byte-identical
.text in both libavr modes. Legacy stays on master.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 04:37:26 +02:00
76d6b1583b Indicate auto/manual fan speed 2021-02-12 17:12:56 +01:00
5ec4a5441a Implement setting fan speed manually 2021-02-12 16:38:41 +01:00
7aa98a8ebd Replace sprintf with dtostrf to save about 1.5k of flash 2020-04-13 01:13:44 +02:00
64d6df256d Fix string being located in RAM 2020-04-13 00:54:44 +02:00
16ebed63c2 Change to stk500v2 compatible bootloader 2020-04-13 00:49:21 +02:00
eee2e6172a Fix rounding of percentage calculation 2020-04-10 17:21:39 +02:00
d6269952a6 Add saving to EEPROM on bootloader entry 2020-04-09 13:21:53 +02:00
6a7213de60 Add statistics reset command 2020-04-08 13:45:14 +02:00
ed2fddc427 Fix handling of statistics before data is available and fix normalization factor 2020-04-08 13:44:54 +02:00
1694e3bbab Change histogram to only print from min to max 2020-04-08 12:58:59 +02:00
02565c9396 Add percentages to histogram 2020-04-08 12:41:53 +02:00
00082617d1 Make help messages consistent 2020-04-08 09:52:39 +02:00
b8a40aed17 Implement eeprom stored persistent histogram 2020-04-08 02:17:34 +02:00
cae18b98e7 Reduce code duplication 2020-04-07 21:54:15 +02:00
48 changed files with 1874 additions and 1037 deletions

View File

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

30
.clangd Normal file
View File

@@ -0,0 +1,30 @@
CompileFlags:
# Named here rather than in the editor's settings because nothing vendors
# this repo: the one build tree is the one an editor should read.
CompilationDatabase: build/atmega328p-generated
Add:
# clang has no 24-bit integer and GCC's are keywords, not macros, so the
# editor needs a stand-in for avr::uint24_t. The next width up is the only
# one available — clang rejects _BitInt(24) on this target.
- -D__uint24=unsigned long
- -D__int24=long
# clangd forwards the driver's system includes but not its own header
# directory, so <stdint.h> resolves to avr-libc's, which still gates the
# limit and constant macros on the C++98 opt-in.
- -D__STDC_LIMIT_MACROS
- -D__STDC_CONSTANT_MACROS
# isr::emit spells a vector number into [[gnu::signal(N)]], which clang
# rejects rather than ignores — enough of them in one TU to reach the
# default limit of 19 inside the headers and truncate the parse.
- -ferror-limit=0
Remove:
# The build promotes warnings for the compiler that has to be right about
# them; in the editor the flag paints a second frontend's opinions in the
# colour reserved for things that do not compile.
- -Werror
Diagnostics:
Suppress:
# clang's AVR `signal` attribute takes no arguments and it knows none of
# progmem, naked or OS_main. A misspelling is what the build is for.
- attribute_wrong_number_arguments
- unknown-attributes

13
.gitattributes vendored
View File

@@ -1,8 +1,11 @@
*.h eol=lf
*.hpp eol=lf
*.c eol=lf
*.cpp eol=lf
.git* eol=lf
# Line endings are the repository's, not the editing machine's: this checkout
# is reached from two hosts, and a file rewritten by a Windows tool comes back
# with every line changed unless something says otherwise. Naming the source
# extensions left Markdown, Python, shell and CMake to whatever the writing
# tool defaulted to, which is CRLF on one of the two.
* text=auto eol=lf
# Atmel Studio writes these and expects them back.
*.vcxproj* eol=crlf
*.cppproj eol=crlf
*.sln eol=crlf

21
.gitignore vendored
View File

@@ -1,11 +1,10 @@
.vs
Release
Debug
*.componentinfo.xml
*.elf
*.o
*.hex
*.srec
*.eeprom
*.lss
*.map
build/
local/
.cache/
# Atmel Studio: generated per machine, and its build outputs
ide/*.componentinfo.xml
ide/Debug/
ide/Release/
ide/.vs/
ide/*.log

24
.gitmodules vendored
View File

@@ -1,18 +1,6 @@
[submodule "fantemp/uart"]
path = fantemp/uart
url = git@git.blackmark.me:avr/uart.git
[submodule "fantemp/flash"]
path = fantemp/flash
url = git@git.blackmark.me:avr/flash.git
[submodule "fantemp/io"]
path = fantemp/io
url = git@git.blackmark.me:avr/io.git
[submodule "fantemp/adc"]
path = fantemp/adc
url = git@git.blackmark.me:avr/adc.git
[submodule "fantemp/type"]
path = fantemp/type
url = git@git.blackmark.me:avr/type.git
[submodule "fantemp/eeprom"]
path = fantemp/eeprom
url = git@git.blackmark.me:avr/eeprom.git
[submodule "libavr"]
path = libavr
url = ../libavr.git
[submodule "pureboot"]
path = pureboot
url = ../pureboot.git

6
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"recommendations": [
"llvm-vs-code-extensions.vscode-clangd",
"ms-vscode.cmake-tools"
]
}

33
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
// clangd is the language server; the cpptools engine would parse every file
// a second time and disagree, since nothing tells it about a cross
// compiler. Which database to read is in .clangd.
"C_Cpp.intelliSenseEngine": "disabled",
// --query-driver lets clangd ask the cross compiler for its own system
// includes and target; without it every standard header is missing.
"clangd.arguments": [
"--query-driver=**avr-g++*",
"--header-insertion=never"
],
// The presets are the build interface, and the toolchain file inside the
// libavr submodule is the one place the compiler is chosen. **No prefix is
// named here**: a committed file may not name a path that is true of one
// machine (libavr guidance rule 50), so the gitignored local/machine.cmake
// at this repository's root is where a checkout says where its toolchain
// is - one file, and it answers for both hosts.
"cmake.useCMakePresets": "always",
"cmake.configureOnOpen": true,
"cmake.options.statusBarVisibility": "compact",
"files.watcherExclude": {
"**/build/**": true,
"**/libavr/**": true
},
"files.associations": {
".clangd": "yaml",
".clang-format": "yaml"
}
}

51
CMakeLists.txt Normal file
View File

@@ -0,0 +1,51 @@
cmake_minimum_required(VERSION 3.28)
project(fantemp LANGUAGES CXX)
# libavr rides as the pinned submodule; LIBAVR_ROOT (cache or environment)
# overrides it for tandem development against a working tree. The toolchain
# file comes from the submodule via CMakePresets.json either way.
if(NOT LIBAVR_ROOT AND DEFINED ENV{LIBAVR_ROOT})
set(LIBAVR_ROOT $ENV{LIBAVR_ROOT})
endif()
if(NOT LIBAVR_ROOT)
set(LIBAVR_ROOT ${CMAKE_CURRENT_SOURCE_DIR}/libavr)
endif()
if(NOT EXISTS ${LIBAVR_ROOT}/CMakeLists.txt)
message(FATAL_ERROR "libavr not found at ${LIBAVR_ROOT} - run: git submodule update --init libavr")
endif()
# pureboot rides as a pinned submodule too: this board's only way in is its
# resident loader, so the commit that names this firmware names the loader it
# has to hand over to. Consumed for the geometry it exports - the loader links
# the libavr target above, so pureboot's own libavr submodule stays
# uninitialised.
if(NOT EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/pureboot/CMakeLists.txt)
message(FATAL_ERROR "pureboot not found - run: git submodule update --init pureboot")
endif()
add_subdirectory(${LIBAVR_ROOT} libavr-build)
add_subdirectory(pureboot pureboot-build)
include(${LIBAVR_ROOT}/cmake/checks.cmake)
add_executable(fantemp src/main.cpp)
target_link_libraries(fantemp PRIVATE libavr)
# The raw image is what the loader takes, and what the reachability check
# measures the boot-section clearance against.
add_custom_command(TARGET fantemp POST_BUILD
COMMAND ${CMAKE_SIZE} $<TARGET_FILE:fantemp>
COMMAND ${CMAKE_OBJCOPY} -O binary -R .eeprom
$<TARGET_FILE:fantemp> $<TARGET_FILE_DIR:fantemp>/fantemp.bin
COMMAND ${CMAKE_OBJCOPY} -O ihex -R .eeprom
$<TARGET_FILE:fantemp> $<TARGET_FILE_DIR:fantemp>/fantemp.hex)
# The board's loader, built here rather than named from memory: consuming
# pureboot for its geometry alone left the one image this deployment cannot be
# recovered without outside the repository that pins it. Every parameter is
# pureboot's own default for this chip - USART0 at 115200 on the 16 MHz crystal
# `src/board.hpp` declares - and that is measured rather than assumed, the
# image being byte-for-byte the 386 B the board's slot reads back. It goes on
# over the wire (`--update-loader`), which is why there is no flash target
# beside it: no programmer has ever been in this board's path.
pureboot_add_loader(pureboot)
enable_testing()
add_subdirectory(test)

59
CMakePresets.json Normal file
View File

@@ -0,0 +1,59 @@
{
"version": 8,
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"toolchainFile": "${sourceDir}/libavr/cmake/avr-toolchain.cmake",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
"CMAKE_COLOR_DIAGNOSTICS": "ON"
}
},
{
"name": "atmega328p-generated",
"inherits": "base",
"cacheVariables": {
"LIBAVR_MCU": "atmega328p",
"LIBAVR_REFLECT": "OFF"
}
},
{
"name": "atmega328p-reflect",
"inherits": "base",
"cacheVariables": {
"LIBAVR_MCU": "atmega328p",
"LIBAVR_REFLECT": "ON"
}
}
],
"buildPresets": [
{
"name": "atmega328p-generated",
"configurePreset": "atmega328p-generated"
},
{
"name": "atmega328p-reflect",
"configurePreset": "atmega328p-reflect"
}
],
"testPresets": [
{
"name": "atmega328p-generated",
"configurePreset": "atmega328p-generated",
"output": {
"outputOnFailure": true
}
},
{
"name": "atmega328p-reflect",
"configurePreset": "atmega328p-reflect",
"output": {
"outputOnFailure": true
}
}
]
}

105
README.md Normal file
View File

@@ -0,0 +1,105 @@
# fantemp
**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
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 used `0x7800`,
a 2 KB boot section's base, which on a board with a 512-byte boot section reads
erased — so its `bootloader` command silently never arrived anywhere.
- `UCSR0B` is cleared first. While `TXEN0` is 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 SteinhartHart 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:
```sh
git submodule update --init libavr
cmake --preset atmega328p-generated
cmake --build --preset atmega328p-generated
```
The firmware is **8004 B** of flash, byte-identical between the generated and
reflect modes, and `ctest` holds it to that number.
## 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/`):
```sh
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`.

40
dev/tasks.md Normal file
View File

@@ -0,0 +1,40 @@
# Tasks
This file is current work. The repo is the sole task tracker (libavr guidance
rule 20).
## Open
Nothing.
## Decided against
Both of these are measured, and both are recorded here rather than deleted so
that the next person to measure them does not read a number as an opportunity
and re-propose work the owner has already refused.
### The millisecond clock stays 64 bits
Narrowing `uptime::millis()` and the three timestamps that hold its value from
`uint64_t` to `uint32_t` is **608 B of flash, 7.6 % of the image** (8004 B down
to 7396), plus 16 B of RAM.
**Refused, owner-stated: this board runs continuously and an uptime that
restarts every 49.7 days is not acceptable.** That is what a 32-bit
millisecond counter wraps at, and the display is not the only cost - the
interval tests would have to become subtractions, since `now - last >=
interval` survives a wrap where `now >= last + interval` does not, and
`terminal.hpp`'s monitor tick is written the second way. The 64-bit counter is
what puts the wrap out of reach, which is the property being bought.
### The 1 kHz tick keeps its 64-bit increment
The compare handler increments 64 bits, so it calls libgcc's `__adddi3_s8`, and
a call inside a signal handler decides the prologue - twelve push/pop pairs for
what the helper might clobber. Splitting the counter into two 32-bit halves
removes the call and keeps the full range, taking the handler from ~47
instructions to ~26 with the carry running once every 49.7 days.
**Refused: it costs 66 B of flash to buy about 0.4 % of the CPU**, and nothing
here is timing-critical. It was only ever worth considering alongside the
narrowing above, which is refused outright.

Submodule fantemp/adc deleted from 7fe32b9717

View File

@@ -1,46 +0,0 @@
#include "bootloader.hpp"
#include <avr/io.h>
#include <avr/pgmspace.h>
#include <avr/wdt.h>
namespace {
typedef void (*jmp_fn)() __attribute__((noreturn));
jmp_fn boot = reinterpret_cast<jmp_fn>(0x0000);
jmp_fn bootloader = reinterpret_cast<jmp_fn>(0x7E00 / 2);
} // namespace
bool Bootloader::handleReset()
{
wdt_reset();
uint8_t mcuStatus = MCUSR;
MCUSR &= ~(1 << WDRF);
wdt_disable();
return (mcuStatus & (1 << WDRF));
}
void Bootloader::reset()
{
wdt_enable(WDTO_15MS);
while (true)
;
}
bool Bootloader::check()
{
if (pgm_read_byte(reinterpret_cast<uint16_t>(bootloader) * 2) == 0xF8)
return true;
return false;
}
void Bootloader::call()
{
if (check())
bootloader();
else
boot();
}

View File

@@ -1,24 +0,0 @@
#pragma once
class Bootloader {
public:
template <typename Fn>
static inline void init(Fn callback)
{
if (handleReset()) {
callback();
call();
}
}
static inline void enter()
{
reset();
}
private:
static bool handleReset();
static void reset();
static bool check();
static void call();
};

View File

@@ -1,37 +0,0 @@
#include "clock.hpp"
#include <avr/interrupt.h>
#include <avr/io.h>
namespace clk {
namespace detail {
volatile uint64_t sm_millisCounter = 0;
ISR(TIMER2_COMPA_vect)
{
++sm_millisCounter;
}
} // namespace detail
void init()
{
TCCR2A |= (1 << WGM21);
TCCR2B |= (1 << CS22) | (1 << CS20);
OCR2A = 124;
TIMSK2 |= (1 << OCIE2A);
}
uint64_t millis()
{
const auto oldSreg = SREG;
cli();
const auto millisCounter = detail::sm_millisCounter;
SREG = oldSreg;
return millisCounter;
}
} // namespace clk

View File

@@ -1,13 +0,0 @@
#pragma once
#define F_CPU 16000000
#include <util/delay.h>
#include <stdint.h>
namespace clk {
void init();
uint64_t millis();
} // namespace clk

View File

@@ -1,66 +0,0 @@
#include "controller.hpp"
#define ADC_INT_VECTOR
#include "adc/adc.hpp"
double Controller::m_adcSample;
double Controller::m_resistance;
double Controller::m_temperature;
uint8_t Controller::m_fanSpeed;
bool Controller::m_dataAvailable = false;
volatile uint32_t Controller::m_adcSampleSum;
volatile bool Controller::m_adcSampleReady = false;
void Controller::init()
{
m_adcPin.init(sampleCallback);
pwm::init();
pwm::setDuty(100);
}
void Controller::callback()
{
if (m_adcSampleReady) {
m_adcSample = static_cast<double>(m_adcSampleSum) / NUM_ADC_SAMPLES;
m_dataAvailable = true;
m_adcSampleReady = false;
m_resistance = m_thermistor.getResistance(m_adcSample);
m_temperature = m_thermistor.getTemperature(m_resistance);
m_fanSpeed = mapTemperature(m_temperature);
pwm::setDuty(m_fanSpeed);
}
}
uint8_t Controller::mapTemperature(double temperature)
{
[[maybe_unused]] constexpr auto linearCurve = [](double x) { return (10 * x - 200) / 3; };
constexpr auto cubicCurve = [](double x) {
if (x < 20)
return 0.0;
return 0.002246 * x * x * x - 0.09 * x * x + 0.91 * x;
};
double fanSpeed = cubicCurve(temperature);
return clamp<uint8_t>(fanSpeed, 0, 100);
}
void Controller::sampleCallback(const uint16_t &adcSample)
{
static uint32_t s_sampleSum = 0;
static auto s_sampleCounter = NUM_ADC_SAMPLES;
s_sampleSum += adcSample;
if (--s_sampleCounter <= 0) {
if (!m_adcSampleReady) {
m_adcSampleSum = s_sampleSum;
m_adcSampleReady = true;
}
// else lose this sample, which happens during long running commands like "curve", but has no impact
s_sampleSum = 0;
s_sampleCounter = NUM_ADC_SAMPLES;
}
}

View File

@@ -1,45 +0,0 @@
#pragma once
#include "adc/adc.hpp"
#include "io/io.hpp"
#include "pwm.hpp"
#include "thermistor.hpp"
class Controller {
public:
static double m_adcSample;
static double m_resistance;
static double m_temperature;
static uint8_t m_fanSpeed;
static bool m_dataAvailable;
static void init();
static void callback();
static uint8_t mapTemperature(double temperature);
private:
using adc_conf = adc::Config<adc::FreeRunningMode>;
static adc::Adc<adc_conf, io::P, io::P::C0> m_adcPin;
static constexpr auto NUM_ADC_SAMPLES = 1000;
static volatile uint32_t m_adcSampleSum;
static volatile bool m_adcSampleReady;
static Thermistor m_thermistor;
static void sampleCallback(const uint16_t &adcSample);
template <typename T>
static T clamp(double value, T lower, T upper)
{
if (value < lower)
return lower;
if (value > upper)
return upper;
return static_cast<T>(value);
}
};

Submodule fantemp/eeprom deleted from 33a4d55f03

View File

@@ -1,298 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
<PropertyGroup>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>7.0</ProjectVersion>
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
<ProjectGuid>dce6c7e3-ee26-4d79-826b-08594b9ad897</ProjectGuid>
<avrdevice>ATmega328P</avrdevice>
<avrdeviceseries>none</avrdeviceseries>
<OutputType>Executable</OutputType>
<Language>CPP</Language>
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
<OutputFileExtension>.elf</OutputFileExtension>
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
<AssemblyName>fantemp</AssemblyName>
<Name>fantemp</Name>
<RootNamespace>fantemp</RootNamespace>
<ToolchainFlavour>avr-g++-9.1.0</ToolchainFlavour>
<KeepTimersRunning>true</KeepTimersRunning>
<OverrideVtor>false</OverrideVtor>
<CacheFlash>true</CacheFlash>
<ProgFlashFromRam>true</ProgFlashFromRam>
<RamSnippetAddress>0x20000000</RamSnippetAddress>
<UncachedRange />
<preserveEEPROM>true</preserveEEPROM>
<OverrideVtorValue>exception_table</OverrideVtorValue>
<BootSegment>2</BootSegment>
<ResetRule>0</ResetRule>
<eraseonlaunchrule>0</eraseonlaunchrule>
<EraseKey />
<avrtool>com.atmel.avrdbg.tool.atmelice</avrtool>
<avrtoolserialnumber>J41800099437</avrtoolserialnumber>
<avrdeviceexpectedsignature>0x1E950F</avrdeviceexpectedsignature>
<com_atmel_avrdbg_tool_stk500>
<ToolOptions>
<InterfaceProperties>
<IspClock>125000</IspClock>
</InterfaceProperties>
<InterfaceName>ISP</InterfaceName>
</ToolOptions>
<ToolType>com.atmel.avrdbg.tool.stk500</ToolType>
<ToolNumber>
</ToolNumber>
<ToolName>STK500</ToolName>
</com_atmel_avrdbg_tool_stk500>
<avrtoolinterface>ISP</avrtoolinterface>
<avrtoolinterfaceclock>125000</avrtoolinterfaceclock>
<AsfFrameworkConfig>
<framework-data xmlns="">
<options />
<configurations />
<files />
<documentation help="" />
<offline-documentation help="" />
<dependencies>
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.47.0" />
</dependencies>
</framework-data>
</AsfFrameworkConfig>
<com_atmel_avrdbg_tool_atmelice>
<ToolOptions>
<InterfaceProperties>
<IspClock>125000</IspClock>
</InterfaceProperties>
<InterfaceName>ISP</InterfaceName>
</ToolOptions>
<ToolType>com.atmel.avrdbg.tool.atmelice</ToolType>
<ToolNumber>J41800099437</ToolNumber>
<ToolName>Atmel-ICE</ToolName>
</com_atmel_avrdbg_tool_atmelice>
<custom>
<ToolOptions>
<InterfaceProperties>
<IspClock>125000</IspClock>
</InterfaceProperties>
<InterfaceName>
</InterfaceName>
</ToolOptions>
<ToolType>custom</ToolType>
<ToolNumber>
</ToolNumber>
<ToolName>Custom Programming Tool</ToolName>
</custom>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcc.compiler.general.ChangeDefaultCharTypeUnsigned>True</avrgcc.compiler.general.ChangeDefaultCharTypeUnsigned>
<avrgcc.compiler.general.ChangeDefaultBitFieldUnsigned>True</avrgcc.compiler.general.ChangeDefaultBitFieldUnsigned>
<avrgcc.compiler.symbols.DefSymbols>
<ListValues>
<Value>NDEBUG</Value>
</ListValues>
</avrgcc.compiler.symbols.DefSymbols>
<avrgcc.compiler.directories.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcc.compiler.directories.IncludePaths>
<avrgcc.compiler.optimization.level>Optimize for size (-Os)</avrgcc.compiler.optimization.level>
<avrgcc.compiler.optimization.AllocateBytesNeededForEnum>True</avrgcc.compiler.optimization.AllocateBytesNeededForEnum>
<avrgcc.compiler.warnings.AllWarnings>True</avrgcc.compiler.warnings.AllWarnings>
<avrgcc.compiler.warnings.ExtraWarnings>True</avrgcc.compiler.warnings.ExtraWarnings>
<avrgcc.compiler.warnings.Pedantic>True</avrgcc.compiler.warnings.Pedantic>
<avrgcc.compiler.miscellaneous.OtherFlags>-fno-threadsafe-statics -std=c11</avrgcc.compiler.miscellaneous.OtherFlags>
<avrgcccpp.compiler.general.ChangeDefaultCharTypeUnsigned>True</avrgcccpp.compiler.general.ChangeDefaultCharTypeUnsigned>
<avrgcccpp.compiler.general.ChangeDefaultBitFieldUnsigned>True</avrgcccpp.compiler.general.ChangeDefaultBitFieldUnsigned>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>NDEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.AllocateBytesNeededForEnum>True</avrgcccpp.compiler.optimization.AllocateBytesNeededForEnum>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.warnings.Pedantic>True</avrgcccpp.compiler.warnings.Pedantic>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-fno-threadsafe-statics -Wextra -std=c++17</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.general.UseVprintfLibrary>True</avrgcccpp.linker.general.UseVprintfLibrary>
<avrgcccpp.linker.libraries.Libraries>
<ListValues>
<Value>libm</Value>
<Value>libprintf_flt</Value>
</ListValues>
</avrgcccpp.linker.libraries.Libraries>
<avrgcccpp.assembler.general.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcccpp.assembler.general.IncludePaths>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcc.compiler.general.ChangeDefaultCharTypeUnsigned>True</avrgcc.compiler.general.ChangeDefaultCharTypeUnsigned>
<avrgcc.compiler.general.ChangeDefaultBitFieldUnsigned>True</avrgcc.compiler.general.ChangeDefaultBitFieldUnsigned>
<avrgcc.compiler.symbols.DefSymbols>
<ListValues>
<Value>DEBUG</Value>
</ListValues>
</avrgcc.compiler.symbols.DefSymbols>
<avrgcc.compiler.directories.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcc.compiler.directories.IncludePaths>
<avrgcc.compiler.optimization.level>Optimize (-O1)</avrgcc.compiler.optimization.level>
<avrgcc.compiler.optimization.AllocateBytesNeededForEnum>True</avrgcc.compiler.optimization.AllocateBytesNeededForEnum>
<avrgcc.compiler.optimization.DebugLevel>Maximum (-g3)</avrgcc.compiler.optimization.DebugLevel>
<avrgcc.compiler.warnings.AllWarnings>True</avrgcc.compiler.warnings.AllWarnings>
<avrgcc.compiler.warnings.ExtraWarnings>True</avrgcc.compiler.warnings.ExtraWarnings>
<avrgcc.compiler.warnings.Pedantic>True</avrgcc.compiler.warnings.Pedantic>
<avrgcc.compiler.miscellaneous.OtherFlags>-fno-threadsafe-statics -std=c11</avrgcc.compiler.miscellaneous.OtherFlags>
<avrgcccpp.compiler.general.ChangeDefaultCharTypeUnsigned>True</avrgcccpp.compiler.general.ChangeDefaultCharTypeUnsigned>
<avrgcccpp.compiler.general.ChangeDefaultBitFieldUnsigned>True</avrgcccpp.compiler.general.ChangeDefaultBitFieldUnsigned>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>DEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize (-O1)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.AllocateBytesNeededForEnum>True</avrgcccpp.compiler.optimization.AllocateBytesNeededForEnum>
<avrgcccpp.compiler.optimization.DebugLevel>Maximum (-g3)</avrgcccpp.compiler.optimization.DebugLevel>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.warnings.Pedantic>True</avrgcccpp.compiler.warnings.Pedantic>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-fno-threadsafe-statics -Wextra -std=c++17</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.general.UseVprintfLibrary>True</avrgcccpp.linker.general.UseVprintfLibrary>
<avrgcccpp.linker.libraries.Libraries>
<ListValues>
<Value>libm</Value>
<Value>libprintf_flt</Value>
</ListValues>
</avrgcccpp.linker.libraries.Libraries>
<avrgcccpp.assembler.general.IncludePaths>
<ListValues>
<Value>%24(PackRepoDir)\Atmel\ATmega_DFP\1.4.346\include</Value>
</ListValues>
</avrgcccpp.assembler.general.IncludePaths>
<avrgcccpp.assembler.debugging.DebugLevel>Default (-Wa,-g)</avrgcccpp.assembler.debugging.DebugLevel>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<ItemGroup>
<Compile Include="adc\adc.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="adc\config.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="adc\hardware.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="bootloader.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="bootloader.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="clock.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="clock.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="controller.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="controller.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="eeprom\eeprom.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="flash\flash.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="io\io.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="main.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="pwm.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="pwm.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="statistics.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="statistics.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="terminal.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="thermistor.cpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="thermistor.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="type\type.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\config.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\hardware.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\hardware0.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\hardware1.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\software.hpp">
<SubType>compile</SubType>
</Compile>
<Compile Include="uart\uart.hpp">
<SubType>compile</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="flash" />
<Folder Include="io" />
<Folder Include="adc" />
<Folder Include="eeprom" />
<Folder Include="type" />
<Folder Include="uart" />
</ItemGroup>
<Import Project="$(AVRSTUDIO_EXE_PATH)\\Vs\\Compiler.targets" />
</Project>

Submodule fantemp/flash deleted from 6edb2e5a21

Submodule fantemp/io deleted from 80de36ee7e

View File

@@ -1,36 +0,0 @@
#include "clock.hpp"
#include "uart/uart.hpp"
#define UART0_INT_VECTORS
#include "uart/hardware0.hpp"
#include "bootloader.hpp"
#include "controller.hpp"
#include "statistics.hpp"
#include "terminal.hpp"
int main()
{
Bootloader::init([]() {});
clk::init();
using serial = uart::Uart0<uart::Config<115200>>;
Terminal<serial> terminal;
terminal.init();
Controller controller;
controller.init();
Statistics statistics;
statistics.init();
while (true) {
controller.callback();
terminal.callback();
statistics.callback();
}
return 0;
}

View File

@@ -1,25 +0,0 @@
#include "pwm.hpp"
#include "io/io.hpp"
namespace pwm {
static constexpr uint8_t PWM_TOP = 40;
void init()
{
io::Pin<io::P::D5> pwmPin;
pwmPin.dir(io::Dir::OUT);
pwmPin = false;
TCCR0A = (1 << COM0B1) | (1 << WGM00);
TCCR0B = (1 << WGM02) | (1 << CS01);
OCR0A = PWM_TOP;
}
void setDuty(uint8_t percent)
{
OCR0B = (percent * PWM_TOP) / 100;
}
} // namespace pwm

View File

@@ -1,10 +0,0 @@
#pragma once
#include <stdint.h>
namespace pwm {
void init();
void setDuty(uint8_t percent);
} // namespace pwm

View File

@@ -1,62 +0,0 @@
#include "statistics.hpp"
#include <math.h>
#include "eeprom/eeprom.hpp"
#include "type/type.hpp"
#include "clock.hpp"
#include "controller.hpp"
uint64_t Statistics::m_lastTempSave = 0;
double Statistics::m_minTemp;
double Statistics::m_maxTemp;
EEVAR(double, e_minTemp);
EEVAR(double, e_maxTemp);
void Statistics::init()
{
m_minTemp = getMinTemp();
m_maxTemp = getMaxTemp();
if (isnan(m_minTemp)) {
m_minTemp = type::numeric_limits<double>::max();
}
if (isnan(m_maxTemp)) {
m_maxTemp = type::numeric_limits<double>::lowest();
}
}
void Statistics::callback()
{
Eeprom<e_minTemp, true> eepromMinTemp;
Eeprom<e_maxTemp, true> eepromMaxTemp;
if (Controller::m_dataAvailable) {
if (Controller::m_temperature < m_minTemp) {
m_minTemp = Controller::m_temperature;
}
if (Controller::m_temperature > m_maxTemp) {
m_maxTemp = Controller::m_temperature;
}
if (clk::millis() >= m_lastTempSave + TEMP_SAVE_DELAY) {
eepromMinTemp = m_minTemp;
eepromMaxTemp = m_maxTemp;
m_lastTempSave = clk::millis();
}
}
}
double Statistics::getMinTemp()
{
Eeprom<e_minTemp, true> eepromMinTemp;
return eepromMinTemp;
}
double Statistics::getMaxTemp()
{
Eeprom<e_maxTemp, true> eepromMaxTemp;
return eepromMaxTemp;
}

View File

@@ -1,20 +0,0 @@
#pragma once
#include <stdint.h>
class Statistics {
public:
static void init();
static void callback();
static double getMinTemp();
static double getMaxTemp();
private:
static constexpr auto TEMP_SAVE_DELAY = 60000;
static uint64_t m_lastTempSave;
static double m_minTemp;
static double m_maxTemp;
};

View File

@@ -1,274 +0,0 @@
#pragma once
#include <ctype.h>
#include <stdint.h>
#include <stdio.h>
#include <avr/pgmspace.h>
#include "flash/flash.hpp"
#include "clock.hpp"
#include "controller.hpp"
#include "statistics.hpp"
namespace detail {
GF(ENDL, "\r\n");
GF(HELP_CMD, "help");
GF(SHOW_CMD, "show");
GF(CURVE_CMD, "curve");
GF(MONITOR_CMD, "monitor");
GF(BOOTLOADER_CMD, "bootloader");
GF(UPTIME_CMD, "uptime");
GF(STATISTICS_CMD, "statistics");
GF(VERSION_CMD, "version");
GF(VERSION, "1.4");
static inline bool substringEquals(const char *str, const ::detail::FlashString *flashStr, const size_t &size)
{
return (strncmp_P(str, reinterpret_cast<const char *>(flashStr), size) == 0);
}
static inline bool stringEquals(const char *str, const ::detail::FlashString *flashStr, const size_t &size)
{
if (size == strlen_P(reinterpret_cast<const char *>(flashStr))) {
return substringEquals(str, flashStr, size);
}
return false;
}
} // namespace detail
template <class Uart>
class Terminal {
public:
static void init()
{
m_serial.init();
m_serial << detail::ENDL;
printVersion();
m_serial << detail::ENDL << F("$ ");
}
static void callback()
{
if (receiveInput()) {
parseInput();
}
if (m_state == State::MONITOR && clk::millis() >= m_monitorDelayLastUpdate + MONITOR_DELAY) {
showState();
m_monitorDelayLastUpdate = clk::millis();
}
}
private:
static constexpr auto INPUT_BUFFER_SIZE = 128;
static constexpr auto BACKSPACE = uint8_t{0x7f};
static constexpr auto CTRL_C = uint8_t{0x03};
static constexpr auto MONITOR_DELAY = 500;
enum class State {
NONE,
MONITOR,
};
static Uart m_serial;
static char m_inputBuffer[INPUT_BUFFER_SIZE];
static uint16_t m_inputSize;
static State m_state;
static uint64_t m_monitorDelayLastUpdate;
static bool receiveInput()
{
uint8_t inputByte;
while (m_serial.rxByte(inputByte)) {
if (isprint(inputByte) || inputByte == CTRL_C) {
m_inputBuffer[m_inputSize++] = inputByte;
// Handle Ctrl + C
if (inputByte == CTRL_C) {
m_serial << F("^C") << detail::ENDL;
return true;
}
// Echo
else {
m_serial << static_cast<char>(inputByte);
}
}
// Handle backspace
if (inputByte == BACKSPACE && m_inputSize > 0) {
m_serial << F("\b \b");
--m_inputSize;
}
// Handle line terminator
else if (inputByte == '\r' || inputByte == '\n') {
// Consume possible second line terminator
if (m_serial.peek(inputByte) && (inputByte == '\r' || inputByte == '\n')) {
m_serial.rxByte(inputByte);
}
m_serial << detail::ENDL;
return true;
}
if (m_inputSize >= INPUT_BUFFER_SIZE) {
m_serial << detail::ENDL << F("WARNING: Terminal input buffer overflow!") << detail::ENDL;
return true;
}
}
return false;
}
static void parseInput()
{
if (m_inputSize) {
if (m_inputBuffer[m_inputSize - 1] == CTRL_C) {
handleCtrlC();
} else if (m_state == State::NONE) {
if (substringEquals(m_inputBuffer, detail::HELP_CMD, m_inputSize)) {
printHelp();
} else if (substringEquals(m_inputBuffer, detail::SHOW_CMD, m_inputSize)) {
showState();
} else if (substringEquals(m_inputBuffer, detail::CURVE_CMD, m_inputSize)) {
printCurve();
} else if (substringEquals(m_inputBuffer, detail::MONITOR_CMD, m_inputSize)) {
m_state = State::MONITOR;
} else if (substringEquals(m_inputBuffer, detail::BOOTLOADER_CMD, m_inputSize)) {
handleBootloader();
} else if (substringEquals(m_inputBuffer, detail::UPTIME_CMD, m_inputSize)) {
printUptime();
} else if (substringEquals(m_inputBuffer, detail::STATISTICS_CMD, m_inputSize)) {
printStatistics();
} else if (substringEquals(m_inputBuffer, detail::VERSION_CMD, m_inputSize)) {
printVersion();
} else {
printUnknown();
}
}
}
m_inputSize = 0;
if (m_state == State::NONE)
m_serial << F("$ ");
}
static void handleCtrlC()
{
m_serial << F("Abort!") << detail::ENDL;
m_state = State::NONE;
}
static void printHelp()
{
m_serial << F("FanTemp command overview: ") << detail::ENDL;
m_serial << detail::HELP_CMD << F(" .......: print this help message") << detail::ENDL;
m_serial << detail::SHOW_CMD << F(" .......: shows current temperature and fan speed") << detail::ENDL;
m_serial << detail::CURVE_CMD << F(" ......: shows mapping from temperature to fan speed") << detail::ENDL;
m_serial << detail::MONITOR_CMD << F(" ....: loops the show command until Ctrl + C is pressed") << detail::ENDL;
m_serial << detail::BOOTLOADER_CMD << F(" .: enters the bootloader after 3 seconds") << detail::ENDL;
m_serial << detail::UPTIME_CMD << F(" .....: show system uptime") << detail::ENDL;
m_serial << detail::STATISTICS_CMD << F(" .: Print overall statistics like min and max temp") << detail::ENDL;
m_serial << detail::VERSION_CMD << F(" ....: displays firmware version") << detail::ENDL;
}
static void showState()
{
if (Controller::m_dataAvailable) {
char floatBuffer[16];
sprintf(floatBuffer, "%.2f", Controller::m_adcSample);
m_serial << F("ADC value ...: ") << floatBuffer << F(" / 1023") << detail::ENDL;
sprintf(floatBuffer, "%.2f", Controller::m_resistance);
m_serial << F("Resistance ..: ") << floatBuffer << F(" Ohm") << detail::ENDL;
sprintf(floatBuffer, "%.2f", Controller::m_temperature);
m_serial << F("Temperature .: ") << floatBuffer << F(" C") << detail::ENDL;
m_serial << F("Fan speed ...: ") << Controller::m_fanSpeed << F("%") << detail::ENDL;
} else {
m_serial << F("No data available yet!") << detail::ENDL;
}
}
static void printCurve()
{
for (uint8_t i = 10; i <= 60; ++i) {
m_serial << i << F(" C = ");
m_serial.template txNumber<uint8_t, 10, 3, ' '>(Controller::mapTemperature(i));
m_serial << F("%\t");
for (uint8_t s = 0; s < Controller::mapTemperature(i); ++s) {
m_serial << "#";
}
m_serial << detail::ENDL;
}
}
static void handleBootloader()
{
m_serial << F("Entering bootloader...") << detail::ENDL;
m_serial.flushTx();
_delay_ms(3000);
Bootloader::enter();
}
static void printUptime()
{
constexpr auto delimiter = ':';
const auto uptime = clk::millis();
const auto hours = static_cast<uint16_t>(uptime / 1000 / 60 / 60);
const auto minutes = static_cast<uint8_t>((uptime / 1000 / 60) % 60);
const auto seconds = static_cast<uint8_t>((uptime / 1000) % 60);
m_serial << F("System uptime: ");
m_serial.template txNumber<uint16_t, 10, 2>(hours);
m_serial << delimiter;
m_serial.template txNumber<uint8_t, 10, 2>(minutes);
m_serial << delimiter;
m_serial.template txNumber<uint8_t, 10, 2>(seconds);
m_serial << detail::ENDL;
}
static void printStatistics()
{
char floatBuffer[16];
sprintf(floatBuffer, "%.2f", Statistics::getMinTemp());
m_serial << F("Minimum temperature .: ") << floatBuffer << F(" C") << detail::ENDL;
sprintf(floatBuffer, "%.2f", Statistics::getMaxTemp());
m_serial << F("Maximum temperature .: ") << floatBuffer << F(" C") << detail::ENDL;
}
static void printVersion()
{
m_serial << F("FanTemp v") << detail::VERSION << detail::ENDL;
}
static void printUnknown()
{
m_serial << F("Unknown command \"");
for (uint16_t i = 0; i < m_inputSize; ++i)
m_serial << static_cast<char>(m_inputBuffer[i]);
m_serial << F("\"") << detail::ENDL;
}
};
template <class Uart>
char Terminal<Uart>::m_inputBuffer[INPUT_BUFFER_SIZE];
template <class Uart>
uint16_t Terminal<Uart>::m_inputSize = 0;
template <class Uart>
typename Terminal<Uart>::State Terminal<Uart>::m_state = State::NONE;
template <class Uart>
uint64_t Terminal<Uart>::m_monitorDelayLastUpdate = 0;

View File

@@ -1,19 +0,0 @@
#include "thermistor.hpp"
#include <math.h>
double Thermistor::getResistance(double adcSample)
{
return SERIES_RESISTOR * adcSample / (1023 - adcSample);
}
double Thermistor::getTemperature(double resistance)
{
double steinhart = resistance / THERMISTOR_NOMINAL;
steinhart = log(steinhart);
steinhart /= BETA_COEFFICIENT;
steinhart += 1.0 / (NOMINAL_TEMPERATURE + 273.15);
steinhart = 1.0 / steinhart;
steinhart -= 273.15;
return steinhart;
}

View File

@@ -1,16 +0,0 @@
#pragma once
#include <stdint.h>
class Thermistor {
public:
static double getResistance(double adcSample);
static double getTemperature(double resistance);
private:
static constexpr auto SERIES_RESISTOR = 9951;
static constexpr auto THERMISTOR_NOMINAL = 9270;
static constexpr auto BETA_COEFFICIENT = 3212;
static constexpr auto NOMINAL_TEMPERATURE = 25;
};

Submodule fantemp/type deleted from ce31ef017f

Submodule fantemp/uart deleted from ae03c8d43e

View File

@@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Atmel Studio Solution File, Format Version 11.00
VisualStudioVersion = 14.0.23107.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "fantemp", "fantemp\fantemp.cppproj", "{DCE6C7E3-EE26-4D79-826B-08594B9AD897}"
Project("{E66E83B9-2572-4076-B26E-6BE79FF3018A}") = "fantemp", "fantemp.cppproj", "{4F0C1D92-6A5B-4E33-9A71-2C8F5B0D47AE}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -11,10 +11,10 @@ Global
Release|AVR = Release|AVR
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DCE6C7E3-EE26-4D79-826B-08594B9AD897}.Debug|AVR.ActiveCfg = Debug|AVR
{DCE6C7E3-EE26-4D79-826B-08594B9AD897}.Debug|AVR.Build.0 = Debug|AVR
{DCE6C7E3-EE26-4D79-826B-08594B9AD897}.Release|AVR.ActiveCfg = Release|AVR
{DCE6C7E3-EE26-4D79-826B-08594B9AD897}.Release|AVR.Build.0 = Release|AVR
{4F0C1D92-6A5B-4E33-9A71-2C8F5B0D47AE}.Debug|AVR.ActiveCfg = Debug|AVR
{4F0C1D92-6A5B-4E33-9A71-2C8F5B0D47AE}.Debug|AVR.Build.0 = Debug|AVR
{4F0C1D92-6A5B-4E33-9A71-2C8F5B0D47AE}.Release|AVR.ActiveCfg = Release|AVR
{4F0C1D92-6A5B-4E33-9A71-2C8F5B0D47AE}.Release|AVR.Build.0 = Release|AVR
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

140
ide/fantemp.cppproj Normal file
View File

@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="14.0">
<PropertyGroup>
<SchemaVersion>2.0</SchemaVersion>
<ProjectVersion>7.0</ProjectVersion>
<ToolchainName>com.Atmel.AVRGCC8.CPP</ToolchainName>
<ProjectGuid>4f0c1d92-6a5b-4e33-9a71-2c8f5b0d47ae</ProjectGuid>
<avrdevice>ATmega328P</avrdevice>
<avrdeviceseries>none</avrdeviceseries>
<OutputType>Executable</OutputType>
<Language>CPP</Language>
<OutputFileName>$(MSBuildProjectName)</OutputFileName>
<OutputFileExtension>.elf</OutputFileExtension>
<OutputDirectory>$(MSBuildProjectDirectory)\$(Configuration)</OutputDirectory>
<AssemblyName>fantemp</AssemblyName>
<Name>fantemp</Name>
<RootNamespace>fantemp</RootNamespace>
<ToolchainFlavour>avr-g++-16.1.0</ToolchainFlavour>
<KeepTimersRunning>true</KeepTimersRunning>
<OverrideVtor>false</OverrideVtor>
<CacheFlash>true</CacheFlash>
<ProgFlashFromRam>true</ProgFlashFromRam>
<RamSnippetAddress>0x20000000</RamSnippetAddress>
<UncachedRange />
<preserveEEPROM>true</preserveEEPROM>
<OverrideVtorValue>exception_table</OverrideVtorValue>
<BootSegment>2</BootSegment>
<ResetRule>0</ResetRule>
<eraseonlaunchrule>0</eraseonlaunchrule>
<EraseKey />
<AsfFrameworkConfig>
<framework-data xmlns="">
<options />
<configurations />
<files />
<documentation help="" />
<offline-documentation help="" />
<dependencies>
<content-extension eid="atmel.asf" uuidref="Atmel.ASF" version="3.52.0" />
</dependencies>
</framework-data>
</AsfFrameworkConfig>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>NDEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize for size (-Os)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<ToolchainSettings>
<AvrGccCpp>
<avrgcc.common.Device>-mmcu=atmega328p</avrgcc.common.Device>
<avrgcc.common.outputfiles.hex>True</avrgcc.common.outputfiles.hex>
<avrgcc.common.outputfiles.lss>True</avrgcc.common.outputfiles.lss>
<avrgcc.common.outputfiles.eep>True</avrgcc.common.outputfiles.eep>
<avrgcc.common.outputfiles.srec>True</avrgcc.common.outputfiles.srec>
<avrgcc.common.outputfiles.usersignatures>False</avrgcc.common.outputfiles.usersignatures>
<avrgcccpp.compiler.symbols.DefSymbols>
<ListValues>
<Value>DEBUG</Value>
</ListValues>
</avrgcccpp.compiler.symbols.DefSymbols>
<avrgcccpp.compiler.directories.IncludePaths>
<ListValues>
<Value>$(MSBuildProjectDirectory)\..\libavr\include</Value>
</ListValues>
</avrgcccpp.compiler.directories.IncludePaths>
<avrgcccpp.compiler.optimization.level>Optimize debugging experience (-Og)</avrgcccpp.compiler.optimization.level>
<avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareFunctionsForGarbageCollection>
<avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>True</avrgcccpp.compiler.optimization.PrepareDataForGarbageCollection>
<avrgcccpp.compiler.warnings.AllWarnings>True</avrgcccpp.compiler.warnings.AllWarnings>
<avrgcccpp.compiler.miscellaneous.OtherFlags>-std=c++26 -Wextra -mrelax -fno-exceptions -fno-rtti -fno-threadsafe-statics -gdwarf-4</avrgcccpp.compiler.miscellaneous.OtherFlags>
<avrgcccpp.linker.optimization.GarbageCollectUnusedSections>True</avrgcccpp.linker.optimization.GarbageCollectUnusedSections>
<avrgcccpp.linker.miscellaneous.LinkerFlags>-mrelax</avrgcccpp.linker.miscellaneous.LinkerFlags>
</AvrGccCpp>
</ToolchainSettings>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\src\board.hpp">
<SubType>compile</SubType>
<Link>src\board.hpp</Link>
</Compile>
<Compile Include="..\src\bootloader.hpp">
<SubType>compile</SubType>
<Link>src\bootloader.hpp</Link>
</Compile>
<Compile Include="..\src\controller.hpp">
<SubType>compile</SubType>
<Link>src\controller.hpp</Link>
</Compile>
<Compile Include="..\src\curve.hpp">
<SubType>compile</SubType>
<Link>src\curve.hpp</Link>
</Compile>
<Compile Include="..\src\main.cpp">
<SubType>compile</SubType>
<Link>src\main.cpp</Link>
</Compile>
<Compile Include="..\src\statistics.hpp">
<SubType>compile</SubType>
<Link>src\statistics.hpp</Link>
</Compile>
<Compile Include="..\src\terminal.hpp">
<SubType>compile</SubType>
<Link>src\terminal.hpp</Link>
</Compile>
<Compile Include="..\src\thermistor.hpp">
<SubType>compile</SubType>
<Link>src\thermistor.hpp</Link>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="src" />
</ItemGroup>
<Import Project="$(AVRSTUDIO_EXE_PATH)\Vs\Compiler.targets" />
</Project>

1
libavr Submodule

Submodule libavr added at 93d8b0e491

1
pureboot Submodule

Submodule pureboot added at 26a86213ca

79
src/board.hpp Normal file
View File

@@ -0,0 +1,79 @@
#pragma once
#include <libavr/libavr.hpp>
// The board composition: every peripheral of the fan controller in one
// place. ATmega328P at 16 MHz - thermistor divider on ADC0 (PC0), fan on
// OC0B (PD5) at 50 kHz, console on the hardware UART.
namespace app {
using namespace avr::literals;
using dev = avr::device<{.clock = 16_MHz}>;
// Millisecond uptime from timer2 CTC (the fan owns timer0).
class uptime {
static inline volatile std::uint64_t m_ms = 0;
public:
using ticker = dev::timer2<{.frequency = 1_kHz, .on_compare = [] { m_ms = m_ms + 1; }}>;
static std::uint64_t millis()
{
avr::irq::interrupt_guard lock;
return m_ms;
}
};
// 1000-sample averaging window fed by the conversion interrupt.
class sampler {
static inline volatile std::uint32_t m_sum = 0;
static inline volatile std::uint16_t m_count = 0;
static inline volatile std::uint16_t m_window = 0;
static inline volatile bool m_ready = false;
static constexpr std::uint16_t samples = 1000;
public:
using input = dev::adc<{.trigger = avr::adc::trigger::free_running,
.on_conversion =
[](std::uint16_t value) {
m_sum = m_sum + value;
m_count = m_count + 1;
if (m_count >= samples) {
m_window = static_cast<std::uint16_t>(m_sum / samples);
m_sum = 0;
m_count = 0;
m_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.
static bool take(std::uint16_t &value)
{
avr::irq::interrupt_guard lock;
if (!m_ready) {
return false;
}
value = m_window;
m_ready = false;
return true;
}
};
using fan = dev::pwm<avr::pd5, {.frequency = 50_kHz}>;
// 115200 at 16 MHz lands +2.1 % off, past the receiver-tolerance table the
// solver holds rates to. It is the rate the console on the other end of the
// cable expects, so the override states that the miss is meant.
using serial_t = dev::uart0<{
.baud = 115200_Bd,
.rx_buffer = 32,
.allow_baud_error = true,
}>;
inline constexpr serial_t serial{};
} // namespace app

92
src/bootloader.hpp Normal file
View File

@@ -0,0 +1,92 @@
#pragma once
#include <libavr/libavr.hpp>
#include "board.hpp"
// Reaching the resident bootloader from the console.
//
// 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
// section. That works for TinySafeBoot and **does not work for pureboot**, which
// 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
// 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.
//
// This route therefore never resets. It jumps, with the reset flags already
// clear, so the loader starts as if from a clean power-on and opens its window.
//
// 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
// 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
// was always false and its `bootloader` command never actually arrived anywhere.
namespace app {
class bootloader {
using jump_fn = void (*)();
using guard = dev::watchdog<{.timeout = 16_ms}>;
// 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"
// 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.
static constexpr std::uint16_t base = 0x7e00;
static bool present()
{
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
// halves. [[gnu::noipa]] keeps the call indirect: a constant target folds
// into a relative call that cannot reach across flash.
[[gnu::noipa, noreturn]] static void call(jump_fn target)
{
target();
__builtin_unreachable();
}
public:
// Call first thing in main. Clearing MCUSR is the whole job: a lingering
// WDRF forces the watchdog back on at its shortest timeout, which is a
// reset loop rather than a boot. The cause is not routed on - pureboot
// peeks WDRF without clearing it and hands back on purpose, so a watchdog
// reset arrives here with the flag still set, and diverting into the
// loader on it would reopen the very window that policy closes.
static void handle_reset()
{
avr::power::consume_reset_cause();
guard::disable();
}
// Hand over for real: no reset, so no WDRF for the loader to refuse.
[[noreturn]] static void enter()
{
// 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 to vector into.
avr::irq::disable();
guard::disable();
// 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
// perfectly and answers into nothing - mute, not deaf, and unverifiable
// from the host. pureboot clears this itself; TinySafeBoot, which is what
// 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
// before the loader is replaced.
avr::hw::ucsr0b::write(0);
call(reinterpret_cast<jump_fn>(base / 2));
}
static bool available()
{
return present();
}
};
} // namespace app

79
src/controller.hpp Normal file
View File

@@ -0,0 +1,79 @@
#pragma once
#include <cstdint>
#include "board.hpp"
#include "curve.hpp"
#include "thermistor.hpp"
// Control loop: averaged thermistor samples -> temperature -> fan duty
// through the curve table (auto) or a console-set value (manual).
namespace app {
class controller {
static inline std::uint16_t m_adc_average = 0;
static inline std::int16_t m_temp_quarters = 0;
static inline std::uint8_t m_percent = 100;
static inline bool m_auto_mode = true;
static inline bool m_have_data = false;
public:
static void init()
{
fan::set_duty(100_pct); // full blast until the first reading
}
static void poll()
{
std::uint16_t sample;
if (!sampler::take(sample)) {
return;
}
m_adc_average = sample;
m_temp_quarters = thermistor::quarters(sample);
m_have_data = true;
if (m_auto_mode) {
m_percent = curve::duty(thermistor::whole_degrees(m_temp_quarters));
}
fan::set_duty(avr::percent_t::of(m_percent));
}
static void set_manual(std::uint8_t p)
{
m_auto_mode = false;
m_percent = p;
fan::set_duty(avr::percent_t::of(p));
}
static void set_automatic()
{
m_auto_mode = true;
}
static bool automatic()
{
return m_auto_mode;
}
static bool data_available()
{
return m_have_data;
}
static std::int16_t temperature_quarters()
{
return m_temp_quarters;
}
static std::uint16_t last_adc()
{
return m_adc_average;
}
static std::uint8_t fan_percent()
{
return m_percent;
}
};
} // namespace app

60
src/curve.hpp Normal file
View File

@@ -0,0 +1,60 @@
#pragma once
#include <array>
#include <cstdint>
#include <libavr/flash.hpp>
// The auto-mode fan curve, tabulated at compile time as a flash_table of duty
// percent per C: a cubic that is held at zero below the temperature the fan
// starts at.
namespace app::curve {
inline constexpr double cubic_term = 0.002246;
inline constexpr double square_term = -0.09;
inline constexpr double linear_term = 0.91;
inline constexpr std::int8_t start_celsius = 20;
namespace detail {
consteval std::uint8_t duty_entry(std::int32_t celsius)
{
double x = celsius;
if (x < start_celsius) {
return 0;
}
double duty = cubic_term * x * x * x + square_term * x * x + linear_term * x;
if (duty < 0) {
duty = 0;
}
if (duty > 100) {
duty = 100;
}
return static_cast<std::uint8_t>(duty + 0.5);
}
inline constexpr avr::flash_table<[] {
std::array<std::uint8_t, 100> out{};
for (std::int32_t t = 0; t < static_cast<std::int32_t>(out.size()); ++t) {
out[static_cast<std::size_t>(t)] = duty_entry(t);
}
return out;
}()>
table;
} // namespace detail
// Duty percent for a temperature, clamped to the table's own window.
inline std::uint8_t duty(std::int8_t celsius)
{
constexpr auto highest = static_cast<std::int8_t>(detail::table.size() - 1);
if (celsius < 0) {
celsius = 0;
}
if (celsius > highest) {
celsius = highest;
}
return detail::table[static_cast<std::uint8_t>(celsius)];
}
} // namespace app::curve

31
src/main.cpp Normal file
View File

@@ -0,0 +1,31 @@
#include <libavr/libavr.hpp>
#include "board.hpp"
#include "bootloader.hpp"
#include "controller.hpp"
#include "statistics.hpp"
#include "terminal.hpp"
#include "thermistor.hpp"
using namespace app;
template struct avr::isr::emit<uptime::ticker, sampler::input, serial_t>;
int main()
{
bootloader::handle_reset();
avr::init<uptime::ticker, sampler::input, fan, serial_t, statistics>();
avr::irq::enable();
sampler::thermistor::start();
controller::init();
terminal::init();
while (true) {
controller::poll();
if (controller::data_available()) {
statistics::record(thermistor::whole_degrees(controller::temperature_quarters()));
}
terminal::poll();
}
}

118
src/statistics.hpp Normal file
View File

@@ -0,0 +1,118 @@
#pragma once
#include <array>
#include <cstdint>
#include <libavr/libavr.hpp>
#include "board.hpp"
// Temperature histogram: one uint32 bucket per C 0..99, sampled once a
// second, written back to EEPROM every 30 minutes (update() only touches
// changed bytes).
namespace app {
class statistics {
static constexpr std::uint8_t range = 100;
static constexpr std::uint32_t sample_delay_ms = 1'000;
static constexpr std::uint32_t writeback_delay_ms = 1'800'000;
// What an erased cell reads back as, so a bucket nobody has written yet
// counts as no samples rather than four billion.
static constexpr std::uint32_t never_written = ~std::uint32_t{0};
using stored = avr::eeprom::var<std::array<std::uint32_t, range>, 0>;
static inline std::array<std::uint32_t, range> m_histogram{};
static inline std::uint64_t m_last_sample = 0;
static inline std::uint64_t m_last_writeback = 0;
static constexpr std::uint8_t clamp(std::int8_t t)
{
return t < 0 ? 0 : (t >= range ? range - 1 : static_cast<std::uint8_t>(t));
}
public:
static constexpr auto claims = stored::claims;
static void init()
{
m_histogram = stored::read();
for (auto &bucket : m_histogram) {
if (bucket == never_written) {
bucket = 0;
}
}
}
static void record(std::int8_t celsius)
{
auto now = uptime::millis();
if (now >= m_last_sample + sample_delay_ms) {
++m_histogram[clamp(celsius)];
m_last_sample = now;
}
if (now >= m_last_writeback + writeback_delay_ms) {
save();
m_last_writeback = now;
}
}
static void save()
{
stored::update(m_histogram);
}
static void reset()
{
m_histogram = {};
stored::update(m_histogram);
}
static std::uint8_t min_temperature()
{
for (std::uint8_t i = 0; i < range; ++i) {
if (m_histogram[i]) {
return i;
}
}
return range;
}
static std::uint8_t max_temperature()
{
for (std::uint8_t i = range; i > 0; --i) {
if (m_histogram[i - 1]) {
return i - 1;
}
}
return 0;
}
static std::uint64_t total_samples()
{
std::uint64_t total = 0;
for (auto bucket : m_histogram) {
total += bucket;
}
return total;
}
static std::uint32_t highest_bucket()
{
std::uint32_t highest = 0;
for (auto bucket : m_histogram) {
if (bucket > highest) {
highest = bucket;
}
}
return highest;
}
static std::uint32_t bucket(std::uint8_t celsius)
{
return m_histogram[clamp(static_cast<std::int8_t>(celsius))];
}
};
} // namespace app

531
src/terminal.hpp Normal file
View File

@@ -0,0 +1,531 @@
#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, command names may be abbreviated, `monitor` streams
// until Ctrl+C, and Ctrl+C abandons a half-typed line anywhere else.
namespace app {
// 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.
//
// A function rather than a variable, so the list exists only while the flash
// blob below is being built and never as storage of its own.
consteval auto command_names()
{
return std::to_array<std::string_view>({
"help",
"show",
"curve",
"monitor",
"bootloader",
"uptime",
"statistics",
"histogram",
"reset",
"set",
"auto",
"version",
"save",
});
}
class terminal {
static constexpr char ctrl_c = 0x03;
static constexpr char backspace = 0x08;
static constexpr char del = 0x7f;
static inline std::array<char, 24> m_line{};
static inline std::uint8_t m_at = 0;
static inline bool m_overflowed = false;
static inline bool m_monitoring = false;
static inline std::uint64_t m_last_monitor = 0;
static constexpr std::uint8_t command_count = command_names().size();
static constexpr std::size_t name_bytes = [] {
std::size_t total = 0;
for (auto one : command_names()) {
total += one.size() + 1;
}
return total;
}();
// The names as one NUL-separated blob in flash. Matched at run time, which
// is not a reason to sit in RAM on a machine with two address spaces: a
// table of `string_view` puts the characters *and* its own pointers there,
// and on this part that was 216 B of 2 KB spent on data that never changes.
// Separators rather than an offset table, because an offset table is the
// RAM this exists to give back.
static constexpr auto names_data = [] {
std::array<char, name_bytes> chars{};
std::size_t at = 0;
for (auto one : command_names()) {
for (char c : one) {
chars[at++] = c;
}
chars[at++] = '\0';
}
return chars;
}();
static constexpr avr::flash_table<names_data> names{};
// `reset` must be typed in full - an abbreviation must not be able to wipe
// data. Found in the list rather than written as an index, so reordering the
// commands cannot move the protection onto a different one.
static constexpr std::uint8_t exact_command = [] {
const auto list = command_names();
for (std::uint8_t i = 0; i < list.size(); ++i) {
if (list[i] == "reset") {
return i;
}
}
return static_cast<std::uint8_t>(list.size());
}();
// 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 dotted label the original used everywhere it printed a
// list of things, which is what makes a column of values readable without
// counting spaces. Its width comes from the type, so the padding needs no
// hand-counted constant.
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;
}
// The same shape for a command name, whose width is not a type but the walk
// to its separator - so it is counted as it is printed rather than measured
// first, which would read the blob twice.
static void help_row(std::uint8_t which)
{
auto at = name_start(which);
std::uint8_t width = 0;
for (char c; (c = names.at(at + width)) != '\0'; ++width) {
serial << c;
}
serial << ' ';
for (std::uint8_t i = width + 1; i < help_column; ++i) {
serial << '.';
}
serial << ": "_P;
}
// 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()
{
serial << "\r\nFanTemp "_P << version << " command overview\r\n"_P;
help_row(0);
serial << "prints this help message\r\n"_P;
help_row(1);
serial << "shows current temperature and fan speed\r\n"_P;
help_row(2);
serial << "shows mapping from temperature to fan speed\r\n"_P;
help_row(3);
serial << "loops the show command until Ctrl+C is pressed\r\n"_P;
help_row(4);
serial << "enters the bootloader\r\n"_P;
help_row(5);
serial << "shows system uptime\r\n"_P;
help_row(6);
serial << "prints overall statistics like min and max temp\r\n"_P;
help_row(7);
serial << "prints a histogram of the temperature\r\n"_P;
help_row(8);
serial << "resets statistics to 0 in EEPROM and RAM (no abbreviation)\r\n"_P;
help_row(9);
serial << "sets the fan speed to the provided value, 0-100\r\n"_P;
help_row(10);
serial << "turns on automatic fan control\r\n"_P;
help_row(11);
serial << "displays firmware version\r\n"_P;
help_row(12);
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 >= thermistor::adc_full_scale) {
return open_circuit;
}
return static_cast<std::uint32_t>(thermistor::series_resistor) * adc / (thermistor::adc_full_scale - 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;
}
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\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\r\n"_P;
} else {
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::uint8_t t = curve_low; t <= curve_high; ++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)
<< "% |"_P;
for (std::uint8_t i = 0; i < duty; ++i) {
serial << '#';
}
serial << "\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()
{
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<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;
}
}
// Where the given command's name begins in the blob. A walk over the
// separators, for the two callers that need to start at one name.
static constexpr std::uint8_t name_start(std::uint8_t which)
{
std::uint8_t at = 0;
for (std::uint8_t i = 0; i < which; ++i) {
while (names.at(at) != '\0') {
++at;
}
++at;
}
return at;
}
public:
// Which command a word names, or `command_count` for none. The input
// matches when it is a non-empty prefix of a name; `reset` is the exception
// and must be typed in full.
//
// One pass over the blob answers both, because the names sit in it in match
// order: each name is compared as it is walked, and the walk to its
// separator is also what measures it.
//
// Public and `constexpr` so the battery can pin it: the match order is
// load-bearing, an abbreviation must never reach `reset`, and reading the
// names from flash must not have changed either.
static constexpr std::uint8_t lookup(std::string_view input)
{
if (input.empty()) {
return command_count;
}
std::uint8_t at = 0;
for (std::uint8_t i = 0; i < command_count; ++i) {
std::uint8_t length = 0;
bool prefix = true;
for (char c; (c = names.at(at + length)) != '\0'; ++length) {
if (length < input.size() && input[length] != c) {
prefix = false;
}
}
const bool whole = input.size() == length;
if (prefix && input.size() <= length && (i != exact_command || whole)) {
return i;
}
at += length + 1;
}
return command_count;
}
static constexpr std::uint8_t no_command = command_count;
private:
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 (m_overflowed) {
serial << "input too long, ignored\r\n"_P;
m_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;
}
const auto which = lookup(word);
switch (which) {
case 0:
help();
return;
case 1:
show();
return;
case 2:
print_curve();
return;
case 3:
m_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:
// A flash string, like every other literal the console prints: as a
// `string_view` it was the last constant left in RAM, holding its own
// characters and a pointer to them.
static constexpr auto version = "v2.2"_P;
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;
prompt();
}
static void poll()
{
if (m_monitoring) {
if (uptime::millis() >= m_last_monitor + 1000) {
show();
m_last_monitor = uptime::millis();
}
// 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;
m_monitoring = false;
prompt();
}
return;
}
while (auto in = serial_t::read()) {
char c = static_cast<char>(*in);
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;
m_at = 0;
m_overflowed = false;
prompt();
} else if (c == '\r' || c == '\n') {
serial << "\r\n"_P;
if (m_at == 0 && !m_overflowed) {
prompt(); // a bare Enter just reprompts, no gap needed
continue;
}
dispatch(std::string_view{m_line.data(), m_at});
m_at = 0;
if (!m_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 == del || c == backspace) {
if (m_at) {
--m_at;
serial << "\b \b"_P;
}
} else if (c >= ' ') {
if (m_at < m_line.size()) {
m_line[m_at++] = c;
serial << c; // echo
} else {
m_overflowed = true; // reported when the line is submitted
}
}
}
}
};
} // namespace app

85
src/thermistor.hpp Normal file
View File

@@ -0,0 +1,85 @@
#pragma once
#include <array>
#include <cstdint>
#include <libavr/flash.hpp>
// NTC thermistor on a series divider, solved entirely at compile time:
// the Beta equation (logarithm and all) runs consteval into a libavr
// flash_table - the firmware never does floating point. Raw 10-bit ADC
// counts map to quarter- C with linear interpolation between table steps.
namespace app::thermistor {
// The converter's top count: this board reads the divider at the ADC's
// full 10 bits, so a reading and the resistance it implies both scale by it.
inline constexpr std::uint16_t adc_full_scale = (1u << 10) - 1;
inline constexpr double series_resistor = 9951;
inline constexpr double nominal_resistance = 9270;
inline constexpr double beta = 3212;
inline constexpr double nominal_temperature = 25;
namespace detail {
consteval double temperature_of(double adc)
{
double resistance = series_resistor * adc / (adc_full_scale - adc);
// __builtin_log constant-folds on the AVR backend, so the table is
// built at compile time with no runtime libm.
double steinhart = __builtin_log(resistance / nominal_resistance) / beta + 1.0 / (nominal_temperature + 273.15);
return 1.0 / steinhart - 273.15;
}
// 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
// at full scale has a right neighbour.
consteval std::int16_t quarters_entry(std::int32_t index)
{
double adc = index * 4.0;
if (adc < 4) {
adc = 4;
}
if (adc > 1019) {
adc = 1019;
}
double t = temperature_of(adc) * 4.0;
if (t < -40 * 4) {
t = -40 * 4;
}
if (t > 125 * 4) {
t = 125 * 4;
}
return static_cast<std::int16_t>(t < 0 ? t - 0.5 : t + 0.5);
}
inline constexpr avr::flash_table<[] {
std::array<std::int16_t, 257> out{};
for (std::int32_t i = 0; i < static_cast<std::int32_t>(out.size()); ++i) {
out[static_cast<std::size_t>(i)] = quarters_entry(i < 256 ? i : 255);
}
return out;
}()>
table;
} // namespace detail
// Temperature in quarter- C from a raw (or averaged) 10-bit sample.
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::uint8_t frac = adc & 3;
auto a = detail::table[index];
auto b = detail::table[static_cast<std::uint16_t>(index + 1)];
return static_cast<std::int16_t>(a + ((b - a) * frac) / 4);
}
// Whole degrees from quarter-degrees, rounded to nearest. The shift is the
// point: C truncates a negative quotient toward zero, so `(q + 2) / 4` reads
// -3.00 C as -2, where an arithmetic shift floors and reads it as -3.
inline constexpr std::int8_t whole_degrees(std::int16_t quarters)
{
return static_cast<std::int8_t>((quarters + 2) >> 2);
}
} // namespace app::thermistor

34
test/CMakeLists.txt Normal file
View File

@@ -0,0 +1,34 @@
libavr_format_test()
# The image must stay below the boot section at 0x7e00, and the README states
# what it measures; a library advance that moves it says nothing on its own.
libavr_size_claim_test(fantemp 8004)
# The README says the two modes emit the same image, and only a tree with
# both built can say whether they do.
libavr_mode_identity_test(fantemp)
# The battery is a compile: a static_assert that fails is the failure. It is a
# target rather than only a ctest so a plain build catches a regression too.
add_library(consteval_tests OBJECT consteval.cpp)
target_include_directories(consteval_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src)
target_link_libraries(consteval_tests PRIVATE libavr)
add_test(NAME fantemp.consteval
COMMAND ${CMAKE_COMMAND} --build ${CMAKE_BINARY_DIR} --target consteval_tests)
# 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
# and an unreflashable board.
# A check whose interpreter this host lacks is registered and *skips*, rather
# than leaving the suite a different size on a different machine - which is a
# suite nothing can be compared against. The launcher and the marker are
# libavr's (`cmake/checks.cmake`).
libavr_launcher(_python "${LIBAVR_NO_PYTHON}" ${Python3_EXECUTABLE})
add_test(NAME fantemp.reachability
COMMAND ${_python} ${CMAKE_CURRENT_SOURCE_DIR}/check_reachability.py
--objdump ${CMAKE_OBJDUMP} --elf $<TARGET_FILE:fantemp>
--image $<TARGET_FILE_DIR:fantemp>/fantemp.bin
--loader-base ${PUREBOOT_BASE_HEX})
libavr_skip_unverified()

136
test/check_reachability.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""The board's only way in, checked in the emitted image.
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
gets that route wrong is a board that cannot be reflashed - and the failure is
silent, because everything else still works.
It has been wrong before. The firmware this one replaces probed and jumped to
`0x7800`, the base of a 2 KB boot section, while the board's loader sits at
`0x7e00`; `check()` therefore read an erased byte, was false, and the command
never arrived anywhere. Nothing about that is visible short of trying it on the
hardware, which is what this replaces.
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
application reaching into it would be overwritten by the loader - or worse,
executed at reset, since BOOTRST points there.
2. The hand-over targets the loader base. A word address of 0x3f00 is byte
0x7e00; anything else is the 0x7800 bug again.
3. The hand-over does not arm the watchdog. pureboot hands straight back on
WDRF by design, so a reset-based route reaches it and opens no window. The
legacy firmware's route was exactly that, and it is the one change that
cannot be walked back from the host.
check_reachability.py --objdump avr-objdump --elf fantemp --image fantemp.bin
"""
from __future__ import annotations
import argparse
import pathlib
import re
import subprocess
import sys
BOOT_SECTION = 0x7C00 # hfuse d4: BOOTSZ 512 words - a fuse fact, not a loader one
WDTCSR = 0x60
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--objdump", required=True)
parser.add_argument("--elf", type=pathlib.Path, required=True)
parser.add_argument("--image", type=pathlib.Path, required=True)
# pureboot's own geometry, from the pinned submodule that exports it, so the
# slot's base is stated once for the loader this firmware is deployed with.
parser.add_argument("--loader-base", required=True,
type=lambda v: int(v, 0), metavar="ADDR")
args = parser.parse_args()
loader_base = args.loader_base
failures = []
size = args.image.stat().st_size
if size >= BOOT_SECTION:
failures.append(f"the image is {size} B and reaches 0x{size - 1:04x}, "
f"into the boot section at 0x{BOOT_SECTION:04x}")
else:
print(f" ok image {size} B, ends 0x{size - 1:04x}, "
f"{BOOT_SECTION - size} B clear of the boot section")
text = subprocess.run([args.objdump, "-d", str(args.elf)],
capture_output=True, text=True, check=True).stdout
# 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
# 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.
#
# bootloader::call() takes the target as a function pointer, so each call site
# loads the *word* address into a register pair immediately before it.
lines = text.splitlines()
helper = re.compile(r"\b(?:r?call)\b.*<_ZN3app10bootloader4call")
sites = []
for index, line in enumerate(lines):
if not helper.search(line):
continue
held: dict[str, int] = {}
for back in lines[max(0, index - 8):index]:
if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", back):
held[m.group(1)] = int(m.group(2), 16)
# The AVR ABI passes the pointer in r25:r24.
if "r24" in held and "r25" in held:
sites.append(held["r25"] << 8 | held["r24"])
want = loader_base // 2
if not sites:
failures.append("no call to bootloader::call with a loaded target - the "
"hand-over could not be read out of the image")
elif wrong := [a for a in sites if a != want]:
failures.append(f"the hand-over targets word {[hex(a) for a in wrong]} "
f"(byte {[hex(a * 2) for a in wrong]}), not the loader at "
f"0x{loader_base:04x}")
else:
print(f" ok all {len(sites)} hand-over site(s) target word 0x{want:04x} "
f"(byte 0x{loader_base:04x})")
# An icall/ijmp has to exist for that address to be jumped to indirectly.
if not re.search(r"\b(icall|ijmp)\b", text):
failures.append("no icall/ijmp - the hand-over cannot reach across flash")
else:
print(" ok an indirect call exists (a relative one cannot reach)")
# 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
# *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
# be a check for "a prescaler is present".
WDCE, WDE = 0x10, 0x08
values, held = [], {}
for line in text.splitlines():
if m := re.search(r"\bldi\s+(r\d+),\s*0x([0-9A-Fa-f]{2})", line):
held[m.group(1)] = int(m.group(2), 16)
elif m := re.search(rf"\bsts\s+0x00{WDTCSR:02X},\s*(r\d+)", line, re.I):
reg = m.group(1)
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)]
if armed:
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"opens no pureboot window")
elif not values:
print(" ok the watchdog is never written")
else:
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")
for line in failures:
print(f" FAIL {line}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())

120
test/consteval.cpp Normal file
View File

@@ -0,0 +1,120 @@
// Compile-only battery, built with the cross compiler so the target's 16-bit
// int is exercised. Two of this firmware's three numeric surfaces are decided
// entirely at compile time - the fan curve and the thermistor table - and the
// third, quarter-degrees to whole ones, is the arithmetic between them.
#include <cstdint>
#include "curve.hpp"
#include "terminal.hpp"
#include "thermistor.hpp"
namespace {
using app::thermistor::whole_degrees;
// Rounding to nearest, across zero. A negative quotient truncates toward zero
// in C, so a `(q + 2) / 4` reads -3.00 C as -2 and every case below the tie
// with it; the arithmetic shift floors, which is what these hold it to.
static_assert(whole_degrees(0) == 0);
static_assert(whole_degrees(1) == 0); // +0.25
static_assert(whole_degrees(2) == 1); // +0.50, the tie
static_assert(whole_degrees(3) == 1); // +0.75
static_assert(whole_degrees(4) == 1); // +1.00
static_assert(whole_degrees(-1) == 0); // -0.25
static_assert(whole_degrees(-2) == 0); // -0.50, the tie, toward zero
static_assert(whole_degrees(-3) == -1);
static_assert(whole_degrees(-4) == -1);
static_assert(whole_degrees(-12) == -3);
static_assert(whole_degrees(-160) == -40); // the table's own floor
static_assert(whole_degrees(500) == 125); // and its ceiling
// The curve is held at zero below the temperature the fan starts at, and
// saturates inside the table's window rather than at its edge.
using app::curve::detail::duty_entry;
static_assert(duty_entry(app::curve::start_celsius - 1) == 0);
static_assert(duty_entry(app::curve::start_celsius) == 0);
static_assert(duty_entry(30) == 7);
static_assert(duty_entry(40) == 36);
static_assert(duty_entry(49) == 93);
static_assert(duty_entry(50) == 100);
static_assert(duty_entry(99) == 100);
// Monotone across the whole table: a warmer reading never asks for less air.
consteval bool curve_rises()
{
for (std::int32_t t = 1; t < 100; ++t) {
if (duty_entry(t) < duty_entry(t - 1)) {
return false;
}
}
return true;
}
static_assert(curve_rises());
// The thermistor table, anchored where the Beta equation fixes it rather than
// against numbers this file computed the same way: the divider reads the
// thermistor's nominal resistance at
// adc = full_scale * nominal / (series + nominal), and the equation's own
// definition puts that count at the nominal temperature.
using app::thermistor::detail::quarters_entry;
inline constexpr std::int32_t nominal_count =
static_cast<std::int32_t>(app::thermistor::adc_full_scale * app::thermistor::nominal_resistance /
(app::thermistor::series_resistor + app::thermistor::nominal_resistance));
static_assert(quarters_entry(nominal_count / 4) >= 100); // 25.00 C, in quarters
static_assert(quarters_entry(nominal_count / 4 + 1) < 100); // and the step below it
// Both clamps, at the ends the divider cannot leave.
static_assert(quarters_entry(0) == 125 * 4);
static_assert(quarters_entry(255) == -40 * 4);
// An NTC on this divider falls with the count: more counts is more resistance
// is a colder sensor, over every step of the table.
consteval bool thermistor_falls()
{
for (std::int32_t i = 1; i < 256; ++i) {
if (quarters_entry(i) > quarters_entry(i - 1)) {
return false;
}
}
return true;
}
static_assert(thermistor_falls());
// The console's one parsing rule, read out of the flash blob the names live
// in. Every case here is a property of the *order* the names sit in, so this
// is what an edit to that list has to answer to.
using app::terminal;
// Full names, and the ends of the list - a walk that miscounts a separator
// lands on a neighbour rather than failing, so both ends are named.
static_assert(terminal::lookup("help") == 0);
static_assert(terminal::lookup("save") == 12);
static_assert(terminal::lookup("bootloader") == 4);
// Abbreviations resolve to the first entry they prefix. `s` prefixes show,
// statistics, set and save, and show is first - which is the original's
// behaviour and the reason the list is ordered rather than sorted.
static_assert(terminal::lookup("s") == 1);
static_assert(terminal::lookup("st") == 6);
static_assert(terminal::lookup("se") == 9);
static_assert(terminal::lookup("sa") == 12);
static_assert(terminal::lookup("up") == 5);
static_assert(terminal::lookup("b") == 4);
// `reset` is the exception: it must be typed in full, so no abbreviation of it
// resolves - and none of its prefixes names anything else either.
static_assert(terminal::lookup("reset") == 8);
static_assert(terminal::lookup("rese") == terminal::no_command);
static_assert(terminal::lookup("res") == terminal::no_command);
static_assert(terminal::lookup("r") == terminal::no_command);
// Nothing, and nothing that matches.
static_assert(terminal::lookup("") == terminal::no_command);
static_assert(terminal::lookup("xyzzy") == terminal::no_command);
// Longer than the name it prefixes is not a match: `helpful` is not `help`.
static_assert(terminal::lookup("helpful") == terminal::no_command);
} // namespace

11
tools/check.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# The task gate: every configure preset built, and only then every one tested.
#
# The loop itself is libavr's (tools/check-presets.sh) because the order in it
# is subtle and was got wrong by hand more than once - the mode-identity checks
# read the sibling mode's tree, so a build-then-test-per-preset run compares a
# fresh image against a stale sibling. Anything this repository needs beyond
# the presets is written after the call, where every tree is built and tested.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
"${LIBAVR_ROOT:-libavr}/tools/check-presets.sh" "$@"