Skip to content

ILI9431 Support - #120

Merged
TZlindra merged 25 commits into
mainfrom
ili9341-display
Jul 29, 2026
Merged

ILI9431 Support#120
TZlindra merged 25 commits into
mainfrom
ili9341-display

Conversation

@TZlindra

Copy link
Copy Markdown
Collaborator

No description provided.

TZlindra and others added 25 commits July 26, 2026 17:57
The firmware set CMAKE_C_STANDARD but never CMAKE_CXX_STANDARD, so the C++
standard was whatever the toolchain happened to default to -- gnu++17 for the
pinned ARM GCC 13.3.rel1, and liable to move under us on a toolchain bump.

Pin it, and pin it at 20: the display work that follows checks both LCD drivers
against a concept, which needs C++20. firmware/tests/CMakeLists.txt already
builds at 20, so this also stops the two from drifting apart.

Nothing else changes. Debug and Release both build clean, compile_commands.json
confirms -std=gnu++20 (C stays at gnu11), and the 60 host tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SessionController FSM formatted its own text and sent the Lumex panel
(row, column, string) triples. That protocol is the panel: 16x2 literals with
hand-counted padding, field widths chosen to land in exact cells, and column
offsets baked into the state machine. A second display cannot share it -- the
intersection of a 16x2 character LCD and a 320x240 TFT caps the TFT at 16x2,
and their union is meaningless on the LCD.

So move the seam to what the values mean. session_controller_to_display carries
a screen id plus every value any screen shows, and laying that out is the
driver's job. The FSM keeps its state machine and loses only its formatting;
SessionController is untouched apart from a queue rename, because the four
methods it calls -- DisplayRpm, DisplayForce, DisplayPIDEnabled,
DisplayManualBPMDutyCycle -- were already saying what rather than how.

This is also what makes a TFT viable later. Sending a rendered string leaves a
driver unable to tell which quantity moved, so it can only repaint everything;
at 320x240x16bpp that is ~50-100 ms over SPI. Sending state lets a driver diff
and repaint one field in ~1-2 ms. The Lumex driver already does exactly that,
writing only the runs of cells that differ.

Clearing is now derived rather than passed: the driver clears when the screen id
changes. That is the old rule exactly -- every Show*Screen cleared, and the one
redraw that deliberately did not (a tick inside the RPM editor) is also the one
that does not change screen. ShowDesiredRpmEditor loses its bool for that reason.

The layout lives in lumex_layout.c as a pure function -- state in, a full 2x16
frame out, no HAL or RTOS -- so tests/lumex_layout_tests.cpp pins all six screens
cell-for-cell on the host. Expectations were transcribed by hand from the
literals the FSM used to write.

Two things found on the way and deliberately preserved rather than fixed here:
  - The session screen's force field is six characters at columns 2-7 while the
    label literal carries "0.00" at columns 6-9, so columns 8-9 keep a stale "00"
    and 12.34 N reads as "12.3400". Pinned by a test that says so.
  - What the screen labels "rpm" is the optical encoder's angular_velocity,
    which is rad/s. The struct field is named honestly; the rendering is not
    changed.

The queue is CubeMX-owned, so the rename goes in the .ioc too -- otherwise the
next regen restores the old name and emits a type that no longer exists.

Debug and Release both build; the 3 generated headers are in sync with their
schemas; 74 host tests pass, 14 of them new. Unverified on hardware: the panel
walk-through is still to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two display bugs, both pre-existing and both found while moving the layout out
of the FSM. They were left alone there so that commit stayed behaviour-preserving.

The session screen's force field is six characters at columns 2-7, but the row's
label literal carried a "0.00" of its own at columns 6-9. Nothing ever rewrote
columns 8-9, so two digits of the label stayed on screen underneath every
reading: 12.34 N displayed as "12.3400". The literals now carry labels and units
only, with the units placed just past where each field ends, so a value and its
unit cannot overlap however wide the reading gets.

The other is a unit error. encoder_angular_velocity() returns rad/s -- its header
says so -- and SessionController passed it straight to DisplayRpm, which printed
it under an "rpm" label. A shaft at 3000 RPM read as 314. The conversion now
happens once, in encoder_rpm() next to the measurement it converts, and the FSM
applies it on the way in; the message field is named rpm because that is now what
it holds. DisplayRpm is renamed DisplayAngularVelocity, which is what its caller
was always passing it.

Putting the conversion in encoder_math.c rather than in a display driver keeps it
in one place no matter how many panels end up showing the number, and it is
host-tested there alongside the arithmetic it belongs to.

78 host tests pass, 7 new. Debug and Release build; generated headers in sync.
Still unverified on hardware -- and note this one does change what the panel
shows, so the walk-through is checking new output, not identical output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an ILI9341 320x240 SPI TFT alongside the Lumex 16x2, selected in
Config/debug.h and flashed. Both read the same queue and the same
session_controller_to_display message, so the SessionController and its FSM are
byte-identical either way; only the driver linked in changes. Both
configurations are built in CI terms here -- Debug and Release, each way.

