Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ spricht deutsch.
| Directory | Purpose |
|-----------|---------|
| [firmware/](firmware/) | **Single source of truth** for the clock logic — platform-agnostic (animations, clock, display, scheduler, overlays, communication). |
| [docs/](docs/) | Reference documentation: the [serial command reference](docs/serial-commands.md), the [font tables](docs/fonts.md) and the [roadmap](docs/roadmap.md). |
| [docs/](docs/) | Reference documentation: the [serial command reference](docs/serial-commands.md), the [font tables](docs/fonts.md), the [roadmap](docs/roadmap.md) of what is still open, the [comparison](docs/comparison.md) with the other published word clocks and the [measured decisions](docs/decisions.md). |
| [assets/](assets/) | The icon's SVG masters and the script that generates the `.ico`, the `.xpm` and `docs/images/logo.png` from them. |
| [web/](web/) | The two pages a networked clock serves: the panel at `/` for what is changed often, the console at `/console` for every command. Compiled into the firmware by [platform/scripts/embed_web.py](platform/scripts/embed_web.py) — the clock has nowhere to fetch anything from. See its [README](web/README.md). |
| [platform/simulator/](platform/simulator/) | wxWidgets desktop backend: renders the matrix in a window so the firmware can be developed and debugged on a PC. |
Expand Down
66 changes: 66 additions & 0 deletions docs/comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# The projects this one was measured against

A word clock is a well-populated idea, and most of what is worth deciding has been
decided somewhere else first. This is what was read, what each one contributed, and
where this project ended up ahead — kept separately from the [roadmap](roadmap.md),
because a yardstick does not change when work gets done.

## wordclock24h