Not the Adafruit library, and not a submodule. That would be three submodules
(ILI9341 -> GFX -> BusIO), and the chain is Adafruit_ILI9341 : Adafruit_SPITFT :
Adafruit_GFX : Print -- twenty virtual functions on a codebase that builds
-fno-rtti -fno-exceptions to avoid exactly that. Adafruit_SPITFT.cpp is also
2621 lines of per-MCU #ifdef over Arduino's digitalWrite/SPIClass with no STM32
branch, so using it means permanently forking someone else's dispatch tree. What
is actually panel-specific is ~30 lines: the init/gamma table, the address-window
command, the MADCTL rotations. Those are vendored with Adafruit's BSD notice,
along with the 5x7 GFX font (1280 bytes of pure data), and the transport is
written against HAL_SPI_Transmit. Same call the ADS1115 driver already made.

The common interface is a C++20 concept rather than a base class: the panel is
fixed at link time, so DisplayDriver checks Init/Clear/Render at compile time and
inlines through it, with a static_assert in each driver. It exposes no drawing
primitives on purpose -- the intersection of a character grid and a TFT caps the
TFT at 16x2, the union is meaningless on the LCD -- so everything the TFT can do
that the Lumex cannot lives inside its Render() and never surfaces. Both drivers
now share one queue-drain loop.

Blocking SPI, not DMA. The display task is osPriorityBelowNormal, so a polling
wait is preempted by anything that matters. DMA would also be real work rather
than a flag here: DMA1/DMA2 cannot reach DTCM on the H7, and the linker script
puts .data, .bss, the FreeRTOS heap and every task stack there, so it would need
a scratch buffer in a new section in the (currently unused) AXI SRAM.

Four hardware faults fixed in the .ioc, all of which would have left a dead
panel, and none reachable by editing main.c since CubeMX owns it:
  - SPI1 DataSize was SPI_DATASIZE_4BIT (CubeMX default, never set) -> 8-bit
  - SPI1 prescaler 2 off a 200 MHz kernel clock = a 100 MHz SCK -> 16, 12.5 MHz
  - ILI_SPI1_LCD_CS was driven low at init, leaving chip select asserted
  - ILI_LCD_RST was driven low at init, holding the panel in reset forever
The display task's stack also goes 512 -> 1024 bytes; the old figure predates any
driver with pixel buffers.

TASK_OFFSET_LUMEX_LCD becomes TASK_OFFSET_DISPLAY and the error enum follows,
which regenerates the C# mirrors too -- deferred out of the previous commit so
that one stayed firmware-only. No hand-written C# referenced either name.

93 host tests pass, 15 new. The ILI layout tests check what the driver actually
depends on rather than a pixel golden: that fields stay on the panel at their
widest values, that a screen's field list is positionally stable whatever the
values (which is what makes the index-wise diff valid), and that fields sharing a
slot are equal width so a redraw erases the previous value.

Generated headers, C# mirrors and CubeMX output all pass their drift checks.
Unverified on hardware: the panel has not been driven. Bring-up order is in
Drivers/ILI9341/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The blink task has no LED of its own -- it toggles ILI_SPI2_SD_CS (PH7), the
microSD slot's chip select on the ILI9341 module, picked as a convenient scope
point back when nothing else used the pin. Once that module is fitted the pin is
not free, and driving a chip select on a timer is not something anyone would
notice from the symptoms.

Both flags being 1 is now a compile error rather than a runtime surprise. The
check reads ILI9341_LCD_TASK_ENABLE, which is defined above it, and is guarded on
definedness as well: an undefined macro is 0 to the preprocessor, so moving either
#define below this point would otherwise switch the check off silently -- the same
class of accident being guarded against.

All four combinations exercised: Lumex+LED off, Lumex+LED on and ILI+LED off all
build; ILI+LED on fails with the message naming the pin. Defaults are unchanged
(Lumex, blink task off), so nothing that built before stops building.

The real fix is a pin of its own for the blink task, which is a board question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel reads upside down on the rig: it is mounted 180 degrees from the
controller's default landscape, so ILI9341_ROTATION_LANDSCAPE put the origin in
the wrong corner. Use LANDSCAPE_FLIP.

That is MADCTL 0x28 -> 0xE8. The difference is MX|MY, a mirror in both axes and
nothing else: MV stays set so the panel is still 320x240, and BGR is untouched so
colours are unaffected. The layout needs no changes for the same reason.

Which way up the panel sits is a property of the enclosure rather than of the
driver, so it is now ILI9341_DISPLAY_ROTATION in config.h beside the Lumex's grid
dimensions -- remounting the panel should not mean editing driver code.

Everything else about the bring-up was already right: the panel initialised, took
the gamma sequence, drew the font and got its colours correct, which exercises
reset, chip select, D/C and the SPI settings.

Both drivers build Debug and Release; 93 host tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing the brake button bricked the board: no LEDs, no buttons, only a reset.
That is vApplicationStackOverflowHook, which does taskDISABLE_INTERRUPTS() then
spins -- with interrupts off nothing runs, so the symptom is a dead device rather
than a reported fault.

The overflowing task was the display. Entering a session is the only way to reach
the session screen, and the session screen was the only screen that formatted a
float: snprintf("%6.2f") for the force reading, the sole floating-point
conversion in the whole firmware. newlib's float formatter needs several hundred
bytes of stack, on top of the 448 the ILI render path already used, against a
1 KB task stack. Every other screen formats integers only, which is why nothing
else triggered it.

Two fixes, either of which would have been enough, both worth having:

  - display_format_fixed2() replaces the "%6.2f" in both layouts. It rounds into
    hundredths once and formats integers from there, so the display path no longer
    reaches a routine whose stack cost cannot be read off -fstack-usage output.
  - ILI9341Display::Render's ili9341_frame local becomes a member. The object is
    already a static in ili9341_lcd_main, so the frame moves to .bss: Render goes
    from 256 bytes of stack to 32.

The ILI session path is now RunDisplayTask 48 + Render 32 + ili9341_layout 48 +
layout_session 64 + display_format_fixed2 80 = 272 bytes, all measurable, against
448 plus an unbounded formatter before. The Lumex path benefits the same way and
had the same latent bug with less margin.

Six tests pin the new formatter against what %6.2f produced, including the
sign-when-the-whole-part-is-zero case (-0.50 must not render as 0.50) that
truncating division would otherwise lose.

-u _printf_float stays in the link for now; nothing calls %f any more, so it is
removable, but that is a global setting and a separate decision.

99 host tests pass; both drivers build Debug and Release. TaskMonitor reports
per-task stack high-water marks over USB, which is how to confirm the headroom on
real hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The actual cause of the board dying on brake press, and it was mine.

RunDisplayTask did `if (!display.Render(state)) return;`. Returning from a
FreeRTOS task function lands in prvTaskExitError() (port.c:217), which fails a
configASSERT, calls portDISABLE_INTERRUPTS() and spins forever. With interrupts
masked the button EXTI never fires again -- and since the brake LED is driven
inside that ISR (input_manager_interrupts.c:35), it freezes mid-press. That is
exactly the reported symptom: whole rig dead, LED stuck, only a reset recovers.

So one failed SPI write killed the entire dynamometer. A display is the least
important thing on this board and had the most fatal failure mode on it.

Why the session screen and nothing else: it is the busiest render -- most fields,
largest glyphs -- and it happens precisely as the USB, PID, BPM and sensor tasks
spin up. HAL_SPI_Transmit's timeout is wall-clock and keeps counting while the
caller is preempted, so the lowest-priority task on the board can blow a 100 ms
timeout on scheduling latency alone, with nothing wrong on the bus. That is now
1000 ms, far past any real transfer (96 bytes is ~61 us), so it fires on a wedged
bus rather than on a busy scheduler.

Three changes:
  - RunDisplayTask is [[noreturn]] and swallows the render result. A failure is
    already recorded in the task error buffer and reaches the host over USB; the
    loop carries on.
  - Both drivers clear _hasRendered when a write fails, so the next pass clears
    and repaints in full. Without that the shadow copy disagrees with the panel
    and the field diff would skip cells that were never actually painted.
  - Both _main functions suspend instead of falling off the end.

The previous commit is reverted. It theorised a stack overflow in the float
formatter, which I inferred rather than confirmed and which turned out to be
wrong -- and it made things worse on the bench.

Both drivers build Debug and Release; 93 host tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… Lumex

Adds three in-session readouts that only the ILI9341 has room for: angular
acceleration, peak force this session, and elapsed time. They join the
DisplayDriver concept as ShowAngularAcceleration / ShowPeakForce /
ShowSessionElapsed, which LumexLCD implements as one-line no-ops that discard the
argument.

This is deliberately the "union" interface argued against when the seam was
designed: the concept now carries methods one panel cannot honour. The trade is
worth making here because the alternative -- keeping the interface to the
intersection -- means the TFT can never show more than a 2x16 character grid can,
which is most of the reason for fitting it. The stubs are inline and empty, so
the Lumex build emits no code for them at all: they do not appear in its
-fstack-usage output. What it costs is honesty in the header, and the comments
say plainly that the asymmetry is intended.

They sit on the concept rather than only on ILI9341Display so the shared task
loop can call them without knowing which panel it drives, and so a driver that
silently stopped implementing one is a compile error rather than a link error.
Adding a fourth readout is one real implementation and one (void) line.

Data plumbing: angular acceleration was already measured by the optical encoder
and thrown away at the display. Peak force is tracked by magnitude, since the rig
is loaded whichever way the cell is driven. Elapsed time comes from the same
microsecond counter the samples are stamped with. Both per-session figures reset
on entry to a session rather than exit, so the screen keeps the last run's
numbers until a new one starts.

The detail fields clamp rather than overflow their formats. The driver diffs
field i against field i and repaints only the movers, so a reading that outgrew
its width would shift its neighbours and leave their old pixels behind -- a test
pins the widths against absurd inputs for exactly that reason. ili9341_frame grew
to 12 fields and with it past what belongs on a 1 KB task stack, so Render's
working frame is now a member of the (static) driver object.

98 host tests pass, 5 new. Both drivers build Debug and Release; generated
headers and C# mirrors in sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Home page showed BPM duty cycle as a read-only figure, and there was no way
for the host to set it: the only two commands in the protocol were USB_CMD_ACK
and USB_CMD_SET_SYSCONFIG, and duty cycle is not a sysconfig parameter. Swapping
the TextBlock for a TextBox alone would have produced a box that displays
telemetry, fights the user as BPM samples overwrite it several times a second,
and sends nothing. So this is both halves.

Firmware: a new routed command, SESSION_CMD_SET_BRAKE_DUTY_CYCLE, addressed to
TASK_OFFSET_SESSION_CONTROLLER. The USB task already had the routing -- a
task_offset -> queue switch whose comment invited exactly this -- so it needed a
queue and a case rather than new machinery.