[ukw100/wordclock24h](https://github.com/ukw100/wordclock24h) (Frank Meyer, documented
at [mikrocontroller.net](https://www.mikrocontroller.net/articles/WordClock_mit_WS2812))
is the most complete of the published word clocks and the one this project overlaps with
most. The backlog was originally written as a comparison against it, item by item, which
is why the finished work reads as a list of things it already had: WiFi provisioning, a
night switch, OTA, an RTC with a battery, colour animations.

It is also the source of the *deliberately not planned* list. Weather reports, MP3
playback, alarms and games are what it grew over years, and seeing them together is what
made it clear that none of them is a word clock.

## Multilayout-ESP-Wordclock

[ESPWortuhr/Multilayout-ESP-Wordclock](https://github.com/ESPWortuhr/Multilayout-ESP-Wordclock)
is the one to measure a configuration page against, and the one that ships a flashable
file per chip per release.

Its configuration page is what the panel at `/` was built against: a sidebar of task
pages with a colour wheel, sliders and preset swatches. Four things came out of looking
at it, and all four are in the panel — the presets carry the weight rather than the
wheel, since nobody picks a colour twice; a numeric `#RRGGBB` field belongs beside the
wheel, or a colour found by dragging cannot be written down or restored; one labelled
slider beats two unlabelled ones, with the automatic that overrides it directly beneath;
and the clock face can be drawn in the page itself, which is the difference between
changing a colour and walking into the other room to see what it did.

Its 35 layouts across 13 languages are also the shape to copy if a second language is
ever wanted — one header per layout, collected by a generated file, chosen at runtime.
Why that is an idea rather than a plan is in the [roadmap](roadmap.md).

## Arduino-ESP32-Nano-Wordclock

[ednieuw/Arduino-ESP32-Nano-Wordclock](https://github.com/ednieuw/Arduino-ESP32-Nano-Wordclock)
contributed its *transports* rather than its structure: it reaches the same command set
over serial, Bluetooth, a browser and an SD card log through one pair of functions. That
is the argument for the Bluetooth item in the roadmap, and it is a good one — a command
set with three consumers takes a fourth without moving.

Its structure is the opposite of this one: a single `.ino` with global state and clock
faces chosen by `#define`. Nothing here should move towards it.

## Where this project is ahead

Worth writing down, because it is what the roadmap's items must not break.

- Fifteen transition animations with three selection modes, a speed per animation and a
favourite flag ([Animations.h](../firmware/inc/Animation/Animations.h)).
- Four German regional wordings switchable at runtime
([Clock.h](../firmware/inc/Clock/Clock.h)).
- A platform abstraction with a full desktop simulator, so the firmware is developed and
tested without hardware.
- A BH1750 over I²C with min/max calibration and gamma correction, rather than an LDR on
an ADC pin.
- One command set for the serial line, the browser console and the simulator dialog,
machine-readable in the
[MessageCatalog](../firmware/inc/Communication/MessageCatalog.h) — a command added
once appears in all three.
103 changes: 103 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Measured decisions

What is written down here was settled by building the image both ways rather than by
argument, and has no header that owns it: it is about a pattern spread over the whole
tree, not about one class. The numbers describe the change that was made and are
therefore history — they were true when measured, and updating them would make them a
lie. The [platform contract](../platform/avr-dx/README.md) states the rule that came out
of it; this is the measurement behind the rule.

## The `Fast` accessor pair

Every platform accessor used to come in two forms: one that validated its index and
answered `StdReturnType`, one that trusted the caller and answered the value. The
justification was real for this target — a freestanding 8-bit part built with `-Os` and a
loop over 110 pixels per frame, where a bounds check per pixel is time rather than a
formality.

What put it in doubt is that `getOutputPixel` existed for its whole life with only the
`Fast` half, on all three backends, and nothing noticed. A pattern in two parts whose
second part nothing enforces loses its second part.

**The justification did not survive the measurement.** The bounds check was put inside
the platform seam's `Fast` forms, so that all three hundred call sites paid for it without
one of them being rewritten, and the AVR image was built either way:

| | text |
|---|---|
| as it was | 48 210 |
| with the seam checking every access | **48 114** |

The checked build was **96 bytes smaller**, and the reason is worth more than the number.
Per symbol, `Pixels::setPixelFast` grew by 66 bytes and then stopped being inlined, which
took 48 bytes off `Display::setPixel`, 46 off `Display::setPixelFast`, 42 off
`AnimationMatrix::setTimeTask` and 48 off the RPC dispatcher. So what the difference
measured is an inlining threshold, not the cost of a comparison — **and a flash argument
that turns on the inliner's mood is not an argument.**

Two things the measurement turned up that nobody had anticipated.

The pair was invisible to the linker almost everywhere. Of 45 declared pairs, **35
appeared in the image as neither half** — fully inlined or dropped — and only three
(`getPixel`, `setPixel`, `writePixel`) existed as both. For most of the pattern there was
no runtime object to be cheap or expensive about; it was upkeep and nothing else.

And in the core, `Fast` did not mean unchecked. `Display::setPixel(Column, Row)`
validated nothing itself: it computed an index and handed it to `Pixels::setPixel`, which
is where the only check in the chain lived. The two halves in `Display` differed in
whether the caller was *told*, not in whether anything was verified — so half the pattern
was not a safety mechanism at all.

**The time argument, which no host can measure, comes out negligible by arithmetic.** The
frame path is 110 accesses, and `render()` returns early unless the buffer is dirty, so
the worst case is a display changing on every 10 ms tick: 11 000 checks a second. At a
generous four cycles each that is 44 000 of 24 000 000, **0.18 % of the part**, and a
clock showing a settled face pays none of it.

### What replaced it

The check moved into the single implementation and the name went with the pattern. There
is one accessor per operation now, and where a reader has two forms the argument list says
which — `getPixel(Index, Pixel)` answers a code, `getPixel(Index)` answers the pixel, and
**both check**. Writers have one form, which answers; a caller with nothing to do with
that answer ignores it. 440 mentions across the core and all four backends, and the image
came out **586 bytes smaller** — 48 664 before, 48 078 after, RAM unchanged to the byte.

Six things it settled, and they are the reason the shape is what it is:

- **The asymmetry decided itself, and not by taste.** Two writers cannot differ in their
return type alone, so a writer's unchecked half had nowhere to go but out. Readers
differ in their argument list, so both of theirs live under one name — and the one
answering a value needs something to answer for an index that is not there. That is a
default in every case: an unlit pixel, a null character, a word of zero length, an empty
glyph row. None of them can be mistaken for something that is on the display, which is
what makes the answer honest rather than a zero that reads like data.
- **The unchecked read did not disappear, it went private.**
`getDisplayCharactersTableElement`, `getDisplayWordsTableElement`, `getFontTableElement`
and the pixel buffer are the primitives both public forms go through, so each path
checks exactly once and the class keeps one place that touches the table.
- **A column past the last one used to light the next row.**
`Display::setPixel(Column, Row)` computed an index and handed it to `Pixels`, where 11
on an 11-wide display is a perfectly valid index — the first letter of the row below. So
the write was neither refused nor put where it was asked for. `isColumnAndRowValid` is
asked by every entry point taking a column now, and the case that found it is in the
tests; reading the code had not.
- **Two branches that had never compiled fell out.** `FontChar`'s checked `setRow` and
`setColumn` assigned a member from a `const` function, and `Display::togglePixel`'s
serpentine branch passed a pointer where a reference was expected. A template nobody
instantiates and a `#if` branch nobody selects are not code — the same thesis arriving
from the other side. The glyph accessors are instantiated by a test now.
- **The duplication was worth more than the checks were.** That is where the 586 bytes
came from, and not from the bounds tests: `Text::setChar` existed as two 900-byte bodies
inlined into a caller each and is one function now, and six `#if` ladders in `Display`
mapping a column and a row onto an index became one `toIndex`. Of the 45 declared pairs,
nine had both halves in the image at 486 bytes; `Display::setPixel(Column, Row)` alone
was 258 bytes of pair and is 78 as one.
- **One substitution had to be moved rather than dropped.** The scrolling text relied on
the unchecked `setChar` drawing a space for a character it could not map, which is what
clears the cells the previous shift step wrote; the surviving `setChar` refuses, because
a test says it must. So the shift task substitutes the space itself, where it can be
read — and a case fails if it stops. The `[[nodiscard]]` that used to police the four
`Text` twins went with them, and so did the `assert` in three backends' unchecked
writers: what they caught is what two new cases check, in CI rather than under a
debugger.
6 changes: 5 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ before half five, the kind of wording the word tables have to cover.
- [Serial command reference](serial-commands.md) — every command the clock
answers, over the serial port and over the web console alike.
- [Fonts](fonts.md) — the bitmap font table format, and how to regenerate one.
- [Roadmap](roadmap.md) — what is planned, why, and what each item touches.
- [Roadmap](roadmap.md) — what is still open, and what settles each item.
- [Comparison](comparison.md) — the other published word clocks this one was
measured against, and where it ended up ahead.
- [Measured decisions](decisions.md) — a pattern that spanned the whole tree, and
the build that decided it.

## Auf Deutsch

Expand Down
Loading