It is applied through FSM::SetHostBrakeDutyCycle, which writes the same state a
rotary-encoder tick does. That is deliberate: the clamp to
MIN/MAX_DUTY_CYCLE_PERCENT, the post to the BPM task and the on-screen readout
are then identical whether the request came from the rig or the PC, rather than
being a second path that has to remember the same rules.

The brake is an actuator, so the command is refused unless a session is running,
answering USB_RSP_NOT_SUPPORTED rather than a silent OK -- the app should not
show a duty cycle the brake never went to. That preserves the existing invariant
that the brake is never actuated outside a session, however the request arrives.
Unlike a sysconfig write the command is not retried: re-sending one whose ack was
lost could drive the brake to a figure the user has since moved away from.

App: the readout becomes a text box, in percent to match what it replaced. It is
disabled unless connected and in-session, sends on Enter or focus loss rather
than per keystroke, takes 1% per scroll notch (sent immediately -- a notch is a
complete intent, unlike a half-typed number), and reds its border when the device
refuses. Telemetry stops writing to the box while it has focus; without that the
caret would jump to the end between keystrokes.

USB_PROTOCOL_VERSION 7 -> 8: a new opcode is wire-visible, and the handshake
refuses a mismatch, so an old app and new firmware will say so instead of
misbehaving. Flash the board and rebuild the app together.

CommandOpcodes learned the new enum so the event log names it. Its tripwire test
-- which fails when a task gains commands the host cannot name -- did its job.

258 C# tests and 98 firmware host tests pass; both display drivers build Debug
and Release; generated headers, C# mirrors and CubeMX output all in sync.
Unverified on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y's stack telemetry

The BPM broke the moment the ILI9341 was switched on, and the branch history makes that
precise: every commit before 70e95b8 built with LUMEX_LCD_TASK_ENABLE 1, so SPI1 was idle
and the TFT had never run on hardware. 70e95b8 flipped the panel over and raised the SPI
pins to GPIO_SPEED_FREQ_VERY_HIGH in the same change. None of the three fixes below depend
on which of those two did it.

ShowSessionScreen() was re-entrant. HandleButtonBrakeInput calls it on every press edge,
and BTN_BRAKE is GPIO_MODE_IT_RISING_FALLING with report_press = true, so a bouncing
contact -- or an edge coupled in from somewhere else -- re-entered it mid-session. It is
destructive: it zeroes _desiredManualBpmDutyCycle, wipes _peakForce and restarts the
session clock, so the brake dropped to 0% under the user's hand. It is the only path in the
firmware that reaches 0% without leaving the session. Only a real IDLE -> IN_SESSION
transition may reset those now.

SPI1's pins go back to GPIO_SPEED_FREQ_MEDIUM. VERY_HIGH is the fastest edge rate the part
can produce and buys nothing at 12.5 MHz, where MEDIUM is already comfortable; on flying
leads beside unfiltered EXTI inputs it is a cost with no benefit. Changed in the .ioc as
well as the generated MSP, so a CubeMX regen stays a no-op.

Three #if LUMEX_LCD_TASK_ENABLE guards went dead when that macro became 0. Two were null
checks. The third gated TaskMonitor's per-task report for TASK_OFFSET_DISPLAY, so the
display task's stack high-water mark silently stopped being streamed -- which is exactly
the telemetry needed to judge whether that task is close to overflowing, and it went away
in the same change that started running the panel that puts it under pressure. All three
now test a new DISPLAY_TASK_ENABLE, which is what code outside the two drivers actually
means, and SessionController.hpp's !defined tripwire covers it so it cannot silently
evaluate to 0 again.

CheckTaskQueuesValid also gained the two queues 7f70c55 added but did not validate: a null
usb_command made the SessionController swallow every host command instead of reporting the
bad wiring.

Both panels build Debug and Release; 100 firmware host tests pass. Unverified on hardware --
the first thing to read off the board is that restored stack high-water mark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…L_Delay out of the task

Three changes that all land on the same files, so they go together rather than as a rename
commit that would not build without the fix inside it.

Tasks/LCD and Tasks/Display read as two modules. They are one task: one task_offset, one
queue, one entry point chosen at compile time, and the dependency already ran one way --
LumexLCD.cpp included Tasks/Display/DisplayDriver.hpp, never the reverse. LCD was simply
where the files were when the Lumex was the only panel. Everything now lives under
Tasks/Display with the shared code at the top and a subdirectory per panel:

    DisplayDriver.hpp     the concept every panel satisfies + the shared task loop
    display_common.{h,c}  helpers neither panel owns
    Lumex/                the 16x2 character driver and its layout
    ILI9341/              the 320x240 TFT driver and its layout

Neither panel subdirectory includes the other. The firmware CMakeLists needed nothing --
APP_SOURCES is a GLOB_RECURSE -- so only the two explicit paths in tests/CMakeLists.txt and
the include lines moved. The two module READMEs merge into one.

display_format_fixed2 comes back. 45c917f removed newlib's float formatter from the display
path after it overflowed the task's 1 KB stack and bricked the board on the first brake
press; dacdd1f reverted that wholesale, and de0b341 then added a second "%6.2f" for peak
force. Measured on a Release build, the session render path went from ~570 bytes to ~384:

    RunDisplayTask 48 + Render 32 + ili9341_layout 88 + display_format_fixed2 64
      versus 48 + 32 + 96 + ~396 for _svfiprintf_r -> _printf_float -> _dtoa_r -> _malloc_r

The size matters less than what left with it: _dtoa_r allocates, so the old cost could not
be read off -fstack-usage at all. That stack's neighbour in the heap is the
SessionController's -- they are created back to back -- and its outermost frame holds the
FSM, so an overflow too small to trip the 16-byte canary lands on the commanded brake duty
cycle. The six tests pinning the formatter against printf come back with it.

The ILI9341 driver takes an injected delay callback instead of calling HAL_Delay. HAL_Delay
spins rather than yields, so Init() burnt ~325 ms of CPU at the display task's priority, and
it cannot return at all inside a FreeRTOS critical section -- HAL's tick is a TIM at
TICK_INT_PRIORITY 15, which is masked there. A void(*)(uint32_t) defaulting to HAL_Delay
keeps Drivers/ILI9341 free of cmsis_os2.h, which is what makes it host-testable; the task
passes a one-line osDelay wrapper. A static_assert on configTICK_RATE_HZ turns a change of
tick rate into a build error rather than a panel that quietly misses its init timings.
LumexLCD::ClearDisplay's lone HAL_Delay(20) becomes osDelay(20); the rest of that driver was
already correct.

Both panels build Debug and Release; 106 firmware host tests pass; both codegen checkers
report everything still in sync. Unverified on hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The box jumped while you used it. It was bound to a property telemetry also
wrote: a BpmSample lands several times a second, so a moment after every scroll
notch the device's own reading overwrote what you had just dialled in, and the
value snapped back.

The focus gate added with the box was never going to hold. It only covered
typing, and scrolling does not focus a control -- so the wheel, the one input
that produces a change per notch, was exactly the case it did not cover. Widening
the gate would not have fixed the cause either, which is that one property was
being asked to be two things: what you have asked the brake for, and what the
brake is doing. Those are different numbers and they legitimately differ -- while
a command is in flight, and permanently when the firmware clamps to the
MIN/MAX_DUTY_CYCLE_PERCENT envelope.

So they are two things now. The box is a setpoint that telemetry never touches;
the measured figure sits beside it as "(now 45.0%)". Seeding happens at exactly
two moments -- when a session starts, and when a link drops -- rather than
continuously, because a setpoint that re-synced to the measurement would drag
whatever you had dialled in back to wherever the brake happened to be.

That also removes the IsEditingDutyCycle flag and its GotFocus handler entirely:
nothing writes the box behind the user any more, so there is nothing to referee.
Escape now reverts to the brake's actual figure rather than waiting for a
telemetry sample to put it back, which is what it was really relying on.

Showing both is worth it on its own. The old readout could not tell you whether a
duty cycle you asked for had been clamped; the pair does.

App only -- no firmware change. 258 C# tests pass. Unverified on hardware: the
thing to check is that a scroll notch now stays put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sets both panel enables to 0, so no display task runs and nothing drives SPI1.
Everything else on the branch stays exactly as it is: the host command route, the
session-detail message, the app's duty-cycle control, the SPI1 peripheral setup.

Why this and not a debounce: the search for the brake fault came back with the
encoder path byte-identical to main. AdjustBrakeDutyCycle, the BPM and PID tasks,
every ISR, input_manager_interrupts.c, and the encoder and brake pin
configuration all diff clean. The FreeRTOS heap is ~13.6 KB of 46 KB. The one
functional difference left is that a display task drives SPI1 at 12.5 MHz on
PD7/PG9/PG10/PG11 while the encoder is being turned -- and the fault appears only
when the encoder is turned, never when the app sets the same value over USB,
which rules out everything downstream of the setpoint since both paths share it.

So this changes one variable rather than adding logic on top of a guess.

Verified the isolation rather than assuming it: no ILI9341:: or LumexLCD:: symbol
survives in the image, and HAL_SPI_Transmit is not linked in at all, so no code
path can reach SPI1. Only the two timer handle variables in main.c remain, which
are data.

Reading it:
  - brake behaves       -> the display's SPI activity is implicated, and the next
                           question is which part (clock rate, edge rate, wiring)
  - brake still misbehaves -> the display is exonerated and the cause is elsewhere

Supporting changes, both of which outlive the experiment: "at most one panel" is
now a legal configuration rather than "exactly one", and the display task parks
instead of falling through to the Lumex when neither is enabled. The
SessionController no longer treats a display as a hard dependency -- it never
needed one, it posts to a queue nobody has to drain.

All three configurations build Debug and Release (ILI, Lumex, none); 100 host
tests pass. Put ILI9341_LCD_TASK_ENABLE back to 1 to undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loating pin

ROT_EN_B (PI8) was configured GPIO_NOPULL. It is the only user input on the board
without a pull resistor: ROT_EN_A, ROT_EN_SW, BTN_SELECT, BTN_BACK and
ADS1115_ALERT are all PULLUP, and BTN_BRAKE is PULLDOWN because it is active
high.

ROT_EN_A is PULLUP with GPIO_MODE_IT_FALLING, so the encoder switches its
contacts to ground -- which means B needs a pull-up as much as A does. Without
one it floats whenever the B contact is open, and register_rotary_encoder_input()
samples exactly that pin, at every A edge, to decide which way the knob turned.

So the direction bit was noise. A high-impedance input is an antenna and does not
need a neighbouring trace to pick something up, which is why the encoder and SPI
traces not crossing did not rule this out. With SPI1 idle the pin held its last
level long enough to usually read right, which is why main and the display-off
build were both fine. With the panel driving SPI1 at 12.5 MHz it read randomly,
so ticks came back with random direction and the duty cycle random-walked instead
of climbing -- and since it is clamped at MIN_DUTY_CYCLE_PERCENT (0.0), the walk
had a floor to collect at. That is the "drops to 0%" symptom, and it explains why
only the encoder was affected while the app's command path, which never touches
PI8, worked throughout.

The panel is back on: this is the build the fault should be judged against.

Also keeps two things from the diagnostic commit, which are worth their own
keeping: at most one panel is a legal configuration (so the display can be turned
off again without editing dispatch), and the display task parks instead of
falling through to the Lumex when neither is enabled.

Both panels build Debug and Release; 100 host tests pass. The .ioc is the source
of the change, so a CubeMX regen is a no-op. Unverified on hardware -- the test is
the one that has been failing: turn the encoder during a session with the panel
running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two mitigations together rather than one per flash cycle, because iterating a
guess at a time has already cost several.

Answering the question directly first: there is no configuration that is both
"same as main" and "runs the TFT". On main SPI1 is never used -- the Lumex is a
GPIO bit-banger -- so those pins simply never toggle, which is why main's LOW
edge rate and prescaler-2 on SPI1 mean nothing there. Turning the panel on is the
difference, and the display-off build proved it is the trigger.

The previous commit's pull-up was not the fix; the board already has external
pull-ups. It is left in place because a stronger pull on a line demonstrably
picking up noise costs nothing, but it should not be read as the cause.

Quieter bus: SPI1 goes 12.5 -> 6.25 MHz and its pins from MEDIUM back to LOW,
which is what they were before this branch touched them. Half the clock and the
slowest edge rate the part offers, so whatever is coupling has less to couple. A
full repaint goes ~98 -> ~197 ms, which is only visible on a screen change; a
single field is ~4 ms and the readouts are all single fields.

Harder input: the encoder was decoded with no filtering at all -- an EXTI on
ROT_EN_A reading ROT_EN_B's level for direction. One disturbed read of B reverses
the tick, and a reversed tick is worse than a lost one, because the setpoint then
random-walks instead of lagging; clamped at 0.0 it collects at the bottom, which
is the reported symptom. Now an A edge within 1 ms of the last accepted one is
ignored, and B is read three times with a majority vote.

Both counters are exposed as globals -- rotary_encoder_rejected_edges and
rotary_encoder_split_direction_reads. If the encoder behaves now, those two say
whether it is because the noise stopped or because it is being filtered out, and
a split-direction count that climbs while the knob is still is a coupling
measurement rather than an inference.

If this still is not enough, the proper fix is to stop decoding the encoder in
software: TIM1/2/3/4/8 have a hardware encoder interface with a digital input
filter, which is what that filter exists for. That is a real change, so it is not
being made on a guess.

Both panels build Debug and Release; 100 host tests pass. .ioc is the source for
the SPI change, so a regen is a no-op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both display READMEs rewritten to answer the question they did not: how does
anything actually get on the screen.

Drivers/ILI9341/README.md now documents the wire. The panel has no idea what text
is -- it is a framebuffer with a cursor -- and nothing said so. Added: what each
of the six signals does and that ILI_LCD_DC is the entire framing mechanism,
since there are no addresses or headers on this bus; the bus settings and why
NSS_SOFT matters (CS can stay low across a command and the pixels that follow);
the transaction shape as a CS window with D/C toggling inside it; two worked
byte-level examples, a FillRect with its real CASET/PASET/RAMWR arguments and a
character cell; RGB565 with its bit layout and big-endian byte order; and the
auto-advancing cursor, which is why the rule is blit rectangles and never pixels.

Also written down: that there is no read-modify-write, so everything drawn is
opaque and that is what forces fixed-width padded fields one layer up; the glyph
format, with 'A' drawn out as its five column bytes; that `size` is pixel
replication rather than a font, so size 5 is the same 35 dots five times
blockier; that nothing reflows or shrinks, only clips; the init table's
self-describing format including the high-bit-means-delay wrinkle; the four
MADCTL rotations and which bit does what.

Core/Src/Tasks/Display/README.md now opens with the full path from a force
reading to pixels, as a diagram with the six stages and what each one discards,
then explains each. The parts that were previously only in commit messages are
now where someone will find them: why the queue put uses timeout 0, why
RunDisplayTask is [[noreturn]] and what returning from a task function actually
does, why field equality includes colour, why a failed render clears
_hasRendered, and the session screen's field order as a table -- the index-wise
diff depends on that order and it was written nowhere.

Every number checked against the code rather than carried over: the ten field
positions match ili9341_layout.c, and the timings are recomputed at the current
6.25 MHz (full frame ~197 ms, one size-5 field ~18 ms, one size-3 character
~1.1 ms). The stale "~61 us at 12.5 MHz" in the SPI timeout comment is corrected
to ~123 us at 6.25 MHz; that clock changed two commits ago and the comment did
not.

Docs and one comment only. Builds Debug; 100 host tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LumexLCD was two classes wearing one hat. Its public surface was task-level
(Init/Clear/Render, satisfying DisplayDriver) and its private surface was pure
HD44780 protocol (SendByte, WriteCommand, SetCursor, DisplayString, ToggleBlink).
That is exactly the ILI9341Display-over-ILI9341 boundary, just never drawn, so
the codebase taught two patterns for one job.

Drivers/Lumex/LumexPanel now holds the protocol and nothing above it: board
wiring handed in as a struct, the instruction set named rather than written as
magic numbers, and no knowledge of screens, diffing or the RTOS.
Tasks/Display/Lumex/LumexLCD keeps the layout, the changed-cell diff and the task
around it. Neither file changed behaviour.

TIM13 is gone entirely -- peripheral, NVIC line, IRQ handler, MSP init, the
global handle, the main.c dispatch, the ISR, the volatile flag and the .ioc
entry. It was never earning its place:

    StartTimer(40);
    while (!timerCallbackFlag);   // spun for the full 40 us anyway

The timer's only job was to drop E and set the flag the task was already
spinning on, so it cost a whole peripheral and saved no CPU. The same 40 us now
comes from the free-running microsecond timestamp counter that every sample is
already stamped from.

Not osDelay, which was the first suggestion and does not fit: at a 1 kHz tick its
floor is 1 ms, and the wait is 40 us. That is not a pulse-width requirement to be
rounded up -- with no R/W pin on this board the busy flag can never be read, so
the enable pulse doubles as the ~37 us instruction-execution wait, which is why
SetCursor and the character that follows it need nothing between them. Rounding
40 us to 1 ms would stretch a full 32-cell repaint from ~2.6 ms to ~64 ms. So the
microsecond wait busy-waits and the millisecond ones (power-on, CLEAR) use
osDelay, exactly as the ILI9341 driver's injected delay does.

The busy-wait is bounded as well as timed, because get_timestamp() reads a
counter SessionController starts and SESSION_CONTROLLER_TASK_ENABLE 0 is a legal
configuration -- a purely time-based loop would then never exit and would wedge
the display task. LumexLCD::Init starts the counter itself, and the bound turns a
failure there into a mistimed panel rather than a hung one.

Drivers/Lumex/README.md documents the panel the way the ILI9341 one documents
its: the signals and that RS is the whole framing mechanism, the write cycle with
its falling-edge latch, all eight instructions and their option bits, the four
codes this driver actually sends, DDRAM's non-contiguous rows (row 1 starts at
0x40, so a cell is not a linear offset), a worked byte-level example, why
FUNCTION_SET is sent three times, and the timing. It opens by contrasting the two
panels, since the interesting thing about this one is that it contains a
character generator and the ILI9341 does not.

Both panels build Debug and Release; 100 host tests pass; CubeMX drift check
passes, so the .ioc surgery -- which needed the Mcu.IP/Mcu.Pin index lists
renumbered and their counts decremented -- came back clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Display README had grown to cover three things at once: the shared path, the
Lumex, and the ILI9341. Someone working on one panel had to read past the other,
and the two "rendering" sections at the bottom were where the panel-specific
detail had collected because there was nowhere better to put it.

Now it mirrors the code, which since the last commit has one directory per panel:

  Tasks/Display/README.md            what is true of both
  Tasks/Display/Lumex/README.md      the 16x2 character grid
  Tasks/Display/ILI9341/README.md    the 320x240 TFT

The top level keeps the end-to-end path (stages 1-3 in full, 4-6 as a pointer to
whichever panel is fitted), the contract and why there is no common drawing API,
the DisplayDriver concept, panel selection, the two shared helpers, and units. It
also names the rule both panels obey and neither README should have to restate:
neither supports read-modify-write, so a field is erased only by being repainted,
and every layout rule in both subdirectories descends from that.

Each panel README picks up stages 4-6 for itself. Lumex/ gains the six screens
drawn out, the run-of-changed-cells diff, the clear-on-screen-change rule and
where it came from, the two fixed layout bugs recorded so they are not
reintroduced, and what it does with the three readouts it has no room for.
ILI9341/ gains the field model, the positional-stability and fixed-width
properties the diff depends on, the session screen's field table, why the driver
object is a function-local static, and the timings that make diffing necessary
rather than optional.

Nothing is dropped and nothing is duplicated: each fact is in exactly one of the
four files, with links where it is needed from more than one.

Four display READMEs is more than it sounds like -- two describe rendering and
two describe hardware protocols, and they were already separate concerns before
this. Core/README.md's module index lists all of them.

Every [[wiki link]] across the six files was checked against the module names
actually defined; all resolve.

Docs only. 100 host tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The counter is read by every task that stamps a sample or times a wait, and
owned by none of them. It was started by whichever Init() ran first, which made
startup depend on a scheduling race -- and the display task lost that race.

HAL_TIM_Base_Start returns HAL_ERROR whenever the handle is not in READY state,
and a timer someone has already started is BUSY. It answers "already running"
and "failed to start" with the same value. SessionController runs at
osPriorityHigh against the display's osPriorityBelowNormal, so it always got
there first; LumexLCD::Init took the HAL_ERROR at face value, reported
ERROR_DISPLAY_INIT_FAILURE and suspended its own task. A blank panel, driven by
a timer that was working perfectly.

The board said so, too: the error arrived stamped "@ 220514" -- 220 ms of
successfully counted time, read from the counter it was reporting as failed to
start.

So it starts once in main()'s USER CODE BEGIN 2, next to sysconfig_init(),
before any task exists. Both Init()s drop their call. The return is ignored on
purpose: if TIM2 is configured, MX_TIM2_Init has already called Error_Handler on
anything that could fail, so by that line the handle is READY; and if
STM32_PERIPHERAL_TIM2_ENABLE is 0 the timer is deliberately absent, where
halting the board would defeat the point of the switch.

start_timestamp_timer stays idempotent regardless -- it checks TIM2->CR1.CEN and
only calls the HAL when the counter is stopped, asking the hardware whether it
is running rather than asking the HAL whether this caller is the one who started
it. A handle that was never initialised still fails honestly: CEN stays clear
and HAL_ERROR comes back.

Both functions in the header are now static inline rather than plain inline. A
bare `inline` in C provides only an inline definition -- the compiler need not
emit an external one -- so at -O0 a C caller links against a symbol nothing
defines. It went unnoticed while every caller was C++, where inline has vague
linkage, and surfaced the moment main.c called one: Debug failed to link while
Release inlined it and passed.

LumexLCD::Init now reports ERROR_DISPLAY_INIT_FAILURE when the panel itself
fails, matching ILI9341Display. It previously returned false silently, so the
one error code the display owns went from meaning the wrong thing to meaning
nothing.

Lumex Debug and Release, ILI9341 Debug, and 106 host tests all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lude each other

Four things, all about the compile-time settings page.

Sections. The page takes a category from the comment block heading a group in
the header, so the display settings had never really had one: with no banner
line the parser falls back to the first line of the prose above the define, and
"The Lumex panel's character grid. The display message no longer carries
strings..." was a section name on screen. Banners now name three:

    Display                        debug.h    the two panel switches
    Display: Lumex 16x2            config.h   LUMEX_LCD_ROWS / _COLUMNS
    Display: ILI9341 320x240 TFT   config.h   ILI9341_DISPLAY_ROTATION

Three cards rather than one because the page groups by (file, category), and
these span two headers by nature -- the switches are enables and the rest are
quantities. The switches also pick up trailing comments, which is the only way a
setting gets a description when its block is a banner, so the Display card reads
as two named panels instead of two bare macro names.

DISPLAY_TASK_ENABLE is gone. It was a derived (A || B) sitting in a file the app
offers as a list of switches to override, where nothing good could come of
editing it. Its four sites spell the disjunction out. It did exist for a reason
-- gating on one panel's enable silently compiles a feature out when the other
is selected, which is how the display task's stack high-water mark fell off the
USB stream on the ILI9341 move -- so that warning now sits in the Display
section comment, where someone adding a fifth site will read it.

Mutual exclusion. Turning one panel on turns the other off, rather than staging
a build the firmware's #error would reject minutes later. Turning one off
touches nothing, which is what keeps "both off" reachable: that is a real
configuration, the one where the display task parks and nothing drives SPI1, and
it is how the panel gets ruled in or out of a fault elsewhere on the board. So
it is deliberately not a radio group.

The rule lives in Dyno.Core as ConfigExclusiveGroups -- it is firmware knowledge
rather than view state, and it is worth testing. The view model wires it once
both headers are parsed, since a group may span files. Saved values are left
exactly as loaded: normalising a stored combination on open would silently
rewrite the user's settings, and the header check still catches one that arrived
by some other route. The firmware's #error remains the authority; this only
decides what a click does.

The committed default is now the ILI9341.

Also folded in: csharpier had drifted on MainWindowViewModel.cs and
CommandOpcodeTests.cs from earlier commits on this branch, which the CI
formatting gate would have failed, and the duty-cycle StackPanel in HomeView
gets the multi-line attribute style the rest of that file uses.

All three panel configurations build -- ILI9341, Lumex, and both off, that last
one exercising the new disjunction's false branch. 266 C# tests pass, seven of
them new: four on the exclusion rule and three pinning the section grouping and
the switch descriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h one

The box showed what you had asked for and a muted "(now 45.0%)" beside it showed
what the brake was doing. Two numbers for one quantity, and the second one there
only because the first could not be trusted to follow the board.

Now the box follows the board itself, so a change made at the rig's rotary
encoder -- or the PID driving the brake -- lands in it, and the second readout is
gone.

Telemetry stops writing it in exactly the two cases where the box is not the
board's to write:

Uncommitted edits. Any change the app did not make itself marks the box as the
user's, and it is left alone until Enter, focus-out or Escape. Tracked with a
guard flag around the app's own writes rather than by focus, so Enter hands the
box back at once even though the caret is still in it.

Commands in flight. After a send, readings are ignored until the board reports
the commanded figure or a second passes. This is what the "(now ...)" split was
working around: a BPM sample lands several times a second, so without it every
scroll notch would be overwritten by the reading that follows it. That was the
jumping the box had before, and the previous fix was to stop telemetry writing
the box at all -- which is what made a second readout necessary.

The timeout is the exit that matters for clamping. Ask for 95% against a firmware
MAX_DUTY_CYCLE_PERCENT of 80 and the board never reports 95, so matching the
commanded value would never end the wait. A second later the box drops to what
the brake is actually at. That is a visible change in behaviour: the box used to
hold 95 indefinitely with the truth only in the text beside it, and now a clamp
looks like the number moving a beat after it is committed.

Not covered by tests -- this is in Dyno.App and the only test project is
Dyno.Core.Tests. Solution builds clean and 266 tests still pass, but the
mirror-versus-hold behaviour wants confirming on the rig.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TZlindra
TZlindra merged commit 7d8d703 into main Jul 29, 2026
16 checks passed
@TZlindra
TZlindra deleted the ili9341-display branch July 29, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant