From 6962c68d5398feca7c52a177e236b510e01fe843 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 17:57:27 -0700 Subject: [PATCH 01/25] firmware: pin the C++ standard at C++20 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 --- firmware/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 22ffd45..a414edd 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -12,6 +12,12 @@ set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS ON) +# Pinned rather than left to the compiler default, which was gnu++17 and would drift with +# the toolchain. C++20 is needed for the concept that both display drivers are checked +# against; firmware/tests/CMakeLists.txt is already on 20. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + # Define the build type if(NOT CMAKE_BUILD_TYPE) From d669c48055922a5bba09649b49b22ad586643d9e Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 18:26:50 -0700 Subject: [PATCH 02/25] display: send screen state instead of draw commands 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 --- firmware/Core/Inc/Config/config.h | 8 +- .../Inc/MessagePassing/messages_private.h | 63 +++-- firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp | 20 +- firmware/Core/Inc/Tasks/LCD/lumex_layout.h | 40 +++ firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h | 2 +- .../SessionController/FiniteStateMachine.hpp | 37 +-- .../sessioncontroller_main.h | 3 +- .../Inc/Tasks/TaskMonitor/taskmonitor_main.h | 2 +- firmware/Core/README.md | 2 +- firmware/Core/Src/MessagePassing/README.md | 2 +- firmware/Core/Src/Tasks/LCD/LumexLCD.cpp | 129 +++++++--- firmware/Core/Src/Tasks/LCD/README.md | 53 +++- firmware/Core/Src/Tasks/LCD/lumex_layout.c | 153 ++++++++++++ .../SessionController/FiniteStateMachine.cpp | 157 ++++++------ .../Src/Tasks/SessionController/README.md | 6 +- .../SessionController/SessionController.cpp | 4 +- firmware/Core/Src/Tasks/TaskMonitor/README.md | 2 +- .../Src/Tasks/TaskMonitor/TaskMonitor.cpp | 4 +- firmware/Core/Src/main.c | 18 +- firmware/stm32_dyno_firmware_v2.ioc | 2 +- firmware/tests/CMakeLists.txt | 2 + firmware/tests/lumex_layout_tests.cpp | 235 ++++++++++++++++++ firmware/tests/stubs/cmsis_os2.h | 10 + .../message_gen/schema/messages_private.yaml | 63 +++-- 24 files changed, 803 insertions(+), 214 deletions(-) create mode 100644 firmware/Core/Inc/Tasks/LCD/lumex_layout.h create mode 100644 firmware/Core/Src/Tasks/LCD/lumex_layout.c create mode 100644 firmware/tests/lumex_layout_tests.cpp create mode 100644 firmware/tests/stubs/cmsis_os2.h diff --git a/firmware/Core/Inc/Config/config.h b/firmware/Core/Inc/Config/config.h index e0f4bee..02d802e 100644 --- a/firmware/Core/Inc/Config/config.h +++ b/firmware/Core/Inc/Config/config.h @@ -105,7 +105,13 @@ // LCD config #define LCD_TASK_OSDELAY 20 -#define SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE 16 + 1 + +// The Lumex panel's character grid. The display message no longer carries strings -- it +// carries screen state, and the Lumex driver lays that out into a grid this size -- so these +// describe the panel itself rather than a queue payload, which is what the old +// SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE was really doing. +#define LUMEX_LCD_ROWS 2 +#define LUMEX_LCD_COLUMNS 16 // LED config #define LED_TASK_OSDELAY 500 diff --git a/firmware/Core/Inc/MessagePassing/messages_private.h b/firmware/Core/Inc/MessagePassing/messages_private.h index 00a4c3f..e1b0c9d 100644 --- a/firmware/Core/Inc/MessagePassing/messages_private.h +++ b/firmware/Core/Inc/MessagePassing/messages_private.h @@ -27,25 +27,56 @@ extern "C" { #endif -// Opcodes for controlling the Lumex LCD display from the session controller +// Which screen the session controller's FSM is showing. The message below carries screen +// state, not draw commands: the FSM says what it is displaying and each display driver +// renders that however its panel allows. A 16x2 character LCD and a 320x240 TFT have no +// useful common drawing API -- the intersection caps the TFT at 16x2, the union is +// meaningless on the character LCD -- so the seam is here instead, at what the values mean. typedef enum : uint32_t { - CLEAR_DISPLAY = 0, // Clear the entire display - WRITE_TO_DISPLAY = 1 // Write a string to a specific location on the display -} session_controller_to_lumex_lcd_opcode; - -DYNO_STATIC_ASSERT(sizeof(session_controller_to_lumex_lcd_opcode) == 4, "Size of session_controller_to_lumex_lcd_opcode must be 4 bytes"); - -// Message sent from the session controller to the Lumex LCD + DISPLAY_SCREEN_IDLE = 0, // Attract screen; SELECT opens the settings menu + DISPLAY_SCREEN_SD_LOGGING, // Settings: SD logging on/off + DISPLAY_SCREEN_PID_ENABLE, // Settings: whether the PID option may be toggled in-session + DISPLAY_SCREEN_DESIRED_RPM, // Settings: the desired-RPM setpoint + DISPLAY_SCREEN_DESIRED_RPM_EDIT, // Settings: the same setpoint with the digit cursor showing + DISPLAY_SCREEN_SESSION // Live readout while a session runs +} display_screen_id; + +DYNO_STATIC_ASSERT(sizeof(display_screen_id) == 4, "Size of display_screen_id must be 4 bytes"); + +// Which decimal digit of the desired RPM the encoder is editing. Mirrors +// State::DesiredRpmUnitsState in FiniteStateMachine.hpp. Sent as the cursor position +// rather than as the step size it implies, so a driver can mark the digit itself +// instead of only printing the increment. +typedef enum : uint32_t +{ + DISPLAY_RPM_DIGIT_TEN_THOUSAND = 0, + DISPLAY_RPM_DIGIT_THOUSAND, + DISPLAY_RPM_DIGIT_HUNDRED, + DISPLAY_RPM_DIGIT_TEN, + DISPLAY_RPM_DIGIT_ONE +} display_rpm_digit; + +DYNO_STATIC_ASSERT(sizeof(display_rpm_digit) == 4, "Size of display_rpm_digit must be 4 bytes"); + +// Everything any screen shows, sent whole on every update. Drivers diff it against the +// last one they rendered and repaint only what moved -- which is what makes a 320x240 +// panel viable at all, since a full frame over SPI costs ~50-100 ms but a single field +// costs ~1-2 ms. The older protocol sent (" 1234", row 0, col 3) and left the driver +// unable to tell which quantity had changed. typedef struct { - session_controller_to_lumex_lcd_opcode op; // Operation to perform on the display - uint32_t row; // Row number on the LCD - uint32_t column; // Column number on the LCD - size_t size; - char display_string[SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE]; // String to write (if WRITE_TO_DISPLAY) -} session_controller_to_lumex_lcd; - -DYNO_STATIC_ASSERT(sizeof(session_controller_to_lumex_lcd) >= offsetof(session_controller_to_lumex_lcd, display_string) + sizeof(((session_controller_to_lumex_lcd *)0)->display_string), "Size of session_controller_to_lumex_lcd must be correct"); + display_screen_id screen; // Which screen to render + float angular_velocity; // Measured angular velocity in rad/s, as the optical encoder reports it + float force; // Measured force in N + float bpm_duty_cycle; // Commanded brake duty cycle, 0 - 1 + uint32_t desired_rpm; // The PID setpoint being displayed or edited + display_rpm_digit cursor_digit; // Digit the encoder edits (DESIRED_RPM_EDIT only) + bool pid_enabled; // Whether the PID loop is armed for this session + bool pid_option_toggleable; // Whether the menu allows arming it; also selects the in-session drive-mode field + bool sd_logging_enabled; // Whether SD logging is switched on +} session_controller_to_display; + +DYNO_STATIC_ASSERT(sizeof(session_controller_to_display) <= 32, "session_controller_to_display is queued 25 deep -- keep it small"); // Opcodes for controlling the BPM (Pulse Width Modulation) module from the session controller typedef enum : uint32_t diff --git a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp index d63989e..6a8e726 100644 --- a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp +++ b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp @@ -17,6 +17,8 @@ #include "MessagePassing/messages_public.h" #include "MessagePassing/osqueue_helpers.h" +#include "Tasks/LCD/lumex_layout.h" + #include "TimeKeeping/timestamps.h" #ifdef __cplusplus @@ -26,12 +28,21 @@ extern "C" { class LumexLCD { public: - LumexLCD(osMessageQueueId_t lumexLcdToSessionControllerqHandle); + LumexLCD(osMessageQueueId_t sessionControllerToDisplayqHandle); ~LumexLCD() = default; bool Init(); void Run(); + // Blanks the panel and forgets what was on it, so the next Render redraws in full. + bool Clear(); + + // Lays the screen state out on the 2x16 grid and writes only the cells that differ + // from what is already up there. Reposts are frequent -- the FSM sends the whole + // state whenever any part of it moves -- and this panel is slow, so the diff is what + // keeps a changed RPM reading to the five cells it occupies. + bool Render(const session_controller_to_display& state); + private: bool StartTimer(uint8_t microseconds); @@ -47,6 +58,13 @@ class LumexLCD CircularBufferWriter _task_error_buffer_writer; osMessageQueueId_t _fromSCqHandle; + + // What is currently on the panel, and which screen put it there. A change of screen + // forces a physical clear -- the old code cleared inside every Show*Screen, and this + // reproduces exactly that, including not clearing on a redraw of the same screen. + lumex_frame _lastFrame; + display_screen_id _lastScreen; + bool _hasRendered; }; diff --git a/firmware/Core/Inc/Tasks/LCD/lumex_layout.h b/firmware/Core/Inc/Tasks/LCD/lumex_layout.h new file mode 100644 index 0000000..b4f7434 --- /dev/null +++ b/firmware/Core/Inc/Tasks/LCD/lumex_layout.h @@ -0,0 +1,40 @@ +#ifndef INC_TASKS_LCD_LUMEX_LAYOUT_H_ +#define INC_TASKS_LCD_LUMEX_LAYOUT_H_ + +// The Lumex panel's share of the display split: screen state in, a 2x16 character grid out. +// +// This is deliberately free of HAL, RTOS and driver state so the host tests can pin every +// screen against the literals the FSM used to write directly. It used to live in +// FiniteStateMachine.cpp as runs of WriteText(row, column, "...") with hand-counted padding +// and column offsets -- layout, not state-machine logic, and the wrong side of the seam once +// a second panel exists. + +#include + +#include "Config/config.h" +#include "MessagePassing/messages_private.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// A whole panel's worth of characters. Not NUL-terminated: every cell is a character to be +// written, and blank cells are spaces, so the grid is always exactly full. +typedef struct +{ + char cells[LUMEX_LCD_ROWS][LUMEX_LCD_COLUMNS]; +} lumex_frame; + +// Renders one screen. Every cell is written on every call -- unset cells become spaces -- so +// the result depends only on `state` and never on what was on screen before. The driver is +// what turns two of these into the minimal set of writes. +void lumex_render(const session_controller_to_display *state, lumex_frame *out); + +// The step size the encoder applies at a given cursor position: 10000 down to 1. +uint32_t lumex_rpm_digit_increment(display_rpm_digit digit); + +#ifdef __cplusplus +} +#endif + +#endif /* INC_TASKS_LCD_LUMEX_LAYOUT_H_ */ diff --git a/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h b/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h index 953a5b9..12f1bb7 100644 --- a/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h +++ b/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h @@ -11,7 +11,7 @@ extern "C" { #endif void lumex_lcd_timer_interrupt(); -void lumex_lcd_main(osMessageQueueId_t lumexLcdToSessionControllerqHandle); +void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayqHandle); #ifdef __cplusplus } diff --git a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp index f1b531d..045a109 100644 --- a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp @@ -75,16 +75,13 @@ struct State class FSM { public: - FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle); + FSM(osMessageQueueId_t sessionControllerToDisplayHandle); // Drains everything the input ISRs have queued since the last call and applies it. void HandleUserInputs(); - // Display - void ClearDisplay(); - void AddToLumexLCDMessageQueue(session_controller_to_lumex_lcd_opcode opcode, uint8_t row, uint8_t column, const char* display_string, size_t size); - - // Fields the SessionController refreshes on the in-session screen. + // Fields the SessionController refreshes on the in-session screen. Each records the value + // and reposts the whole screen state; the driver works out what actually moved. void DisplayRpm(float rpm); void DisplayForce(float force); void DisplayPIDEnabled(); @@ -104,14 +101,13 @@ class FSM float GetDesiredAngularVelocity() const; private: - // --- Screens. Each sets the state it represents and redraws the LCD for it. + // --- Screens. Each sets the state it represents and reposts it. void ShowIdleScreen(); void ShowSdLoggingPage(); void ShowPidEnablePage(); void ShowDesiredRpmPage(); - void ShowDesiredRpmEditor(bool clearDisplay); + void ShowDesiredRpmEditor(); void ShowSessionScreen(); - void ShowEnabledDisabled(bool enabled); // --- One handler per input, dispatched from HandleUserInputs. void HandleRotaryEncoderInput(bool positiveTick); @@ -131,16 +127,16 @@ class FSM int DesiredRpmDigitIncrement() const; bool StepDesiredRpmDigit(int direction); - // Writes a string at (row, column), taking the length from the array itself. Every caller - // passes either a literal or a snprintf'd fixed-width field, and in both cases the text - // fills the array exactly, so N - 1 is the number of characters on screen. - template - void WriteText(uint8_t row, uint8_t column, const char (&text)[N]) - { - AddToLumexLCDMessageQueue(WRITE_TO_DISPLAY, row, column, text, N - 1); - } + // Posts the whole of what is on screen: which screen, and every value any screen shows. + // The FSM no longer formats anything -- a 16x2 character LCD and a 320x240 TFT want + // completely different layouts of the same facts, so laying out is the driver's job and + // this is the seam between them. Which panel is listening is a compile-time choice. + void PostDisplayState(); - osMessageQueueId_t _sessionControllerToLumexLcdHandle; + // Maps the FSM's own state pair onto the screen id the drivers switch on. + display_screen_id CurrentScreen() const; + + osMessageQueueId_t _toDisplayHandle; State _state; @@ -154,6 +150,11 @@ class FSM bool _pidEnabled; float _desiredManualBpmDutyCycle; + // Newest readings the SessionController has handed over. Held because every post carries + // the whole screen state, so a force update still has to say what the RPM is. + float _angularVelocity; + float _force; + // Whether a brake press may start a session. Cleared when the button is already held as this // FSM comes up, and set again by the release that follows -- see HandleButtonBrakeInput. bool _brakeArmed; diff --git a/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h b/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h index bc59f29..d08d069 100644 --- a/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h +++ b/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h @@ -17,7 +17,8 @@ typedef struct osMessageQueueId_t bpm_controller; osMessageQueueId_t pid_controller; osMessageQueueId_t pid_controller_ack; - osMessageQueueId_t lumex_lcd; + // Whichever display driver was compiled in -- the message is the same either way. + osMessageQueueId_t display; } session_controller_os_task_queues; diff --git a/firmware/Core/Inc/Tasks/TaskMonitor/taskmonitor_main.h b/firmware/Core/Inc/Tasks/TaskMonitor/taskmonitor_main.h index 2d59024..1bb3c49 100644 --- a/firmware/Core/Inc/Tasks/TaskMonitor/taskmonitor_main.h +++ b/firmware/Core/Inc/Tasks/TaskMonitor/taskmonitor_main.h @@ -17,7 +17,7 @@ typedef struct osThreadId_t bpm_controller; osThreadId_t pid_controller; osThreadId_t pid_controller_ack; - osThreadId_t lumex_lcd; + osThreadId_t display; // Whichever display driver was compiled in } taskmonitor_osthreadids; diff --git a/firmware/Core/README.md b/firmware/Core/README.md index fffbe92..729cc44 100644 --- a/firmware/Core/README.md +++ b/firmware/Core/README.md @@ -21,7 +21,7 @@ never by calling into another task directly. | PID | `Core/Src/Tasks/PID/README.md` | Closed-loop brake control from encoder feedback | | ForceSensor | `Core/Src/Tasks/ForceSensor/README.md` | On-board force: i2c (ADS1115) and internal ADC | | OpticalSensor | `Core/Src/Tasks/OpticalSensor/README.md` | Angular velocity / acceleration from an optical encoder | -| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex character display | +| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex character display; renders the shared display message | | USB | `Core/Src/Tasks/USB/README.md` | Streams data + errors to the PC over USB CDC | | TaskMonitor | `Core/Src/Tasks/TaskMonitor/README.md` | Per-task state and stack usage | | MessagePassing | `Core/Src/MessagePassing/README.md` | Queue helpers, circular buffers, USB wire protocol | diff --git a/firmware/Core/Src/MessagePassing/README.md b/firmware/Core/Src/MessagePassing/README.md index 2cc8b71..65de2a6 100644 --- a/firmware/Core/Src/MessagePassing/README.md +++ b/firmware/Core/Src/MessagePassing/README.md @@ -26,7 +26,7 @@ so the two ends cannot drift apart by hand. See `tools/message_gen/README.md`. (`optical_encoder_output_data`, `forcesensor_output_data`, `bpm_output_data`, `task_monitor_output_data`). - **messages_private.h** — firmware-internal queue payloads: - `session_controller_to_lumex_lcd`, `session_controller_to_bpm`, + `session_controller_to_display`, `session_controller_to_bpm`, `session_controller_to_pid_controller` (+ their opcode enums). Includes the public header. - **sysconfig_table.inc** — the runtime store's parameter table ([[Config]]), from the same schema. diff --git a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp index e30e737..7e235af 100644 --- a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp @@ -9,10 +9,14 @@ extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZ static volatile bool timerCallbackFlag = false; -LumexLCD::LumexLCD(osMessageQueueId_t sessionControllerToLumexLcdHandle) : +LumexLCD::LumexLCD(osMessageQueueId_t sessionControllerToDisplayHandle) : _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), - _fromSCqHandle(sessionControllerToLumexLcdHandle) -{} + _fromSCqHandle(sessionControllerToDisplayHandle), + _lastScreen(DISPLAY_SCREEN_IDLE), + _hasRendered(false) +{ + memset(_lastFrame.cells, ' ', sizeof(_lastFrame.cells)); +} bool LumexLCD::Init() { @@ -69,7 +73,7 @@ bool LumexLCD::Init() void LumexLCD::Run(void) { - session_controller_to_lumex_lcd msg; + session_controller_to_display msg; memset(&msg, 0, sizeof(msg)); while (1) @@ -77,40 +81,83 @@ bool LumexLCD::Init() // Block until a message arrives if (osMessageQueueGet(_fromSCqHandle, &msg, 0, osWaitForever) == osOK) { - // Drain any remaining messages to ensure we process all pending commands - do + // Drain to the newest state before drawing anything. Each message is the whole of + // what should be on screen, so the ones behind it are already stale -- rendering + // them in turn would only paint values the user is not going to see. + while (osMessageQueueGet(_fromSCqHandle, &msg, 0, 0) == osOK); + + if (!Render(msg)) { - switch (msg.op) - { - case CLEAR_DISPLAY: - ClearDisplay(); - break; - - case WRITE_TO_DISPLAY: - if (!DisplayString(msg.row, msg.column, (const char*) msg.display_string, msg.size)) - { - return; - } - break; - - default: - break; - } + return; } - while (osMessageQueueGet(_fromSCqHandle, &msg, 0, 0) == osOK); } osDelay(sysconfig_get_u32(SYSCFG_LCD_TASK_OSDELAY)); } } -//void LumexLCD::Run() -//{ -// while(1) -// { -// DisplayString(0, 0, "hi"); -// } -//} +bool LumexLCD::Clear() +{ + if (!ClearDisplay()) + { + return false; + } + + memset(_lastFrame.cells, ' ', sizeof(_lastFrame.cells)); + + return true; +} + +bool LumexLCD::Render(const session_controller_to_display& state) +{ + lumex_frame frame; + lumex_render(&state, &frame); + + // Every Show*Screen used to open with a ClearDisplay, and the one redraw that deliberately + // did not -- a tick inside the RPM editor -- is also the one that does not change screen. + // So "clear when the screen id moves" is the same rule, derived rather than passed along. + if (!_hasRendered || state.screen != _lastScreen) + { + if (!Clear()) + { + return false; + } + } + + // Write each run of changed cells in one go. Runs rather than whole rows because the + // common case in a session is one field moving: five cells out of thirty-two. + for (uint8_t row = 0; row < LUMEX_LCD_ROWS; row++) + { + uint8_t column = 0; + + while (column < LUMEX_LCD_COLUMNS) + { + if (frame.cells[row][column] == _lastFrame.cells[row][column]) + { + column++; + continue; + } + + const uint8_t start = column; + while (column < LUMEX_LCD_COLUMNS + && frame.cells[row][column] != _lastFrame.cells[row][column]) + { + column++; + } + + if (!DisplayString(row, start, &frame.cells[row][start], column - start)) + { + return false; + } + } + } + + _lastFrame = frame; + _lastScreen = state.screen; + _hasRendered = true; + + return true; +} bool LumexLCD::StartTimer(uint8_t microseconds) @@ -231,10 +278,17 @@ bool LumexLCD::DisplayChar(uint8_t row, uint8_t column, uint8_t character) bool LumexLCD::DisplayString(uint8_t row, uint8_t column, const char* string, size_t size) { - assert_param(size < SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE); - + assert_param(row < LUMEX_LCD_ROWS); + for (uint8_t i = 0; i < size; i++) { + // Clamp instead of wrapping: drop any chars past the last column so an + // overflow fails visibly in one cell rather than corrupting another row. + if (column >= LUMEX_LCD_COLUMNS) + { + break; + } + if (!SetCursor(row, column)) { return false; @@ -246,13 +300,6 @@ bool LumexLCD::DisplayString(uint8_t row, uint8_t column, const char* string, si } column++; - - // Clamp instead of wrapping: drop any chars past the last column so an - // overflow fails visibly in one cell rather than corrupting another row. - if (column >= 16) - { - break; - } } return true; @@ -292,9 +339,9 @@ extern "C" void lumex_lcd_timer_interrupt() } -extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToLumexLcdHandle) +extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayHandle) { - LumexLCD lcd = LumexLCD(sessionControllerToLumexLcdHandle); + LumexLCD lcd = LumexLCD(sessionControllerToDisplayHandle); if (!lcd.Init()) { diff --git a/firmware/Core/Src/Tasks/LCD/README.md b/firmware/Core/Src/Tasks/LCD/README.md index 4628094..b362afa 100644 --- a/firmware/Core/Src/Tasks/LCD/README.md +++ b/firmware/Core/Src/Tasks/LCD/README.md @@ -1,38 +1,69 @@ --- module: LumexLCD -summary: Drives the Lumex character LCD; renders strings the SessionController FSM sends. +summary: Drives the Lumex character LCD; lays the SessionController's screen state onto a 2x16 grid. code: - Core/Src/Tasks/LCD/LumexLCD.cpp + - Core/Src/Tasks/LCD/lumex_layout.c - Core/Inc/Tasks/LCD/LumexLCD.hpp + - Core/Inc/Tasks/LCD/lumex_layout.h - Core/Inc/Tasks/LCD/lumexlcd_main.h entry_point: lumex_lcd_main() task_offset: TASK_OFFSET_LUMEX_LCD -consumes: [session_controller_to_lumex_lcd (SessionController)] +consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] related: [SessionController, MessagePassing] --- # LumexLCD — character display task -Bit-bangs a Lumex parallel LCD over GPIO and renders what the [[SessionController]] FSM sends. +Bit-bangs a Lumex parallel LCD over GPIO and renders the screen state the +[[SessionController]] FSM sends. + +## The display seam + +The FSM sends **what it is showing**, not how to draw it: `session_controller_to_display` +carries a `display_screen_id` plus every value any screen displays. Turning that into +characters is this task's job. + +That split exists because a 16x2 character LCD and a 320x240 TFT have no useful common +drawing API — the intersection caps the TFT at 16x2, the union is meaningless here — so the +seam sits at what the values *mean* instead. `AddToLumexLCDMessageQueue(op, row, column, +string)` was the old protocol; it also left a driver unable to tell *which* quantity had +changed, since all it received was `(" 1234", row 0, col 3)`. ## Flow 1. `lumex_lcd_main()` → construct, `Init()`, `Run()`. -2. `Init()`: 8-bit / 2-line / 5×8 font, display on (no cursor/blink), clear. -3. `Run()` blocks on `session_controller_to_lumex_lcd`; opcodes: - - `CLEAR_DISPLAY` — clear screen. - - `WRITE_TO_DISPLAY` — write `display_string` at `(row, column)`. - Drains the queue each wake, then delays `LCD_TASK_OSDELAY`. +2. `Init()`: 8-bit / 2-line / 5x8 font, display on (no cursor/blink), clear. +3. `Run()` blocks on the display queue, **drains to the newest message**, then `Render()`s it. + Intermediate messages are skipped: each one is the whole screen state, so the ones behind + the newest are already stale. Then delays `SYSCFG_LCD_TASK_OSDELAY`. + +## Rendering +- `lumex_render()` (`lumex_layout.c`) is pure: screen state in, a full 2x16 `lumex_frame` out, + every cell written. No HAL, no RTOS, no driver state — so `tests/lumex_layout_tests.cpp` + pins all six screens cell-for-cell on the host. +- `Render()` diffs that frame against `_lastFrame` and writes only the runs that differ. The + common in-session update moves one field: five cells out of thirty-two. +- A change of `screen` forces a physical `ClearDisplay()`. That reproduces the old behaviour + exactly — every `Show*Screen` used to clear, and the one redraw that deliberately did not + (a tick inside the RPM editor) is also the one that does not change screen id. ## Internals -- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer (`StartTimer`). +- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer + (`StartTimer`). - `WriteData` / `WriteCommand` / `SetCursor` / `DisplayChar` / `DisplayString` / `ToggleBlink`. +## Known display artifact +The session screen's force field is six characters at columns 2-7, but 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`. Pre-existing; pinned by `SessionScreenForceFieldLeavesStaleDigits` rather than +blessed. Fixing it means shortening the literal in `lumex_layout.c`. + ## Errors - `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. -## Key constants (config.h) -- `LCD_TASK_OSDELAY`, `SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE` +## Key constants +- `SYSCFG_LCD_TASK_OSDELAY` (sysconfig), `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` (config.h) ## Related [[SessionController]] · [[MessagePassing]] diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/LCD/lumex_layout.c new file mode 100644 index 0000000..5f5c850 --- /dev/null +++ b/firmware/Core/Src/Tasks/LCD/lumex_layout.c @@ -0,0 +1,153 @@ +#include "Tasks/LCD/lumex_layout.h" + +#include +#include +#include + +// Writes `n` characters at (row, column), dropping anything past the last column. The old +// LumexLCD::DisplayString clamped the same way -- deliberately, so an overlong field fails +// visibly in its own cells rather than wrapping onto the other row. +// +// `n` is always the field's width, never strlen: the FSM's WriteText took its length from the +// array (`N - 1`), so a snprintf that truncated still wrote a full-width field. Keeping that +// exact is what makes a truncated reading occupy the same cells it always did. +static void put(lumex_frame *out, unsigned row, unsigned column, const char *text, size_t n) +{ + for (size_t i = 0; i < n && (column + i) < LUMEX_LCD_COLUMNS; i++) + { + out->cells[row][column + i] = text[i]; + } +} + +#define PUT_LITERAL(out, row, column, literal) \ + put((out), (row), (column), (literal), sizeof(literal) - 1) + +// Formats into a scratch buffer and writes exactly `width` characters, so an over-wide value +// occupies its field and no more -- the same clipping the old code got implicitly from +// WriteText taking its length from a just-big-enough array, but without asking snprintf to +// truncate (which -Wformat-truncation rightly flags, since there it was load-bearing). +#define SCRATCH_SIZE 32 + +static void put_field(lumex_frame *out, unsigned row, unsigned column, size_t width, + const char *scratch) +{ + put(out, row, column, scratch, width); +} + +uint32_t lumex_rpm_digit_increment(display_rpm_digit digit) +{ + switch (digit) + { + case DISPLAY_RPM_DIGIT_TEN_THOUSAND: return 10000; + case DISPLAY_RPM_DIGIT_THOUSAND: return 1000; + case DISPLAY_RPM_DIGIT_HUNDRED: return 100; + case DISPLAY_RPM_DIGIT_TEN: return 10; + case DISPLAY_RPM_DIGIT_ONE: return 1; + default: return 0; + } +} + +// The second row shared by both toggle pages. +static void render_enabled_disabled(lumex_frame *out, bool enabled) +{ + if (enabled) PUT_LITERAL(out, 1, 4, "ENABLED"); + else PUT_LITERAL(out, 1, 4, "DISABLED"); +} + +static void render_session(const session_controller_to_display *state, lumex_frame *out) +{ + // The static labels, then each live field overlaid on top -- the same order, and the same + // cells, as ShowSessionScreen() followed by the SessionController's Display* calls. + // + // col: 0123456789012345 + PUT_LITERAL(out, 0, 0, "n: 0 rpm "); + PUT_LITERAL(out, 1, 0, "F: 0.00 N "); + + // Whatever the SessionController hands over. Today that is the optical encoder's + // angular_velocity, which is rad/s -- printed here under an "rpm" label. Preserved as-is: + // this file changes where the layout lives, not what the numbers mean. + char scratch[SCRATCH_SIZE]; + + uint32_t rpm = (uint32_t)roundf(state->angular_velocity); + snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)rpm); + put_field(out, 0, 3, 5, scratch); + + // Six characters at cols 2-7. Note this leaves the label literal's own "0.00" sitting at + // cols 6-9, so cols 8-9 keep a stale "00" that nothing ever rewrites -- see the test + // SessionScreen_ForceFieldLeavesStaleDigits, which pins the artifact rather than blessing + // it. Fixing it means shortening the literal above; that is a display change, not a + // refactor, so it is deliberately not done here. + float force = roundf(state->force * 100.0f) / 100.0f; + snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + put_field(out, 1, 2, 6, scratch); + + // The drive-mode field. Which of the two appears is the menu option, not the live PID + // state -- with the option off there is nothing to arm, so the brake command is shown. + if (state->pid_option_toggleable) + { + if (state->pid_enabled) PUT_LITERAL(out, 1, 12, "PIDE"); + else PUT_LITERAL(out, 1, 12, "PIDD"); + } + else + { + uint8_t duty = (uint8_t)roundf(state->bpm_duty_cycle * 100.0f); + snprintf(scratch, sizeof(scratch), "B%3u", duty); + put_field(out, 1, 12, 4, scratch); + } +} + +void lumex_render(const session_controller_to_display *state, lumex_frame *out) +{ + // Start blank. Every screen used to open with an explicit ClearDisplay, so a cell no screen + // writes is a space; rendering the whole grid every time is what lets the driver diff. + memset(out->cells, ' ', sizeof(out->cells)); + + switch (state->screen) + { + case DISPLAY_SCREEN_IDLE: + PUT_LITERAL(out, 0, 6, "DYNO"); + PUT_LITERAL(out, 1, 2, "PRESS SELECT"); + break; + + case DISPLAY_SCREEN_SD_LOGGING: + PUT_LITERAL(out, 0, 3, "SD LOGGING"); + render_enabled_disabled(out, state->sd_logging_enabled); + break; + + case DISPLAY_SCREEN_PID_ENABLE: + PUT_LITERAL(out, 0, 2, "PID LOGGING"); + render_enabled_disabled(out, state->pid_option_toggleable); + break; + + case DISPLAY_SCREEN_DESIRED_RPM: + { + PUT_LITERAL(out, 0, 2, "PID DES RPM"); + + char scratch[SCRATCH_SIZE]; + snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)state->desired_rpm); + put_field(out, 1, 5, 5, scratch); + break; + } + + // The same page with the cursor's step size alongside the value, so the user can see + // which digit a tick will move. + case DISPLAY_SCREEN_DESIRED_RPM_EDIT: + { + PUT_LITERAL(out, 0, 2, "PID DES RPM"); + + char scratch[SCRATCH_SIZE]; + snprintf(scratch, sizeof(scratch), "%5lu %5lu", + (unsigned long)state->desired_rpm, + (unsigned long)lumex_rpm_digit_increment(state->cursor_digit)); + put_field(out, 1, 2, 11, scratch); + break; + } + + case DISPLAY_SCREEN_SESSION: + render_session(state, out); + break; + + default: + break; + } +} diff --git a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp index 1617b83..5d0b3bc 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -2,8 +2,8 @@ #include "Config/sysconfig.h" -FSM::FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle) : - _sessionControllerToLumexLcdHandle(sessionControllerToLumexLcdHandle), +FSM::FSM(osMessageQueueId_t sessionControllerToDisplayHandle) : + _toDisplayHandle(sessionControllerToDisplayHandle), _state{ State::MainDynoState::INIT_STATE, State::SettingsState::INIT_STATE, @@ -14,6 +14,8 @@ FSM::FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle) : _desiredRpm(5000), _pidEnabled(false), _desiredManualBpmDutyCycle(0), + _angularVelocity(0.0f), + _force(0.0f), // A brake already held as we come up is not a request to start a session -- it is just how // the board was left, or a finger on the button during a reset. Start disarmed in that case // so nothing can run until the button has been released and pressed deliberately. @@ -25,7 +27,9 @@ FSM::FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle) : // a UI that was not yet on screen, and replaying it would act on it. _fsmInputDataIndex(interrupt_input_data_index) { - ClearDisplay(); + // No explicit clear: the driver clears whenever the screen id changes, and its + // "nothing rendered yet" state counts as a change, so coming up on the idle + // screen still starts from a blank panel. ShowIdleScreen(); } @@ -97,7 +101,7 @@ void FSM::HandleRotaryEncoderInSettings(bool positiveTick) // Inside the editor a tick changes the digit under the cursor rather than the page. case State::SettingsState::PID_DESIRED_RPM_EDIT: AdjustDesiredRpm(positiveTick); - ShowDesiredRpmEditor(false); + ShowDesiredRpmEditor(); break; // Unreachable -- the toggle settings have no edit screen (see State::SettingsState). @@ -140,7 +144,7 @@ void FSM::HandleButtonBackInSettings() // In the editor, BACK walks the digit cursor left; off the left end it leaves the editor. case State::SettingsState::PID_DESIRED_RPM_EDIT: if (StepDesiredRpmDigit(-1)) ShowDesiredRpmPage(); - else ShowDesiredRpmEditor(false); + else ShowDesiredRpmEditor(); break; // Unreachable -- the toggle settings have no edit screen (see State::SettingsState). @@ -188,13 +192,13 @@ void FSM::HandleButtonSelectInSettings() break; case State::SettingsState::PID_DESIRED_RPM_DISPLAYED: - ShowDesiredRpmEditor(true); + ShowDesiredRpmEditor(); break; // In the editor, SELECT walks the digit cursor right; off the right end it leaves. case State::SettingsState::PID_DESIRED_RPM_EDIT: if (StepDesiredRpmDigit(+1)) ShowDesiredRpmPage(); - else ShowDesiredRpmEditor(false); + else ShowDesiredRpmEditor(); break; // Unreachable -- the toggle settings have no edit screen (see State::SettingsState). @@ -295,10 +299,7 @@ void FSM::ShowIdleScreen() { _state.mainState = State::MainDynoState::IDLE; - ClearDisplay(); - - WriteText(0, 6, "DYNO"); - WriteText(1, 2, "PRESS SELECT"); + PostDisplayState(); } void FSM::ShowSdLoggingPage() @@ -306,10 +307,7 @@ void FSM::ShowSdLoggingPage() _state.mainState = State::MainDynoState::SETTINGS_MENU; _state.settingsState = State::SettingsState::SD_LOGGING_OPTION_DISPLAYED; - ClearDisplay(); - - WriteText(0, 3, "SD LOGGING"); - ShowEnabledDisabled(_sdLoggingEnabled); + PostDisplayState(); } void FSM::ShowPidEnablePage() @@ -317,17 +315,7 @@ void FSM::ShowPidEnablePage() _state.mainState = State::MainDynoState::SETTINGS_MENU; _state.settingsState = State::SettingsState::PID_ENABLE_DISPLAYED; - ClearDisplay(); - - WriteText(0, 2, "PID LOGGING"); - ShowEnabledDisabled(_pidOptionToggleableEnabled); -} - -// The second row shared by both toggle pages. -void FSM::ShowEnabledDisabled(bool enabled) -{ - if (enabled) WriteText(1, 4, "ENABLED"); - else WriteText(1, 4, "DISABLED"); + PostDisplayState(); } void FSM::ShowDesiredRpmPage() @@ -336,32 +324,20 @@ void FSM::ShowDesiredRpmPage() _state.settingsState = State::SettingsState::PID_DESIRED_RPM_DISPLAYED; _state.desiredRpmUnitsState = State::DesiredRpmUnitsState::INIT_STATE; - ClearDisplay(); - - WriteText(0, 2, "PID DES RPM"); - - char buffer[6]; - snprintf(buffer, sizeof(buffer), "%5d", _desiredRpm); - - WriteText(1, 5, buffer); + PostDisplayState(); } -// Same page with the cursor's step size alongside the value, so the user can see which digit a -// tick will move. Entering from the display page clears first; redraws while editing do not, -// because the layout does not change. -void FSM::ShowDesiredRpmEditor(bool clearDisplay) +// The same page with the cursor's step size shown alongside the value, so the user can see +// which digit a tick will move. It no longer takes a "clear first" flag: entering from the +// display page is a change of screen id and the driver clears on that by itself, while a +// redraw mid-edit is not and so does not clear -- exactly the old distinction, but derived +// rather than passed in. +void FSM::ShowDesiredRpmEditor() { _state.mainState = State::MainDynoState::SETTINGS_MENU; _state.settingsState = State::SettingsState::PID_DESIRED_RPM_EDIT; - if (clearDisplay) ClearDisplay(); - - WriteText(0, 2, "PID DES RPM"); - - char buffer[12]; - snprintf(buffer, sizeof(buffer), "%5d %5d", _desiredRpm, DesiredRpmDigitIncrement()); - - WriteText(1, 2, buffer); + PostDisplayState(); } void FSM::ShowSessionScreen() @@ -374,74 +350,81 @@ void FSM::ShowSessionScreen() // first encoder tick moves into the envelope. _desiredManualBpmDutyCycle = 0.0f; - ClearDisplay(); - - // The values are filled in by the SessionController through the Display* methods below. - // Two measured quantities and the drive mode. Torque and power used to sit here, but the - // device no longer derives them -- the host does, from these same measurements. - // col: 0123456789012345 - WriteText(0, 0, "n: 0 rpm "); - WriteText(1, 0, "F: 0.00 N "); + PostDisplayState(); } // ---------------------------------------------------------------------------- display fields +// Each of these records a value and reposts everything. The SessionController already calls +// them only when its reading has moved, and the driver diffs again on its side, so reposting +// the whole state costs one queue message and no panel traffic. + void FSM::DisplayRpm(float rpm) { - char buf[6]; - uint32_t value = static_cast(std::round(rpm)); - // uint32_t is unsigned long on this target, but not everywhere the file is compiled. - snprintf(buf, sizeof(buf), "%5lu", static_cast(value)); - - WriteText(0, 3, buf); + _angularVelocity = rpm; + PostDisplayState(); } void FSM::DisplayForce(float force) { - char buf[7]; - float value = std::round(force * 100.0) / 100.0; - snprintf(buf, sizeof(buf), "%6.2f", value); - - // %6.2f is 6 chars wide, at cols 2-7: clear of the "F:" label and of the drive-mode - // field at col 12, so neither can be overwritten however large the reading gets. - WriteText(1, 2, buf); + _force = force; + PostDisplayState(); } void FSM::DisplayPIDEnabled() { - if (_pidEnabled) WriteText(1, 12, "PIDE"); - else WriteText(1, 12, "PIDD"); + PostDisplayState(); } void FSM::DisplayManualBPMDutyCycle() { - char buf[5]; - uint8_t value = static_cast(std::round(_desiredManualBpmDutyCycle * 100.0)); - snprintf(buf, sizeof(buf), "B%3u", value); - - WriteText(1, 12, buf); + PostDisplayState(); } -void FSM::ClearDisplay() +display_screen_id FSM::CurrentScreen() const { - // The LCD task ignores the string for CLEAR_DISPLAY. It is empty rather than null because - // AddToLumexLCDMessageQueue copies it unconditionally. - AddToLumexLCDMessageQueue(CLEAR_DISPLAY, 0, 0, "", 0); + switch (_state.mainState) + { + case State::MainDynoState::IN_SESSION: + return DISPLAY_SCREEN_SESSION; + + case State::MainDynoState::SETTINGS_MENU: + switch (_state.settingsState) + { + case State::SettingsState::PID_ENABLE_DISPLAYED: + return DISPLAY_SCREEN_PID_ENABLE; + case State::SettingsState::PID_DESIRED_RPM_DISPLAYED: + return DISPLAY_SCREEN_DESIRED_RPM; + case State::SettingsState::PID_DESIRED_RPM_EDIT: + return DISPLAY_SCREEN_DESIRED_RPM_EDIT; + // SD_LOGGING_OPTION_DISPLAYED, plus the two edit states nothing ever enters. + default: + return DISPLAY_SCREEN_SD_LOGGING; + } + + case State::MainDynoState::IDLE: + default: + return DISPLAY_SCREEN_IDLE; + } } -void FSM::AddToLumexLCDMessageQueue(session_controller_to_lumex_lcd_opcode opcode, uint8_t row, uint8_t column, const char* display_string, size_t size) +void FSM::PostDisplayState() { - session_controller_to_lumex_lcd msg; - msg.op = opcode; - msg.row = row; - msg.column = column; - msg.size = size; + session_controller_to_display msg; + memset(&msg, 0, sizeof(msg)); - strncpy(msg.display_string, display_string, sizeof(msg.display_string) - 1); - msg.display_string[sizeof(msg.display_string) - 1] = '\0'; // Ensure null termination + msg.screen = CurrentScreen(); + msg.angular_velocity = _angularVelocity; + msg.force = _force; + msg.bpm_duty_cycle = _desiredManualBpmDutyCycle; + msg.desired_rpm = static_cast(_desiredRpm); + msg.cursor_digit = static_cast(_state.desiredRpmUnitsState); + msg.pid_enabled = _pidEnabled; + msg.pid_option_toggleable = _pidOptionToggleableEnabled; + msg.sd_logging_enabled = _sdLoggingEnabled; - osMessageQueuePut(_sessionControllerToLumexLcdHandle, &msg, 0, 0); + osMessageQueuePut(_toDisplayHandle, &msg, 0, 0); } diff --git a/firmware/Core/Src/Tasks/SessionController/README.md b/firmware/Core/Src/Tasks/SessionController/README.md index 47ba874..869e12e 100644 --- a/firmware/Core/Src/Tasks/SessionController/README.md +++ b/firmware/Core/Src/Tasks/SessionController/README.md @@ -12,7 +12,7 @@ code: entry_point: sessioncontroller_main() task_offset: TASK_OFFSET_SESSION_CONTROLLER consumes: [button/encoder GPIO interrupts, pid_controller_ack queue, forcesensor_circular_buffer, optical_encoder_circular_buffer] -produces: [commands to usb/sd/bpm/pid/lumex/force_sensor/optical_sensor queues, task_error_circular_buffer] +produces: [commands to usb/sd/bpm/pid/display/force_sensor/optical_sensor queues, task_error_circular_buffer] related: [BPM, PID, USB, LCD, ForceSensor, OpticalSensor, TimeKeeping] --- @@ -28,7 +28,7 @@ UI/FSM, dispatches commands to all other tasks, and drives the LCD readout. the press edge is reported (BRAKE reports both, because the session lasts as long as it is held). - **FiniteStateMachine** — `MainDynoState` (`IDLE` / `SETTINGS_MENU` / `IN_SESSION`) + settings sub-states; the state model is drawn at the top of `FiniteStateMachine.hpp`. Owns the LCD UI - (`session_controller_to_lumex_lcd` messages) and target RPM editing. `Show*` methods each enter + (`session_controller_to_display` messages) and target RPM editing. `Show*` methods each enter one screen and redraw it; `Handle*Input` methods each take one input and dispatch on state. ## Run() loop (per iteration) @@ -55,7 +55,7 @@ so a steady state produces no queue traffic. `PublishStartupState()` runs once b its value changed. An iteration with no new samples keeps the last reading. ## Queues out — `session_controller_os_task_queues` -`usb_controller, sd_controller, force_sensor, optical_sensor, bpm_controller, pid_controller, pid_controller_ack, lumex_lcd` +`usb_controller, sd_controller, force_sensor, optical_sensor, bpm_controller, pid_controller, pid_controller_ack, display` ## Nothing is derived here Torque and power used to be computed in this task and shown on the LCD. They are not: the device diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index 1d23804..6ede20a 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -15,7 +15,7 @@ SessionController::SessionController(session_controller_os_task_queues* task_que _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), _forcesensor_buffer_reader(forcesensor_circular_buffer, &forcesensor_circular_buffer_index_writer, FORCESENSOR_CIRCULAR_BUFFER_SIZE), _optical_encoder_buffer_reader(optical_encoder_circular_buffer, &optical_encoder_circular_buffer_index_writer, OPTICAL_ENCODER_CIRCULAR_BUFFER_SIZE), - _fsm(task_queues->lumex_lcd), + _fsm(task_queues->display), _task_queues(task_queues), _prevSDLoggingEnabled(false), _prevPIDEnabled(false), @@ -61,7 +61,7 @@ bool SessionController::CheckTaskQueuesValid() || _task_queues->pid_controller_ack == nullptr #endif #if LUMEX_LCD_TASK_ENABLE - || _task_queues->lumex_lcd == nullptr + || _task_queues->display == nullptr #endif ) { diff --git a/firmware/Core/Src/Tasks/TaskMonitor/README.md b/firmware/Core/Src/Tasks/TaskMonitor/README.md index 848d0a6..ddb5038 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/README.md +++ b/firmware/Core/Src/Tasks/TaskMonitor/README.md @@ -24,7 +24,7 @@ mark and forwards a `task_monitor_output_data` to [[USB]]. ## Inputs — `taskmonitor_osthreadids` Thread handles: `session_controller, usb_controller, sd_controller, force_sensor, optical_sensor, -bpm_controller, pid_controller, lumex_lcd`. The single `force_sensor` handle is whichever variant +bpm_controller, pid_controller, display`. The single `force_sensor` handle is whichever variant is enabled; it's reported with the matching offset (`TASK_OFFSET_FORCE_SENSOR_ADS1115` or `_ADC`). ## Errors diff --git a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp index 0517757..205568d 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp +++ b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp @@ -37,7 +37,7 @@ bool TaskMonitor::Init() || _osThreadIdPtrs->pid_controller == nullptr #endif #if LUMEX_LCD_TASK_ENABLE - || _osThreadIdPtrs->lumex_lcd == nullptr + || _osThreadIdPtrs->display == nullptr #endif ) { @@ -95,7 +95,7 @@ void TaskMonitor::Run() GetTaskDataAndSendToUsbController(TASK_OFFSET_PID_CONTROLLER, _osThreadIdPtrs->pid_controller); #endif #if LUMEX_LCD_TASK_ENABLE - GetTaskDataAndSendToUsbController(TASK_OFFSET_LUMEX_LCD, _osThreadIdPtrs->lumex_lcd); + GetTaskDataAndSendToUsbController(TASK_OFFSET_LUMEX_LCD, _osThreadIdPtrs->display); #endif GetTaskDataAndSendToUsbController(TASK_OFFSET_TASK_MONITOR, osThreadGetId()); diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index fad461f..b896e61 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -141,10 +141,10 @@ const osThreadAttr_t taskMonitorTask_attributes = { .stack_size = 128 * 4, .priority = (osPriority_t) osPriorityHigh, }; -/* Definitions for sessionControllerToLumexLcd */ -osMessageQueueId_t sessionControllerToLumexLcdHandle; -const osMessageQueueAttr_t sessionControllerToLumexLcd_attributes = { - .name = "sessionControllerToLumexLcd" +/* Definitions for sessionControllerToDisplay */ +osMessageQueueId_t sessionControllerToDisplayHandle; +const osMessageQueueAttr_t sessionControllerToDisplay_attributes = { + .name = "sessionControllerToDisplay" }; /* Definitions for sessionControllerToBpm */ osMessageQueueId_t sessionControllerToBpmHandle; @@ -330,8 +330,8 @@ int main(void) /* USER CODE END RTOS_TIMERS */ /* Create the queue(s) */ - /* creation of sessionControllerToLumexLcd */ - sessionControllerToLumexLcdHandle = osMessageQueueNew (25, sizeof(session_controller_to_lumex_lcd), &sessionControllerToLumexLcd_attributes); + /* creation of sessionControllerToDisplay */ + sessionControllerToDisplayHandle = osMessageQueueNew (25, sizeof(session_controller_to_display), &sessionControllerToDisplay_attributes); /* creation of sessionControllerToBpm */ sessionControllerToBpmHandle = osMessageQueueNew (10, sizeof(session_controller_to_bpm), & sessionControllerToBpm_attributes); @@ -1315,7 +1315,7 @@ void sessionControllerTaskEntryFunction(void* argument) .bpm_controller = sessionControllerToBpmHandle, .pid_controller = sessionControllerToPidControllerHandle, .pid_controller_ack = pidControllerToSessionControllerAckHandle, - .lumex_lcd = sessionControllerToLumexLcdHandle + .display = sessionControllerToDisplayHandle }; sessioncontroller_main(&tasks); #endif @@ -1340,7 +1340,7 @@ void lcdDisplayTaskEntryFunction(void *argument) #elif LUMEX_LCD_TASK_ENABLE == 0 osThreadSuspend(osThreadGetId()); #else - lumex_lcd_main(sessionControllerToLumexLcdHandle); + lumex_lcd_main(sessionControllerToDisplayHandle); #endif } @@ -1382,7 +1382,7 @@ void taskMonitorEntryFunction(void *argument) .bpm_controller = bpmTaskHandle, .pid_controller = pidTaskHandle, .pid_controller_ack = pidControllerToSessionControllerAckHandle, - .lumex_lcd = lcdDisplayTaskHandle + .display = lcdDisplayTaskHandle } ; taskmonitor_main(&osthreadids, taskMonitorToUsbControllerHandle); #endif diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index b7be50d..2b3a619 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -22,7 +22,7 @@ CORTEX_M7.IPParameters=default_mode_Activation CORTEX_M7.default_mode_Activation=1 FREERTOS.FootprintOK=true FREERTOS.IPParameters=Tasks01,configUSE_NEWLIB_REENTRANT,FootprintOK,Queues01,configMAX_TASK_NAME_LEN,configENABLE_FPU,configTOTAL_HEAP_SIZE,configCHECK_FOR_STACK_OVERFLOW -FREERTOS.Queues01=sessionControllerToLumexLcd,25,session_controller_to_lumex_lcd,0,Dynamic,NULL,NULL; sessionControllerToBpm,10,session_controller_to_bpm,0,Dynamic,NULL,NULL; sessionControllerToForceSensor,16,bool,0,Dynamic,NULL,NULL; sessionControllerToPidController,5,session_controller_to_pid_controller,0,Dynamic,NULL,NULL; opticalEncoderToPidController,10,optical_encoder_output_data,0,Dynamic,NULL,NULL; pidControllerToBpm,10,float,0,Dynamic,NULL,NULL; sessionControllerToOpticalSensor,16,uint16_t,0,Dynamic,NULL,NULL;sessionControllertoUsbController,16,uint16_t,0,Dynamic,NULL,NULL;taskMonitorToUsbController,50,task_monitor_output_data,0,Dynamic,NULL,NULL;usbToForceSensorCommand,8,usb_task_command,0,Dynamic,NULL,NULL;taskToUsbControllerResponse,8,usb_task_completion,0,Dynamic,NULL,NULL;pidControllerToSessionControllerAck,5,bool,0,Dynamic,NULL,NULL +FREERTOS.Queues01=sessionControllerToDisplay,25,session_controller_to_display,0,Dynamic,NULL,NULL; sessionControllerToBpm,10,session_controller_to_bpm,0,Dynamic,NULL,NULL; sessionControllerToForceSensor,16,bool,0,Dynamic,NULL,NULL; sessionControllerToPidController,5,session_controller_to_pid_controller,0,Dynamic,NULL,NULL; opticalEncoderToPidController,10,optical_encoder_output_data,0,Dynamic,NULL,NULL; pidControllerToBpm,10,float,0,Dynamic,NULL,NULL; sessionControllerToOpticalSensor,16,uint16_t,0,Dynamic,NULL,NULL;sessionControllertoUsbController,16,uint16_t,0,Dynamic,NULL,NULL;taskMonitorToUsbController,50,task_monitor_output_data,0,Dynamic,NULL,NULL;usbToForceSensorCommand,8,usb_task_command,0,Dynamic,NULL,NULL;taskToUsbControllerResponse,8,usb_task_completion,0,Dynamic,NULL,NULL;pidControllerToSessionControllerAck,5,bool,0,Dynamic,NULL,NULL FREERTOS.Tasks01=usbTask,40,512,usbTaskEntryFunction,As weak,NULL,Dynamic,NULL,NULL; bpmTask,40,128,bpmTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; forceSensorTask,32,256,forceSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; pidTask,40,256,pidControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; opticalSensorTask,32,256,opticalSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;sessionControllerTask,40,256,sessionControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;lcdDisplayTask,16,128,lcdDisplayTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;ledBlinkTask,8,128,ledBlinkTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;taskMonitorTask,40,128,taskMonitorEntryFunction,As external,NULL,Dynamic,NULL,NULL FREERTOS.configCHECK_FOR_STACK_OVERFLOW=2 FREERTOS.configENABLE_FPU=1 diff --git a/firmware/tests/CMakeLists.txt b/firmware/tests/CMakeLists.txt index c6155df..25c9722 100644 --- a/firmware/tests/CMakeLists.txt +++ b/firmware/tests/CMakeLists.txt @@ -35,11 +35,13 @@ add_executable(fw_tests ${FIRMWARE_DIR}/Core/Src/Tasks/USB/usb_rx_ring.c ${FIRMWARE_DIR}/Core/Src/Tasks/USB/usb_framer.cpp ${FIRMWARE_DIR}/Core/Src/Tasks/OpticalSensor/encoder_math.c + ${FIRMWARE_DIR}/Core/Src/Tasks/LCD/lumex_layout.c usb_rx_ring_tests.cpp usb_framer_tests.cpp sysconfig_tests.cpp circular_buffer_tests.cpp encoder_math_tests.cpp + lumex_layout_tests.cpp ) target_include_directories(fw_tests PRIVATE diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp new file mode 100644 index 0000000..4e39b32 --- /dev/null +++ b/firmware/tests/lumex_layout_tests.cpp @@ -0,0 +1,235 @@ +// Pins the Lumex panel's rendering, cell for cell. +// +// The layout used to live in FiniteStateMachine.cpp as runs of WriteText(row, column, "...") +// with hand-counted padding. Moving it behind a screen-state message is a refactor, so the +// expectations below are the literals that code wrote, transcribed by hand from it -- these +// tests exist to catch the move changing what a user sees. +// +// Written as whole 16-character rows rather than as field assertions, because the bugs worth +// catching here are off-by-one column errors that a field-level check would step over. + +#include + +#include + +extern "C" { +#include "Tasks/LCD/lumex_layout.h" +} + +namespace +{ + +std::string Row(const lumex_frame &frame, unsigned row) +{ + return std::string(frame.cells[row], LUMEX_LCD_COLUMNS); +} + +session_controller_to_display State(display_screen_id screen) +{ + session_controller_to_display state{}; + state.screen = screen; + return state; +} + +lumex_frame Render(const session_controller_to_display &state) +{ + lumex_frame frame{}; + lumex_render(&state, &frame); + return frame; +} + +// --------------------------------------------------------------------------- frame invariants + +TEST(LumexLayout, EveryScreenFillsEveryCell) +{ + // No cell is left uninitialised: the driver diffs whole frames, so a stray NUL would be a + // difference it tried to write to the panel. + const display_screen_id screens[] = { + DISPLAY_SCREEN_IDLE, DISPLAY_SCREEN_SD_LOGGING, + DISPLAY_SCREEN_PID_ENABLE, DISPLAY_SCREEN_DESIRED_RPM, + DISPLAY_SCREEN_DESIRED_RPM_EDIT, DISPLAY_SCREEN_SESSION, + }; + + for (display_screen_id screen : screens) + { + const lumex_frame frame = Render(State(screen)); + + for (unsigned row = 0; row < LUMEX_LCD_ROWS; row++) + { + for (unsigned column = 0; column < LUMEX_LCD_COLUMNS; column++) + { + EXPECT_GE(frame.cells[row][column], ' ') + << "screen " << screen << " cell (" << row << ", " << column << ")"; + } + } + } +} + +TEST(LumexLayout, RenderingIsAPureFunctionOfState) +{ + // The driver relies on this: it renders, diffs against the last frame, and trusts that an + // unchanged state produces an unchanged frame. + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.angular_velocity = 123.4f; + state.force = 56.78f; + + const lumex_frame first = Render(state); + const lumex_frame second = Render(state); + + EXPECT_EQ(Row(first, 0), Row(second, 0)); + EXPECT_EQ(Row(first, 1), Row(second, 1)); +} + +// --------------------------------------------------------------------------- idle + +TEST(LumexLayout, IdleScreen) +{ + const lumex_frame frame = Render(State(DISPLAY_SCREEN_IDLE)); + + // 0123456789012345 + EXPECT_EQ(Row(frame, 0), " DYNO "); + EXPECT_EQ(Row(frame, 1), " PRESS SELECT "); +} + +// --------------------------------------------------------------------------- settings pages + +TEST(LumexLayout, SdLoggingPageShowsItsOwnFlag) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SD_LOGGING); + + state.sd_logging_enabled = false; + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), " SD LOGGING "); + EXPECT_EQ(Row(Render(state), 1), " DISABLED "); + + state.sd_logging_enabled = true; + EXPECT_EQ(Row(Render(state), 1), " ENABLED "); +} + +TEST(LumexLayout, PidEnablePageShowsTheToggleableFlagNotTheLiveOne) +{ + // The page is about whether the option may be armed at all, so it reads + // pid_option_toggleable; pid_enabled is the in-session state and must not leak in here. + session_controller_to_display state = State(DISPLAY_SCREEN_PID_ENABLE); + state.pid_enabled = true; + + state.pid_option_toggleable = false; + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), " PID LOGGING "); + EXPECT_EQ(Row(Render(state), 1), " DISABLED "); + + state.pid_option_toggleable = true; + EXPECT_EQ(Row(Render(state), 1), " ENABLED "); +} + +TEST(LumexLayout, DesiredRpmPage) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_DESIRED_RPM); + state.desired_rpm = 5000; + + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), " PID DES RPM "); + EXPECT_EQ(Row(Render(state), 1), " 5000 "); +} + +TEST(LumexLayout, DesiredRpmPagePadsToFiveColumns) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_DESIRED_RPM); + + state.desired_rpm = 0; + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 1), " 0 "); + + state.desired_rpm = 99999; + EXPECT_EQ(Row(Render(state), 1), " 99999 "); +} + +TEST(LumexLayout, DesiredRpmEditorShowsTheStepBesideTheValue) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_DESIRED_RPM_EDIT); + state.desired_rpm = 5000; + state.cursor_digit = DISPLAY_RPM_DIGIT_HUNDRED; + + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), " PID DES RPM "); + EXPECT_EQ(Row(Render(state), 1), " 5000 100 "); +} + +TEST(LumexLayout, EveryCursorPositionMapsToItsStep) +{ + EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN_THOUSAND), 10000u); + EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_THOUSAND), 1000u); + EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_HUNDRED), 100u); + EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN), 10u); + EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_ONE), 1u); +} + +// --------------------------------------------------------------------------- session + +TEST(LumexLayout, SessionScreenAtRest) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = false; + + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), "n: 0 rpm "); +} + +TEST(LumexLayout, SessionScreenRoundsAndRightAlignsTheRpmField) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + + state.angular_velocity = 1234.6f; + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), "n: 1235 rpm "); + + state.angular_velocity = 7.0f; + EXPECT_EQ(Row(Render(state), 0), "n: 7 rpm "); +} + +TEST(LumexLayout, SessionScreenForceFieldLeavesStaleDigits) +{ + // Pins a pre-existing artifact rather than blessing it. The label literal carries "0.00" + // at columns 6-9, but the force field is six characters at columns 2-7 -- so columns 8-9 + // keep a "00" that nothing ever rewrites, and 12.34 N reads as "12.3400". Reproduced here + // exactly because this commit moves the layout without changing it; the fix is to shorten + // the literal in lumex_layout.c, which is a display change and its own decision. + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = false; + state.force = 12.34f; + + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 1), "F: 12.3400 NB 0"); +} + +TEST(LumexLayout, SessionScreenShowsPidStateWhenTheOptionIsArmable) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = true; + + state.pid_enabled = true; + EXPECT_EQ(Row(Render(state), 1).substr(12, 4), "PIDE"); + + state.pid_enabled = false; + EXPECT_EQ(Row(Render(state), 1).substr(12, 4), "PIDD"); +} + +TEST(LumexLayout, SessionScreenShowsBrakeDutyWhenTheOptionIsNot) +{ + // With the option off the PID cannot be armed, so the cell shows what the encoder is + // actually driving -- the brake -- as a whole-percent B-prefixed field. + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = false; + state.pid_enabled = true; // must be ignored + + state.bpm_duty_cycle = 0.0f; + EXPECT_EQ(Row(Render(state), 1).substr(12, 4), "B 0"); + + state.bpm_duty_cycle = 0.07f; + EXPECT_EQ(Row(Render(state), 1).substr(12, 4), "B 7"); + + state.bpm_duty_cycle = 0.95f; + EXPECT_EQ(Row(Render(state), 1).substr(12, 4), "B 95"); +} + +} // namespace diff --git a/firmware/tests/stubs/cmsis_os2.h b/firmware/tests/stubs/cmsis_os2.h new file mode 100644 index 0000000..5d0a066 --- /dev/null +++ b/firmware/tests/stubs/cmsis_os2.h @@ -0,0 +1,10 @@ +// Host-test stub. messages_private.h includes cmsis_os2.h for the queue handle type used by +// other headers in that tree, but the message payloads themselves are plain data -- so the +// display renderer under test needs the struct definitions and none of the RTOS. The one +// typedef below is all the generated header's includers ask for. +#ifndef DYNO_TEST_STUB_CMSIS_OS2_H +#define DYNO_TEST_STUB_CMSIS_OS2_H + +typedef void *osMessageQueueId_t; + +#endif diff --git a/firmware/tools/message_gen/schema/messages_private.yaml b/firmware/tools/message_gen/schema/messages_private.yaml index 48bfc85..45b3cca 100644 --- a/firmware/tools/message_gen/schema/messages_private.yaml +++ b/firmware/tools/message_gen/schema/messages_private.yaml @@ -22,30 +22,61 @@ sections: #endif - kind: enum - name: session_controller_to_lumex_lcd_opcode + name: display_screen_id base: uint32_t comment: |- - Opcodes for controlling the Lumex LCD display from the session controller + Which screen the session controller's FSM is showing. The message below carries screen + state, not draw commands: the FSM says what it is displaying and each display driver + renders that however its panel allows. A 16x2 character LCD and a 320x240 TFT have no + useful common drawing API -- the intersection caps the TFT at 16x2, the union is + meaningless on the character LCD -- so the seam is here instead, at what the values mean. values: - - { name: CLEAR_DISPLAY, value: "0", comment: "Clear the entire display" } - - { name: WRITE_TO_DISPLAY, value: "1", comment: "Write a string to a specific location on the display" } + - { name: DISPLAY_SCREEN_IDLE, value: "0", comment: "Attract screen; SELECT opens the settings menu" } + - { name: DISPLAY_SCREEN_SD_LOGGING, comment: "Settings: SD logging on/off" } + - { name: DISPLAY_SCREEN_PID_ENABLE, comment: "Settings: whether the PID option may be toggled in-session" } + - { name: DISPLAY_SCREEN_DESIRED_RPM, comment: "Settings: the desired-RPM setpoint" } + - { name: DISPLAY_SCREEN_DESIRED_RPM_EDIT, comment: "Settings: the same setpoint with the digit cursor showing" } + - { name: DISPLAY_SCREEN_SESSION, comment: "Live readout while a session runs" } - - { kind: static_assert, expr: "sizeof(session_controller_to_lumex_lcd_opcode) == 4", message: "Size of session_controller_to_lumex_lcd_opcode must be 4 bytes" } + - { kind: static_assert, expr: "sizeof(display_screen_id) == 4", message: "Size of display_screen_id must be 4 bytes" } + + - kind: enum + name: display_rpm_digit + base: uint32_t + comment: |- + Which decimal digit of the desired RPM the encoder is editing. Mirrors + State::DesiredRpmUnitsState in FiniteStateMachine.hpp. Sent as the cursor position + rather than as the step size it implies, so a driver can mark the digit itself + instead of only printing the increment. + values: + - { name: DISPLAY_RPM_DIGIT_TEN_THOUSAND, value: "0" } + - { name: DISPLAY_RPM_DIGIT_THOUSAND } + - { name: DISPLAY_RPM_DIGIT_HUNDRED } + - { name: DISPLAY_RPM_DIGIT_TEN } + - { name: DISPLAY_RPM_DIGIT_ONE } + + - { kind: static_assert, expr: "sizeof(display_rpm_digit) == 4", message: "Size of display_rpm_digit must be 4 bytes" } - kind: struct - name: session_controller_to_lumex_lcd + name: session_controller_to_display comment: |- - Message sent from the session controller to the Lumex LCD + Everything any screen shows, sent whole on every update. Drivers diff it against the + last one they rendered and repaint only what moved -- which is what makes a 320x240 + panel viable at all, since a full frame over SPI costs ~50-100 ms but a single field + costs ~1-2 ms. The older protocol sent (" 1234", row 0, col 3) and left the driver + unable to tell which quantity had changed. fields: - - { type: session_controller_to_lumex_lcd_opcode, name: op, comment: "Operation to perform on the display" } - - { type: uint32_t, name: row, comment: "Row number on the LCD" } - - { type: uint32_t, name: column, comment: "Column number on the LCD" } - - { type: size_t, name: size } - - { type: char, name: display_string, array: SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE, comment: "String to write (if WRITE_TO_DISPLAY)" } - - - kind: static_assert - expr: "sizeof(session_controller_to_lumex_lcd) >= offsetof(session_controller_to_lumex_lcd, display_string) + sizeof(((session_controller_to_lumex_lcd *)0)->display_string)" - message: "Size of session_controller_to_lumex_lcd must be correct" + - { type: display_screen_id, name: screen, comment: "Which screen to render" } + - { type: float, name: angular_velocity, comment: "Measured angular velocity in rad/s, as the optical encoder reports it" } + - { type: float, name: force, comment: "Measured force in N" } + - { type: float, name: bpm_duty_cycle, comment: "Commanded brake duty cycle, 0 - 1" } + - { type: uint32_t, name: desired_rpm, comment: "The PID setpoint being displayed or edited" } + - { type: display_rpm_digit, name: cursor_digit, comment: "Digit the encoder edits (DESIRED_RPM_EDIT only)" } + - { type: bool, name: pid_enabled, comment: "Whether the PID loop is armed for this session" } + - { type: bool, name: pid_option_toggleable, comment: "Whether the menu allows arming it; also selects the in-session drive-mode field" } + - { type: bool, name: sd_logging_enabled, comment: "Whether SD logging is switched on" } + + - { kind: static_assert, expr: "sizeof(session_controller_to_display) <= 32", message: "session_controller_to_display is queued 25 deep -- keep it small" } - kind: enum name: session_controller_to_bpm_opcode From 582c43a370d8a865b673cf2789f162c7615c6fe5 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 18:44:45 -0700 Subject: [PATCH 03/25] display: fix the force field's stale digits and the rad/s "rpm" readout 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 --- .../Inc/MessagePassing/messages_private.h | 2 +- .../Inc/Tasks/OpticalSensor/encoder_math.h | 9 +++++ .../SessionController/FiniteStateMachine.hpp | 9 +++-- firmware/Core/Src/Tasks/LCD/README.md | 9 ++--- firmware/Core/Src/Tasks/LCD/lumex_layout.c | 22 +++++------ .../Src/Tasks/OpticalSensor/encoder_math.c | 6 +++ .../SessionController/FiniteStateMachine.cpp | 11 ++++-- .../SessionController/SessionController.cpp | 4 +- firmware/tests/encoder_math_tests.cpp | 31 +++++++++++++++ firmware/tests/lumex_layout_tests.cpp | 39 ++++++++++++------- .../message_gen/schema/messages_private.yaml | 2 +- 11 files changed, 102 insertions(+), 42 deletions(-) diff --git a/firmware/Core/Inc/MessagePassing/messages_private.h b/firmware/Core/Inc/MessagePassing/messages_private.h index e1b0c9d..8a12390 100644 --- a/firmware/Core/Inc/MessagePassing/messages_private.h +++ b/firmware/Core/Inc/MessagePassing/messages_private.h @@ -66,7 +66,7 @@ DYNO_STATIC_ASSERT(sizeof(display_rpm_digit) == 4, "Size of display_rpm_digit mu // unable to tell which quantity had changed. typedef struct { display_screen_id screen; // Which screen to render - float angular_velocity; // Measured angular velocity in rad/s, as the optical encoder reports it + float rpm; // Measured shaft speed in RPM, already converted from the encoder's rad/s float force; // Measured force in N float bpm_duty_cycle; // Commanded brake duty cycle, 0 - 1 uint32_t desired_rpm; // The PID setpoint being displayed or edited diff --git a/firmware/Core/Inc/Tasks/OpticalSensor/encoder_math.h b/firmware/Core/Inc/Tasks/OpticalSensor/encoder_math.h index 769545f..846642c 100644 --- a/firmware/Core/Inc/Tasks/OpticalSensor/encoder_math.h +++ b/firmware/Core/Inc/Tasks/OpticalSensor/encoder_math.h @@ -60,6 +60,15 @@ float encoder_velocity_upper_bound(uint32_t ticks_since_last_pulse, uint32_t apertures, uint32_t ticks_per_second); +/** + * @brief Revolutions per minute from an angular velocity in rad/s. + * + * Everything this file produces is rad/s, which is the right unit to compute in and the wrong + * one to read off a panel. Kept here rather than in a display driver so the conversion happens + * once, next to the measurement it belongs to, however many panels end up showing it. + */ +float encoder_rpm(float angular_velocity); + /** * @brief Angular acceleration between two velocity samples. * @param delta_ticks Ticks between the instants the two velocities are attributed to. diff --git a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp index 045a109..2f7cfa4 100644 --- a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp @@ -15,6 +15,8 @@ #include "input_manager_interrupts.h" +#include "Tasks/OpticalSensor/encoder_math.h" + // Where the user interface is. // // IDLE ---------------- SELECT ---------------> SETTINGS_MENU @@ -82,7 +84,7 @@ class FSM // Fields the SessionController refreshes on the in-session screen. Each records the value // and reposts the whole screen state; the driver works out what actually moved. - void DisplayRpm(float rpm); + void DisplayAngularVelocity(float angularVelocity); void DisplayForce(float force); void DisplayPIDEnabled(); void DisplayManualBPMDutyCycle(); @@ -151,8 +153,9 @@ class FSM float _desiredManualBpmDutyCycle; // Newest readings the SessionController has handed over. Held because every post carries - // the whole screen state, so a force update still has to say what the RPM is. - float _angularVelocity; + // the whole screen state, so a force update still has to say what the RPM is. Stored as + // RPM: the conversion from the encoder's rad/s happens on the way in. + float _rpm; float _force; // Whether a brake press may start a session. Cleared when the button is already held as this diff --git a/firmware/Core/Src/Tasks/LCD/README.md b/firmware/Core/Src/Tasks/LCD/README.md index b362afa..fb30f41 100644 --- a/firmware/Core/Src/Tasks/LCD/README.md +++ b/firmware/Core/Src/Tasks/LCD/README.md @@ -53,11 +53,10 @@ changed, since all it received was `(" 1234", row 0, col 3)`. (`StartTimer`). - `WriteData` / `WriteCommand` / `SetCursor` / `DisplayChar` / `DisplayString` / `ToggleBlink`. -## Known display artifact -The session screen's force field is six characters at columns 2-7, but 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`. Pre-existing; pinned by `SessionScreenForceFieldLeavesStaleDigits` rather than -blessed. Fixing it means shortening the literal in `lumex_layout.c`. +## Units +`session_controller_to_display.rpm` is RPM. The optical encoder measures rad/s, and the FSM +converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]) — so a driver renders the +number it is given and no panel repeats the conversion. ## Errors - `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/LCD/lumex_layout.c index 5f5c850..adf887f 100644 --- a/firmware/Core/Src/Tasks/LCD/lumex_layout.c +++ b/firmware/Core/Src/Tasks/LCD/lumex_layout.c @@ -56,27 +56,23 @@ static void render_enabled_disabled(lumex_frame *out, bool enabled) static void render_session(const session_controller_to_display *state, lumex_frame *out) { - // The static labels, then each live field overlaid on top -- the same order, and the same - // cells, as ShowSessionScreen() followed by the SessionController's Display* calls. + // Labels and units only. The cells each live field occupies are left blank and filled in + // below, so a unit always sits just past where its value ends. The row 1 literal used to + // carry a "0.00" of its own at cols 6-9 while the force field wrote cols 2-7, which left + // two digits of it stranded on screen: 12.34 N read as "12.3400". // // col: 0123456789012345 - PUT_LITERAL(out, 0, 0, "n: 0 rpm "); - PUT_LITERAL(out, 1, 0, "F: 0.00 N "); + PUT_LITERAL(out, 0, 0, "n: rpm "); + PUT_LITERAL(out, 1, 0, "F: N "); - // Whatever the SessionController hands over. Today that is the optical encoder's - // angular_velocity, which is rad/s -- printed here under an "rpm" label. Preserved as-is: - // this file changes where the layout lives, not what the numbers mean. char scratch[SCRATCH_SIZE]; - uint32_t rpm = (uint32_t)roundf(state->angular_velocity); + uint32_t rpm = (uint32_t)roundf(state->rpm); snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)rpm); put_field(out, 0, 3, 5, scratch); - // Six characters at cols 2-7. Note this leaves the label literal's own "0.00" sitting at - // cols 6-9, so cols 8-9 keep a stale "00" that nothing ever rewrites -- see the test - // SessionScreen_ForceFieldLeavesStaleDigits, which pins the artifact rather than blessing - // it. Fixing it means shortening the literal above; that is a display change, not a - // refactor, so it is deliberately not done here. + // Six characters at cols 2-7, clear of the "F:" label and of the drive-mode field at + // col 12 however large the reading gets. float force = roundf(state->force * 100.0f) / 100.0f; snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); put_field(out, 1, 2, 6, scratch); diff --git a/firmware/Core/Src/Tasks/OpticalSensor/encoder_math.c b/firmware/Core/Src/Tasks/OpticalSensor/encoder_math.c index 6a1981a..b9985f4 100644 --- a/firmware/Core/Src/Tasks/OpticalSensor/encoder_math.c +++ b/firmware/Core/Src/Tasks/OpticalSensor/encoder_math.c @@ -53,6 +53,12 @@ float encoder_velocity_upper_bound(uint32_t ticks_since_last_pulse, return radians_per_count(apertures) / seconds; } +float encoder_rpm(float angular_velocity) +{ + // rad/s -> rev/min: one revolution is 2*pi radians, one minute is 60 seconds. + return angular_velocity * (float)(60.0 / (2.0 * M_PI)); +} + float encoder_angular_acceleration(float previous_velocity, float velocity, uint32_t delta_ticks, diff --git a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp index 5d0b3bc..8908b95 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -14,7 +14,7 @@ FSM::FSM(osMessageQueueId_t sessionControllerToDisplayHandle) : _desiredRpm(5000), _pidEnabled(false), _desiredManualBpmDutyCycle(0), - _angularVelocity(0.0f), + _rpm(0.0f), _force(0.0f), // A brake already held as we come up is not a request to start a session -- it is just how // the board was left, or a finger on the button during a reset. Start disarmed in that case @@ -360,9 +360,12 @@ void FSM::ShowSessionScreen() // them only when its reading has moved, and the driver diffs again on its side, so reposting // the whole state costs one queue message and no panel traffic. -void FSM::DisplayRpm(float rpm) +// Takes rad/s, because that is what the optical encoder measures and what every other consumer +// of that reading wants. The panel is the only place RPM is the right unit, so the conversion +// happens here, once, rather than in each display driver. +void FSM::DisplayAngularVelocity(float angularVelocity) { - _angularVelocity = rpm; + _rpm = encoder_rpm(angularVelocity); PostDisplayState(); } @@ -415,7 +418,7 @@ void FSM::PostDisplayState() memset(&msg, 0, sizeof(msg)); msg.screen = CurrentScreen(); - msg.angular_velocity = _angularVelocity; + msg.rpm = _rpm; msg.force = _force; msg.bpm_duty_cycle = _desiredManualBpmDutyCycle; msg.desired_rpm = static_cast(_desiredRpm); diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index 6ede20a..53b4e12 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -136,7 +136,7 @@ void SessionController::PublishSessionTransition(bool inSession) if (inSession) { // Draw the fields of the in-session screen at their starting values. - _fsm.DisplayRpm(0); + _fsm.DisplayAngularVelocity(0); _fsm.DisplayForce(0); if (_fsm.GetPIDOptionToggleableEnabledStatus()) _fsm.DisplayPIDEnabled(); @@ -219,7 +219,7 @@ void SessionController::UpdateMeasurementDisplay() if (_prevAngularVelocity != _opticalData.angular_velocity) { - _fsm.DisplayRpm(_opticalData.angular_velocity); + _fsm.DisplayAngularVelocity(_opticalData.angular_velocity); _prevAngularVelocity = _opticalData.angular_velocity; } diff --git a/firmware/tests/encoder_math_tests.cpp b/firmware/tests/encoder_math_tests.cpp index 59f25d5..684c153 100644 --- a/firmware/tests/encoder_math_tests.cpp +++ b/firmware/tests/encoder_math_tests.cpp @@ -271,3 +271,34 @@ TEST(EncoderCounterTest, ConsecutiveWindowsLoseNoCounts) } EXPECT_EQ(summed, encoder_count_delta(readings[std::size(readings) - 1], readings[0])); } + +// --------------------------------------------------------------------------- unit conversion + +// Everything above is rad/s, which is the right unit to compute in and the wrong one to read off +// a panel. The display used to print rad/s under an "rpm" label -- a shaft at 3000 RPM read as +// 314 -- so the conversion is pinned here, next to the measurement it converts. +TEST(EncoderRpmTest, ConvertsRadiansPerSecondToRevolutionsPerMinute) +{ + // One revolution per second is 2*pi rad/s and 60 RPM. + EXPECT_NEAR(encoder_rpm(2.0f * static_cast(M_PI)), 60.0f, 1e-3f); + + // The case that made the bug visible. + EXPECT_NEAR(encoder_rpm(314.159f), 3000.0f, 0.1f); +} + +TEST(EncoderRpmTest, IsLinearAndSignPreserving) +{ + EXPECT_FLOAT_EQ(encoder_rpm(0.0f), 0.0f); + EXPECT_NEAR(encoder_rpm(-2.0f * static_cast(M_PI)), -60.0f, 1e-3f); + EXPECT_NEAR(encoder_rpm(20.0f), 2.0f * encoder_rpm(10.0f), 1e-3f); +} + +// The two halves of the round trip live on opposite sides of the display seam: the FSM converts +// rad/s to RPM on the way to the panel, and back again for the PID setpoint (GetDesiredAngularVelocity). +TEST(EncoderRpmTest, RoundTripsWithTheSetpointConversion) +{ + const float rpm = 4250.0f; + const float radiansPerSecond = rpm * 2.0f * static_cast(M_PI) / 60.0f; + + EXPECT_NEAR(encoder_rpm(radiansPerSecond), rpm, 1e-2f); +} diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp index 4e39b32..dd0a1a0 100644 --- a/firmware/tests/lumex_layout_tests.cpp +++ b/firmware/tests/lumex_layout_tests.cpp @@ -1,9 +1,12 @@ // Pins the Lumex panel's rendering, cell for cell. // // The layout used to live in FiniteStateMachine.cpp as runs of WriteText(row, column, "...") -// with hand-counted padding. Moving it behind a screen-state message is a refactor, so the -// expectations below are the literals that code wrote, transcribed by hand from it -- these -// tests exist to catch the move changing what a user sees. +// with hand-counted padding. Most expectations below are those literals transcribed by hand, +// so the move behind a screen-state message cannot quietly change what a user sees. +// +// The session screen's row 1 is the exception, and deliberately so: the old label literal +// carried a "0.00" at columns 6-9 while the force field wrote columns 2-7, leaving two digits +// stranded (12.34 N read as "12.3400"). That row is pinned to the corrected layout. // // Written as whole 16-character rows rather than as field assertions, because the bugs worth // catching here are off-by-one column errors that a field-level check would step over. @@ -70,7 +73,7 @@ TEST(LumexLayout, RenderingIsAPureFunctionOfState) // The driver relies on this: it renders, diffs against the last frame, and trusts that an // unchanged state produces an unchanged frame. session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); - state.angular_velocity = 123.4f; + state.rpm = 123.4f; state.force = 56.78f; const lumex_frame first = Render(state); @@ -173,33 +176,43 @@ TEST(LumexLayout, SessionScreenAtRest) // 0123456789012345 EXPECT_EQ(Row(Render(state), 0), "n: 0 rpm "); + EXPECT_EQ(Row(Render(state), 1), "F: 0.00 N B 0"); } TEST(LumexLayout, SessionScreenRoundsAndRightAlignsTheRpmField) { session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); - state.angular_velocity = 1234.6f; + state.rpm = 1234.6f; // 0123456789012345 EXPECT_EQ(Row(Render(state), 0), "n: 1235 rpm "); - state.angular_velocity = 7.0f; + state.rpm = 7.0f; EXPECT_EQ(Row(Render(state), 0), "n: 7 rpm "); } -TEST(LumexLayout, SessionScreenForceFieldLeavesStaleDigits) +TEST(LumexLayout, SessionScreenForceFieldEndsWhereItsUnitBegins) { - // Pins a pre-existing artifact rather than blessing it. The label literal carries "0.00" - // at columns 6-9, but the force field is six characters at columns 2-7 -- so columns 8-9 - // keep a "00" that nothing ever rewrites, and 12.34 N reads as "12.3400". Reproduced here - // exactly because this commit moves the layout without changing it; the fix is to shorten - // the literal in lumex_layout.c, which is a display change and its own decision. + // Regression: the label literal used to carry a "0.00" of its own at columns 6-9 while the + // force field wrote columns 2-7, stranding two of its digits on screen -- 12.34 N read as + // "12.3400". The literal now holds labels and units only. session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); state.pid_option_toggleable = false; state.force = 12.34f; // 0123456789012345 - EXPECT_EQ(Row(Render(state), 1), "F: 12.3400 NB 0"); + EXPECT_EQ(Row(Render(state), 1), "F: 12.34 N B 0"); +} + +TEST(LumexLayout, SessionScreenForceFieldStaysClearOfTheDriveModeField) +{ + // The widest reading the six-character field can hold must not reach column 12. + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = false; + state.force = 999.99f; + + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 1), "F:999.99 N B 0"); } TEST(LumexLayout, SessionScreenShowsPidStateWhenTheOptionIsArmable) diff --git a/firmware/tools/message_gen/schema/messages_private.yaml b/firmware/tools/message_gen/schema/messages_private.yaml index 45b3cca..6f28bfa 100644 --- a/firmware/tools/message_gen/schema/messages_private.yaml +++ b/firmware/tools/message_gen/schema/messages_private.yaml @@ -67,7 +67,7 @@ sections: unable to tell which quantity had changed. fields: - { type: display_screen_id, name: screen, comment: "Which screen to render" } - - { type: float, name: angular_velocity, comment: "Measured angular velocity in rad/s, as the optical encoder reports it" } + - { type: float, name: rpm, comment: "Measured shaft speed in RPM, already converted from the encoder's rad/s" } - { type: float, name: force, comment: "Measured force in N" } - { type: float, name: bpm_duty_cycle, comment: "Commanded brake duty cycle, 0 - 1" } - { type: uint32_t, name: desired_rpm, comment: "The PID setpoint being displayed or edited" } From e2d740e1f915050d0adf10b932b0e8f805158232 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 19:13:06 -0700 Subject: [PATCH 04/25] display: add the ILI9341 TFT as a second, compile-time-selectable panel 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 --- firmware/CMakeLists.txt | 7 +- firmware/Core/Inc/Config/debug.h | 13 +- .../Core/Inc/MessagePassing/messages_public.h | 10 +- .../Core/Inc/Tasks/Display/DisplayDriver.hpp | 64 ++++ .../Core/Inc/Tasks/Display/ILI9341Display.hpp | 48 +++ .../Core/Inc/Tasks/Display/display_common.h | 26 ++ .../Core/Inc/Tasks/Display/ili9341_layout.h | 63 ++++ .../Core/Inc/Tasks/Display/ili9341_main.h | 20 + firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp | 23 +- firmware/Core/Inc/Tasks/LCD/lumex_layout.h | 4 +- .../SessionController/SessionController.hpp | 3 +- firmware/Core/README.md | 6 +- .../Core/Src/Tasks/Display/ILI9341Display.cpp | 130 +++++++ firmware/Core/Src/Tasks/Display/README.md | 87 +++++ .../Core/Src/Tasks/Display/display_common.c | 14 + .../Core/Src/Tasks/Display/ili9341_layout.c | 178 +++++++++ firmware/Core/Src/Tasks/LCD/LumexLCD.cpp | 43 +-- firmware/Core/Src/Tasks/LCD/README.md | 16 +- firmware/Core/Src/Tasks/LCD/lumex_layout.c | 15 +- .../Src/Tasks/TaskMonitor/TaskMonitor.cpp | 2 +- firmware/Core/Src/main.c | 32 +- firmware/Drivers/ILI9341/ILI9341.cpp | 354 ++++++++++++++++++ firmware/Drivers/ILI9341/ILI9341.hpp | 82 ++++ firmware/Drivers/ILI9341/ILI9341_font.c | 270 +++++++++++++ firmware/Drivers/ILI9341/ILI9341_font.h | 38 ++ firmware/Drivers/ILI9341/ILI9341_main.h | 150 ++++++++ firmware/Drivers/ILI9341/README.md | 85 +++++ firmware/stm32_dyno_firmware_v2.ioc | 14 +- firmware/tests/CMakeLists.txt | 7 +- firmware/tests/ili9341_layout_tests.cpp | 291 ++++++++++++++ firmware/tests/lumex_layout_tests.cpp | 12 +- .../message_gen/schema/messages_public.yaml | 21 +- .../Messages/Generated/ErrorCatalog.cs | 22 +- src/Dyno.Core/Messages/Generated/Messages.cs | 8 +- 34 files changed, 2032 insertions(+), 126 deletions(-) create mode 100644 firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp create mode 100644 firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp create mode 100644 firmware/Core/Inc/Tasks/Display/display_common.h create mode 100644 firmware/Core/Inc/Tasks/Display/ili9341_layout.h create mode 100644 firmware/Core/Inc/Tasks/Display/ili9341_main.h create mode 100644 firmware/Core/Src/Tasks/Display/ILI9341Display.cpp create mode 100644 firmware/Core/Src/Tasks/Display/README.md create mode 100644 firmware/Core/Src/Tasks/Display/display_common.c create mode 100644 firmware/Core/Src/Tasks/Display/ili9341_layout.c create mode 100644 firmware/Drivers/ILI9341/ILI9341.cpp create mode 100644 firmware/Drivers/ILI9341/ILI9341.hpp create mode 100644 firmware/Drivers/ILI9341/ILI9341_font.c create mode 100644 firmware/Drivers/ILI9341/ILI9341_font.h create mode 100644 firmware/Drivers/ILI9341/ILI9341_main.h create mode 100644 firmware/Drivers/ILI9341/README.md create mode 100644 firmware/tests/ili9341_layout_tests.cpp diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index a414edd..027b343 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -65,15 +65,20 @@ file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/Core/Src/interrupts.c ${CMAKE_SOURCE_DIR}/Drivers/ADS1115/ADS1115.cpp + ${CMAKE_SOURCE_DIR}/Drivers/ILI9341/ILI9341.cpp + ${CMAKE_SOURCE_DIR}/Drivers/ILI9341/ILI9341_font.c ${APP_SOURCES} ) # Add include paths target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE - # Application headers and the on-board ADS1115 force-sensor driver headers. + # Application headers, plus the on-board device drivers: the ADS1115 force sensor and the + # ILI9341 display. Both display drivers are always compiled; Config/debug.h picks which one + # the display task actually runs, and --gc-sections drops the other. ${CMAKE_SOURCE_DIR}/Core/Inc ${CMAKE_SOURCE_DIR}/Middlewares/CircularBuffer/Inc ${CMAKE_SOURCE_DIR}/Drivers/ADS1115 + ${CMAKE_SOURCE_DIR}/Drivers/ILI9341 ) # Add project symbols (macros) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index df44cf7..4098b60 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -48,8 +48,17 @@ // BPM Controller Task #define BPM_CONTROLLER_TASK_ENABLE 1 -// Lumex LCD Task -#define LUMEX_LCD_TASK_ENABLE 1 +// Display task -- exactly one driver, chosen here and flashed. +// +// Both panels consume the same session_controller_to_display message, so the SessionController +// and its FSM are identical either way; only the driver linked in changes. There is no runtime +// switch because there is no runtime question: a board has one panel soldered to it. +#define LUMEX_LCD_TASK_ENABLE 1 +#define ILI9341_LCD_TASK_ENABLE 0 + +#if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) != 1 +#error "Exactly one display driver must be enabled: set one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1 and the other to 0." +#endif // USB Controller task settings // The mock-message stream used to live here as DEBUG_USB_CONTROLLER_MOCK_MESSAGES. It is now the diff --git a/firmware/Core/Inc/MessagePassing/messages_public.h b/firmware/Core/Inc/MessagePassing/messages_public.h index 3bc5ac0..211cb25 100644 --- a/firmware/Core/Inc/MessagePassing/messages_public.h +++ b/firmware/Core/Inc/MessagePassing/messages_public.h @@ -56,7 +56,7 @@ typedef enum : uint32_t TASK_OFFSET_FORCE_SENSOR_ADS1115 = 6u << TASK_OFFSET_SHIFT, TASK_OFFSET_BPM_CONTROLLER = 7u << TASK_OFFSET_SHIFT, TASK_OFFSET_PID_CONTROLLER = 8u << TASK_OFFSET_SHIFT, - TASK_OFFSET_LUMEX_LCD = 9u << TASK_OFFSET_SHIFT + TASK_OFFSET_DISPLAY = 9u << TASK_OFFSET_SHIFT } task_offset_t; typedef struct __attribute__((packed)) { @@ -94,10 +94,12 @@ DYNO_STATIC_ASSERT(sizeof(bpm_task_error_ids) == 4, "Size of bpm_task_error_ids typedef enum : uint32_t { - ERROR_LUMEX_LCD_TIMER_START_FAILURE = 0 -} lumex_lcd_task_error_ids; + ERROR_LUMEX_LCD_TIMER_START_FAILURE = 0, + ERROR_DISPLAY_INIT_FAILURE, + ERROR_DISPLAY_SPI_TRANSMIT_FAILURE +} display_task_error_ids; -DYNO_STATIC_ASSERT(sizeof(lumex_lcd_task_error_ids) == 4, "Size of lumex_lcd_task_error_ids must be 4 bytes"); +DYNO_STATIC_ASSERT(sizeof(display_task_error_ids) == 4, "Size of display_task_error_ids must be 4 bytes"); typedef enum : uint32_t { diff --git a/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp new file mode 100644 index 0000000..24760de --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp @@ -0,0 +1,64 @@ +#ifndef INC_TASKS_DISPLAY_DISPLAYDRIVER_HPP_ +#define INC_TASKS_DISPLAY_DISPLAYDRIVER_HPP_ + +// What every display driver has to be, and the task loop they share. +// +// A concept rather than a base class. Virtual dispatch would cost a vtable pointer per object +// and an indirect call per draw for a choice that is fixed at link time -- exactly one driver +// is compiled in -- so this checks the same contract at compile time and inlines through it. +// +// It also deliberately exposes no drawing primitives. A 16x2 character LCD and a 320x240 TFT +// have wildly different capabilities, and any common *drawing* API would either cap the TFT or +// be meaningless on the LCD. Render() takes the whole screen state and each driver does +// whatever its panel can with it, so a richer panel needs nothing added here. + +#include +#include + +#include "cmsis_os2.h" + +#include "Config/sysconfig.h" +#include "MessagePassing/messages_private.h" + +template +concept DisplayDriver = requires(T driver, const session_controller_to_display& state) +{ + // Brings the panel up. False means the task suspends rather than spinning on dead hardware. + { driver.Init() } -> std::same_as; + + // Blanks the panel and forgets what was on it, so the next Render repaints in full. + { driver.Clear() } -> std::same_as; + + // Paints one screen state. Called on every message; drivers are expected to diff against + // what they last drew and repaint only what moved. + { driver.Render(state) } -> std::same_as; +}; + +// The queue-drain loop, identical for every panel. +// +// Drains to the newest message before drawing: each one is the whole of what should be on +// screen, so the ones behind it are already stale and rendering them in turn would only paint +// values the user is never going to see. That matters more the slower the panel is. +template +void RunDisplayTask(Display& display, osMessageQueueId_t queue) +{ + session_controller_to_display state; + memset(&state, 0, sizeof(state)); + + while (1) + { + if (osMessageQueueGet(queue, &state, 0, osWaitForever) == osOK) + { + while (osMessageQueueGet(queue, &state, 0, 0) == osOK); + + if (!display.Render(state)) + { + return; + } + } + + osDelay(sysconfig_get_u32(SYSCFG_LCD_TASK_OSDELAY)); + } +} + +#endif /* INC_TASKS_DISPLAY_DISPLAYDRIVER_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp new file mode 100644 index 0000000..c500b52 --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp @@ -0,0 +1,48 @@ +#ifndef INC_TASKS_DISPLAY_ILI9341DISPLAY_HPP_ +#define INC_TASKS_DISPLAY_ILI9341DISPLAY_HPP_ + +#include "main.h" + +#include "CircularBufferWriter.hpp" + +#include "ILI9341.hpp" + +#include "MessagePassing/messages_private.h" +#include "MessagePassing/messages_public.h" + +#include "Tasks/Display/ili9341_layout.h" + +// The ILI9341's side of the display split: turns screen state into painted pixels. +// +// The counterpart to LumexLCD, and satisfies the same DisplayDriver concept without sharing a +// base class with it. Everything this panel can do that the character LCD cannot -- colour, +// several text sizes, arbitrary positioning -- lives inside Render() and never surfaces in the +// contract, which is the whole reason the contract is screen state rather than draw calls. +class ILI9341Display +{ +public: + ILI9341Display(); + ~ILI9341Display() = default; + + bool Init(); + bool Clear(); + bool Render(const session_controller_to_display& state); + +private: + // Paints one field over its own background, which is also how the previous value is erased: + // fields are fixed-width per screen, so a redraw covers every pixel the old one touched. + bool DrawField(const ili9341_field& field); + + ILI9341 _panel; + + CircularBufferWriter _task_error_buffer_writer; + + // What is currently painted, and which screen put it there. A change of screen repaints + // from a cleared panel; within a screen the layout is positionally stable, so field i can + // be compared against field i and only the movers redrawn. + ili9341_frame _lastFrame; + display_screen_id _lastScreen; + bool _hasRendered; +}; + +#endif /* INC_TASKS_DISPLAY_ILI9341DISPLAY_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/display_common.h b/firmware/Core/Inc/Tasks/Display/display_common.h new file mode 100644 index 0000000..167683b --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/display_common.h @@ -0,0 +1,26 @@ +#ifndef INC_TASKS_DISPLAY_DISPLAY_COMMON_H_ +#define INC_TASKS_DISPLAY_DISPLAY_COMMON_H_ + +// The parts of reading a display message that are the message's business rather than any one +// panel's. Both layouts include this; neither includes the other. + +#include + +#include "MessagePassing/messages_private.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// The step one encoder tick applies at a given cursor position: 10000 down to 1. +// +// The message carries the cursor position rather than the step it implies, so that a panel with +// room can mark the digit itself instead of only printing a number. Panels that just print the +// number use this. +uint32_t display_rpm_digit_increment(display_rpm_digit digit); + +#ifdef __cplusplus +} +#endif + +#endif /* INC_TASKS_DISPLAY_DISPLAY_COMMON_H_ */ diff --git a/firmware/Core/Inc/Tasks/Display/ili9341_layout.h b/firmware/Core/Inc/Tasks/Display/ili9341_layout.h new file mode 100644 index 0000000..ee90e1c --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ili9341_layout.h @@ -0,0 +1,63 @@ +#ifndef INC_TASKS_DISPLAY_ILI9341_LAYOUT_H_ +#define INC_TASKS_DISPLAY_ILI9341_LAYOUT_H_ + +// The ILI9341 panel's share of the display split: screen state in, positioned text fields out. +// +// The counterpart to Tasks/LCD/lumex_layout.h, and deliberately a different shape. Both take +// the same session_controller_to_display and neither constrains the other -- that is the point +// of sending screen state rather than draw commands. This one lays out a 320x240 landscape +// panel with several text sizes; the Lumex one lays out a 2x16 character grid. +// +// Free of HAL, RTOS and driver state so the host tests can pin every screen's geometry. + +#include +#include + +#include "MessagePassing/messages_private.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Panel geometry in the orientation this layout assumes. +#define ILI9341_LAYOUT_WIDTH 320 +#define ILI9341_LAYOUT_HEIGHT 240 + +#define ILI9341_MAX_FIELDS 8 +#define ILI9341_FIELD_TEXT_MAX 20 + +// One run of text at a fixed position and scale. +// +// `text` is fixed-width per screen and space-padded, never trimmed: drawing paints both +// foreground and background, so a field redrawn with a shorter value would leave the tail of +// the longer one behind. Padding is what erases it, exactly as on the character panel. +typedef struct +{ + uint16_t x; + uint16_t y; + uint16_t colour; + uint8_t size; // font scale; the cell is 6*size by 8*size pixels + uint8_t length; + char text[ILI9341_FIELD_TEXT_MAX]; +} ili9341_field; + +typedef struct +{ + ili9341_field fields[ILI9341_MAX_FIELDS]; + uint8_t count; +} ili9341_frame; + +// Lays out one screen. For a given screen id the field count, order, positions and sizes are +// fixed, so the driver can diff field i against field i of the previous frame and repaint only +// those whose text or colour moved. +void ili9341_layout(const session_controller_to_display *state, ili9341_frame *out); + +// Whether two fields would paint the same pixels. Position and size are stable within a +// screen, so in practice this compares text and colour. +bool ili9341_field_equal(const ili9341_field *a, const ili9341_field *b); + +#ifdef __cplusplus +} +#endif + +#endif /* INC_TASKS_DISPLAY_ILI9341_LAYOUT_H_ */ diff --git a/firmware/Core/Inc/Tasks/Display/ili9341_main.h b/firmware/Core/Inc/Tasks/Display/ili9341_main.h new file mode 100644 index 0000000..18a951a --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ili9341_main.h @@ -0,0 +1,20 @@ +#ifndef INC_TASKS_DISPLAY_ILI9341_MAIN_H_ +#define INC_TASKS_DISPLAY_ILI9341_MAIN_H_ + +#include "main.h" + +#include "cmsis_os2.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// Entry point for the display task when ILI9341_LCD_TASK_ENABLE is the selected driver. +// Mirrors lumex_lcd_main(): same queue, same message, different panel. +void ili9341_lcd_main(osMessageQueueId_t sessionControllerToDisplayqHandle); + +#ifdef __cplusplus +} +#endif + +#endif /* INC_TASKS_DISPLAY_ILI9341_MAIN_H_ */ diff --git a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp index 6a8e726..ab8f4e6 100644 --- a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp +++ b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp @@ -7,10 +7,8 @@ #include "string.h" - #include "Config/config.h" - #include "CircularBufferWriter.hpp" #include "MessagePassing/messages_private.h" @@ -21,18 +19,18 @@ #include "TimeKeeping/timestamps.h" -#ifdef __cplusplus -extern "C" { -#endif - +// Lumex 16x2 character LCD, bit-banged over GPIO. +// +// Satisfies the DisplayDriver concept (Tasks/Display/DisplayDriver.hpp) without inheriting +// anything: the panel choice is fixed at link time, so the contract is checked at compile time +// and there is no vtable. See Core/Src/Tasks/LCD/README.md for the display split. class LumexLCD { public: - LumexLCD(osMessageQueueId_t sessionControllerToDisplayqHandle); + LumexLCD(); ~LumexLCD() = default; bool Init(); - void Run(); // Blanks the panel and forgets what was on it, so the next Render redraws in full. bool Clear(); @@ -57,8 +55,6 @@ class LumexLCD CircularBufferWriter _task_error_buffer_writer; - osMessageQueueId_t _fromSCqHandle; - // What is currently on the panel, and which screen put it there. A change of screen // forces a physical clear -- the old code cleared inside every Show*Screen, and this // reproduces exactly that, including not clearing on a redraw of the same screen. @@ -67,11 +63,4 @@ class LumexLCD bool _hasRendered; }; - -#ifdef __cplusplus -} -#endif - - - #endif /* INC_TASKS_LCD_LUMEXLCD_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/LCD/lumex_layout.h b/firmware/Core/Inc/Tasks/LCD/lumex_layout.h index b4f7434..8014436 100644 --- a/firmware/Core/Inc/Tasks/LCD/lumex_layout.h +++ b/firmware/Core/Inc/Tasks/LCD/lumex_layout.h @@ -13,6 +13,7 @@ #include "Config/config.h" #include "MessagePassing/messages_private.h" +#include "Tasks/Display/display_common.h" #ifdef __cplusplus extern "C" { @@ -30,9 +31,6 @@ typedef struct // what turns two of these into the minimal set of writes. void lumex_render(const session_controller_to_display *state, lumex_frame *out); -// The step size the encoder applies at a given cursor position: 10000 down to 1. -uint32_t lumex_rpm_digit_increment(display_rpm_digit digit); - #ifdef __cplusplus } #endif diff --git a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp index 9da2c21..3fb6120 100644 --- a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp @@ -17,7 +17,8 @@ #if !defined(USB_CONTROLLER_TASK_ENABLE) || !defined(SD_CONTROLLER_TASK_ENABLE) \ || !defined(FORCE_SENSOR_ADS1115_TASK_ENABLE) || !defined(FORCE_SENSOR_ADC_TASK_ENABLE) \ || !defined(OPTICAL_ENCODER_TASK_ENABLE) || !defined(BPM_CONTROLLER_TASK_ENABLE) \ - || !defined(PID_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE) + || !defined(PID_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE) \ + || !defined(ILI9341_LCD_TASK_ENABLE) #error "A *_TASK_ENABLE macro is not visible here; SessionController's #if-gated queue posts would silently compile out (include Config/debug.h)" #endif diff --git a/firmware/Core/README.md b/firmware/Core/README.md index 729cc44..f9f7e18 100644 --- a/firmware/Core/README.md +++ b/firmware/Core/README.md @@ -2,7 +2,7 @@ module: Core summary: Firmware application — FreeRTOS tasks, message passing, and STM32H743 hardware bring-up. entry: Core/Src/main.c -related: [MessagePassing, SessionController, USB, TaskMonitor, BPM, PID, LCD, ForceSensor, OpticalSensor, Config, TimeKeeping] +related: [MessagePassing, SessionController, USB, TaskMonitor, BPM, PID, LCD, Display, ForceSensor, OpticalSensor, Config, TimeKeeping] --- # Core — application firmware @@ -21,7 +21,8 @@ never by calling into another task directly. | PID | `Core/Src/Tasks/PID/README.md` | Closed-loop brake control from encoder feedback | | ForceSensor | `Core/Src/Tasks/ForceSensor/README.md` | On-board force: i2c (ADS1115) and internal ADC | | OpticalSensor | `Core/Src/Tasks/OpticalSensor/README.md` | Angular velocity / acceleration from an optical encoder | -| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex character display; renders the shared display message | +| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex 16x2 character display | +| Display | `Core/Src/Tasks/Display/README.md` | The display seam; ILI9341 320x240 TFT | | USB | `Core/Src/Tasks/USB/README.md` | Streams data + errors to the PC over USB CDC | | TaskMonitor | `Core/Src/Tasks/TaskMonitor/README.md` | Per-task state and stack usage | | MessagePassing | `Core/Src/MessagePassing/README.md` | Queue helpers, circular buffers, USB wire protocol | @@ -29,6 +30,7 @@ never by calling into another task directly. | Config | `Core/Inc/Config/README.md` | Constants (`config.h`) + task/peripheral enables (`debug.h`) | | CircularBuffer | `Middlewares/CircularBuffer/README.md` | Heap-free single-writer / multi-reader buffers | | ADS1115 driver | `Drivers/ADS1115/README.md` | I2C 16-bit ADC driver used by the force sensor | +| ILI9341 driver | `Drivers/ILI9341/README.md` | SPI TFT driver used by the ILI9341 display task | ## main.c conventions - Timer handles are renamed for clarity: `timestampTimer`, `lumexLcdTimer`, `bpmTimer`. diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp new file mode 100644 index 0000000..d0015d4 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -0,0 +1,130 @@ +#include "Tasks/Display/ILI9341Display.hpp" + +#include + +#include "Config/sysconfig.h" + +#include "Tasks/Display/DisplayDriver.hpp" +#include "Tasks/Display/ili9341_main.h" + +#include "TimeKeeping/timestamps.h" + +extern SPI_HandleTypeDef hspi1; + +extern size_t task_error_circular_buffer_index_writer; +extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZE]; + +// The panel is painted on black; every field carries its own foreground. +#define ILI9341_DISPLAY_BACKGROUND ILI9341_BLACK + + +ILI9341Display::ILI9341Display() : + _panel(&hspi1, + ILI_SPI1_LCD_CS_GPIO_Port, ILI_SPI1_LCD_CS_Pin, + ILI_LCD_DC_GPIO_Port, ILI_LCD_DC_Pin, + ILI_LCD_RST_GPIO_Port, ILI_LCD_RST_Pin), + _task_error_buffer_writer(task_error_circular_buffer, + &task_error_circular_buffer_index_writer, + TASK_ERROR_CIRCULAR_BUFFER_SIZE), + _lastScreen(DISPLAY_SCREEN_IDLE), + _hasRendered(false) +{ + memset(&_lastFrame, 0, sizeof(_lastFrame)); +} + +bool ILI9341Display::Init() +{ + if (!_panel.Init(ILI9341_ROTATION_LANDSCAPE)) + { + task_error_data error_data = PopulateTaskErrorDataStruct( + get_timestamp(), + TASK_OFFSET_DISPLAY, + static_cast(ERROR_DISPLAY_INIT_FAILURE) + ); + + _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); + return false; + } + + return Clear(); +} + +bool ILI9341Display::Clear() +{ + if (!_panel.FillScreen(ILI9341_DISPLAY_BACKGROUND)) + { + return false; + } + + memset(&_lastFrame, 0, sizeof(_lastFrame)); + + return true; +} + +bool ILI9341Display::DrawField(const ili9341_field& field) +{ + return _panel.DrawString(field.x, field.y, field.text, field.length, + field.colour, ILI9341_DISPLAY_BACKGROUND, field.size); +} + +bool ILI9341Display::Render(const session_controller_to_display& state) +{ + ili9341_frame frame; + ili9341_layout(&state, &frame); + + // A new screen has a different set of fields in different places, so there is nothing to + // diff against -- blank the panel and paint all of it. Within a screen the field list is + // positionally stable, which is what makes the index-wise comparison below valid. + const bool screenChanged = !_hasRendered || state.screen != _lastScreen; + + if (screenChanged && !Clear()) + { + return false; + } + + for (uint8_t i = 0; i < frame.count; i++) + { + if (!screenChanged + && i < _lastFrame.count + && ili9341_field_equal(&frame.fields[i], &_lastFrame.fields[i])) + { + continue; + } + + if (!DrawField(frame.fields[i])) + { + task_error_data error_data = PopulateTaskErrorDataStruct( + get_timestamp(), + TASK_OFFSET_DISPLAY, + static_cast(ERROR_DISPLAY_SPI_TRANSMIT_FAILURE) + ); + + _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); + return false; + } + } + + _lastFrame = frame; + _lastScreen = state.screen; + _hasRendered = true; + + return true; +} + +static_assert(DisplayDriver, + "ILI9341Display must satisfy DisplayDriver -- see Tasks/Display/DisplayDriver.hpp"); + +extern "C" void ili9341_lcd_main(osMessageQueueId_t sessionControllerToDisplayHandle) +{ + // Static rather than a local: the display task runs on a small FreeRTOS stack and this + // object carries a frame of layout state. -fno-threadsafe-statics is set and this function + // runs exactly once, so there is no guard variable and no initialisation race. + static ILI9341Display display; + + if (!display.Init()) + { + osThreadSuspend(osThreadGetId()); + } + + RunDisplayTask(display, sessionControllerToDisplayHandle); +} diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md new file mode 100644 index 0000000..b264568 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -0,0 +1,87 @@ +--- +module: Display +summary: The display seam — screen state in, whichever panel is fitted out. Holds the ILI9341 driver. +code: + - Core/Inc/Tasks/Display/DisplayDriver.hpp + - Core/Inc/Tasks/Display/display_common.h + - Core/Src/Tasks/Display/display_common.c + - Core/Inc/Tasks/Display/ILI9341Display.hpp + - Core/Src/Tasks/Display/ILI9341Display.cpp + - Core/Inc/Tasks/Display/ili9341_layout.h + - Core/Src/Tasks/Display/ili9341_layout.c + - Core/Inc/Tasks/Display/ili9341_main.h +entry_point: ili9341_lcd_main() +task_offset: TASK_OFFSET_DISPLAY +consumes: [session_controller_to_display (SessionController)] +produces: [task_error_circular_buffer] +related: [LumexLCD, SessionController, MessagePassing] +--- + +# Display — the panel-independent seam + +Two panels are supported and exactly one is compiled in: the Lumex 16x2 character LCD +([[LumexLCD]]) and an ILI9341 320x240 TFT. Both read the same queue and the same message. + +## The contract + +`session_controller_to_display` carries a `display_screen_id` plus every value any screen +shows. The FSM says **what it is displaying**; each driver decides **how**. + +There is deliberately no common *drawing* API. The intersection of a character grid and a +320x240 TFT (`WriteText(row, column, string)`) caps the TFT at 16x2; the union +(`DrawRect`, `SetFont`, `DrawBitmap`) is meaningless on the LCD. Putting the seam at what +the values *mean* leaves each panel free: everything the TFT can do that the LCD cannot +lives inside its `Render()` and never appears in the contract. + +## DisplayDriver — a concept, not a base class + +```cpp +template +concept DisplayDriver = requires(T d, const session_controller_to_display& s) { + { d.Init() } -> std::same_as; + { d.Clear() } -> std::same_as; + { d.Render(s) } -> std::same_as; +}; +``` + +Virtual dispatch would cost a vtable pointer and an indirect call per draw for a choice +fixed at link time. The concept checks the same contract at compile time and inlines +through it. Each driver's `.cpp` carries `static_assert(DisplayDriver<...>)`, so a +signature mismatch is an error at the driver rather than at the call site. + +`RunDisplayTask` is the shared queue-drain loop. It **drains to the newest +message** before drawing: each one is the whole screen state, so the ones behind it are +already stale. + +## Choosing a panel + +`Core/Inc/Config/debug.h`, exactly one set to 1: + +```c +#define LUMEX_LCD_TASK_ENABLE 1 +#define ILI9341_LCD_TASK_ENABLE 0 +``` + +A `#error` catches both or neither. `lcdDisplayTaskEntryFunction` in `main.c` dispatches to +`lumex_lcd_main()` or `ili9341_lcd_main()`. Both drivers are always compiled; +`--gc-sections` drops the unused one. + +## ILI9341 rendering + +- `ili9341_layout()` is pure: screen state in, up to `ILI9341_MAX_FIELDS` positioned text + fields out. No HAL, no RTOS — `tests/ili9341_layout_tests.cpp` checks it host-side. +- For a given screen the field list is **positionally stable**: same count, order, + positions and widths whatever the values. That is what makes the driver's index-wise diff + valid, and it is asserted in the tests rather than assumed. +- Fields are fixed-width and space-padded. Drawing paints foreground *and* background, so a + redraw erases the previous value — there is no read-modify-write on this bus. +- `Render()` repaints only fields whose text or colour moved. A change of `screen` clears + and repaints in full. This is not an optimisation: a full frame is ~98 ms at 12.5 MHz, + against ~1-2 ms for one field. + +## Errors +`ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → +`task_error_circular_buffer`. + +## Related +[[LumexLCD]] · [[ILI9341 driver]] · [[SessionController]] · [[MessagePassing]] diff --git a/firmware/Core/Src/Tasks/Display/display_common.c b/firmware/Core/Src/Tasks/Display/display_common.c new file mode 100644 index 0000000..2f97697 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/display_common.c @@ -0,0 +1,14 @@ +#include "Tasks/Display/display_common.h" + +uint32_t display_rpm_digit_increment(display_rpm_digit digit) +{ + switch (digit) + { + case DISPLAY_RPM_DIGIT_TEN_THOUSAND: return 10000; + case DISPLAY_RPM_DIGIT_THOUSAND: return 1000; + case DISPLAY_RPM_DIGIT_HUNDRED: return 100; + case DISPLAY_RPM_DIGIT_TEN: return 10; + case DISPLAY_RPM_DIGIT_ONE: return 1; + default: return 0; + } +} diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ili9341_layout.c new file mode 100644 index 0000000..608bcad --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/ili9341_layout.c @@ -0,0 +1,178 @@ +#include "Tasks/Display/ili9341_layout.h" + +#include +#include +#include + +#include "ILI9341_main.h" + +#include "Tasks/Display/display_common.h" + +// Palette. Values white, labels and units grey, the drive mode coloured by what it is doing -- +// the one thing worth spotting across the room while the rig is running. +#define COLOUR_BACKGROUND ILI9341_BLACK +#define COLOUR_VALUE ILI9341_WHITE +#define COLOUR_LABEL ILI9341_LIGHTGREY +#define COLOUR_ON ILI9341_GREEN +#define COLOUR_OFF ILI9341_RED +#define COLOUR_BRAKE ILI9341_YELLOW + +// Text scales, in 6x8 cells. +#define SIZE_TITLE 3 // 18x24 +#define SIZE_HUGE 6 // 36x48 +#define SIZE_VALUE 5 // 30x40 +#define SIZE_TOGGLE 4 // 24x32 +#define SIZE_SMALL 2 // 12x16 + +#define CELL_WIDTH(size) (ILI9341_FONT_CELL_WIDTH * (size)) + +// Centres a `length`-character run of the given scale. +static uint16_t centred(uint8_t length, uint8_t size) +{ + const uint16_t width = (uint16_t)length * CELL_WIDTH(size); + + return (width >= ILI9341_LAYOUT_WIDTH) ? 0 : (uint16_t)((ILI9341_LAYOUT_WIDTH - width) / 2); +} + +static void add_field(ili9341_frame *out, uint16_t x, uint16_t y, uint8_t size, uint16_t colour, + const char *text) +{ + if (out->count >= ILI9341_MAX_FIELDS) + { + return; + } + + ili9341_field *field = &out->fields[out->count++]; + + field->x = x; + field->y = y; + field->size = size; + field->colour = colour; + + size_t length = strlen(text); + if (length > ILI9341_FIELD_TEXT_MAX - 1) + { + length = ILI9341_FIELD_TEXT_MAX - 1; + } + + memcpy(field->text, text, length); + field->text[length] = '\0'; + field->length = (uint8_t)length; +} + +// Centred horizontally, which is what every screen but the session readout wants. +static void add_centred(ili9341_frame *out, uint16_t y, uint8_t size, uint16_t colour, + const char *text) +{ + add_field(out, centred((uint8_t)strlen(text), size), y, size, colour, text); +} + +bool ili9341_field_equal(const ili9341_field *a, const ili9341_field *b) +{ + return a->x == b->x + && a->y == b->y + && a->size == b->size + && a->colour == b->colour + && a->length == b->length + && memcmp(a->text, b->text, a->length) == 0; +} + +// The two toggle pages share a value row. Fixed at eight characters so "ENABLED " paints over +// the whole of a previous "DISABLED". +static void add_enabled_disabled(ili9341_frame *out, bool enabled) +{ + add_field(out, centred(8, SIZE_TOGGLE), 130, SIZE_TOGGLE, + enabled ? COLOUR_ON : COLOUR_OFF, + enabled ? "ENABLED " : "DISABLED"); +} + +static void layout_session(const session_controller_to_display *state, ili9341_frame *out) +{ + char scratch[ILI9341_FIELD_TEXT_MAX]; + + // Speed: label, big value, unit alongside. + add_field(out, 12, 18, SIZE_SMALL, COLOUR_LABEL, "SPEED"); + + uint32_t rpm = (uint32_t)roundf(state->rpm); + snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)rpm); + add_field(out, 12, 40, SIZE_VALUE, COLOUR_VALUE, scratch); + + add_field(out, 172, 64, SIZE_SMALL, COLOUR_LABEL, "rpm"); + + // Force, the same shape one row down. + add_field(out, 12, 100, SIZE_SMALL, COLOUR_LABEL, "FORCE"); + + float force = roundf(state->force * 100.0f) / 100.0f; + snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + add_field(out, 12, 122, SIZE_VALUE, COLOUR_VALUE, scratch); + + add_field(out, 200, 146, SIZE_SMALL, COLOUR_LABEL, "N"); + + // Drive mode. Which of the two appears is the menu option, not the live PID state: with the + // option off there is nothing to arm, so what the encoder actually drives is shown instead. + // Ten characters either way so one paints over the other. + if (state->pid_option_toggleable) + { + add_field(out, 12, 196, SIZE_TITLE, + state->pid_enabled ? COLOUR_ON : COLOUR_OFF, + state->pid_enabled ? "PID ARMED " : "PID OFF "); + } + else + { + uint8_t duty = (uint8_t)roundf(state->bpm_duty_cycle * 100.0f); + snprintf(scratch, sizeof(scratch), "BRAKE %3u%%", duty); + add_field(out, 12, 196, SIZE_TITLE, COLOUR_BRAKE, scratch); + } +} + +void ili9341_layout(const session_controller_to_display *state, ili9341_frame *out) +{ + memset(out, 0, sizeof(*out)); + + char scratch[ILI9341_FIELD_TEXT_MAX]; + + switch (state->screen) + { + case DISPLAY_SCREEN_IDLE: + add_centred(out, 70, SIZE_HUGE, COLOUR_VALUE, "DYNO"); + add_centred(out, 150, SIZE_SMALL, COLOUR_LABEL, "PRESS SELECT"); + break; + + case DISPLAY_SCREEN_SD_LOGGING: + add_centred(out, 60, SIZE_TITLE, COLOUR_LABEL, "SD LOGGING"); + add_enabled_disabled(out, state->sd_logging_enabled); + break; + + case DISPLAY_SCREEN_PID_ENABLE: + add_centred(out, 60, SIZE_TITLE, COLOUR_LABEL, "PID LOGGING"); + add_enabled_disabled(out, state->pid_option_toggleable); + break; + + case DISPLAY_SCREEN_DESIRED_RPM: + add_centred(out, 60, SIZE_TITLE, COLOUR_LABEL, "PID DES RPM"); + + snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)state->desired_rpm); + add_field(out, centred(5, SIZE_VALUE), 130, SIZE_VALUE, COLOUR_VALUE, scratch); + break; + + // The same page with the step the encoder is about to apply, so the user can see which + // digit a tick will move. + case DISPLAY_SCREEN_DESIRED_RPM_EDIT: + add_centred(out, 60, SIZE_TITLE, COLOUR_LABEL, "PID DES RPM"); + + snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)state->desired_rpm); + add_field(out, centred(5, SIZE_VALUE), 120, SIZE_VALUE, COLOUR_VALUE, scratch); + + snprintf(scratch, sizeof(scratch), "STEP %5lu", + (unsigned long)display_rpm_digit_increment(state->cursor_digit)); + add_field(out, centred(10, SIZE_SMALL), 185, SIZE_SMALL, COLOUR_BRAKE, scratch); + break; + + case DISPLAY_SCREEN_SESSION: + layout_session(state, out); + break; + + default: + break; + } +} diff --git a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp index 7e235af..d617b38 100644 --- a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp @@ -2,6 +2,8 @@ #include #include +#include "Tasks/Display/DisplayDriver.hpp" + extern TIM_HandleTypeDef* lumexLcdTimer; extern size_t task_error_circular_buffer_index_writer; @@ -9,9 +11,8 @@ extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZ static volatile bool timerCallbackFlag = false; -LumexLCD::LumexLCD(osMessageQueueId_t sessionControllerToDisplayHandle) : +LumexLCD::LumexLCD() : _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), - _fromSCqHandle(sessionControllerToDisplayHandle), _lastScreen(DISPLAY_SCREEN_IDLE), _hasRendered(false) { @@ -70,32 +71,6 @@ bool LumexLCD::Init() return true; } - void LumexLCD::Run(void) - { - - session_controller_to_display msg; - memset(&msg, 0, sizeof(msg)); - - while (1) - { - // Block until a message arrives - if (osMessageQueueGet(_fromSCqHandle, &msg, 0, osWaitForever) == osOK) - { - // Drain to the newest state before drawing anything. Each message is the whole of - // what should be on screen, so the ones behind it are already stale -- rendering - // them in turn would only paint values the user is not going to see. - while (osMessageQueueGet(_fromSCqHandle, &msg, 0, 0) == osOK); - - if (!Render(msg)) - { - return; - } - } - - osDelay(sysconfig_get_u32(SYSCFG_LCD_TASK_OSDELAY)); - } - } - bool LumexLCD::Clear() { if (!ClearDisplay()) @@ -168,7 +143,7 @@ bool LumexLCD::StartTimer(uint8_t microseconds) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), - TASK_OFFSET_LUMEX_LCD, + TASK_OFFSET_DISPLAY, static_cast(ERROR_LUMEX_LCD_TIMER_START_FAILURE) ); @@ -339,17 +314,19 @@ extern "C" void lumex_lcd_timer_interrupt() } +static_assert(DisplayDriver, + "LumexLCD must satisfy DisplayDriver -- see Tasks/Display/DisplayDriver.hpp"); + extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayHandle) { - LumexLCD lcd = LumexLCD(sessionControllerToDisplayHandle); + LumexLCD lcd; if (!lcd.Init()) { - osThreadSuspend(osThreadGetId());; + osThreadSuspend(osThreadGetId()); } - - lcd.Run(); + RunDisplayTask(lcd, sessionControllerToDisplayHandle); } diff --git a/firmware/Core/Src/Tasks/LCD/README.md b/firmware/Core/Src/Tasks/LCD/README.md index fb30f41..485dc59 100644 --- a/firmware/Core/Src/Tasks/LCD/README.md +++ b/firmware/Core/Src/Tasks/LCD/README.md @@ -8,10 +8,10 @@ code: - Core/Inc/Tasks/LCD/lumex_layout.h - Core/Inc/Tasks/LCD/lumexlcd_main.h entry_point: lumex_lcd_main() -task_offset: TASK_OFFSET_LUMEX_LCD +task_offset: TASK_OFFSET_DISPLAY consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] -related: [SessionController, MessagePassing] +related: [Display, SessionController, MessagePassing] --- # LumexLCD — character display task @@ -20,16 +20,10 @@ Bit-bangs a Lumex parallel LCD over GPIO and renders the screen state the [[SessionController]] FSM sends. ## The display seam - The FSM sends **what it is showing**, not how to draw it: `session_controller_to_display` carries a `display_screen_id` plus every value any screen displays. Turning that into -characters is this task's job. - -That split exists because a 16x2 character LCD and a 320x240 TFT have no useful common -drawing API — the intersection caps the TFT at 16x2, the union is meaningless here — so the -seam sits at what the values *mean* instead. `AddToLumexLCDMessageQueue(op, row, column, -string)` was the old protocol; it also left a driver unable to tell *which* quantity had -changed, since all it received was `(" 1234", row 0, col 3)`. +characters is this task's job. See [[Display]] for why the seam sits there, and for the +`DisplayDriver` concept this class satisfies. ## Flow 1. `lumex_lcd_main()` → construct, `Init()`, `Run()`. @@ -65,4 +59,4 @@ number it is given and no panel repeats the conversion. - `SYSCFG_LCD_TASK_OSDELAY` (sysconfig), `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` (config.h) ## Related -[[SessionController]] · [[MessagePassing]] +[[Display]] · [[SessionController]] · [[MessagePassing]] diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/LCD/lumex_layout.c index adf887f..7243621 100644 --- a/firmware/Core/Src/Tasks/LCD/lumex_layout.c +++ b/firmware/Core/Src/Tasks/LCD/lumex_layout.c @@ -34,19 +34,6 @@ static void put_field(lumex_frame *out, unsigned row, unsigned column, size_t wi put(out, row, column, scratch, width); } -uint32_t lumex_rpm_digit_increment(display_rpm_digit digit) -{ - switch (digit) - { - case DISPLAY_RPM_DIGIT_TEN_THOUSAND: return 10000; - case DISPLAY_RPM_DIGIT_THOUSAND: return 1000; - case DISPLAY_RPM_DIGIT_HUNDRED: return 100; - case DISPLAY_RPM_DIGIT_TEN: return 10; - case DISPLAY_RPM_DIGIT_ONE: return 1; - default: return 0; - } -} - // The second row shared by both toggle pages. static void render_enabled_disabled(lumex_frame *out, bool enabled) { @@ -134,7 +121,7 @@ void lumex_render(const session_controller_to_display *state, lumex_frame *out) char scratch[SCRATCH_SIZE]; snprintf(scratch, sizeof(scratch), "%5lu %5lu", (unsigned long)state->desired_rpm, - (unsigned long)lumex_rpm_digit_increment(state->cursor_digit)); + (unsigned long)display_rpm_digit_increment(state->cursor_digit)); put_field(out, 1, 2, 11, scratch); break; } diff --git a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp index 205568d..eaedc2d 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp +++ b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp @@ -95,7 +95,7 @@ void TaskMonitor::Run() GetTaskDataAndSendToUsbController(TASK_OFFSET_PID_CONTROLLER, _osThreadIdPtrs->pid_controller); #endif #if LUMEX_LCD_TASK_ENABLE - GetTaskDataAndSendToUsbController(TASK_OFFSET_LUMEX_LCD, _osThreadIdPtrs->display); + GetTaskDataAndSendToUsbController(TASK_OFFSET_DISPLAY, _osThreadIdPtrs->display); #endif GetTaskDataAndSendToUsbController(TASK_OFFSET_TASK_MONITOR, osThreadGetId()); diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index b896e61..bd86267 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -27,6 +27,7 @@ #include #include #include +#include #include #include @@ -124,7 +125,7 @@ const osThreadAttr_t sessionControllerTask_attributes = { osThreadId_t lcdDisplayTaskHandle; const osThreadAttr_t lcdDisplayTask_attributes = { .name = "lcdDisplayTask", - .stack_size = 128 * 4, + .stack_size = 256 * 4, .priority = (osPriority_t) osPriorityBelowNormal, }; /* Definitions for ledBlinkTask */ @@ -738,11 +739,11 @@ static void MX_SPI1_Init(void) hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; - hspi1.Init.DataSize = SPI_DATASIZE_4BIT; + hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; - hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; + hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; @@ -1095,10 +1096,13 @@ static void MX_GPIO_Init(void) HAL_GPIO_WritePin(GPIOH, ILI_SPI2_TOUCH_CS_Pin|ILI_SPI2_SD_CS_Pin, GPIO_PIN_RESET); /*Configure GPIO pin Output Level */ - HAL_GPIO_WritePin(GPIOD, ILI_LCD_DC_Pin|ILI_LCD_RST_Pin, GPIO_PIN_RESET); + HAL_GPIO_WritePin(ILI_LCD_DC_GPIO_Port, ILI_LCD_DC_Pin, GPIO_PIN_RESET); /*Configure GPIO pin Output Level */ - HAL_GPIO_WritePin(ILI_SPI1_LCD_CS_GPIO_Port, ILI_SPI1_LCD_CS_Pin, GPIO_PIN_RESET); + HAL_GPIO_WritePin(ILI_LCD_RST_GPIO_Port, ILI_LCD_RST_Pin, GPIO_PIN_SET); + + /*Configure GPIO pin Output Level */ + HAL_GPIO_WritePin(ILI_SPI1_LCD_CS_GPIO_Port, ILI_SPI1_LCD_CS_Pin, GPIO_PIN_SET); /*Configure GPIO pin Output Level */ HAL_GPIO_WritePin(GPIOI, LED_BACK_Pin|LED_SELECT_Pin|LED_BRAKE_Pin, GPIO_PIN_SET); @@ -1300,12 +1304,12 @@ void pidControllerTaskEntryFunction(void *argument) void sessionControllerTaskEntryFunction(void* argument) { - #if (!defined(SESSION_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE)) - #error "SESSION_CONTROLLER_TASK_ENABLE or LUMEX_LCD_TASK_ENABLE is not defined. Please define it as 0 or 1 in the configuration header." + #if (!defined(SESSION_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE) || !defined(ILI9341_LCD_TASK_ENABLE)) + #error "SESSION_CONTROLLER_TASK_ENABLE or the display driver enables are not defined. Please define them as 0 or 1 in the configuration header." #elif SESSION_CONTROLLER_TASK_ENABLE == 0 osThreadSuspend(osThreadGetId()); - #elif LUMEX_LCD_TASK_ENABLE == 0 - #error "Lumex LCD is a hard dependency of the Session Controller task. Please enable LUMEX_LCD_TASK_ENABLE." + #elif (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) == 0 + #error "A display is a hard dependency of the Session Controller task. Enable one display driver." #else session_controller_os_task_queues tasks = { .usb_controller = sessionControllertoUsbControllerHandle, @@ -1335,10 +1339,12 @@ void opticalSensorTaskEntryFunction(void *argument) void lcdDisplayTaskEntryFunction(void *argument) { - #if (!defined(LUMEX_LCD_TASK_ENABLE)) - #error "LUMEX_LCD_TASK_ENABLE is not defined. Please define it as 0 or 1 in the configuration header." - #elif LUMEX_LCD_TASK_ENABLE == 0 - osThreadSuspend(osThreadGetId()); + /* Config/debug.h enforces that exactly one of these is 1, so there is no "neither" case + here -- both panels read the same queue and the same message. */ + #if (!defined(LUMEX_LCD_TASK_ENABLE) || !defined(ILI9341_LCD_TASK_ENABLE)) + #error "LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE are not defined. Please define them in the configuration header." + #elif ILI9341_LCD_TASK_ENABLE == 1 + ili9341_lcd_main(sessionControllerToDisplayHandle); #else lumex_lcd_main(sessionControllerToDisplayHandle); #endif diff --git a/firmware/Drivers/ILI9341/ILI9341.cpp b/firmware/Drivers/ILI9341/ILI9341.cpp new file mode 100644 index 0000000..5fdc069 --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341.cpp @@ -0,0 +1,354 @@ +#include "ILI9341.hpp" + +#include + +// The controller's power-on sequence, transcribed from Adafruit_ILI9341.cpp's initcmd[]. +// Format: command, argument count, arguments... A count with the high bit set means "and then +// delay 150 ms", which is what SLPOUT and DISPON need. Terminated by a zero command. +// +// This table is the one genuinely panel-specific thing the Adafruit library knows that a +// datasheet reading would take a long time to reproduce -- the gamma curves in particular are +// tuned values, not derivations. +static const uint8_t ILI9341_INIT_COMMANDS[] = { + 0xEF, 3, 0x03, 0x80, 0x02, + 0xCF, 3, 0x00, 0xC1, 0x30, + 0xED, 4, 0x64, 0x03, 0x12, 0x81, + 0xE8, 3, 0x85, 0x00, 0x78, + 0xCB, 5, 0x39, 0x2C, 0x00, 0x34, 0x02, + 0xF7, 1, 0x20, + 0xEA, 2, 0x00, 0x00, + ILI9341_PWCTR1, 1, 0x23, // Power control VRH[5:0] + ILI9341_PWCTR2, 1, 0x10, // Power control SAP[2:0];BT[3:0] + ILI9341_VMCTR1, 2, 0x3E, 0x28, // VCOM control + ILI9341_VMCTR2, 1, 0x86, // VCOM control 2 + ILI9341_MADCTL, 1, 0x48, // Memory access control (SetRotation overrides) + ILI9341_VSCRSADD, 1, 0x00, // Vertical scroll zero + ILI9341_PIXFMT, 1, 0x55, // 16 bits/pixel, RGB565 + ILI9341_FRMCTR1, 2, 0x00, 0x18, + ILI9341_DFUNCTR, 3, 0x08, 0x82, 0x27, // Display function control + 0xF2, 1, 0x00, // 3Gamma function disable + ILI9341_GAMMASET, 1, 0x01, // Gamma curve selected + ILI9341_GMCTRP1, 15, 0x0F, 0x31, 0x2B, 0x0C, 0x0E, 0x08, + 0x4E, 0xF1, 0x37, 0x07, 0x10, 0x03, 0x0E, 0x09, 0x00, + ILI9341_GMCTRN1, 15, 0x00, 0x0E, 0x14, 0x03, 0x11, 0x07, + 0x31, 0xC1, 0x48, 0x08, 0x0F, 0x0C, 0x31, 0x36, 0x0F, + ILI9341_SLPOUT, 0x80, // Exit sleep, then wait + ILI9341_DISPON, 0x80, // Display on, then wait + 0x00 // End of list +}; + +// MADCTL value per rotation index. MV is the row/column exchange that makes it landscape; BGR +// is set because these modules wire the panel that way -- a correct image in wrong colours is +// this bit. +static const uint8_t ILI9341_ROTATION_MADCTL[4] = { + ILI9341_MADCTL_MX | ILI9341_MADCTL_BGR, + ILI9341_MADCTL_MV | ILI9341_MADCTL_BGR, + ILI9341_MADCTL_MY | ILI9341_MADCTL_BGR, + ILI9341_MADCTL_MX | ILI9341_MADCTL_MY | ILI9341_MADCTL_MV | ILI9341_MADCTL_BGR, +}; + +// Pixel scratch, deliberately in .bss rather than on the caller's stack: the display task runs +// on a small FreeRTOS stack and there is exactly one panel, so a shared buffer is both cheaper +// and safer than a local. Sized to one glyph row at the largest text scale, which is also a +// convenient chunk for filling rectangles. +#define ILI9341_SCRATCH_PIXELS (ILI9341_FONT_CELL_WIDTH * ILI9341_MAX_TEXT_SIZE) +static uint8_t ili9341_scratch[ILI9341_SCRATCH_PIXELS * 2]; + +// How long HAL_SPI_Transmit may block. Generous: it only matters if the bus is wedged, and the +// display task is the lowest-priority thing that could be waiting. +#define ILI9341_SPI_TIMEOUT_MS 100 + + +ILI9341::ILI9341(SPI_HandleTypeDef* spi, + GPIO_TypeDef* csPort, uint16_t csPin, + GPIO_TypeDef* dcPort, uint16_t dcPin, + GPIO_TypeDef* rstPort, uint16_t rstPin) : + _spi(spi), + _csPort(csPort), _dcPort(dcPort), _rstPort(rstPort), + _csPin(csPin), _dcPin(dcPin), _rstPin(rstPin), + _rotation(ILI9341_ROTATION_LANDSCAPE) +{} + + +// ---------------------------------------------------------------------------- transport + +void ILI9341::Select() +{ + HAL_GPIO_WritePin(_csPort, _csPin, GPIO_PIN_RESET); +} + +void ILI9341::Deselect() +{ + HAL_GPIO_WritePin(_csPort, _csPin, GPIO_PIN_SET); +} + +bool ILI9341::WriteCommand(uint8_t command) +{ + HAL_GPIO_WritePin(_dcPort, _dcPin, GPIO_PIN_RESET); + + return HAL_SPI_Transmit(_spi, &command, 1, ILI9341_SPI_TIMEOUT_MS) == HAL_OK; +} + +bool ILI9341::WriteData(const uint8_t* data, size_t length) +{ + if (length == 0) + { + return true; + } + + HAL_GPIO_WritePin(_dcPort, _dcPin, GPIO_PIN_SET); + + // HAL_SPI_Transmit takes a uint16_t count, so anything longer goes in chunks. Callers here + // never exceed it, but a future full-frame blit would. + while (length > 0) + { + const uint16_t chunk = (length > UINT16_MAX) ? UINT16_MAX : (uint16_t)length; + + if (HAL_SPI_Transmit(_spi, (uint8_t*)data, chunk, ILI9341_SPI_TIMEOUT_MS) != HAL_OK) + { + return false; + } + + data += chunk; + length -= chunk; + } + + return true; +} + +bool ILI9341::SendCommand(uint8_t command, const uint8_t* data, size_t length) +{ + Select(); + + const bool ok = WriteCommand(command) && WriteData(data, length); + + Deselect(); + + return ok; +} + + +// ---------------------------------------------------------------------------- setup + +bool ILI9341::Init(uint8_t rotation) +{ + Deselect(); + + // Reset is active low and must be held well past the controller's 10 us minimum; the panel + // then needs time before it will accept commands. + HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_SET); + HAL_Delay(5); + HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_RESET); + HAL_Delay(20); + HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_SET); + HAL_Delay(150); + + const uint8_t* command = ILI9341_INIT_COMMANDS; + + while (*command) + { + const uint8_t opcode = *command++; + uint8_t count = *command++; + const bool delayAfter = (count & 0x80) != 0; + + count &= 0x7F; + + if (!SendCommand(opcode, command, count)) + { + return false; + } + + command += count; + + if (delayAfter) + { + HAL_Delay(150); + } + } + + return SetRotation(rotation); +} + +bool ILI9341::SetRotation(uint8_t rotation) +{ + _rotation = rotation & 0x03; + + const uint8_t madctl = ILI9341_ROTATION_MADCTL[_rotation]; + + return SendCommand(ILI9341_MADCTL, &madctl, 1); +} + +bool ILI9341::InvertDisplay(bool invert) +{ + return SendCommand(invert ? ILI9341_INVON : ILI9341_INVOFF, NULL, 0); +} + +uint16_t ILI9341::Width() const +{ + return (_rotation == ILI9341_ROTATION_LANDSCAPE + || _rotation == ILI9341_ROTATION_LANDSCAPE_FLIP) + ? ILI9341_TFTHEIGHT : ILI9341_TFTWIDTH; +} + +uint16_t ILI9341::Height() const +{ + return (_rotation == ILI9341_ROTATION_LANDSCAPE + || _rotation == ILI9341_ROTATION_LANDSCAPE_FLIP) + ? ILI9341_TFTWIDTH : ILI9341_TFTHEIGHT; +} + + +// ---------------------------------------------------------------------------- drawing + +bool ILI9341::SetAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h) +{ + const uint16_t panelWidth = Width(); + const uint16_t panelHeight = Height(); + + if (x >= panelWidth || y >= panelHeight || w == 0 || h == 0) + { + return false; + } + + // Clip rather than reject: a field drawn near the edge should lose its overhang, not + // vanish, and an unclipped window makes the controller wrap the write onto the next row. + if (x + w > panelWidth) w = panelWidth - x; + if (y + h > panelHeight) h = panelHeight - y; + + const uint16_t x1 = x + w - 1; + const uint16_t y1 = y + h - 1; + + const uint8_t columns[4] = { (uint8_t)(x >> 8), (uint8_t)x, (uint8_t)(x1 >> 8), (uint8_t)x1 }; + const uint8_t pages[4] = { (uint8_t)(y >> 8), (uint8_t)y, (uint8_t)(y1 >> 8), (uint8_t)y1 }; + + Select(); + + const bool ok = WriteCommand(ILI9341_CASET) && WriteData(columns, sizeof(columns)) + && WriteCommand(ILI9341_PASET) && WriteData(pages, sizeof(pages)) + && WriteCommand(ILI9341_RAMWR); + + // Left selected on purpose: the caller streams pixel data straight into the open RAMWR. + if (!ok) + { + Deselect(); + } + + return ok; +} + +bool ILI9341::FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t colour) +{ + const uint16_t panelWidth = Width(); + const uint16_t panelHeight = Height(); + + if (x >= panelWidth || y >= panelHeight || w == 0 || h == 0) + { + return true; // entirely off-panel: nothing to do, and not an error + } + + if (x + w > panelWidth) w = panelWidth - x; + if (y + h > panelHeight) h = panelHeight - y; + + if (!SetAddrWindow(x, y, w, h)) + { + return false; + } + + for (size_t i = 0; i < ILI9341_SCRATCH_PIXELS; i++) + { + ili9341_scratch[i * 2] = (uint8_t)(colour >> 8); + ili9341_scratch[i * 2 + 1] = (uint8_t)colour; + } + + size_t remaining = (size_t)w * (size_t)h; + bool ok = true; + + while (remaining > 0 && ok) + { + const size_t pixels = (remaining > ILI9341_SCRATCH_PIXELS) + ? ILI9341_SCRATCH_PIXELS : remaining; + + ok = WriteData(ili9341_scratch, pixels * 2); + remaining -= pixels; + } + + Deselect(); + + return ok; +} + +bool ILI9341::FillScreen(uint16_t colour) +{ + return FillRect(0, 0, Width(), Height(), colour); +} + +bool ILI9341::DrawChar(uint16_t x, uint16_t y, char c, uint16_t fg, uint16_t bg, uint8_t size) +{ + if (size == 0 || size > ILI9341_MAX_TEXT_SIZE) + { + return false; + } + + const uint16_t cellWidth = ILI9341_FONT_CELL_WIDTH * size; + const uint16_t cellHeight = ILI9341_FONT_CELL_HEIGHT * size; + + // One window for the whole cell, then every row streamed into the open RAMWR. Writing the + // cell as a single run rather than pixel by pixel is the difference between one command + // sequence and several hundred. + if (!SetAddrWindow(x, y, cellWidth, cellHeight)) + { + return false; + } + + bool ok = true; + + for (uint8_t glyphRow = 0; glyphRow < ILI9341_FONT_CELL_HEIGHT && ok; glyphRow++) + { + // Build one glyph row at scale, then repeat it `size` times down the panel. + for (uint8_t column = 0; column < ILI9341_FONT_CELL_WIDTH; column++) + { + const bool lit = ili9341_font_pixel(c, column, glyphRow); + const uint16_t pixel = lit ? fg : bg; + + for (uint8_t repeat = 0; repeat < size; repeat++) + { + const size_t at = ((size_t)column * size + repeat) * 2; + + ili9341_scratch[at] = (uint8_t)(pixel >> 8); + ili9341_scratch[at + 1] = (uint8_t)pixel; + } + } + + for (uint8_t repeat = 0; repeat < size && ok; repeat++) + { + ok = WriteData(ili9341_scratch, (size_t)cellWidth * 2); + } + } + + Deselect(); + + return ok; +} + +bool ILI9341::DrawString(uint16_t x, uint16_t y, const char* text, size_t length, + uint16_t fg, uint16_t bg, uint8_t size) +{ + const uint16_t advance = ILI9341_FONT_CELL_WIDTH * size; + + for (size_t i = 0; i < length; i++) + { + const uint16_t at = x + (uint16_t)(i * advance); + + if (at >= Width()) + { + break; // clip rather than wrap onto the row below + } + + if (!DrawChar(at, y, text[i], fg, bg, size)) + { + return false; + } + } + + return true; +} diff --git a/firmware/Drivers/ILI9341/ILI9341.hpp b/firmware/Drivers/ILI9341/ILI9341.hpp new file mode 100644 index 0000000..fa2b755 --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341.hpp @@ -0,0 +1,82 @@ +#ifndef DRIVERS_ILI9341_ILI9341_HPP_ +#define DRIVERS_ILI9341_ILI9341_HPP_ + +// ILI9341 240x320 SPI TFT, driven through the STM32 HAL. +// +// Written against HAL_SPI_Transmit rather than ported from Adafruit_ILI9341. That library's +// transport lives in Adafruit_SPITFT.cpp -- 2600 lines of per-MCU #ifdef over Arduino's +// digitalWrite/SPIClass, with no STM32 branch -- and its class chain +// (Adafruit_ILI9341 : Adafruit_SPITFT : Adafruit_GFX : Print) carries twenty virtual +// functions this codebase deliberately does not pay for. What is actually panel-specific is +// the init table and the address-window command, which are vendored in ILI9341_main.h and +// below; see README.md. +// +// Same shape as Drivers/ADS1115: a plain class, no base, no virtuals, HAL handles passed in. + +#include +#include +#include + +#include "ILI9341_font.h" +#include "ILI9341_main.h" + +#include "main.h" + +class ILI9341 +{ +public: + ILI9341(SPI_HandleTypeDef* spi, + GPIO_TypeDef* csPort, uint16_t csPin, + GPIO_TypeDef* dcPort, uint16_t dcPin, + GPIO_TypeDef* rstPort, uint16_t rstPin); + ~ILI9341() = default; + + // Hardware reset pulse, then the vendored power/gamma sequence, then `rotation`. Leaves + // the panel on with undefined framebuffer contents -- callers fill before showing. + bool Init(uint8_t rotation = ILI9341_ROTATION_LANDSCAPE); + + bool SetRotation(uint8_t rotation); + bool InvertDisplay(bool invert); + + bool FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t colour); + bool FillScreen(uint16_t colour); + + // One glyph in a (6*size x 8*size) cell, foreground on background. Both are painted, so + // drawing over a cell erases what was there -- there is no read-modify-write on this bus. + bool DrawChar(uint16_t x, uint16_t y, char c, uint16_t fg, uint16_t bg, uint8_t size); + + // `length` characters, advancing one cell each. Not NUL-aware: callers pass fixed-width + // fields so that a shorter value overwrites the tail of a longer one. + bool DrawString(uint16_t x, uint16_t y, const char* text, size_t length, + uint16_t fg, uint16_t bg, uint8_t size); + + // Follow the active rotation: 320x240 in landscape, 240x320 in portrait. + uint16_t Width() const; + uint16_t Height() const; + +private: + bool WriteCommand(uint8_t command); + bool WriteData(const uint8_t* data, size_t length); + bool SendCommand(uint8_t command, const uint8_t* data, size_t length); + + // Clips to the panel and returns false if nothing is left, so callers can skip the write + // rather than send a malformed window the controller would interpret as a wrap. + bool SetAddrWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h); + + void Select(); + void Deselect(); + + SPI_HandleTypeDef* _spi; + + GPIO_TypeDef* _csPort; + GPIO_TypeDef* _dcPort; + GPIO_TypeDef* _rstPort; + + uint16_t _csPin; + uint16_t _dcPin; + uint16_t _rstPin; + + uint8_t _rotation; +}; + +#endif /* DRIVERS_ILI9341_ILI9341_HPP_ */ diff --git a/firmware/Drivers/ILI9341/ILI9341_font.c b/firmware/Drivers/ILI9341/ILI9341_font.c new file mode 100644 index 0000000..78b7d2a --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341_font.c @@ -0,0 +1,270 @@ +// The classic 5x7 GFX font: 256 glyphs, 5 column-bytes each, 1280 bytes total. +// +// Pure data vendored from Adafruit-GFX-Library/glcdfont.c. That file's only Arduino +// dependency was a PROGMEM attribute which is defined away on every non-AVR target -- the +// bytes themselves carry no code, so nothing needed porting. Each byte is one column of a +// glyph, LSB at the top; the 6th column and 8th row of the cell are the inter-character gap +// the renderer adds, not stored here. +// +// Adafruit-GFX is BSD-licensed; see Drivers/ILI9341/ILI9341_main.h for the notice. + +#include "ILI9341_font.h" + +const uint8_t ili9341_font[ILI9341_FONT_GLYPH_COUNT * ILI9341_FONT_GLYPH_WIDTH] = { + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x3E, 0x5B, 0x4F, 0x5B, 0x3E, + 0x3E, 0x6B, 0x4F, 0x6B, 0x3E, + 0x1C, 0x3E, 0x7C, 0x3E, 0x1C, + 0x18, 0x3C, 0x7E, 0x3C, 0x18, + 0x1C, 0x57, 0x7D, 0x57, 0x1C, + 0x1C, 0x5E, 0x7F, 0x5E, 0x1C, + 0x00, 0x18, 0x3C, 0x18, 0x00, + 0xFF, 0xE7, 0xC3, 0xE7, 0xFF, + 0x00, 0x18, 0x24, 0x18, 0x00, + 0xFF, 0xE7, 0xDB, 0xE7, 0xFF, + 0x30, 0x48, 0x3A, 0x06, 0x0E, + 0x26, 0x29, 0x79, 0x29, 0x26, + 0x40, 0x7F, 0x05, 0x05, 0x07, + 0x40, 0x7F, 0x05, 0x25, 0x3F, + 0x5A, 0x3C, 0xE7, 0x3C, 0x5A, + 0x7F, 0x3E, 0x1C, 0x1C, 0x08, + 0x08, 0x1C, 0x1C, 0x3E, 0x7F, + 0x14, 0x22, 0x7F, 0x22, 0x14, + 0x5F, 0x5F, 0x00, 0x5F, 0x5F, + 0x06, 0x09, 0x7F, 0x01, 0x7F, + 0x00, 0x66, 0x89, 0x95, 0x6A, + 0x60, 0x60, 0x60, 0x60, 0x60, + 0x94, 0xA2, 0xFF, 0xA2, 0x94, + 0x08, 0x04, 0x7E, 0x04, 0x08, + 0x10, 0x20, 0x7E, 0x20, 0x10, + 0x08, 0x08, 0x2A, 0x1C, 0x08, + 0x08, 0x1C, 0x2A, 0x08, 0x08, + 0x1E, 0x10, 0x10, 0x10, 0x10, + 0x0C, 0x1E, 0x0C, 0x1E, 0x0C, + 0x30, 0x38, 0x3E, 0x38, 0x30, + 0x06, 0x0E, 0x3E, 0x0E, 0x06, + 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x5F, 0x00, 0x00, + 0x00, 0x07, 0x00, 0x07, 0x00, + 0x14, 0x7F, 0x14, 0x7F, 0x14, + 0x24, 0x2A, 0x7F, 0x2A, 0x12, + 0x23, 0x13, 0x08, 0x64, 0x62, + 0x36, 0x49, 0x56, 0x20, 0x50, + 0x00, 0x08, 0x07, 0x03, 0x00, + 0x00, 0x1C, 0x22, 0x41, 0x00, + 0x00, 0x41, 0x22, 0x1C, 0x00, + 0x2A, 0x1C, 0x7F, 0x1C, 0x2A, + 0x08, 0x08, 0x3E, 0x08, 0x08, + 0x00, 0x80, 0x70, 0x30, 0x00, + 0x08, 0x08, 0x08, 0x08, 0x08, + 0x00, 0x00, 0x60, 0x60, 0x00, + 0x20, 0x10, 0x08, 0x04, 0x02, + 0x3E, 0x51, 0x49, 0x45, 0x3E, + 0x00, 0x42, 0x7F, 0x40, 0x00, + 0x72, 0x49, 0x49, 0x49, 0x46, + 0x21, 0x41, 0x49, 0x4D, 0x33, + 0x18, 0x14, 0x12, 0x7F, 0x10, + 0x27, 0x45, 0x45, 0x45, 0x39, + 0x3C, 0x4A, 0x49, 0x49, 0x31, + 0x41, 0x21, 0x11, 0x09, 0x07, + 0x36, 0x49, 0x49, 0x49, 0x36, + 0x46, 0x49, 0x49, 0x29, 0x1E, + 0x00, 0x00, 0x14, 0x00, 0x00, + 0x00, 0x40, 0x34, 0x00, 0x00, + 0x00, 0x08, 0x14, 0x22, 0x41, + 0x14, 0x14, 0x14, 0x14, 0x14, + 0x00, 0x41, 0x22, 0x14, 0x08, + 0x02, 0x01, 0x59, 0x09, 0x06, + 0x3E, 0x41, 0x5D, 0x59, 0x4E, + 0x7C, 0x12, 0x11, 0x12, 0x7C, + 0x7F, 0x49, 0x49, 0x49, 0x36, + 0x3E, 0x41, 0x41, 0x41, 0x22, + 0x7F, 0x41, 0x41, 0x41, 0x3E, + 0x7F, 0x49, 0x49, 0x49, 0x41, + 0x7F, 0x09, 0x09, 0x09, 0x01, + 0x3E, 0x41, 0x41, 0x51, 0x73, + 0x7F, 0x08, 0x08, 0x08, 0x7F, + 0x00, 0x41, 0x7F, 0x41, 0x00, + 0x20, 0x40, 0x41, 0x3F, 0x01, + 0x7F, 0x08, 0x14, 0x22, 0x41, + 0x7F, 0x40, 0x40, 0x40, 0x40, + 0x7F, 0x02, 0x1C, 0x02, 0x7F, + 0x7F, 0x04, 0x08, 0x10, 0x7F, + 0x3E, 0x41, 0x41, 0x41, 0x3E, + 0x7F, 0x09, 0x09, 0x09, 0x06, + 0x3E, 0x41, 0x51, 0x21, 0x5E, + 0x7F, 0x09, 0x19, 0x29, 0x46, + 0x26, 0x49, 0x49, 0x49, 0x32, + 0x03, 0x01, 0x7F, 0x01, 0x03, + 0x3F, 0x40, 0x40, 0x40, 0x3F, + 0x1F, 0x20, 0x40, 0x20, 0x1F, + 0x3F, 0x40, 0x38, 0x40, 0x3F, + 0x63, 0x14, 0x08, 0x14, 0x63, + 0x03, 0x04, 0x78, 0x04, 0x03, + 0x61, 0x59, 0x49, 0x4D, 0x43, + 0x00, 0x7F, 0x41, 0x41, 0x41, + 0x02, 0x04, 0x08, 0x10, 0x20, + 0x00, 0x41, 0x41, 0x41, 0x7F, + 0x04, 0x02, 0x01, 0x02, 0x04, + 0x40, 0x40, 0x40, 0x40, 0x40, + 0x00, 0x03, 0x07, 0x08, 0x00, + 0x20, 0x54, 0x54, 0x78, 0x40, + 0x7F, 0x28, 0x44, 0x44, 0x38, + 0x38, 0x44, 0x44, 0x44, 0x28, + 0x38, 0x44, 0x44, 0x28, 0x7F, + 0x38, 0x54, 0x54, 0x54, 0x18, + 0x00, 0x08, 0x7E, 0x09, 0x02, + 0x18, 0xA4, 0xA4, 0x9C, 0x78, + 0x7F, 0x08, 0x04, 0x04, 0x78, + 0x00, 0x44, 0x7D, 0x40, 0x00, + 0x20, 0x40, 0x40, 0x3D, 0x00, + 0x7F, 0x10, 0x28, 0x44, 0x00, + 0x00, 0x41, 0x7F, 0x40, 0x00, + 0x7C, 0x04, 0x78, 0x04, 0x78, + 0x7C, 0x08, 0x04, 0x04, 0x78, + 0x38, 0x44, 0x44, 0x44, 0x38, + 0xFC, 0x18, 0x24, 0x24, 0x18, + 0x18, 0x24, 0x24, 0x18, 0xFC, + 0x7C, 0x08, 0x04, 0x04, 0x08, + 0x48, 0x54, 0x54, 0x54, 0x24, + 0x04, 0x04, 0x3F, 0x44, 0x24, + 0x3C, 0x40, 0x40, 0x20, 0x7C, + 0x1C, 0x20, 0x40, 0x20, 0x1C, + 0x3C, 0x40, 0x30, 0x40, 0x3C, + 0x44, 0x28, 0x10, 0x28, 0x44, + 0x4C, 0x90, 0x90, 0x90, 0x7C, + 0x44, 0x64, 0x54, 0x4C, 0x44, + 0x00, 0x08, 0x36, 0x41, 0x00, + 0x00, 0x00, 0x77, 0x00, 0x00, + 0x00, 0x41, 0x36, 0x08, 0x00, + 0x02, 0x01, 0x02, 0x04, 0x02, + 0x3C, 0x26, 0x23, 0x26, 0x3C, + 0x1E, 0xA1, 0xA1, 0x61, 0x12, + 0x3A, 0x40, 0x40, 0x20, 0x7A, + 0x38, 0x54, 0x54, 0x55, 0x59, + 0x21, 0x55, 0x55, 0x79, 0x41, + 0x22, 0x54, 0x54, 0x78, 0x42, + 0x21, 0x55, 0x54, 0x78, 0x40, + 0x20, 0x54, 0x55, 0x79, 0x40, + 0x0C, 0x1E, 0x52, 0x72, 0x12, + 0x39, 0x55, 0x55, 0x55, 0x59, + 0x39, 0x54, 0x54, 0x54, 0x59, + 0x39, 0x55, 0x54, 0x54, 0x58, + 0x00, 0x00, 0x45, 0x7C, 0x41, + 0x00, 0x02, 0x45, 0x7D, 0x42, + 0x00, 0x01, 0x45, 0x7C, 0x40, + 0x7D, 0x12, 0x11, 0x12, 0x7D, + 0xF0, 0x28, 0x25, 0x28, 0xF0, + 0x7C, 0x54, 0x55, 0x45, 0x00, + 0x20, 0x54, 0x54, 0x7C, 0x54, + 0x7C, 0x0A, 0x09, 0x7F, 0x49, + 0x32, 0x49, 0x49, 0x49, 0x32, + 0x3A, 0x44, 0x44, 0x44, 0x3A, + 0x32, 0x4A, 0x48, 0x48, 0x30, + 0x3A, 0x41, 0x41, 0x21, 0x7A, + 0x3A, 0x42, 0x40, 0x20, 0x78, + 0x00, 0x9D, 0xA0, 0xA0, 0x7D, + 0x3D, 0x42, 0x42, 0x42, 0x3D, + 0x3D, 0x40, 0x40, 0x40, 0x3D, + 0x3C, 0x24, 0xFF, 0x24, 0x24, + 0x48, 0x7E, 0x49, 0x43, 0x66, + 0x2B, 0x2F, 0xFC, 0x2F, 0x2B, + 0xFF, 0x09, 0x29, 0xF6, 0x20, + 0xC0, 0x88, 0x7E, 0x09, 0x03, + 0x20, 0x54, 0x54, 0x79, 0x41, + 0x00, 0x00, 0x44, 0x7D, 0x41, + 0x30, 0x48, 0x48, 0x4A, 0x32, + 0x38, 0x40, 0x40, 0x22, 0x7A, + 0x00, 0x7A, 0x0A, 0x0A, 0x72, + 0x7D, 0x0D, 0x19, 0x31, 0x7D, + 0x26, 0x29, 0x29, 0x2F, 0x28, + 0x26, 0x29, 0x29, 0x29, 0x26, + 0x30, 0x48, 0x4D, 0x40, 0x20, + 0x38, 0x08, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x38, + 0x2F, 0x10, 0xC8, 0xAC, 0xBA, + 0x2F, 0x10, 0x28, 0x34, 0xFA, + 0x00, 0x00, 0x7B, 0x00, 0x00, + 0x08, 0x14, 0x2A, 0x14, 0x22, + 0x22, 0x14, 0x2A, 0x14, 0x08, + 0x55, 0x00, 0x55, 0x00, 0x55, + 0xAA, 0x55, 0xAA, 0x55, 0xAA, + 0xFF, 0x55, 0xFF, 0x55, 0xFF, + 0x00, 0x00, 0x00, 0xFF, 0x00, + 0x10, 0x10, 0x10, 0xFF, 0x00, + 0x14, 0x14, 0x14, 0xFF, 0x00, + 0x10, 0x10, 0xFF, 0x00, 0xFF, + 0x10, 0x10, 0xF0, 0x10, 0xF0, + 0x14, 0x14, 0x14, 0xFC, 0x00, + 0x14, 0x14, 0xF7, 0x00, 0xFF, + 0x00, 0x00, 0xFF, 0x00, 0xFF, + 0x14, 0x14, 0xF4, 0x04, 0xFC, + 0x14, 0x14, 0x17, 0x10, 0x1F, + 0x10, 0x10, 0x1F, 0x10, 0x1F, + 0x14, 0x14, 0x14, 0x1F, 0x00, + 0x10, 0x10, 0x10, 0xF0, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0x10, + 0x10, 0x10, 0x10, 0x1F, 0x10, + 0x10, 0x10, 0x10, 0xF0, 0x10, + 0x00, 0x00, 0x00, 0xFF, 0x10, + 0x10, 0x10, 0x10, 0x10, 0x10, + 0x10, 0x10, 0x10, 0xFF, 0x10, + 0x00, 0x00, 0x00, 0xFF, 0x14, + 0x00, 0x00, 0xFF, 0x00, 0xFF, + 0x00, 0x00, 0x1F, 0x10, 0x17, + 0x00, 0x00, 0xFC, 0x04, 0xF4, + 0x14, 0x14, 0x17, 0x10, 0x17, + 0x14, 0x14, 0xF4, 0x04, 0xF4, + 0x00, 0x00, 0xFF, 0x00, 0xF7, + 0x14, 0x14, 0x14, 0x14, 0x14, + 0x14, 0x14, 0xF7, 0x00, 0xF7, + 0x14, 0x14, 0x14, 0x17, 0x14, + 0x10, 0x10, 0x1F, 0x10, 0x1F, + 0x14, 0x14, 0x14, 0xF4, 0x14, + 0x10, 0x10, 0xF0, 0x10, 0xF0, + 0x00, 0x00, 0x1F, 0x10, 0x1F, + 0x00, 0x00, 0x00, 0x1F, 0x14, + 0x00, 0x00, 0x00, 0xFC, 0x14, + 0x00, 0x00, 0xF0, 0x10, 0xF0, + 0x10, 0x10, 0xFF, 0x10, 0xFF, + 0x14, 0x14, 0x14, 0xFF, 0x14, + 0x10, 0x10, 0x10, 0x1F, 0x00, + 0x00, 0x00, 0x00, 0xF0, 0x10, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, + 0xFF, 0xFF, 0xFF, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xFF, 0xFF, + 0x0F, 0x0F, 0x0F, 0x0F, 0x0F, + 0x38, 0x44, 0x44, 0x38, 0x44, + 0xFC, 0x4A, 0x4A, 0x4A, 0x34, + 0x7E, 0x02, 0x02, 0x06, 0x06, + 0x02, 0x7E, 0x02, 0x7E, 0x02, + 0x63, 0x55, 0x49, 0x41, 0x63, + 0x38, 0x44, 0x44, 0x3C, 0x04, + 0x40, 0x7E, 0x20, 0x1E, 0x20, + 0x06, 0x02, 0x7E, 0x02, 0x02, + 0x99, 0xA5, 0xE7, 0xA5, 0x99, + 0x1C, 0x2A, 0x49, 0x2A, 0x1C, + 0x4C, 0x72, 0x01, 0x72, 0x4C, + 0x30, 0x4A, 0x4D, 0x4D, 0x30, + 0x30, 0x48, 0x78, 0x48, 0x30, + 0xBC, 0x62, 0x5A, 0x46, 0x3D, + 0x3E, 0x49, 0x49, 0x49, 0x00, + 0x7E, 0x01, 0x01, 0x01, 0x7E, + 0x2A, 0x2A, 0x2A, 0x2A, 0x2A, + 0x44, 0x44, 0x5F, 0x44, 0x44, + 0x40, 0x51, 0x4A, 0x44, 0x40, + 0x40, 0x44, 0x4A, 0x51, 0x40, + 0x00, 0x00, 0xFF, 0x01, 0x03, + 0xE0, 0x80, 0xFF, 0x00, 0x00, + 0x08, 0x08, 0x6B, 0x6B, 0x08, + 0x36, 0x12, 0x36, 0x24, 0x36, + 0x06, 0x0F, 0x09, 0x0F, 0x06, + 0x00, 0x00, 0x18, 0x18, 0x00, + 0x00, 0x00, 0x10, 0x10, 0x00, + 0x30, 0x40, 0xFF, 0x01, 0x01, + 0x00, 0x1F, 0x01, 0x01, 0x1E, + 0x00, 0x19, 0x1D, 0x17, 0x12, + 0x00, 0x3C, 0x3C, 0x3C, 0x3C, + 0x00, 0x00, 0x00, 0x00, 0x00, +}; diff --git a/firmware/Drivers/ILI9341/ILI9341_font.h b/firmware/Drivers/ILI9341/ILI9341_font.h new file mode 100644 index 0000000..12149c4 --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341_font.h @@ -0,0 +1,38 @@ +#ifndef DRIVERS_ILI9341_ILI9341_FONT_H_ +#define DRIVERS_ILI9341_ILI9341_FONT_H_ + +#include +#include + +#include "ILI9341_main.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define ILI9341_FONT_GLYPH_COUNT 256 + +// One column per byte, LSB at the top of the glyph. Glyph `c` starts at +// `c * ILI9341_FONT_GLYPH_WIDTH`; there is an entry for every value a char can take, so no +// bounds check is needed beyond masking to 8 bits. +extern const uint8_t ili9341_font[ILI9341_FONT_GLYPH_COUNT * ILI9341_FONT_GLYPH_WIDTH]; + +// Whether the pixel at (column, row) within a glyph's 5x7 box is set. Split out from the +// drawing code so the host tests can check glyph extraction without a panel. +static inline bool ili9341_font_pixel(char c, uint8_t column, uint8_t row) +{ + if (column >= ILI9341_FONT_GLYPH_WIDTH || row >= ILI9341_FONT_GLYPH_HEIGHT + 1) + { + return false; + } + + const uint8_t bits = ili9341_font[(uint8_t)c * ILI9341_FONT_GLYPH_WIDTH + column]; + + return (bits >> row) & 0x01; +} + +#ifdef __cplusplus +} +#endif + +#endif /* DRIVERS_ILI9341_ILI9341_FONT_H_ */ diff --git a/firmware/Drivers/ILI9341/ILI9341_main.h b/firmware/Drivers/ILI9341/ILI9341_main.h new file mode 100644 index 0000000..f375a9d --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341_main.h @@ -0,0 +1,150 @@ +// ILI9341 register/command codes and 16-bit colour constants. +// +// Transcribed from the Adafruit_ILI9341 Arduino library (Adafruit_ILI9341.h), which is the +// clearest published statement of this controller's command set and of the power/gamma +// sequence a real panel needs. Only the constants and the init table came across; the +// library's own transport is Arduino's -- see this directory's README for why none of it is +// vendored, submoduled, or forked. +// +/* +Software License Agreement (BSD License) + +Copyright (c) 2012 Adafruit Industries. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holders nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#ifndef DRIVERS_ILI9341_ILI9341_MAIN_H_ +#define DRIVERS_ILI9341_ILI9341_MAIN_H_ + +// Panel geometry in its native portrait orientation. Landscape swaps them -- see +// ILI9341::Width()/Height(), which follow the active rotation. +#define ILI9341_TFTWIDTH 240 +#define ILI9341_TFTHEIGHT 320 + +// --- Commands +#define ILI9341_NOP 0x00 +#define ILI9341_SWRESET 0x01 +#define ILI9341_RDDID 0x04 +#define ILI9341_RDDST 0x09 + +#define ILI9341_SLPIN 0x10 +#define ILI9341_SLPOUT 0x11 +#define ILI9341_PTLON 0x12 +#define ILI9341_NORON 0x13 + +#define ILI9341_RDMODE 0x0A +#define ILI9341_RDMADCTL 0x0B +#define ILI9341_RDPIXFMT 0x0C +#define ILI9341_RDIMGFMT 0x0D +#define ILI9341_RDSELFDIAG 0x0F + +#define ILI9341_INVOFF 0x20 +#define ILI9341_INVON 0x21 +#define ILI9341_GAMMASET 0x26 +#define ILI9341_DISPOFF 0x28 +#define ILI9341_DISPON 0x29 + +#define ILI9341_CASET 0x2A // Column address set +#define ILI9341_PASET 0x2B // Page address set +#define ILI9341_RAMWR 0x2C // Memory write +#define ILI9341_RAMRD 0x2E + +#define ILI9341_PTLAR 0x30 +#define ILI9341_VSCRDEF 0x33 +#define ILI9341_MADCTL 0x36 // Memory access control -- rotation and colour order +#define ILI9341_VSCRSADD 0x37 +#define ILI9341_PIXFMT 0x3A // COLMOD + +#define ILI9341_FRMCTR1 0xB1 +#define ILI9341_FRMCTR2 0xB2 +#define ILI9341_FRMCTR3 0xB3 +#define ILI9341_INVCTR 0xB4 +#define ILI9341_DFUNCTR 0xB6 + +#define ILI9341_PWCTR1 0xC0 +#define ILI9341_PWCTR2 0xC1 +#define ILI9341_PWCTR3 0xC2 +#define ILI9341_PWCTR4 0xC3 +#define ILI9341_PWCTR5 0xC4 +#define ILI9341_VMCTR1 0xC5 +#define ILI9341_VMCTR2 0xC7 + +#define ILI9341_RDID1 0xDA +#define ILI9341_RDID2 0xDB +#define ILI9341_RDID3 0xDC +#define ILI9341_RDID4 0xDD + +#define ILI9341_GMCTRP1 0xE0 +#define ILI9341_GMCTRN1 0xE1 + +// --- MADCTL bits. A blank screen is usually CS or reset polarity; wrong *colours* with a +// correct image is almost always the BGR bit. +#define ILI9341_MADCTL_MY 0x80 // Row address order: bottom to top +#define ILI9341_MADCTL_MX 0x40 // Column address order: right to left +#define ILI9341_MADCTL_MV 0x20 // Row/column exchange -- this is what makes it landscape +#define ILI9341_MADCTL_ML 0x10 +#define ILI9341_MADCTL_RGB 0x00 +#define ILI9341_MADCTL_BGR 0x08 +#define ILI9341_MADCTL_MH 0x04 + +// --- Rotations, as indices into the MADCTL values above. +#define ILI9341_ROTATION_PORTRAIT 0 // 240x320 +#define ILI9341_ROTATION_LANDSCAPE 1 // 320x240 +#define ILI9341_ROTATION_PORTRAIT_FLIP 2 +#define ILI9341_ROTATION_LANDSCAPE_FLIP 3 + +// --- Colours, RGB565. +#define ILI9341_BLACK 0x0000 +#define ILI9341_NAVY 0x000F +#define ILI9341_DARKGREEN 0x03E0 +#define ILI9341_DARKCYAN 0x03EF +#define ILI9341_MAROON 0x7800 +#define ILI9341_PURPLE 0x780F +#define ILI9341_OLIVE 0x7BE0 +#define ILI9341_LIGHTGREY 0xC618 +#define ILI9341_DARKGREY 0x7BEF +#define ILI9341_BLUE 0x001F +#define ILI9341_GREEN 0x07E0 +#define ILI9341_CYAN 0x07FF +#define ILI9341_RED 0xF800 +#define ILI9341_MAGENTA 0xF81F +#define ILI9341_YELLOW 0xFFE0 +#define ILI9341_WHITE 0xFFFF +#define ILI9341_ORANGE 0xFD20 +#define ILI9341_GREENYELLOW 0xAFE5 +#define ILI9341_PINK 0xFC18 + +// --- Largest text scale the driver will draw. Lives here rather than beside the driver class +// so the layout code -- which must not exceed it -- can see it without pulling in the HAL. +#define ILI9341_MAX_TEXT_SIZE 8 + +// --- The classic 5x7 glyphs, one column per byte, drawn in a 6x8 cell (the sixth column and +// eighth row are the inter-character gap). 1280 bytes of pure data lifted from Adafruit-GFX's +// glcdfont.c; the only Arduino-ism there was a PROGMEM attribute that is a no-op off AVR. +#define ILI9341_FONT_GLYPH_WIDTH 5 +#define ILI9341_FONT_GLYPH_HEIGHT 7 +#define ILI9341_FONT_CELL_WIDTH 6 +#define ILI9341_FONT_CELL_HEIGHT 8 + +#endif /* DRIVERS_ILI9341_ILI9341_MAIN_H_ */ diff --git a/firmware/Drivers/ILI9341/README.md b/firmware/Drivers/ILI9341/README.md new file mode 100644 index 0000000..4e71627 --- /dev/null +++ b/firmware/Drivers/ILI9341/README.md @@ -0,0 +1,85 @@ +--- +module: ILI9341 driver +summary: SPI driver for the ILI9341 240x320 TFT, used by the ILI9341 display task. +code: + - Drivers/ILI9341/ILI9341.hpp + - Drivers/ILI9341/ILI9341.cpp + - Drivers/ILI9341/ILI9341_main.h + - Drivers/ILI9341/ILI9341_font.h + - Drivers/ILI9341/ILI9341_font.c +used_by: Display (ILI9341 variant) +related: [Display, Config] +--- + +# ILI9341 — SPI TFT driver + +C++ driver for the ILI9341, written against the STM32 HAL. Same shape as the +[[ADS1115 driver]]: a plain class, no base class, no virtuals, HAL handles passed in. + +## Why this is not the Adafruit library + +The obvious move is to submodule `Adafruit_ILI9341`. That would actually be **three** +submodules — it depends on `Adafruit-GFX-Library` (which ships `Adafruit_SPITFT`), which +depends on `Adafruit_BusIO` — and none of them would work here: + +- **Vtables.** The chain is `Adafruit_ILI9341 : Adafruit_SPITFT : Adafruit_GFX : Print`. + 18 `virtual` in `Adafruit_GFX.h`, 2 more in `Adafruit_SPITFT.h`, plus Arduino's `Print` + base. No build configuration removes them, and the firmware builds `-fno-rtti + -fno-exceptions` precisely to avoid paying for that. +- **No STM32 branch to switch on.** `Adafruit_SPITFT.cpp` is 2621 lines of per-MCU + `#ifdef` — 24 `__AVR` sites, 21 `digitalPinToPort`, 19 `digitalWrite`, 13 + `portOutputRegister`, `SPIClass`/`SPISettings`. Porting means adding a whole new + architecture arm to someone else's dispatch tree, which upstream will never merge, so + the fork is permanent. +- **The payload is tiny.** What is genuinely ILI9341-specific is the init table, the + address-window command, and the MADCTL rotation values — about 30 lines. + +So: vendor the constants and the data, write the transport. Exactly what +`Drivers/ADS1115/README.md` describes for the force sensor's ADC. + +**Vendored, with Adafruit's BSD notice kept** (`ILI9341_main.h`): +- the init/power/gamma command table (`ILI9341_INIT_COMMANDS` in `ILI9341.cpp`) — tuned + values, not derivations, and the one thing worth taking; +- the command codes, MADCTL bits and RGB565 colour constants; +- `ILI9341_font.c`, the classic 5x7 GFX font: 256 glyphs x 5 column-bytes = 1280 bytes of + pure data. Its only Arduino dependency was a `PROGMEM` attribute that is defined away on + every non-AVR target. + +## Wiring +SPI1 is the display's own bus. `ILI_SPI1_MOSI` (PD7), `_MISO` (PG9), `_SCK` (PG11), +`ILI_SPI1_LCD_CS` (PG10), `ILI_LCD_DC` (PD5), `ILI_LCD_RST` (PD6) — all in `main.h`, all +owned by the `.ioc`. + +SPI1 runs 8-bit at `SPI_BAUDRATEPRESCALER_16`: SPI123 is clocked at 200 MHz, so that is a +12.5 MHz SCK. The datasheet allows about 10 MHz for writes and real modules take ~40 MHz, +so prescaler 8 (25 MHz) is the next thing to try once the panel is proven. + +## Key methods +- `Init(rotation)` — reset pulse, walk the init table, apply the rotation. Defaults to + landscape (320x240). +- `FillRect` / `FillScreen`, `DrawChar`, `DrawString`, `SetRotation`, `InvertDisplay`. +- `Width()` / `Height()` follow the active rotation. + +## Performance notes +- **Blocking `HAL_SPI_Transmit`, not DMA.** The display task is `osPriorityBelowNormal`, so + a polling wait is preempted by anything that matters and costs only idle time. DMA would + also be real work here rather than a flag: DMA1/DMA2 cannot reach DTCM on the STM32H7, and + `STM32H743XX_FLASH.ld` puts `.data`, `.bss`, the FreeRTOS heap and every task stack there — + so a transfer would need a scratch buffer in a new linker section in AXI SRAM. Worth doing + if a framebuffer or live graph ever streams full frames; not before. +- **Rectangles, never pixels.** A full frame is 320x240x16bpp = 153,600 bytes, ~98 ms at + 12.5 MHz — far too slow per sensor sample, which is why the display task repaints only + changed fields. `DrawChar` sets one address window per cell and streams the rows into the + open `RAMWR`; drawn pixel by pixel the same cell would be hundreds of command sequences. +- Pixel scratch lives in `.bss`, not on the caller's stack: the display task's stack is + 1 KB and there is exactly one panel. + +## Bring-up order +1. Reset pulse + `SLPOUT` + `FillScreen(WHITE)` → panel and backlight alive. +2. Fill red / green / blue → SPI, D/C and colour order. A blank screen is usually CS or + reset polarity; a correct image in wrong colours is the `MADCTL` BGR bit. +3. `DrawString` of a literal → font path. +4. Landscape rotation → 320x240 origin and orientation. + +## Related +[[Display]] · [[Config]] · upstream: https://github.com/adafruit/Adafruit_ILI9341 diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index 2b3a619..bfb89ee 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -23,7 +23,7 @@ CORTEX_M7.default_mode_Activation=1 FREERTOS.FootprintOK=true FREERTOS.IPParameters=Tasks01,configUSE_NEWLIB_REENTRANT,FootprintOK,Queues01,configMAX_TASK_NAME_LEN,configENABLE_FPU,configTOTAL_HEAP_SIZE,configCHECK_FOR_STACK_OVERFLOW FREERTOS.Queues01=sessionControllerToDisplay,25,session_controller_to_display,0,Dynamic,NULL,NULL; sessionControllerToBpm,10,session_controller_to_bpm,0,Dynamic,NULL,NULL; sessionControllerToForceSensor,16,bool,0,Dynamic,NULL,NULL; sessionControllerToPidController,5,session_controller_to_pid_controller,0,Dynamic,NULL,NULL; opticalEncoderToPidController,10,optical_encoder_output_data,0,Dynamic,NULL,NULL; pidControllerToBpm,10,float,0,Dynamic,NULL,NULL; sessionControllerToOpticalSensor,16,uint16_t,0,Dynamic,NULL,NULL;sessionControllertoUsbController,16,uint16_t,0,Dynamic,NULL,NULL;taskMonitorToUsbController,50,task_monitor_output_data,0,Dynamic,NULL,NULL;usbToForceSensorCommand,8,usb_task_command,0,Dynamic,NULL,NULL;taskToUsbControllerResponse,8,usb_task_completion,0,Dynamic,NULL,NULL;pidControllerToSessionControllerAck,5,bool,0,Dynamic,NULL,NULL -FREERTOS.Tasks01=usbTask,40,512,usbTaskEntryFunction,As weak,NULL,Dynamic,NULL,NULL; bpmTask,40,128,bpmTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; forceSensorTask,32,256,forceSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; pidTask,40,256,pidControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; opticalSensorTask,32,256,opticalSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;sessionControllerTask,40,256,sessionControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;lcdDisplayTask,16,128,lcdDisplayTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;ledBlinkTask,8,128,ledBlinkTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;taskMonitorTask,40,128,taskMonitorEntryFunction,As external,NULL,Dynamic,NULL,NULL +FREERTOS.Tasks01=usbTask,40,512,usbTaskEntryFunction,As weak,NULL,Dynamic,NULL,NULL; bpmTask,40,128,bpmTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; forceSensorTask,32,256,forceSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; pidTask,40,256,pidControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; opticalSensorTask,32,256,opticalSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;sessionControllerTask,40,256,sessionControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;lcdDisplayTask,16,256,lcdDisplayTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;ledBlinkTask,8,128,ledBlinkTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;taskMonitorTask,40,128,taskMonitorEntryFunction,As external,NULL,Dynamic,NULL,NULL FREERTOS.configCHECK_FOR_STACK_OVERFLOW=2 FREERTOS.configENABLE_FPU=1 FREERTOS.configMAX_TASK_NAME_LEN=32 @@ -255,10 +255,11 @@ PD5.GPIOParameters=GPIO_Label PD5.GPIO_Label=ILI_LCD_DC PD5.Locked=true PD5.Signal=GPIO_Output -PD6.GPIOParameters=GPIO_Speed,GPIO_Label +PD6.GPIOParameters=GPIO_Speed,PinState,GPIO_Label PD6.GPIO_Label=ILI_LCD_RST PD6.GPIO_Speed=GPIO_SPEED_FREQ_LOW PD6.Locked=true +PD6.PinState=GPIO_PIN_SET PD6.Signal=GPIO_Output PD7.GPIOParameters=GPIO_Label PD7.GPIO_Label=ILI_SPI1_MOSI @@ -298,10 +299,11 @@ PF7.GPIO_Label=ADC3_BPM_FB PF7.Locked=true PF7.Mode=IN3-Single-Ended PF7.Signal=ADC3_INP3 -PG10.GPIOParameters=GPIO_Speed,GPIO_Label +PG10.GPIOParameters=GPIO_Speed,PinState,GPIO_Label PG10.GPIO_Label=ILI_SPI1_LCD_CS PG10.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM PG10.Locked=true +PG10.PinState=GPIO_PIN_SET PG10.Signal=GPIO_Output PG11.GPIOParameters=GPIO_Label PG11.GPIO_Label=ILI_SPI1_SCK @@ -517,9 +519,11 @@ SH.S_TIM16_CH1.0=TIM16_CH1,PWM Generation1 CH1 SH.S_TIM16_CH1.ConfNb=1 SH.S_TIM4_CH1.0=TIM4_CH1,TriggerSource_TI1FP1 SH.S_TIM4_CH1.ConfNb=1 -SPI1.CalculateBaudRate=100.0 MBits/s +SPI1.BaudRatePrescaler=SPI_BAUDRATEPRESCALER_16 +SPI1.CalculateBaudRate=12.5 MBits/s +SPI1.DataSize=SPI_DATASIZE_8BIT SPI1.Direction=SPI_DIRECTION_2LINES -SPI1.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate +SPI1.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate,DataSize,BaudRatePrescaler SPI1.Mode=SPI_MODE_MASTER SPI1.VirtualType=VM_MASTER SPI2.CalculateBaudRate=100.0 MBits/s diff --git a/firmware/tests/CMakeLists.txt b/firmware/tests/CMakeLists.txt index 25c9722..a7821b3 100644 --- a/firmware/tests/CMakeLists.txt +++ b/firmware/tests/CMakeLists.txt @@ -36,19 +36,24 @@ add_executable(fw_tests ${FIRMWARE_DIR}/Core/Src/Tasks/USB/usb_framer.cpp ${FIRMWARE_DIR}/Core/Src/Tasks/OpticalSensor/encoder_math.c ${FIRMWARE_DIR}/Core/Src/Tasks/LCD/lumex_layout.c + ${FIRMWARE_DIR}/Core/Src/Tasks/Display/display_common.c + ${FIRMWARE_DIR}/Core/Src/Tasks/Display/ili9341_layout.c + ${FIRMWARE_DIR}/Drivers/ILI9341/ILI9341_font.c usb_rx_ring_tests.cpp usb_framer_tests.cpp sysconfig_tests.cpp circular_buffer_tests.cpp encoder_math_tests.cpp lumex_layout_tests.cpp + ili9341_layout_tests.cpp ) target_include_directories(fw_tests PRIVATE ${FIRMWARE_DIR}/Core/Inc ${FIRMWARE_DIR}/Drivers/ADS1115 # config.h includes ADS1115_main.h (register-code macros) + ${FIRMWARE_DIR}/Drivers/ILI9341 # command codes and the vendored font, both HAL-free ${FIRMWARE_DIR}/Middlewares/CircularBuffer/Inc - stubs # no-op FreeRTOS critical-section macros for the buffer headers + stubs # no-op FreeRTOS/CMSIS shims for the headers under test ) target_compile_options(fw_tests PRIVATE -Wall -Wextra) diff --git a/firmware/tests/ili9341_layout_tests.cpp b/firmware/tests/ili9341_layout_tests.cpp new file mode 100644 index 0000000..f9931d2 --- /dev/null +++ b/firmware/tests/ili9341_layout_tests.cpp @@ -0,0 +1,291 @@ +// Pins the ILI9341 panel's layout: which fields each screen produces, where, and how wide. +// +// The counterpart to lumex_layout_tests.cpp, and a different shape on purpose. There is no +// "correct" pixel layout to regress against the way there was a 2x16 grid transcribed from +// older code, so these check the properties the driver actually depends on: +// +// - fields stay on the panel, so nothing is silently clipped away; +// - a screen's field list is positionally stable regardless of the values, which is what +// makes the driver's index-wise diff valid rather than an accident; +// - fields that share a slot are equal width, so redrawing one erases the other -- there is +// no read-modify-write on this bus, and a shorter value would otherwise leave a tail. + +#include + +#include + +extern "C" { +#include "Tasks/Display/ili9341_layout.h" +} + +#include "ILI9341_font.h" +#include "ILI9341_main.h" + +namespace +{ + +session_controller_to_display State(display_screen_id screen) +{ + session_controller_to_display state{}; + state.screen = screen; + return state; +} + +ili9341_frame Layout(const session_controller_to_display &state) +{ + ili9341_frame frame{}; + ili9341_layout(&state, &frame); + return frame; +} + +const display_screen_id kAllScreens[] = { + DISPLAY_SCREEN_IDLE, DISPLAY_SCREEN_SD_LOGGING, + DISPLAY_SCREEN_PID_ENABLE, DISPLAY_SCREEN_DESIRED_RPM, + DISPLAY_SCREEN_DESIRED_RPM_EDIT, DISPLAY_SCREEN_SESSION, +}; + +uint16_t FieldRight(const ili9341_field &field) +{ + return field.x + (uint16_t)(field.length * ILI9341_FONT_CELL_WIDTH * field.size); +} + +uint16_t FieldBottom(const ili9341_field &field) +{ + return field.y + (uint16_t)(ILI9341_FONT_CELL_HEIGHT * field.size); +} + +// --------------------------------------------------------------------------- invariants + +TEST(Ili9341Layout, EveryScreenProducesFields) +{ + for (display_screen_id screen : kAllScreens) + { + EXPECT_GT(Layout(State(screen)).count, 0) << "screen " << screen; + } +} + +TEST(Ili9341Layout, NoFieldRunsOffThePanel) +{ + // Every field is drawn at a fixed position with no wrapping, so anything past an edge is + // simply lost. Checked at the widest values each screen can hold. + session_controller_to_display state{}; + state.desired_rpm = 99999; + state.rpm = 99999.0f; + state.force = 999.99f; + state.bpm_duty_cycle = 1.0f; + + for (display_screen_id screen : kAllScreens) + { + state.screen = screen; + const ili9341_frame frame = Layout(state); + + for (uint8_t i = 0; i < frame.count; i++) + { + const ili9341_field &field = frame.fields[i]; + + EXPECT_LE(FieldRight(field), ILI9341_LAYOUT_WIDTH) + << "screen " << screen << " field " << (int)i << " (\"" << field.text << "\")"; + EXPECT_LE(FieldBottom(field), ILI9341_LAYOUT_HEIGHT) + << "screen " << screen << " field " << (int)i << " (\"" << field.text << "\")"; + } + } +} + +TEST(Ili9341Layout, TextScalesAreWithinWhatTheDriverWillDraw) +{ + // DrawChar rejects a size above ILI9341_MAX_TEXT_SIZE, which would fail a whole render. + for (display_screen_id screen : kAllScreens) + { + const ili9341_frame frame = Layout(State(screen)); + + for (uint8_t i = 0; i < frame.count; i++) + { + EXPECT_GE(frame.fields[i].size, 1); + EXPECT_LE(frame.fields[i].size, ILI9341_MAX_TEXT_SIZE); + } + } +} + +TEST(Ili9341Layout, FieldsAreCappedSoTheFrameCannotOverflow) +{ + for (display_screen_id screen : kAllScreens) + { + EXPECT_LE(Layout(State(screen)).count, ILI9341_MAX_FIELDS); + } +} + +// --------------------------------------------------------------------------- diffability + +TEST(Ili9341Layout, AScreensFieldListIsPositionallyStable) +{ + // The driver compares field i against field i of the last frame and repaints only the + // movers. That is only valid if a screen always produces the same fields in the same + // places at the same sizes, whatever the values are. + session_controller_to_display quiet{}; + session_controller_to_display busy{}; + + busy.rpm = 4321.0f; + busy.force = 123.45f; + busy.desired_rpm = 98765; + busy.bpm_duty_cycle = 0.87f; + busy.pid_enabled = true; + busy.sd_logging_enabled = true; + busy.cursor_digit = DISPLAY_RPM_DIGIT_ONE; + + for (display_screen_id screen : kAllScreens) + { + quiet.screen = screen; + busy.screen = screen; + + const ili9341_frame a = Layout(quiet); + const ili9341_frame b = Layout(busy); + + ASSERT_EQ(a.count, b.count) << "screen " << screen; + + for (uint8_t i = 0; i < a.count; i++) + { + EXPECT_EQ(a.fields[i].x, b.fields[i].x) << "screen " << screen << " field " << (int)i; + EXPECT_EQ(a.fields[i].y, b.fields[i].y) << "screen " << screen << " field " << (int)i; + EXPECT_EQ(a.fields[i].size, b.fields[i].size) + << "screen " << screen << " field " << (int)i; + EXPECT_EQ(a.fields[i].length, b.fields[i].length) + << "screen " << screen << " field " << (int)i << ": widths must match so a " + "redraw erases the previous value"; + } + } +} + +TEST(Ili9341Layout, LayoutIsAPureFunctionOfState) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.rpm = 2500.0f; + + const ili9341_frame first = Layout(state); + const ili9341_frame second = Layout(state); + + ASSERT_EQ(first.count, second.count); + for (uint8_t i = 0; i < first.count; i++) + { + EXPECT_TRUE(ili9341_field_equal(&first.fields[i], &second.fields[i])); + } +} + +TEST(Ili9341Layout, FieldEqualityNoticesWhatTheDriverMustRepaint) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.rpm = 1000.0f; + const ili9341_frame before = Layout(state); + + state.rpm = 2000.0f; + const ili9341_frame after = Layout(state); + + // The speed readout moved; its label did not. + EXPECT_FALSE(ili9341_field_equal(&before.fields[1], &after.fields[1])); + EXPECT_TRUE(ili9341_field_equal(&before.fields[0], &after.fields[0])); +} + +TEST(Ili9341Layout, ColourChangeAloneCountsAsAChange) +{ + // The drive-mode field keeps its text length but changes colour between armed and off; a + // diff that only compared text would leave it the wrong colour. + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = true; + + state.pid_enabled = false; + const ili9341_frame off = Layout(state); + + state.pid_enabled = true; + const ili9341_frame on = Layout(state); + + const uint8_t driveMode = (uint8_t)(off.count - 1); + EXPECT_FALSE(ili9341_field_equal(&off.fields[driveMode], &on.fields[driveMode])); +} + +// --------------------------------------------------------------------------- content + +TEST(Ili9341Layout, TogglePagesUseEqualWidthLabels) +{ + // "ENABLED " is padded to eight so it covers "DISABLED" exactly. + session_controller_to_display state = State(DISPLAY_SCREEN_SD_LOGGING); + + state.sd_logging_enabled = true; + const ili9341_frame enabled = Layout(state); + + state.sd_logging_enabled = false; + const ili9341_frame disabled = Layout(state); + + EXPECT_EQ(std::string(enabled.fields[1].text), "ENABLED "); + EXPECT_EQ(std::string(disabled.fields[1].text), "DISABLED"); + EXPECT_EQ(enabled.fields[1].x, disabled.fields[1].x); +} + +TEST(Ili9341Layout, PidEnablePageShowsTheToggleableFlagNotTheLiveOne) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_PID_ENABLE); + state.pid_enabled = true; // in-session state; must not leak onto this page + + state.pid_option_toggleable = false; + EXPECT_EQ(std::string(Layout(state).fields[1].text), "DISABLED"); +} + +TEST(Ili9341Layout, SessionScreenShowsBrakeDutyWhenThePidCannotBeArmed) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.pid_option_toggleable = false; + state.pid_enabled = true; // must be ignored + state.bpm_duty_cycle = 0.95f; + + const ili9341_frame frame = Layout(state); + + EXPECT_EQ(std::string(frame.fields[frame.count - 1].text), "BRAKE 95%"); +} + +TEST(Ili9341Layout, SessionScreenRoundsTheSpeedReadout) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_SESSION); + state.rpm = 1234.6f; + + EXPECT_EQ(std::string(Layout(state).fields[1].text), " 1235"); +} + +TEST(Ili9341Layout, EditorShowsTheStepTheEncoderWillApply) +{ + session_controller_to_display state = State(DISPLAY_SCREEN_DESIRED_RPM_EDIT); + state.desired_rpm = 5000; + state.cursor_digit = DISPLAY_RPM_DIGIT_HUNDRED; + + const ili9341_frame frame = Layout(state); + + EXPECT_EQ(std::string(frame.fields[1].text), " 5000"); + EXPECT_EQ(std::string(frame.fields[2].text), "STEP 100"); +} + +// --------------------------------------------------------------------------- font + +TEST(Ili9341Font, GlyphExtractionMatchesKnownColumns) +{ + // Space is blank everywhere; '|' has its centre column filled. Cheap sanity that the + // vendored table is indexed correctly rather than off by a glyph. + for (uint8_t column = 0; column < ILI9341_FONT_GLYPH_WIDTH; column++) + { + for (uint8_t row = 0; row < ILI9341_FONT_GLYPH_HEIGHT; row++) + { + EXPECT_FALSE(ili9341_font_pixel(' ', column, row)); + } + } + + bool anyLit = false; + for (uint8_t row = 0; row < ILI9341_FONT_GLYPH_HEIGHT; row++) + { + anyLit = anyLit || ili9341_font_pixel('A', 1, row); + } + EXPECT_TRUE(anyLit) << "'A' should have lit pixels"; +} + +TEST(Ili9341Font, OutOfRangeCoordinatesAreBlankRatherThanOutOfBounds) +{ + EXPECT_FALSE(ili9341_font_pixel('A', ILI9341_FONT_GLYPH_WIDTH, 0)); + EXPECT_FALSE(ili9341_font_pixel('A', 0, ILI9341_FONT_GLYPH_HEIGHT + 1)); +} + +} // namespace diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp index dd0a1a0..c7d7dcb 100644 --- a/firmware/tests/lumex_layout_tests.cpp +++ b/firmware/tests/lumex_layout_tests.cpp @@ -158,13 +158,13 @@ TEST(LumexLayout, DesiredRpmEditorShowsTheStepBesideTheValue) EXPECT_EQ(Row(Render(state), 1), " 5000 100 "); } -TEST(LumexLayout, EveryCursorPositionMapsToItsStep) +TEST(DisplayCommon, EveryCursorPositionMapsToItsStep) { - EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN_THOUSAND), 10000u); - EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_THOUSAND), 1000u); - EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_HUNDRED), 100u); - EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN), 10u); - EXPECT_EQ(lumex_rpm_digit_increment(DISPLAY_RPM_DIGIT_ONE), 1u); + EXPECT_EQ(display_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN_THOUSAND), 10000u); + EXPECT_EQ(display_rpm_digit_increment(DISPLAY_RPM_DIGIT_THOUSAND), 1000u); + EXPECT_EQ(display_rpm_digit_increment(DISPLAY_RPM_DIGIT_HUNDRED), 100u); + EXPECT_EQ(display_rpm_digit_increment(DISPLAY_RPM_DIGIT_TEN), 10u); + EXPECT_EQ(display_rpm_digit_increment(DISPLAY_RPM_DIGIT_ONE), 1u); } // --------------------------------------------------------------------------- session diff --git a/firmware/tools/message_gen/schema/messages_public.yaml b/firmware/tools/message_gen/schema/messages_public.yaml index 0b617c7..4912928 100644 --- a/firmware/tools/message_gen/schema/messages_public.yaml +++ b/firmware/tools/message_gen/schema/messages_public.yaml @@ -75,7 +75,7 @@ sections: - { name: TASK_OFFSET_FORCE_SENSOR_ADS1115, value: "6u << TASK_OFFSET_SHIFT" } - { name: TASK_OFFSET_BPM_CONTROLLER, value: "7u << TASK_OFFSET_SHIFT" } - { name: TASK_OFFSET_PID_CONTROLLER, value: "8u << TASK_OFFSET_SHIFT" } - - { name: TASK_OFFSET_LUMEX_LCD, value: "9u << TASK_OFFSET_SHIFT" } + - { name: TASK_OFFSET_DISPLAY, value: "9u << TASK_OFFSET_SHIFT" } - kind: struct name: task_error_data @@ -136,17 +136,26 @@ sections: - { kind: static_assert, expr: "sizeof(bpm_task_error_ids) == 4", message: "Size of bpm_task_error_ids must be 4 bytes" } - kind: enum - name: lumex_lcd_task_error_ids + name: display_task_error_ids base: uint32_t - task: TASK_OFFSET_LUMEX_LCD + task: TASK_OFFSET_DISPLAY values: - name: ERROR_LUMEX_LCD_TIMER_START_FAILURE value: "0" description: >- - the timer that clocks the on-board LCD would not start, so the display is blank or - frozen. Nothing streamed to this app is affected — only the readout on the rig itself + the timer that clocks the on-board character LCD would not start, so the display is + blank or frozen. Nothing streamed to this app is affected — only the readout on the + rig itself + - name: ERROR_DISPLAY_INIT_FAILURE + description: >- + the on-board display would not initialise, so the rig has no local readout. Nothing + streamed to this app is affected. Usually the display's wiring or its SPI settings + - name: ERROR_DISPLAY_SPI_TRANSMIT_FAILURE + description: >- + a write to the on-board display failed, so its readout has stopped updating and is + showing stale values. Nothing streamed to this app is affected - - { kind: static_assert, expr: "sizeof(lumex_lcd_task_error_ids) == 4", message: "Size of lumex_lcd_task_error_ids must be 4 bytes" } + - { kind: static_assert, expr: "sizeof(display_task_error_ids) == 4", message: "Size of display_task_error_ids must be 4 bytes" } - kind: enum name: task_monitor_task_error_ids diff --git a/src/Dyno.Core/Messages/Generated/ErrorCatalog.cs b/src/Dyno.Core/Messages/Generated/ErrorCatalog.cs index f5322f0..50c6261 100644 --- a/src/Dyno.Core/Messages/Generated/ErrorCatalog.cs +++ b/src/Dyno.Core/Messages/Generated/ErrorCatalog.cs @@ -64,13 +64,29 @@ public static class ErrorCatalog false, "the brake's PWM timer refused to stop, so the brake may still be driven after the session ended. The task parks itself; treat the brake as live until the board is reset" ), - // lumex_lcd_task_error_ids.ERROR_LUMEX_LCD_TIMER_START_FAILURE + // display_task_error_ids.ERROR_LUMEX_LCD_TIMER_START_FAILURE new( 0x90000u, - task_offset_t.TASK_OFFSET_LUMEX_LCD, + task_offset_t.TASK_OFFSET_DISPLAY, "LUMEX_LCD_TIMER_START_FAILURE", false, - "the timer that clocks the on-board LCD would not start, so the display is blank or frozen. Nothing streamed to this app is affected — only the readout on the rig itself" + "the timer that clocks the on-board character LCD would not start, so the display is blank or frozen. Nothing streamed to this app is affected — only the readout on the rig itself" + ), + // display_task_error_ids.ERROR_DISPLAY_INIT_FAILURE + new( + 0x90001u, + task_offset_t.TASK_OFFSET_DISPLAY, + "DISPLAY_INIT_FAILURE", + false, + "the on-board display would not initialise, so the rig has no local readout. Nothing streamed to this app is affected. Usually the display's wiring or its SPI settings" + ), + // display_task_error_ids.ERROR_DISPLAY_SPI_TRANSMIT_FAILURE + new( + 0x90002u, + task_offset_t.TASK_OFFSET_DISPLAY, + "DISPLAY_SPI_TRANSMIT_FAILURE", + false, + "a write to the on-board display failed, so its readout has stopped updating and is showing stale values. Nothing streamed to this app is affected" ), // task_monitor_task_error_ids.ERROR_TASK_MONITOR_INVALID_THREAD_ID_POINTER new( diff --git a/src/Dyno.Core/Messages/Generated/Messages.cs b/src/Dyno.Core/Messages/Generated/Messages.cs index 3e17d6f..6ecd58f 100644 --- a/src/Dyno.Core/Messages/Generated/Messages.cs +++ b/src/Dyno.Core/Messages/Generated/Messages.cs @@ -51,7 +51,7 @@ public enum task_offset_t : uint TASK_OFFSET_FORCE_SENSOR_ADS1115 = 0x60000, TASK_OFFSET_BPM_CONTROLLER = 0x70000, TASK_OFFSET_PID_CONTROLLER = 0x80000, - TASK_OFFSET_LUMEX_LCD = 0x90000, + TASK_OFFSET_DISPLAY = 0x90000, } [StructLayout(LayoutKind.Sequential, Pack = 1)] @@ -73,9 +73,11 @@ public enum bpm_task_error_ids : uint ERROR_BPM_PWM_STOP_FAILURE = 1, } -public enum lumex_lcd_task_error_ids : uint +public enum display_task_error_ids : uint { ERROR_LUMEX_LCD_TIMER_START_FAILURE = 0, + ERROR_DISPLAY_INIT_FAILURE = 1, + ERROR_DISPLAY_SPI_TRANSMIT_FAILURE = 2, } public enum task_monitor_task_error_ids : uint @@ -417,7 +419,7 @@ public static class MessageContract (typeof(task_offset_t), 4), (typeof(session_controller_task_error_ids), 4), (typeof(bpm_task_error_ids), 4), - (typeof(lumex_lcd_task_error_ids), 4), + (typeof(display_task_error_ids), 4), (typeof(task_monitor_task_error_ids), 4), (typeof(pid_controller_task_error_ids), 4), (typeof(usb_controller_task_error_ids), 4), From 038bead77bdd0a1a128f7d1cbe1d34c8c1b6920a Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 19:24:04 -0700 Subject: [PATCH 05/25] display: refuse to build the LED blink task alongside the ILI9341 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 --- firmware/Core/Inc/Config/debug.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index 4098b60..b415a24 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -68,8 +68,23 @@ #define USB_CONTROLLER_TASK_ENABLE 1 // Led Blink Task +// +// It has no LED of its own: it blinks by toggling ILI_SPI2_SD_CS (PH7), which is the microSD +// slot's chip select on the ILI9341 module. That was picked as a convenient scope point back +// when nothing else used the pin. It is not a free pin once that module is fitted, so the +// check below refuses the combination rather than leaving someone to find it with a scope. #define LED_BLINK_TASK_ENABLE 0 +// Guarded on definedness too: an undefined macro is 0 to the preprocessor, so moving either +// #define below this point would silently switch the check off rather than break the build. +#if !defined(ILI9341_LCD_TASK_ENABLE) +#error "ILI9341_LCD_TASK_ENABLE must be defined above LED_BLINK_TASK_ENABLE -- the pin-conflict check below reads it." +#endif + +#if LED_BLINK_TASK_ENABLE && ILI9341_LCD_TASK_ENABLE +#error "LED_BLINK_TASK_ENABLE toggles ILI_SPI2_SD_CS (PH7), a chip select on the ILI9341 module. Disable one of LED_BLINK_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE, or point the blink task at a pin of its own." +#endif + // Task Monitoring Task #define TASK_MONITOR_TASK_ENABLE 1 From dc5ceb8aede1ac65bb0f77bad8a90746c31cfc98 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 20:15:56 -0700 Subject: [PATCH 06/25] display: mount the ILI9341 the way it is actually fitted 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 --- firmware/Core/Inc/Config/config.h | 9 +++++++++ firmware/Core/Src/Tasks/Display/ILI9341Display.cpp | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/firmware/Core/Inc/Config/config.h b/firmware/Core/Inc/Config/config.h index 02d802e..220b33c 100644 --- a/firmware/Core/Inc/Config/config.h +++ b/firmware/Core/Inc/Config/config.h @@ -2,6 +2,7 @@ #define INC_CONFIG_CONFIG_H_ #include "ADS1115_main.h" +#include "ILI9341_main.h" // Tunable quantities below (gains, task delays, thresholds) are *boot defaults*: they // seed the runtime sysconfig store (Config/sysconfig.h), which the host can rewrite live @@ -113,6 +114,14 @@ #define LUMEX_LCD_ROWS 2 #define LUMEX_LCD_COLUMNS 16 +// Which way up the ILI9341 panel is fitted. Both LANDSCAPE and LANDSCAPE_FLIP are 320x240, +// so this changes nothing but the origin corner -- the layout is unaffected either way. +// +// FLIP because the panel is mounted 180 degrees from the controller's default landscape: +// LANDSCAPE rendered the screens upside down on the rig. This is a property of the enclosure, +// not of the driver, so it lives here rather than in ILI9341Display::Init(). +#define ILI9341_DISPLAY_ROTATION ILI9341_ROTATION_LANDSCAPE_FLIP + // LED config #define LED_TASK_OSDELAY 500 diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp index d0015d4..00a1ad7 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -2,6 +2,7 @@ #include +#include "Config/config.h" #include "Config/sysconfig.h" #include "Tasks/Display/DisplayDriver.hpp" @@ -34,7 +35,7 @@ ILI9341Display::ILI9341Display() : bool ILI9341Display::Init() { - if (!_panel.Init(ILI9341_ROTATION_LANDSCAPE)) + if (!_panel.Init(ILI9341_DISPLAY_ROTATION)) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), From 45c917fd50353e87705abaffcba6b1f86d174ccc Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Sun, 26 Jul 2026 20:21:21 -0700 Subject: [PATCH 07/25] display: keep newlib's float formatter out of the display task 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 --- .../Core/Inc/Tasks/Display/ILI9341Display.hpp | 5 ++ .../Core/Inc/Tasks/Display/display_common.h | 14 ++++ .../Core/Src/Tasks/Display/ILI9341Display.cpp | 12 ++-- .../Core/Src/Tasks/Display/display_common.c | 29 ++++++++ .../Core/Src/Tasks/Display/ili9341_layout.c | 3 +- firmware/Core/Src/Tasks/LCD/lumex_layout.c | 3 +- firmware/tests/lumex_layout_tests.cpp | 66 +++++++++++++++++++ 7 files changed, 122 insertions(+), 10 deletions(-) diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp index c500b52..a401291 100644 --- a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp +++ b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp @@ -41,6 +41,11 @@ class ILI9341Display // from a cleared panel; within a screen the layout is positionally stable, so field i can // be compared against field i and only the movers redrawn. ili9341_frame _lastFrame; + + // Scratch for the frame being rendered. A member rather than a local in Render() because + // it is ~256 bytes and this object is a static in ili9341_lcd_main(), so it lands in .bss + // instead of on the display task's kilobyte of stack. + ili9341_frame _frame; display_screen_id _lastScreen; bool _hasRendered; }; diff --git a/firmware/Core/Inc/Tasks/Display/display_common.h b/firmware/Core/Inc/Tasks/Display/display_common.h index 167683b..eba1568 100644 --- a/firmware/Core/Inc/Tasks/Display/display_common.h +++ b/firmware/Core/Inc/Tasks/Display/display_common.h @@ -4,6 +4,7 @@ // The parts of reading a display message that are the message's business rather than any one // panel's. Both layouts include this; neither includes the other. +#include #include #include "MessagePassing/messages_private.h" @@ -19,6 +20,19 @@ extern "C" { // number use this. uint32_t display_rpm_digit_increment(display_rpm_digit digit); +// Formats `value` to two decimal places, right-aligned in `width` columns -- what "%*.2f" +// would produce, without the float. +// +// snprintf("%f") drags in newlib's floating-point formatter, which needs several hundred +// bytes of stack and is the one call in this path whose cost cannot be read off the +// -fstack-usage output. A display task runs on a kilobyte, so it stays out. Rendering a force +// reading was the only float conversion in the firmware, and it overflowed the stack: the +// overflow hook disables interrupts and spins, which looks exactly like a dead board. +// +// Rounds half away from zero, like printf. Values wider than `width` are not truncated, again +// matching printf. +void display_format_fixed2(char *out, size_t out_size, float value, int width); + #ifdef __cplusplus } #endif diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp index 00a1ad7..7fc0432 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -31,6 +31,7 @@ ILI9341Display::ILI9341Display() : _hasRendered(false) { memset(&_lastFrame, 0, sizeof(_lastFrame)); + memset(&_frame, 0, sizeof(_frame)); } bool ILI9341Display::Init() @@ -70,8 +71,7 @@ bool ILI9341Display::DrawField(const ili9341_field& field) bool ILI9341Display::Render(const session_controller_to_display& state) { - ili9341_frame frame; - ili9341_layout(&state, &frame); + ili9341_layout(&state, &_frame); // A new screen has a different set of fields in different places, so there is nothing to // diff against -- blank the panel and paint all of it. Within a screen the field list is @@ -83,16 +83,16 @@ bool ILI9341Display::Render(const session_controller_to_display& state) return false; } - for (uint8_t i = 0; i < frame.count; i++) + for (uint8_t i = 0; i < _frame.count; i++) { if (!screenChanged && i < _lastFrame.count - && ili9341_field_equal(&frame.fields[i], &_lastFrame.fields[i])) + && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) { continue; } - if (!DrawField(frame.fields[i])) + if (!DrawField(_frame.fields[i])) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), @@ -105,7 +105,7 @@ bool ILI9341Display::Render(const session_controller_to_display& state) } } - _lastFrame = frame; + _lastFrame = _frame; _lastScreen = state.screen; _hasRendered = true; diff --git a/firmware/Core/Src/Tasks/Display/display_common.c b/firmware/Core/Src/Tasks/Display/display_common.c index 2f97697..979e4b2 100644 --- a/firmware/Core/Src/Tasks/Display/display_common.c +++ b/firmware/Core/Src/Tasks/Display/display_common.c @@ -1,5 +1,9 @@ #include "Tasks/Display/display_common.h" +#include +#include +#include + uint32_t display_rpm_digit_increment(display_rpm_digit digit) { switch (digit) @@ -12,3 +16,28 @@ uint32_t display_rpm_digit_increment(display_rpm_digit digit) default: return 0; } } + +void display_format_fixed2(char *out, size_t out_size, float value, int width) +{ + // One rounding, into hundredths, and integer formatting from there. + const long hundredths = lroundf(value * 100.0f); + + const long whole = hundredths / 100; + const long frac = labs(hundredths % 100); + + // Wide enough for a 64-bit long on the host test build; on the target it is 32-bit and a + // force reading uses a handful of digits. + char body[32]; + + // Truncating toward zero loses the sign for -0.99 .. -0.01, where the whole part is 0. + if (hundredths < 0 && whole == 0) + { + snprintf(body, sizeof(body), "-0.%02ld", frac); + } + else + { + snprintf(body, sizeof(body), "%ld.%02ld", whole, frac); + } + + snprintf(out, out_size, "%*s", width, body); +} diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ili9341_layout.c index 608bcad..4d4cb7f 100644 --- a/firmware/Core/Src/Tasks/Display/ili9341_layout.c +++ b/firmware/Core/Src/Tasks/Display/ili9341_layout.c @@ -102,8 +102,7 @@ static void layout_session(const session_controller_to_display *state, ili9341_f // Force, the same shape one row down. add_field(out, 12, 100, SIZE_SMALL, COLOUR_LABEL, "FORCE"); - float force = roundf(state->force * 100.0f) / 100.0f; - snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + display_format_fixed2(scratch, sizeof(scratch), state->force, 6); add_field(out, 12, 122, SIZE_VALUE, COLOUR_VALUE, scratch); add_field(out, 200, 146, SIZE_SMALL, COLOUR_LABEL, "N"); diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/LCD/lumex_layout.c index 7243621..4f2c20c 100644 --- a/firmware/Core/Src/Tasks/LCD/lumex_layout.c +++ b/firmware/Core/Src/Tasks/LCD/lumex_layout.c @@ -60,8 +60,7 @@ static void render_session(const session_controller_to_display *state, lumex_fra // Six characters at cols 2-7, clear of the "F:" label and of the drive-mode field at // col 12 however large the reading gets. - float force = roundf(state->force * 100.0f) / 100.0f; - snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + display_format_fixed2(scratch, sizeof(scratch), state->force, 6); put_field(out, 1, 2, 6, scratch); // The drive-mode field. Which of the two appears is the menu option, not the live PID diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp index c7d7dcb..ea2bfa4 100644 --- a/firmware/tests/lumex_layout_tests.cpp +++ b/firmware/tests/lumex_layout_tests.cpp @@ -246,3 +246,69 @@ TEST(LumexLayout, SessionScreenShowsBrakeDutyWhenTheOptionIsNot) } } // namespace + +// --------------------------------------------------------- fixed-point force formatting + +// display_format_fixed2 replaced snprintf("%6.2f"). That call was the only floating-point +// conversion in the firmware and it overflowed the display task's 1 KB stack on the session +// screen -- newlib's float formatter needs several hundred bytes on top of the ~450 the render +// path already used, and the overflow hook disables interrupts and spins, so the board looked +// dead the moment the brake button was pressed. +// +// These pin the replacement against what %6.2f produced, so the fix cannot quietly change the +// reading. Expectations are what printf gives for the same inputs. +namespace +{ + +std::string Fixed2(float value, int width = 6) +{ + char buffer[32]; + display_format_fixed2(buffer, sizeof(buffer), value, width); + return std::string(buffer); +} + +TEST(DisplayFormatFixed2, MatchesPrintfForOrdinaryValues) +{ + EXPECT_EQ(Fixed2(0.0f), " 0.00"); + EXPECT_EQ(Fixed2(12.34f), " 12.34"); + EXPECT_EQ(Fixed2(1.5f), " 1.50"); + EXPECT_EQ(Fixed2(999.99f), "999.99"); + EXPECT_EQ(Fixed2(100.0f), "100.00"); +} + +TEST(DisplayFormatFixed2, RoundsHalfAwayFromZeroLikePrintf) +{ + EXPECT_EQ(Fixed2(1.005f), " 1.01"); + EXPECT_EQ(Fixed2(1.004f), " 1.00"); + EXPECT_EQ(Fixed2(-1.005f), " -1.01"); +} + +TEST(DisplayFormatFixed2, KeepsTheSignWhenTheWholePartIsZero) +{ + // Truncating toward zero makes the whole part 0 for these, so the sign has to be put back + // by hand -- "-0.50" must not come out as "0.50". + EXPECT_EQ(Fixed2(-0.5f), " -0.50"); + EXPECT_EQ(Fixed2(-0.01f), " -0.01"); + EXPECT_EQ(Fixed2(-0.99f), " -0.99"); +} + +TEST(DisplayFormatFixed2, HandlesNegativesGenerally) +{ + EXPECT_EQ(Fixed2(-1.5f), " -1.50"); + EXPECT_EQ(Fixed2(-12.34f), "-12.34"); +} + +TEST(DisplayFormatFixed2, DoesNotTruncateOversizedValues) +{ + // printf lets a value wider than the field push past it rather than clipping; the callers + // clip to their own field width afterwards. + EXPECT_EQ(Fixed2(12345.67f), "12345.67"); +} + +TEST(DisplayFormatFixed2, RespectsTheRequestedWidth) +{ + EXPECT_EQ(Fixed2(1.5f, 8), " 1.50"); + EXPECT_EQ(Fixed2(1.5f, 4), "1.50"); +} + +} // namespace From dacdd1f1af72ba1164a3dc8ab5e77d73bfca0a85 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 07:56:39 -0700 Subject: [PATCH 08/25] Revert "display: keep newlib's float formatter out of the display task" This reverts commit 45c917fd50353e87705abaffcba6b1f86d174ccc. --- .../Core/Inc/Tasks/Display/ILI9341Display.hpp | 5 -- .../Core/Inc/Tasks/Display/display_common.h | 14 ---- .../Core/Src/Tasks/Display/ILI9341Display.cpp | 12 ++-- .../Core/Src/Tasks/Display/display_common.c | 29 -------- .../Core/Src/Tasks/Display/ili9341_layout.c | 3 +- firmware/Core/Src/Tasks/LCD/lumex_layout.c | 3 +- firmware/tests/lumex_layout_tests.cpp | 66 ------------------- 7 files changed, 10 insertions(+), 122 deletions(-) diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp index a401291..c500b52 100644 --- a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp +++ b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp @@ -41,11 +41,6 @@ class ILI9341Display // from a cleared panel; within a screen the layout is positionally stable, so field i can // be compared against field i and only the movers redrawn. ili9341_frame _lastFrame; - - // Scratch for the frame being rendered. A member rather than a local in Render() because - // it is ~256 bytes and this object is a static in ili9341_lcd_main(), so it lands in .bss - // instead of on the display task's kilobyte of stack. - ili9341_frame _frame; display_screen_id _lastScreen; bool _hasRendered; }; diff --git a/firmware/Core/Inc/Tasks/Display/display_common.h b/firmware/Core/Inc/Tasks/Display/display_common.h index eba1568..167683b 100644 --- a/firmware/Core/Inc/Tasks/Display/display_common.h +++ b/firmware/Core/Inc/Tasks/Display/display_common.h @@ -4,7 +4,6 @@ // The parts of reading a display message that are the message's business rather than any one // panel's. Both layouts include this; neither includes the other. -#include #include #include "MessagePassing/messages_private.h" @@ -20,19 +19,6 @@ extern "C" { // number use this. uint32_t display_rpm_digit_increment(display_rpm_digit digit); -// Formats `value` to two decimal places, right-aligned in `width` columns -- what "%*.2f" -// would produce, without the float. -// -// snprintf("%f") drags in newlib's floating-point formatter, which needs several hundred -// bytes of stack and is the one call in this path whose cost cannot be read off the -// -fstack-usage output. A display task runs on a kilobyte, so it stays out. Rendering a force -// reading was the only float conversion in the firmware, and it overflowed the stack: the -// overflow hook disables interrupts and spins, which looks exactly like a dead board. -// -// Rounds half away from zero, like printf. Values wider than `width` are not truncated, again -// matching printf. -void display_format_fixed2(char *out, size_t out_size, float value, int width); - #ifdef __cplusplus } #endif diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp index 7fc0432..00a1ad7 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -31,7 +31,6 @@ ILI9341Display::ILI9341Display() : _hasRendered(false) { memset(&_lastFrame, 0, sizeof(_lastFrame)); - memset(&_frame, 0, sizeof(_frame)); } bool ILI9341Display::Init() @@ -71,7 +70,8 @@ bool ILI9341Display::DrawField(const ili9341_field& field) bool ILI9341Display::Render(const session_controller_to_display& state) { - ili9341_layout(&state, &_frame); + ili9341_frame frame; + ili9341_layout(&state, &frame); // A new screen has a different set of fields in different places, so there is nothing to // diff against -- blank the panel and paint all of it. Within a screen the field list is @@ -83,16 +83,16 @@ bool ILI9341Display::Render(const session_controller_to_display& state) return false; } - for (uint8_t i = 0; i < _frame.count; i++) + for (uint8_t i = 0; i < frame.count; i++) { if (!screenChanged && i < _lastFrame.count - && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) + && ili9341_field_equal(&frame.fields[i], &_lastFrame.fields[i])) { continue; } - if (!DrawField(_frame.fields[i])) + if (!DrawField(frame.fields[i])) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), @@ -105,7 +105,7 @@ bool ILI9341Display::Render(const session_controller_to_display& state) } } - _lastFrame = _frame; + _lastFrame = frame; _lastScreen = state.screen; _hasRendered = true; diff --git a/firmware/Core/Src/Tasks/Display/display_common.c b/firmware/Core/Src/Tasks/Display/display_common.c index 979e4b2..2f97697 100644 --- a/firmware/Core/Src/Tasks/Display/display_common.c +++ b/firmware/Core/Src/Tasks/Display/display_common.c @@ -1,9 +1,5 @@ #include "Tasks/Display/display_common.h" -#include -#include -#include - uint32_t display_rpm_digit_increment(display_rpm_digit digit) { switch (digit) @@ -16,28 +12,3 @@ uint32_t display_rpm_digit_increment(display_rpm_digit digit) default: return 0; } } - -void display_format_fixed2(char *out, size_t out_size, float value, int width) -{ - // One rounding, into hundredths, and integer formatting from there. - const long hundredths = lroundf(value * 100.0f); - - const long whole = hundredths / 100; - const long frac = labs(hundredths % 100); - - // Wide enough for a 64-bit long on the host test build; on the target it is 32-bit and a - // force reading uses a handful of digits. - char body[32]; - - // Truncating toward zero loses the sign for -0.99 .. -0.01, where the whole part is 0. - if (hundredths < 0 && whole == 0) - { - snprintf(body, sizeof(body), "-0.%02ld", frac); - } - else - { - snprintf(body, sizeof(body), "%ld.%02ld", whole, frac); - } - - snprintf(out, out_size, "%*s", width, body); -} diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ili9341_layout.c index 4d4cb7f..608bcad 100644 --- a/firmware/Core/Src/Tasks/Display/ili9341_layout.c +++ b/firmware/Core/Src/Tasks/Display/ili9341_layout.c @@ -102,7 +102,8 @@ static void layout_session(const session_controller_to_display *state, ili9341_f // Force, the same shape one row down. add_field(out, 12, 100, SIZE_SMALL, COLOUR_LABEL, "FORCE"); - display_format_fixed2(scratch, sizeof(scratch), state->force, 6); + float force = roundf(state->force * 100.0f) / 100.0f; + snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); add_field(out, 12, 122, SIZE_VALUE, COLOUR_VALUE, scratch); add_field(out, 200, 146, SIZE_SMALL, COLOUR_LABEL, "N"); diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/LCD/lumex_layout.c index 4f2c20c..7243621 100644 --- a/firmware/Core/Src/Tasks/LCD/lumex_layout.c +++ b/firmware/Core/Src/Tasks/LCD/lumex_layout.c @@ -60,7 +60,8 @@ static void render_session(const session_controller_to_display *state, lumex_fra // Six characters at cols 2-7, clear of the "F:" label and of the drive-mode field at // col 12 however large the reading gets. - display_format_fixed2(scratch, sizeof(scratch), state->force, 6); + float force = roundf(state->force * 100.0f) / 100.0f; + snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); put_field(out, 1, 2, 6, scratch); // The drive-mode field. Which of the two appears is the menu option, not the live PID diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp index ea2bfa4..c7d7dcb 100644 --- a/firmware/tests/lumex_layout_tests.cpp +++ b/firmware/tests/lumex_layout_tests.cpp @@ -246,69 +246,3 @@ TEST(LumexLayout, SessionScreenShowsBrakeDutyWhenTheOptionIsNot) } } // namespace - -// --------------------------------------------------------- fixed-point force formatting - -// display_format_fixed2 replaced snprintf("%6.2f"). That call was the only floating-point -// conversion in the firmware and it overflowed the display task's 1 KB stack on the session -// screen -- newlib's float formatter needs several hundred bytes on top of the ~450 the render -// path already used, and the overflow hook disables interrupts and spins, so the board looked -// dead the moment the brake button was pressed. -// -// These pin the replacement against what %6.2f produced, so the fix cannot quietly change the -// reading. Expectations are what printf gives for the same inputs. -namespace -{ - -std::string Fixed2(float value, int width = 6) -{ - char buffer[32]; - display_format_fixed2(buffer, sizeof(buffer), value, width); - return std::string(buffer); -} - -TEST(DisplayFormatFixed2, MatchesPrintfForOrdinaryValues) -{ - EXPECT_EQ(Fixed2(0.0f), " 0.00"); - EXPECT_EQ(Fixed2(12.34f), " 12.34"); - EXPECT_EQ(Fixed2(1.5f), " 1.50"); - EXPECT_EQ(Fixed2(999.99f), "999.99"); - EXPECT_EQ(Fixed2(100.0f), "100.00"); -} - -TEST(DisplayFormatFixed2, RoundsHalfAwayFromZeroLikePrintf) -{ - EXPECT_EQ(Fixed2(1.005f), " 1.01"); - EXPECT_EQ(Fixed2(1.004f), " 1.00"); - EXPECT_EQ(Fixed2(-1.005f), " -1.01"); -} - -TEST(DisplayFormatFixed2, KeepsTheSignWhenTheWholePartIsZero) -{ - // Truncating toward zero makes the whole part 0 for these, so the sign has to be put back - // by hand -- "-0.50" must not come out as "0.50". - EXPECT_EQ(Fixed2(-0.5f), " -0.50"); - EXPECT_EQ(Fixed2(-0.01f), " -0.01"); - EXPECT_EQ(Fixed2(-0.99f), " -0.99"); -} - -TEST(DisplayFormatFixed2, HandlesNegativesGenerally) -{ - EXPECT_EQ(Fixed2(-1.5f), " -1.50"); - EXPECT_EQ(Fixed2(-12.34f), "-12.34"); -} - -TEST(DisplayFormatFixed2, DoesNotTruncateOversizedValues) -{ - // printf lets a value wider than the field push past it rather than clipping; the callers - // clip to their own field width afterwards. - EXPECT_EQ(Fixed2(12345.67f), "12345.67"); -} - -TEST(DisplayFormatFixed2, RespectsTheRequestedWidth) -{ - EXPECT_EQ(Fixed2(1.5f, 8), " 1.50"); - EXPECT_EQ(Fixed2(1.5f, 4), "1.50"); -} - -} // namespace From e993fdcde6da53c359aba33e91982bac178e56d4 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 08:01:38 -0700 Subject: [PATCH 09/25] display: never return from the display task 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 --- .../Core/Inc/Tasks/Display/DisplayDriver.hpp | 18 ++++++++++++------ .../Core/Src/Tasks/Display/ILI9341Display.cpp | 13 +++++++++++++ firmware/Core/Src/Tasks/LCD/LumexLCD.cpp | 10 +++++++++- firmware/Drivers/ILI9341/ILI9341.cpp | 12 +++++++++--- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp index 24760de..6fc11e6 100644 --- a/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp +++ b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp @@ -39,22 +39,28 @@ concept DisplayDriver = requires(T driver, const session_controller_to_display& // Drains to the newest message before drawing: each one is the whole of what should be on // screen, so the ones behind it are already stale and rendering them in turn would only paint // values the user is never going to see. That matters more the slower the panel is. +// +// Never returns, and that is load-bearing rather than stylistic. A FreeRTOS task function that +// returns lands in prvTaskExitError(), which fails a configASSERT, calls +// portDISABLE_INTERRUPTS() and spins -- so the whole rig dies, buttons and brake included, with +// no LED and no fault report. A display is the least important thing on this board and must not +// be able to do that: a failed write is reported through the error buffer and the loop carries +// on. The driver repaints in full on its next pass, so a half-drawn screen corrects itself. +// [[noreturn]] is the guard: adding a `return` here is what caused the fault above, and the +// compiler now says so rather than leaving it to be found on the bench. template -void RunDisplayTask(Display& display, osMessageQueueId_t queue) +[[noreturn]] void RunDisplayTask(Display& display, osMessageQueueId_t queue) { session_controller_to_display state; memset(&state, 0, sizeof(state)); - while (1) + for (;;) { if (osMessageQueueGet(queue, &state, 0, osWaitForever) == osOK) { while (osMessageQueueGet(queue, &state, 0, 0) == osOK); - if (!display.Render(state)) - { - return; - } + (void)display.Render(state); } osDelay(sysconfig_get_u32(SYSCFG_LCD_TASK_OSDELAY)); diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp index 00a1ad7..a5b9c09 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -80,6 +80,7 @@ bool ILI9341Display::Render(const session_controller_to_display& state) if (screenChanged && !Clear()) { + _hasRendered = false; return false; } @@ -101,6 +102,11 @@ bool ILI9341Display::Render(const session_controller_to_display& state) ); _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); + + // What is on the panel no longer matches _lastFrame, so the field-by-field diff + // would skip cells that were never actually painted. Force the next pass to clear + // and repaint everything. + _hasRendered = false; return false; } } @@ -124,8 +130,15 @@ extern "C" void ili9341_lcd_main(osMessageQueueId_t sessionControllerToDisplayHa if (!display.Init()) { + // Suspend rather than return: returning from a task function disables interrupts and + // spins (prvTaskExitError), which would take the rest of the board down over a display + // that would not start. osThreadSuspend(osThreadGetId()); } RunDisplayTask(display, sessionControllerToDisplayHandle); + + // RunDisplayTask does not return; this is here so that a future edit which lets it return + // parks this task instead of killing the scheduler. + osThreadSuspend(osThreadGetId()); } diff --git a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp index d617b38..4eabffb 100644 --- a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp @@ -95,6 +95,7 @@ bool LumexLCD::Render(const session_controller_to_display& state) { if (!Clear()) { + _hasRendered = false; return false; } } @@ -122,6 +123,9 @@ bool LumexLCD::Render(const session_controller_to_display& state) if (!DisplayString(row, start, &frame.cells[row][start], column - start)) { + // The panel no longer matches _lastFrame, so the diff would skip cells that + // were never written. Force a full clear and repaint next pass. + _hasRendered = false; return false; } } @@ -323,10 +327,14 @@ extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayHand if (!lcd.Init()) { - osThreadSuspend(osThreadGetId()); + // Suspend rather than return: returning from a task function disables interrupts and + // spins (prvTaskExitError), taking the whole board down over a display fault. + osThreadSuspend(osThreadGetId()); } RunDisplayTask(lcd, sessionControllerToDisplayHandle); + + osThreadSuspend(osThreadGetId()); } diff --git a/firmware/Drivers/ILI9341/ILI9341.cpp b/firmware/Drivers/ILI9341/ILI9341.cpp index 5fdc069..14c3da2 100644 --- a/firmware/Drivers/ILI9341/ILI9341.cpp +++ b/firmware/Drivers/ILI9341/ILI9341.cpp @@ -54,9 +54,15 @@ static const uint8_t ILI9341_ROTATION_MADCTL[4] = { #define ILI9341_SCRATCH_PIXELS (ILI9341_FONT_CELL_WIDTH * ILI9341_MAX_TEXT_SIZE) static uint8_t ili9341_scratch[ILI9341_SCRATCH_PIXELS * 2]; -// How long HAL_SPI_Transmit may block. Generous: it only matters if the bus is wedged, and the -// display task is the lowest-priority thing that could be waiting. -#define ILI9341_SPI_TIMEOUT_MS 100 +// How long HAL_SPI_Transmit may block. +// +// Deliberately far longer than any transfer here needs -- the largest is 96 bytes, ~61 us at +// 12.5 MHz. The timeout is wall-clock, and it keeps counting while the caller is preempted: +// this runs in the lowest-priority task on the board, so a burst of sensor, PID and USB work +// during session start can stall it for a long time between HAL's polls. A timeout tuned to +// the transfer would fire on scheduling latency rather than on a real bus fault, which is a +// display glitch reported as hardware failure. +#define ILI9341_SPI_TIMEOUT_MS 1000 ILI9341::ILI9341(SPI_HandleTypeDef* spi, From de0b341410b58b2a73fbf7ee6a0c3d4a809d7ec0 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 08:10:47 -0700 Subject: [PATCH 10/25] display: session detail readouts, drawn by the TFT and ignored by the 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 --- .../Inc/MessagePassing/messages_private.h | 5 +- .../Core/Inc/Tasks/Display/DisplayDriver.hpp | 24 +++++ .../Core/Inc/Tasks/Display/ILI9341Display.hpp | 14 +++ .../Core/Inc/Tasks/Display/ili9341_layout.h | 19 +++- firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp | 26 ++++++ .../SessionController/FiniteStateMachine.hpp | 13 +++ .../Core/Src/Tasks/Display/ILI9341Display.cpp | 33 +++++-- .../Core/Src/Tasks/Display/ili9341_layout.c | 50 +++++++++- .../SessionController/FiniteStateMachine.cpp | 33 +++++++ .../SessionController/SessionController.cpp | 1 + firmware/tests/ili9341_layout_tests.cpp | 92 ++++++++++++++++++- .../message_gen/schema/messages_private.yaml | 10 +- 12 files changed, 304 insertions(+), 16 deletions(-) diff --git a/firmware/Core/Inc/MessagePassing/messages_private.h b/firmware/Core/Inc/MessagePassing/messages_private.h index 8a12390..8968f72 100644 --- a/firmware/Core/Inc/MessagePassing/messages_private.h +++ b/firmware/Core/Inc/MessagePassing/messages_private.h @@ -74,9 +74,12 @@ typedef struct { bool pid_enabled; // Whether the PID loop is armed for this session bool pid_option_toggleable; // Whether the menu allows arming it; also selects the in-session drive-mode field bool sd_logging_enabled; // Whether SD logging is switched on + float angular_acceleration; // Measured angular acceleration in rad/s^2 (session screen detail) + float peak_force; // Largest force magnitude seen this session, in N (session screen detail) + uint32_t session_seconds; // Seconds since the session started (session screen detail) } session_controller_to_display; -DYNO_STATIC_ASSERT(sizeof(session_controller_to_display) <= 32, "session_controller_to_display is queued 25 deep -- keep it small"); +DYNO_STATIC_ASSERT(sizeof(session_controller_to_display) <= 48, "session_controller_to_display is queued 25 deep -- keep it small"); // Opcodes for controlling the BPM (Pulse Width Modulation) module from the session controller typedef enum : uint32_t diff --git a/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp index 6fc11e6..5ab859b 100644 --- a/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp +++ b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp @@ -32,6 +32,24 @@ concept DisplayDriver = requires(T driver, const session_controller_to_display& // Paints one screen state. Called on every message; drivers are expected to diff against // what they last drew and repaint only what moved. { driver.Render(state) } -> std::same_as; + + // --- Extended session detail. + // + // Everything above is the common ground: values every panel can show. These are not. + // They are extra readouts for the in-session screen that need room a 2x16 character grid + // does not have, so LumexLCD implements them as one-line no-ops that discard the argument + // and the ILI9341 draws them. + // + // They sit here rather than only on ILI9341Display so the display task can call them + // without knowing which panel it has, and so a driver that quietly stopped implementing + // one is a compile error. The asymmetry is deliberate and is the cost of letting the TFT + // grow without dragging the character panel along: adding a fourth readout means one real + // implementation and one `(void)` line. + // + // Called before Render(), so a driver may simply record them and lay them out there. + { driver.ShowAngularAcceleration(float{}) } -> std::same_as; + { driver.ShowPeakForce(float{}) } -> std::same_as; + { driver.ShowSessionElapsed(uint32_t{}) } -> std::same_as; }; // The queue-drain loop, identical for every panel. @@ -60,6 +78,12 @@ template { while (osMessageQueueGet(queue, &state, 0, 0) == osOK); + // Detail first: a driver that renders these records them here and lays them out + // in Render. On the character panel all three are no-ops the optimiser deletes. + (void)display.ShowAngularAcceleration(state.angular_acceleration); + (void)display.ShowPeakForce(state.peak_force); + (void)display.ShowSessionElapsed(state.session_seconds); + (void)display.Render(state); } diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp index c500b52..9253a58 100644 --- a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp +++ b/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp @@ -28,6 +28,12 @@ class ILI9341Display bool Clear(); bool Render(const session_controller_to_display& state); + // --- Extended session detail. Recorded here and drawn by the next Render(); see the + // DisplayDriver concept for why these exist and why LumexLCD discards them. + bool ShowAngularAcceleration(float radiansPerSecondSquared); + bool ShowPeakForce(float newtons); + bool ShowSessionElapsed(uint32_t seconds); + private: // Paints one field over its own background, which is also how the previous value is erased: // fields are fixed-width per screen, so a redraw covers every pixel the old one touched. @@ -41,6 +47,14 @@ class ILI9341Display // from a cleared panel; within a screen the layout is positionally stable, so field i can // be compared against field i and only the movers redrawn. ili9341_frame _lastFrame; + + // Scratch for the frame being built. A member, not a local in Render(): at + // ILI9341_MAX_FIELDS entries it is a few hundred bytes and the display task runs on 1 KB. + // This object is a static in ili9341_lcd_main(), so it costs .bss instead of stack. + ili9341_frame _frame; + + // What the DisplayDriver Show* methods last recorded, laid out by the next Render(). + ili9341_session_detail _detail; display_screen_id _lastScreen; bool _hasRendered; }; diff --git a/firmware/Core/Inc/Tasks/Display/ili9341_layout.h b/firmware/Core/Inc/Tasks/Display/ili9341_layout.h index ee90e1c..3374c58 100644 --- a/firmware/Core/Inc/Tasks/Display/ili9341_layout.h +++ b/firmware/Core/Inc/Tasks/Display/ili9341_layout.h @@ -23,7 +23,9 @@ extern "C" { #define ILI9341_LAYOUT_WIDTH 320 #define ILI9341_LAYOUT_HEIGHT 240 -#define ILI9341_MAX_FIELDS 8 +// The session screen is the busiest: two labelled primary readouts, three detail readouts and +// the drive mode. +#define ILI9341_MAX_FIELDS 12 #define ILI9341_FIELD_TEXT_MAX 20 // One run of text at a fixed position and scale. @@ -47,10 +49,23 @@ typedef struct uint8_t count; } ili9341_frame; +// The extra in-session readouts this panel has room for and the character panel does not. +// Kept separate from session_controller_to_display so that what is common to every panel and +// what is this panel's alone stay visibly apart. +typedef struct +{ + float angular_acceleration; // rad/s^2 + float peak_force; // N, largest magnitude this session + uint32_t session_seconds; // since the session started +} ili9341_session_detail; + // Lays out one screen. For a given screen id the field count, order, positions and sizes are // fixed, so the driver can diff field i against field i of the previous frame and repaint only // those whose text or colour moved. -void ili9341_layout(const session_controller_to_display *state, ili9341_frame *out); +// `detail` is only read on the session screen; pass a zeroed struct elsewhere. +void ili9341_layout(const session_controller_to_display *state, + const ili9341_session_detail *detail, + ili9341_frame *out); // Whether two fields would paint the same pixels. Position and size are stable within a // screen, so in practice this compares text and colour. diff --git a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp index ab8f4e6..a8a98db 100644 --- a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp +++ b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp @@ -41,6 +41,32 @@ class LumexLCD // keeps a changed RPM reading to the five cells it occupies. bool Render(const session_controller_to_display& state); + // --- Extended session detail: not shown here. + // + // Thirty-two character cells are fully spoken for by speed, force and drive mode, so + // there is nowhere to put these. They are accepted and discarded rather than left off + // the class, because DisplayDriver requires them of every panel and the display task + // calls them without knowing which one it is driving. + // + // Inline and empty, so each costs nothing: the calls vanish at -O0 as well as -Os. + bool ShowAngularAcceleration(float radiansPerSecondSquared) + { + (void)radiansPerSecondSquared; + return true; + } + + bool ShowPeakForce(float newtons) + { + (void)newtons; + return true; + } + + bool ShowSessionElapsed(uint32_t seconds) + { + (void)seconds; + return true; + } + private: bool StartTimer(uint8_t microseconds); diff --git a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp index 2f7cfa4..f272d8a 100644 --- a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp @@ -86,6 +86,11 @@ class FSM // and reposts the whole screen state; the driver works out what actually moved. void DisplayAngularVelocity(float angularVelocity); void DisplayForce(float force); + + // Extra in-session detail. Only the ILI9341 panel has room to show these; the character + // panel discards them (see the DisplayDriver concept), so they are always sent and it + // costs nothing to record them here. + void DisplayAngularAcceleration(float angularAcceleration); void DisplayPIDEnabled(); void DisplayManualBPMDutyCycle(); @@ -158,6 +163,14 @@ class FSM float _rpm; float _force; + // Session detail, derived here rather than by a panel so both get the same numbers. + // Peak force is the largest magnitude seen since the session started -- a pull and a push + // are both loads on the rig -- and both reset on entry to a session, not on exit, so the + // screen keeps showing the last run's figures until a new one begins. + float _angularAcceleration; + float _peakForce; + uint32_t _sessionStartTimestamp; + // Whether a brake press may start a session. Cleared when the button is already held as this // FSM comes up, and set again by the release that follows -- see HandleButtonBrakeInput. bool _brakeArmed; diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp index a5b9c09..2312c02 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp @@ -31,6 +31,28 @@ ILI9341Display::ILI9341Display() : _hasRendered(false) { memset(&_lastFrame, 0, sizeof(_lastFrame)); + memset(&_frame, 0, sizeof(_frame)); + memset(&_detail, 0, sizeof(_detail)); +} + +// The Show* methods only record. Drawing happens in Render, so that everything on screen still +// goes through one layout pass and one diff -- these must not paint behind its back. +bool ILI9341Display::ShowAngularAcceleration(float radiansPerSecondSquared) +{ + _detail.angular_acceleration = radiansPerSecondSquared; + return true; +} + +bool ILI9341Display::ShowPeakForce(float newtons) +{ + _detail.peak_force = newtons; + return true; +} + +bool ILI9341Display::ShowSessionElapsed(uint32_t seconds) +{ + _detail.session_seconds = seconds; + return true; } bool ILI9341Display::Init() @@ -70,8 +92,7 @@ bool ILI9341Display::DrawField(const ili9341_field& field) bool ILI9341Display::Render(const session_controller_to_display& state) { - ili9341_frame frame; - ili9341_layout(&state, &frame); + ili9341_layout(&state, &_detail, &_frame); // A new screen has a different set of fields in different places, so there is nothing to // diff against -- blank the panel and paint all of it. Within a screen the field list is @@ -84,16 +105,16 @@ bool ILI9341Display::Render(const session_controller_to_display& state) return false; } - for (uint8_t i = 0; i < frame.count; i++) + for (uint8_t i = 0; i < _frame.count; i++) { if (!screenChanged && i < _lastFrame.count - && ili9341_field_equal(&frame.fields[i], &_lastFrame.fields[i])) + && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) { continue; } - if (!DrawField(frame.fields[i])) + if (!DrawField(_frame.fields[i])) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), @@ -111,7 +132,7 @@ bool ILI9341Display::Render(const session_controller_to_display& state) } } - _lastFrame = frame; + _lastFrame = _frame; _lastScreen = state.screen; _hasRendered = true; diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ili9341_layout.c index 608bcad..e5ed8a2 100644 --- a/firmware/Core/Src/Tasks/Display/ili9341_layout.c +++ b/firmware/Core/Src/Tasks/Display/ili9341_layout.c @@ -86,7 +86,47 @@ static void add_enabled_disabled(ili9341_frame *out, bool enabled) enabled ? "ENABLED " : "DISABLED"); } -static void layout_session(const session_controller_to_display *state, ili9341_frame *out) +// Clamps so each detail field is always exactly as wide as its format implies. The driver +// diffs field i against field i and repaints only the movers, which relies on a screen's field +// widths never changing with the values -- a reading that outgrew its format would shift the +// ones beside it and leave the old pixels behind. +static long clamp_long(long value, long low, long high) +{ + if (value < low) return low; + if (value > high) return high; + return value; +} + +static float clamp_float(float value, float low, float high) +{ + if (value < low) return low; + if (value > high) return high; + return value; +} + +// The extra in-session readouts, on one line under the two primary values: angular +// acceleration, the largest force seen this session, and how long it has been running. None of +// these fit on the character panel, which is why they arrive through the DisplayDriver Show* +// methods that LumexLCD discards. +static void layout_session_detail(const ili9341_session_detail *detail, ili9341_frame *out) +{ + char scratch[ILI9341_FIELD_TEXT_MAX]; + + const long accel = clamp_long((long)lroundf(detail->angular_acceleration), -9999, 99999); + snprintf(scratch, sizeof(scratch), "A%6ld", accel); + add_field(out, 12, 168, SIZE_SMALL, COLOUR_LABEL, scratch); + + const float peak = clamp_float(detail->peak_force, 0.0f, 999.99f); + snprintf(scratch, sizeof(scratch), "P%6.2f", (double)peak); + add_field(out, 108, 168, SIZE_SMALL, COLOUR_LABEL, scratch); + + const long seconds = clamp_long((long)detail->session_seconds, 0, 9999); + snprintf(scratch, sizeof(scratch), "T%4lds", seconds); + add_field(out, 216, 168, SIZE_SMALL, COLOUR_LABEL, scratch); +} + +static void layout_session(const session_controller_to_display *state, + const ili9341_session_detail *detail, ili9341_frame *out) { char scratch[ILI9341_FIELD_TEXT_MAX]; @@ -108,6 +148,8 @@ static void layout_session(const session_controller_to_display *state, ili9341_f add_field(out, 200, 146, SIZE_SMALL, COLOUR_LABEL, "N"); + layout_session_detail(detail, out); + // Drive mode. Which of the two appears is the menu option, not the live PID state: with the // option off there is nothing to arm, so what the encoder actually drives is shown instead. // Ten characters either way so one paints over the other. @@ -125,7 +167,9 @@ static void layout_session(const session_controller_to_display *state, ili9341_f } } -void ili9341_layout(const session_controller_to_display *state, ili9341_frame *out) +void ili9341_layout(const session_controller_to_display *state, + const ili9341_session_detail *detail, + ili9341_frame *out) { memset(out, 0, sizeof(*out)); @@ -169,7 +213,7 @@ void ili9341_layout(const session_controller_to_display *state, ili9341_frame *o break; case DISPLAY_SCREEN_SESSION: - layout_session(state, out); + layout_session(state, detail, out); break; default: diff --git a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp index 8908b95..7b4430e 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -2,6 +2,8 @@ #include "Config/sysconfig.h" +#include "TimeKeeping/timestamps.h" + FSM::FSM(osMessageQueueId_t sessionControllerToDisplayHandle) : _toDisplayHandle(sessionControllerToDisplayHandle), _state{ @@ -16,6 +18,9 @@ FSM::FSM(osMessageQueueId_t sessionControllerToDisplayHandle) : _desiredManualBpmDutyCycle(0), _rpm(0.0f), _force(0.0f), + _angularAcceleration(0.0f), + _peakForce(0.0f), + _sessionStartTimestamp(0), // A brake already held as we come up is not a request to start a session -- it is just how // the board was left, or a finger on the button during a reset. Start disarmed in that case // so nothing can run until the button has been released and pressed deliberately. @@ -344,6 +349,11 @@ void FSM::ShowSessionScreen() { _state.mainState = State::MainDynoState::IN_SESSION; + // Reset the per-session figures on the way in, so a new run does not inherit the last + // one's peak or clock. + _peakForce = 0.0f; + _sessionStartTimestamp = get_timestamp(); + // Deliberately 0 rather than the envelope's floor: nothing has been commanded yet, and the // SessionController sends START_PWM the moment this value differs from what it last sent -- // so starting at a non-zero floor would engage the brake on session entry, unasked. The @@ -372,6 +382,20 @@ void FSM::DisplayAngularVelocity(float angularVelocity) void FSM::DisplayForce(float force) { _force = force; + + // Track the peak by magnitude: the rig is loaded whichever way the cell is driven. + const float magnitude = (force < 0.0f) ? -force : force; + if (magnitude > _peakForce) + { + _peakForce = magnitude; + } + + PostDisplayState(); +} + +void FSM::DisplayAngularAcceleration(float angularAcceleration) +{ + _angularAcceleration = angularAcceleration; PostDisplayState(); } @@ -426,6 +450,15 @@ void FSM::PostDisplayState() msg.pid_enabled = _pidEnabled; msg.pid_option_toggleable = _pidOptionToggleableEnabled; msg.sd_logging_enabled = _sdLoggingEnabled; + msg.angular_acceleration = _angularAcceleration; + msg.peak_force = _peakForce; + + // Elapsed seconds, from the microsecond timestamp counter the rest of the board stamps + // samples with. Zero outside a session: _sessionStartTimestamp is only set on entry. + const uint32_t scale = get_timestamp_scale(); + msg.session_seconds = (_sessionStartTimestamp != 0 && scale != 0) + ? (get_timestamp() - _sessionStartTimestamp) / scale + : 0; osMessageQueuePut(_toDisplayHandle, &msg, 0, 0); } diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index 53b4e12..ad9ff16 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -220,6 +220,7 @@ void SessionController::UpdateMeasurementDisplay() if (_prevAngularVelocity != _opticalData.angular_velocity) { _fsm.DisplayAngularVelocity(_opticalData.angular_velocity); + _fsm.DisplayAngularAcceleration(_opticalData.angular_acceleration); _prevAngularVelocity = _opticalData.angular_velocity; } diff --git a/firmware/tests/ili9341_layout_tests.cpp b/firmware/tests/ili9341_layout_tests.cpp index f9931d2..16dff8f 100644 --- a/firmware/tests/ili9341_layout_tests.cpp +++ b/firmware/tests/ili9341_layout_tests.cpp @@ -31,10 +31,11 @@ session_controller_to_display State(display_screen_id screen) return state; } -ili9341_frame Layout(const session_controller_to_display &state) +ili9341_frame Layout(const session_controller_to_display &state, + const ili9341_session_detail &detail = {}) { ili9341_frame frame{}; - ili9341_layout(&state, &frame); + ili9341_layout(&state, &detail, &frame); return frame; } @@ -289,3 +290,90 @@ TEST(Ili9341Font, OutOfRangeCoordinatesAreBlankRatherThanOutOfBounds) } } // namespace + +// ------------------------------------------------- extended session detail (ILI9341 only) + +// These three readouts exist on this panel and not on the character LCD, which discards them +// through its one-line DisplayDriver stubs. The widths matter as much as the values: the +// driver's field-by-field diff assumes a screen's field widths never move, so a reading that +// outgrew its format would shift its neighbours and strand the old pixels. +namespace +{ + +ili9341_session_detail Detail(float accel, float peak, uint32_t seconds) +{ + ili9341_session_detail detail{}; + detail.angular_acceleration = accel; + detail.peak_force = peak; + detail.session_seconds = seconds; + return detail; +} + +const ili9341_field &DetailField(const ili9341_frame &frame, int which) +{ + // The three detail fields sit between the "N" unit and the drive mode. + return frame.fields[6 + which]; +} + +TEST(Ili9341SessionDetail, ShowsAccelerationPeakForceAndElapsedTime) +{ + const ili9341_frame frame = + Layout(State(DISPLAY_SCREEN_SESSION), Detail(-42.0f, 123.45f, 87)); + + EXPECT_EQ(std::string(DetailField(frame, 0).text), "A -42"); + EXPECT_EQ(std::string(DetailField(frame, 1).text), "P123.45"); + EXPECT_EQ(std::string(DetailField(frame, 2).text), "T 87s"); +} + +TEST(Ili9341SessionDetail, WidthsDoNotMoveWithTheValues) +{ + const ili9341_frame small = Layout(State(DISPLAY_SCREEN_SESSION), Detail(0.0f, 0.0f, 0)); + const ili9341_frame large = + Layout(State(DISPLAY_SCREEN_SESSION), Detail(1e9f, 1e9f, 4000000000u)); + + ASSERT_EQ(small.count, large.count); + + for (int i = 0; i < 3; i++) + { + EXPECT_EQ(DetailField(small, i).length, DetailField(large, i).length) + << "detail field " << i << " changed width: \"" << DetailField(small, i).text + << "\" vs \"" << DetailField(large, i).text << "\""; + EXPECT_EQ(DetailField(small, i).x, DetailField(large, i).x); + } +} + +TEST(Ili9341SessionDetail, ClampsRatherThanOverflowingItsField) +{ + const ili9341_frame frame = + Layout(State(DISPLAY_SCREEN_SESSION), Detail(1e9f, 1e9f, 4000000000u)); + + EXPECT_EQ(std::string(DetailField(frame, 0).text), "A 99999"); + EXPECT_EQ(std::string(DetailField(frame, 1).text), "P999.99"); + EXPECT_EQ(std::string(DetailField(frame, 2).text), "T9999s"); +} + +TEST(Ili9341SessionDetail, OnlyAppearsOnTheSessionScreen) +{ + // Every other screen ignores the detail entirely, so a stale peak or clock cannot leak onto + // the idle or settings pages. + const ili9341_session_detail busy = Detail(999.0f, 500.0f, 1234); + + for (display_screen_id screen : kAllScreens) + { + if (screen == DISPLAY_SCREEN_SESSION) continue; + + EXPECT_EQ(Layout(State(screen)).count, Layout(State(screen), busy).count) + << "screen " << screen << " changed with session detail"; + } +} + +TEST(Ili9341SessionDetail, DetailChangesAreVisibleToTheDiff) +{ + const ili9341_frame before = Layout(State(DISPLAY_SCREEN_SESSION), Detail(10.0f, 1.0f, 5)); + const ili9341_frame after = Layout(State(DISPLAY_SCREEN_SESSION), Detail(20.0f, 1.0f, 5)); + + EXPECT_FALSE(ili9341_field_equal(&DetailField(before, 0), &DetailField(after, 0))); + EXPECT_TRUE(ili9341_field_equal(&DetailField(before, 1), &DetailField(after, 1))); +} + +} // namespace diff --git a/firmware/tools/message_gen/schema/messages_private.yaml b/firmware/tools/message_gen/schema/messages_private.yaml index 6f28bfa..7b78b45 100644 --- a/firmware/tools/message_gen/schema/messages_private.yaml +++ b/firmware/tools/message_gen/schema/messages_private.yaml @@ -75,8 +75,14 @@ sections: - { type: bool, name: pid_enabled, comment: "Whether the PID loop is armed for this session" } - { type: bool, name: pid_option_toggleable, comment: "Whether the menu allows arming it; also selects the in-session drive-mode field" } - { type: bool, name: sd_logging_enabled, comment: "Whether SD logging is switched on" } - - - { kind: static_assert, expr: "sizeof(session_controller_to_display) <= 32", message: "session_controller_to_display is queued 25 deep -- keep it small" } + # Extended session detail. Carried for every panel but only shown by one: a 2x16 character + # grid has no room for it, so LumexLCD ignores these and the ILI9341 draws them. See the + # Show* methods on the DisplayDriver concept. + - { type: float, name: angular_acceleration, comment: "Measured angular acceleration in rad/s^2 (session screen detail)" } + - { type: float, name: peak_force, comment: "Largest force magnitude seen this session, in N (session screen detail)" } + - { type: uint32_t, name: session_seconds, comment: "Seconds since the session started (session screen detail)" } + + - { kind: static_assert, expr: "sizeof(session_controller_to_display) <= 48", message: "session_controller_to_display is queued 25 deep -- keep it small" } - kind: enum name: session_controller_to_bpm_opcode From 7f70c554cfc428e341c17dda8e218e7f7c0f9372 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 08:28:16 -0700 Subject: [PATCH 11/25] app: command the brake duty cycle from the PC 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 --- .../Core/Inc/MessagePassing/messages_public.h | 16 ++- .../SessionController/FiniteStateMachine.hpp | 5 + .../SessionController/SessionController.hpp | 7 ++ .../sessioncontroller_main.h | 4 + firmware/Core/Inc/Tasks/USB/USBController.hpp | 2 + .../Core/Inc/Tasks/USB/usbcontroller_main.h | 1 + .../SessionController/FiniteStateMachine.cpp | 21 ++++ .../SessionController/SessionController.cpp | 54 ++++++++++ firmware/Core/Src/Tasks/USB/USBController.cpp | 8 +- firmware/Core/Src/main.c | 14 ++- firmware/stm32_dyno_firmware_v2.ioc | 2 +- .../message_gen/schema/messages_public.yaml | 28 ++++- .../ViewModels/MainWindowViewModel.cs | 100 ++++++++++++++++++ src/Dyno.App/Views/HomeView.axaml | 41 ++++++- src/Dyno.App/Views/HomeView.axaml.cs | 72 ++++++++++++- src/Dyno.Core/DeviceClient.cs | 32 ++++++ src/Dyno.Core/Messages/Generated/Messages.cs | 17 ++- src/Dyno.Core/Protocol/CommandOpcodes.cs | 4 + tests/Dyno.Core.Tests/CommandOpcodeTests.cs | 17 ++- 19 files changed, 428 insertions(+), 17 deletions(-) diff --git a/firmware/Core/Inc/MessagePassing/messages_public.h b/firmware/Core/Inc/MessagePassing/messages_public.h index 211cb25..c630921 100644 --- a/firmware/Core/Inc/MessagePassing/messages_public.h +++ b/firmware/Core/Inc/MessagePassing/messages_public.h @@ -250,7 +250,7 @@ DYNO_STATIC_ASSERT(sizeof(usb_msg_header_t) == 12, "Size of usb_msg_header_t mus // v6 host would decode the trailer as an unknown STATUS record and log it a few hundred // times a second. -#define USB_PROTOCOL_VERSION 7u +#define USB_PROTOCOL_VERSION 8u // Shared CRC so firmware and host compute identical checksums over a frame body. @@ -316,6 +316,20 @@ typedef enum : uint16_t USB_CMD_SET_SYSCONFIG = 1 // body = sysconfig_set_param_body; writes one runtime parameter into the sysconfig store. Applied by the USB task itself (the store is plain RAM), so the OK is still a full-path ack } usb_controller_command_t; +// Session-controller-local commands: frames addressed to TASK_OFFSET_SESSION_CONTROLLER. +typedef enum : uint16_t +{ + SESSION_CMD_SET_BRAKE_DUTY_CYCLE = 0 // body = session_set_brake_duty_body; sets the commanded brake duty cycle, exactly as a rotary-encoder tick on the rig would. Honoured only while a session is running and clamped to the MIN/MAX_DUTY_CYCLE_PERCENT envelope -- the brake is never actuated outside a session, however the request arrives. Answered USB_RSP_OK when applied and USB_RSP_NOT_SUPPORTED when no session is running +} session_controller_command_t; + +// Body of SESSION_CMD_SET_BRAKE_DUTY_CYCLE. Fraction, not percent: 0.0 - 1.0, matching +// MIN/MAX_DUTY_CYCLE_PERCENT and what the BPM task takes. +typedef struct __attribute__((packed)) { + float duty_cycle; +} session_set_brake_duty_body; + +DYNO_STATIC_ASSERT(sizeof(session_set_brake_duty_body) == 4, "Size of session_set_brake_duty_body must be 4 bytes"); + // Device-ready announcement (STM32 -> PC): emitted as USB_MSG_EVENT with task_offset // TASK_OFFSET_USB_CONTROLLER and repeated (~every 200ms) until the host answers with // USB_CMD_ACK. Carries the firmware's USB_PROTOCOL_VERSION so the host can confirm the diff --git a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp index f272d8a..4d7066c 100644 --- a/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/FiniteStateMachine.hpp @@ -94,6 +94,11 @@ class FSM void DisplayPIDEnabled(); void DisplayManualBPMDutyCycle(); + // Sets the commanded brake duty cycle from a host command. Returns false, and changes + // nothing, unless a session is running: the brake is never actuated outside one, however + // the request arrives. The value is clamped to the same envelope the encoder is. + bool SetHostBrakeDutyCycle(float dutyCycle); + // What the SessionController acts on State GetState() const; bool GetSDLoggingEnabledStatus() const; diff --git a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp index 3fb6120..316acbf 100644 --- a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp @@ -47,6 +47,13 @@ class SessionController bool Init(void); void Run(void); + private: + // Applies host commands routed here by the USB task, acking each one. Drained beside + // HandleUserInputs because that is what these are: another source of input, differing + // only in arriving over USB rather than off a button. + void DrainHostCommands(); + + private: bool CheckTaskQueuesValid(); void ReportError(session_controller_task_error_ids error_id); diff --git a/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h b/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h index d08d069..1984867 100644 --- a/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h +++ b/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h @@ -19,6 +19,10 @@ typedef struct osMessageQueueId_t pid_controller_ack; // Whichever display driver was compiled in -- the message is the same either way. osMessageQueueId_t display; + // Host commands routed here by the USB task, and the shared queue their completions go back + // on. See session_controller_command_t. + osMessageQueueId_t usb_command; + osMessageQueueId_t task_completion; } session_controller_os_task_queues; diff --git a/firmware/Core/Inc/Tasks/USB/USBController.hpp b/firmware/Core/Inc/Tasks/USB/USBController.hpp index 22f32db..cbf0c7a 100644 --- a/firmware/Core/Inc/Tasks/USB/USBController.hpp +++ b/firmware/Core/Inc/Tasks/USB/USBController.hpp @@ -27,6 +27,7 @@ class USBController USBController(osMessageQueueId_t sessionControllerToUsbController, osMessageQueueId_t taskMonitorToUsbControllerHandle, osMessageQueueId_t forceSensorCommandQueue, + osMessageQueueId_t sessionControllerCommandQueue, osMessageQueueId_t taskCompletionQueue); ~USBController() = default; // Destructor @@ -207,6 +208,7 @@ class USBController osMessageQueueId_t _taskMonitorToUsbControllerHandle; osMessageQueueId_t _sessionControllerToUsbController; // carries the in-session flag osMessageQueueId_t _forceSensorCommandQueue; // route target for force-sensor settings + osMessageQueueId_t _sessionControllerCommandQueue; // route target for brake duty-cycle commands osMessageQueueId_t _taskCompletionQueue; // shared: tasks post applied-command acks here uint8_t _txBuffer[USB_TX_BUFFER_SIZE]; diff --git a/firmware/Core/Inc/Tasks/USB/usbcontroller_main.h b/firmware/Core/Inc/Tasks/USB/usbcontroller_main.h index 2001e2d..aa4e854 100644 --- a/firmware/Core/Inc/Tasks/USB/usbcontroller_main.h +++ b/firmware/Core/Inc/Tasks/USB/usbcontroller_main.h @@ -13,6 +13,7 @@ extern "C" { void usbcontroller_main(osMessageQueueId_t sessionControllerToUsbController, osMessageQueueId_t taskMonitorToUsbControllerHandle, osMessageQueueId_t forceSensorCommandQueue, + osMessageQueueId_t sessionControllerCommandQueue, osMessageQueueId_t taskCompletionQueue); #ifdef __cplusplus diff --git a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp index 7b4430e..e78d6d2 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -262,6 +262,27 @@ void FSM::AdjustBrakeDutyCycle(bool positiveTick) std::clamp(_desiredManualBpmDutyCycle + increment, minDutyCycle, maxDutyCycle); } +// The host's equivalent of turning the brake knob. Deliberately routed through the same state +// the encoder writes, so everything downstream -- the clamp, the BPM post, the on-screen +// readout -- behaves identically whether the request came from the rig or from the PC. +bool FSM::SetHostBrakeDutyCycle(float dutyCycle) +{ + if (_state.mainState != State::MainDynoState::IN_SESSION) + { + return false; + } + + float minDutyCycle; + float maxDutyCycle; + sysconfig_get_duty_cycle_limits(&minDutyCycle, &maxDutyCycle); + + _desiredManualBpmDutyCycle = std::clamp(dutyCycle, minDutyCycle, maxDutyCycle); + + PostDisplayState(); + + return true; +} + // How much one encoder tick moves the desired RPM, given which digit the cursor is on. int FSM::DesiredRpmDigitIncrement() const { diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index ad9ff16..2f75f7a 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -231,6 +231,59 @@ void SessionController::UpdateMeasurementDisplay() } } +// The host's route into the UI state. Each command is acked through the shared completion +// queue the USB task relays, so the app learns whether it was applied rather than assuming. +void SessionController::DrainHostCommands() +{ + if (_task_queues->usb_command == nullptr) + { + return; + } + + usb_task_command cmd; + + while (osMessageQueueGet(_task_queues->usb_command, &cmd, NULL, 0) == osOK) + { + uint32_t status = USB_RSP_UNKNOWN_COMMAND; + + switch (cmd.opcode) + { + case SESSION_CMD_SET_BRAKE_DUTY_CYCLE: + { + if (cmd.body_len < sizeof(session_set_brake_duty_body)) + { + status = USB_RSP_MALFORMED; + break; + } + + session_set_brake_duty_body body; + memcpy(&body, cmd.body, sizeof(body)); + + // NOT_SUPPORTED rather than OK when no session is running: the command was + // understood and deliberately not obeyed, and the app should say so rather + // than show a duty cycle the brake is not at. + status = _fsm.SetHostBrakeDutyCycle(body.duty_cycle) + ? USB_RSP_OK : USB_RSP_NOT_SUPPORTED; + break; + } + + default: + break; + } + + // msg_id 0 is a firmware-internal command that wants no ack. + if (cmd.msg_id != 0 && _task_queues->task_completion != nullptr) + { + usb_task_completion done; + done.task_offset = TASK_OFFSET_SESSION_CONTROLLER; + done.opcode = cmd.opcode; + done.msg_id = cmd.msg_id; + done.status = status; + osMessageQueuePut(_task_queues->task_completion, &done, 0, 0); + } + } +} + void SessionController::Run() { PublishStartupState(); @@ -238,6 +291,7 @@ void SessionController::Run() while (1) { _fsm.HandleUserInputs(); + DrainHostCommands(); PublishSdLoggingChange(); diff --git a/firmware/Core/Src/Tasks/USB/USBController.cpp b/firmware/Core/Src/Tasks/USB/USBController.cpp index a0427ef..428b0f7 100644 --- a/firmware/Core/Src/Tasks/USB/USBController.cpp +++ b/firmware/Core/Src/Tasks/USB/USBController.cpp @@ -28,6 +28,7 @@ extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZ USBController::USBController(osMessageQueueId_t sessionControllerToUsbController, osMessageQueueId_t taskMonitorToUsbControllerHandle, osMessageQueueId_t forceSensorCommandQueue, + osMessageQueueId_t sessionControllerCommandQueue, osMessageQueueId_t taskCompletionQueue) : _task_errors_buffer_reader(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), _buffer_reader_optical_encoder(optical_encoder_circular_buffer, &optical_encoder_circular_buffer_index_writer, OPTICAL_ENCODER_CIRCULAR_BUFFER_SIZE), @@ -36,6 +37,7 @@ USBController::USBController(osMessageQueueId_t sessionControllerToUsbController _taskMonitorToUsbControllerHandle(taskMonitorToUsbControllerHandle), _sessionControllerToUsbController(sessionControllerToUsbController), _forceSensorCommandQueue(forceSensorCommandQueue), + _sessionControllerCommandQueue(sessionControllerCommandQueue), _taskCompletionQueue(taskCompletionQueue), _txBuffer{}, _txBufferIndex(0), @@ -67,6 +69,8 @@ osMessageQueueId_t USBController::QueueForTaskOffset(task_offset_t taskOffset) { case TASK_OFFSET_FORCE_SENSOR_ADS1115: return _forceSensorCommandQueue; + case TASK_OFFSET_SESSION_CONTROLLER: + return _sessionControllerCommandQueue; // Add more task_offset -> command queue routes here as tasks gain settings. default: return NULL; @@ -807,10 +811,12 @@ bool USBController::IsBufferFull(std::size_t msgSize) extern "C" void usbcontroller_main(osMessageQueueId_t sessionControllerToUsbController, osMessageQueueId_t taskMonitorToUsbControllerHandle, osMessageQueueId_t forceSensorCommandQueue, + osMessageQueueId_t sessionControllerCommandQueue, osMessageQueueId_t taskCompletionQueue) { USBController usb = USBController(sessionControllerToUsbController, taskMonitorToUsbControllerHandle, - forceSensorCommandQueue, taskCompletionQueue); + forceSensorCommandQueue, sessionControllerCommandQueue, + taskCompletionQueue); if (!usb.Init()) { diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index bd86267..d3f23dc 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -192,6 +192,11 @@ osMessageQueueId_t usbToForceSensorCommandHandle; const osMessageQueueAttr_t usbToForceSensorCommand_attributes = { .name = "usbToForceSensorCommand" }; +/* Definitions for usbToSessionControllerCommand */ +osMessageQueueId_t usbToSessionControllerCommandHandle; +const osMessageQueueAttr_t usbToSessionControllerCommand_attributes = { + .name = "usbToSessionControllerCommand" +}; /* Definitions for taskToUsbControllerResponse */ osMessageQueueId_t taskToUsbControllerResponseHandle; const osMessageQueueAttr_t taskToUsbControllerResponse_attributes = { @@ -361,6 +366,9 @@ int main(void) /* creation of usbToForceSensorCommand */ usbToForceSensorCommandHandle = osMessageQueueNew (8, sizeof(usb_task_command), &usbToForceSensorCommand_attributes); + /* creation of usbToSessionControllerCommand */ + usbToSessionControllerCommandHandle = osMessageQueueNew (8, sizeof(usb_task_command), &usbToSessionControllerCommand_attributes); + /* creation of taskToUsbControllerResponse */ taskToUsbControllerResponseHandle = osMessageQueueNew (8, sizeof(usb_task_completion), &taskToUsbControllerResponse_attributes); @@ -1319,7 +1327,9 @@ void sessionControllerTaskEntryFunction(void* argument) .bpm_controller = sessionControllerToBpmHandle, .pid_controller = sessionControllerToPidControllerHandle, .pid_controller_ack = pidControllerToSessionControllerAckHandle, - .display = sessionControllerToDisplayHandle + .display = sessionControllerToDisplayHandle, + .usb_command = usbToSessionControllerCommandHandle, + .task_completion = taskToUsbControllerResponseHandle }; sessioncontroller_main(&tasks); #endif @@ -1412,7 +1422,7 @@ __weak void usbTaskEntryFunction(void *argument) #elif USB_CONTROLLER_TASK_ENABLE == 0 osThreadSuspend(osThreadGetId()); #else - usbcontroller_main(sessionControllertoUsbControllerHandle, taskMonitorToUsbControllerHandle, usbToForceSensorCommandHandle, taskToUsbControllerResponseHandle); + usbcontroller_main(sessionControllertoUsbControllerHandle, taskMonitorToUsbControllerHandle, usbToForceSensorCommandHandle, usbToSessionControllerCommandHandle, taskToUsbControllerResponseHandle); #endif /* USER CODE END 5 */ } diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index bfb89ee..9b64ae5 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -22,7 +22,7 @@ CORTEX_M7.IPParameters=default_mode_Activation CORTEX_M7.default_mode_Activation=1 FREERTOS.FootprintOK=true FREERTOS.IPParameters=Tasks01,configUSE_NEWLIB_REENTRANT,FootprintOK,Queues01,configMAX_TASK_NAME_LEN,configENABLE_FPU,configTOTAL_HEAP_SIZE,configCHECK_FOR_STACK_OVERFLOW -FREERTOS.Queues01=sessionControllerToDisplay,25,session_controller_to_display,0,Dynamic,NULL,NULL; sessionControllerToBpm,10,session_controller_to_bpm,0,Dynamic,NULL,NULL; sessionControllerToForceSensor,16,bool,0,Dynamic,NULL,NULL; sessionControllerToPidController,5,session_controller_to_pid_controller,0,Dynamic,NULL,NULL; opticalEncoderToPidController,10,optical_encoder_output_data,0,Dynamic,NULL,NULL; pidControllerToBpm,10,float,0,Dynamic,NULL,NULL; sessionControllerToOpticalSensor,16,uint16_t,0,Dynamic,NULL,NULL;sessionControllertoUsbController,16,uint16_t,0,Dynamic,NULL,NULL;taskMonitorToUsbController,50,task_monitor_output_data,0,Dynamic,NULL,NULL;usbToForceSensorCommand,8,usb_task_command,0,Dynamic,NULL,NULL;taskToUsbControllerResponse,8,usb_task_completion,0,Dynamic,NULL,NULL;pidControllerToSessionControllerAck,5,bool,0,Dynamic,NULL,NULL +FREERTOS.Queues01=sessionControllerToDisplay,25,session_controller_to_display,0,Dynamic,NULL,NULL; sessionControllerToBpm,10,session_controller_to_bpm,0,Dynamic,NULL,NULL; sessionControllerToForceSensor,16,bool,0,Dynamic,NULL,NULL; sessionControllerToPidController,5,session_controller_to_pid_controller,0,Dynamic,NULL,NULL; opticalEncoderToPidController,10,optical_encoder_output_data,0,Dynamic,NULL,NULL; pidControllerToBpm,10,float,0,Dynamic,NULL,NULL; sessionControllerToOpticalSensor,16,uint16_t,0,Dynamic,NULL,NULL;sessionControllertoUsbController,16,uint16_t,0,Dynamic,NULL,NULL;taskMonitorToUsbController,50,task_monitor_output_data,0,Dynamic,NULL,NULL;usbToForceSensorCommand,8,usb_task_command,0,Dynamic,NULL,NULL;usbToSessionControllerCommand,8,usb_task_command,0,Dynamic,NULL,NULL;taskToUsbControllerResponse,8,usb_task_completion,0,Dynamic,NULL,NULL;pidControllerToSessionControllerAck,5,bool,0,Dynamic,NULL,NULL FREERTOS.Tasks01=usbTask,40,512,usbTaskEntryFunction,As weak,NULL,Dynamic,NULL,NULL; bpmTask,40,128,bpmTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; forceSensorTask,32,256,forceSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; pidTask,40,256,pidControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL; opticalSensorTask,32,256,opticalSensorTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;sessionControllerTask,40,256,sessionControllerTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;lcdDisplayTask,16,256,lcdDisplayTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;ledBlinkTask,8,128,ledBlinkTaskEntryFunction,As external,NULL,Dynamic,NULL,NULL;taskMonitorTask,40,128,taskMonitorEntryFunction,As external,NULL,Dynamic,NULL,NULL FREERTOS.configCHECK_FOR_STACK_OVERFLOW=2 FREERTOS.configENABLE_FPU=1 diff --git a/firmware/tools/message_gen/schema/messages_public.yaml b/firmware/tools/message_gen/schema/messages_public.yaml index 4912928..fb449a2 100644 --- a/firmware/tools/message_gen/schema/messages_public.yaml +++ b/firmware/tools/message_gen/schema/messages_public.yaml @@ -375,7 +375,7 @@ sections: v6 host would decode the trailer as an unknown STATUS record and log it a few hundred times a second. - - { kind: define, name: USB_PROTOCOL_VERSION, value: "7u" } + - { kind: define, name: USB_PROTOCOL_VERSION, value: "8u" } - kind: comment text: |- @@ -458,6 +458,32 @@ sections: - { name: USB_CMD_ACK, value: "0", comment: "host acks the device-ready announce; body = uint32 protocol_version. Firmware replies USB_RSP_OK or USB_RSP_VERSION_MISMATCH" } - { name: USB_CMD_SET_SYSCONFIG, value: "1", comment: "body = sysconfig_set_param_body; writes one runtime parameter into the sysconfig store. Applied by the USB task itself (the store is plain RAM), so the OK is still a full-path ack" } + - kind: enum + name: session_controller_command_t + base: uint16_t + comment: |- + Session-controller-local commands: frames addressed to TASK_OFFSET_SESSION_CONTROLLER. + values: + - name: SESSION_CMD_SET_BRAKE_DUTY_CYCLE + value: "0" + comment: >- + body = session_set_brake_duty_body; sets the commanded brake duty cycle, exactly as a + rotary-encoder tick on the rig would. Honoured only while a session is running and + clamped to the MIN/MAX_DUTY_CYCLE_PERCENT envelope -- the brake is never actuated + outside a session, however the request arrives. Answered USB_RSP_OK when applied and + USB_RSP_NOT_SUPPORTED when no session is running + + - kind: struct + name: session_set_brake_duty_body + packed: true + comment: |- + Body of SESSION_CMD_SET_BRAKE_DUTY_CYCLE. Fraction, not percent: 0.0 - 1.0, matching + MIN/MAX_DUTY_CYCLE_PERCENT and what the BPM task takes. + fields: + - { type: float, name: duty_cycle } + + - { kind: static_assert, expr: "sizeof(session_set_brake_duty_body) == 4", message: "Size of session_set_brake_duty_body must be 4 bytes" } + - kind: comment text: |- Device-ready announcement (STM32 -> PC): emitted as USB_MSG_EVENT with task_offset diff --git a/src/Dyno.App/ViewModels/MainWindowViewModel.cs b/src/Dyno.App/ViewModels/MainWindowViewModel.cs index 36d7fe6..b55981a 100644 --- a/src/Dyno.App/ViewModels/MainWindowViewModel.cs +++ b/src/Dyno.App/ViewModels/MainWindowViewModel.cs @@ -192,6 +192,7 @@ private void Navigate(AppPage page) [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ConnectCommand))] [NotifyCanExecuteChangedFor(nameof(DisconnectCommand))] + [NotifyPropertyChangedFor(nameof(CanCommandDutyCycle))] private bool _isConnected; [ObservableProperty] @@ -202,6 +203,7 @@ private void Navigate(AppPage page) /// so there is nothing truthful to show. [ObservableProperty] [NotifyPropertyChangedFor(nameof(SessionStatus))] + [NotifyPropertyChangedFor(nameof(CanCommandDutyCycle))] private bool _isSessionActive; public string SessionStatus => IsSessionActive ? "Session running" : "No session"; @@ -219,6 +221,96 @@ private void Navigate(AppPage page) [ObservableProperty] private double _dutyCycle; + /// What is typed in the brake duty-cycle box, as a percentage (0 - 100) to match the + /// readout it replaced. The firmware takes a 0 - 1 fraction; the conversion happens on send. + /// + /// Held as text rather than a number so a half-typed value ("4", "4.") is not repeatedly + /// reinterpreted and rewritten under the user's cursor. + [ObservableProperty] + private string _dutyCycleInput = "0.0"; + + /// True while the duty-cycle box has focus. Telemetry stops writing to the box then: + /// the device streams a BPM sample several times a second, and without this the box would + /// overwrite whatever was being typed between one keystroke and the next. + [ObservableProperty] + private bool _isEditingDutyCycle; + + /// Set when the last duty-cycle command was refused or failed, so the box can show + /// that the brake is not at what it says. + [ObservableProperty] + private bool _isDutyCycleInputInvalid; + + /// The brake can only be commanded during a session -- the firmware refuses outside + /// one -- so the box is disabled rather than silently ignored. + public bool CanCommandDutyCycle => IsConnected && IsSessionActive; + + /// How far one scroll-wheel notch moves the duty cycle, in percent. + private const double DutyCycleWheelStepPercent = 1.0; + + /// Sends whatever is in the box. Called when the box loses focus or Enter is pressed -- + /// not on every keystroke, which would command the brake to "4" on the way to typing "45". + public async Task CommitDutyCycleAsync() + { + IsEditingDutyCycle = false; + + if (!double.TryParse(DutyCycleInput, out double percent)) + { + IsDutyCycleInputInvalid = true; + return; + } + + await SendDutyCyclePercentAsync(percent).ConfigureAwait(true); + } + + /// Moves the duty cycle by scroll-wheel steps and sends it + /// immediately. Unlike typing there is no half-finished state to wait for: one notch is one + /// complete intent, so it goes out at once. + public async Task NudgeDutyCycleAsync(int notches) + { + if (!CanCommandDutyCycle || notches == 0) + { + return; + } + + // Start from what is in the box, so several notches in a row accumulate rather than each + // one being applied to whatever the device last reported. + double percent = double.TryParse(DutyCycleInput, out double parsed) ? parsed : DutyCycle * 100.0; + + await SendDutyCyclePercentAsync(percent + notches * DutyCycleWheelStepPercent) + .ConfigureAwait(true); + } + + /// Clamps to 0 - 100, shows the clamped figure, and commands it. The firmware clamps + /// again to the MIN/MAX_DUTY_CYCLE_PERCENT envelope, which is narrower and is the authority; + /// this only keeps the box from offering something nonsensical. + private async Task SendDutyCyclePercentAsync(double percent) + { + var client = _client; + if (client is null || !CanCommandDutyCycle) + { + return; + } + + percent = Math.Clamp(percent, 0.0, 100.0); + DutyCycleInput = percent.ToString("F1"); + + try + { + var response = await client + .SetBrakeDutyCycleAsync((float)(percent / 100.0)) + .ConfigureAwait(true); + + // The device answers NOT_SUPPORTED when no session is running. Flagging it beats + // leaving the box showing a figure the brake never went to. + IsDutyCycleInputInvalid = response.status != (uint)usb_response_status_t.USB_RSP_OK; + } + catch (Exception) + { + // Logged by DeviceClient through CommandFailed, which the event log already shows. + IsDutyCycleInputInvalid = true; + } + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(TorqueGeared))] private double _torque; @@ -941,6 +1033,14 @@ private void Apply(DeviceMessage message) break; case BpmSample s: DutyCycle = s.Data.duty_cycle; + // Follow the device only when the box is not being typed in. BPM samples arrive + // several times a second, so without the gate the box would rewrite itself + // between keystrokes and the caret would jump to the end each time. + if (!IsEditingDutyCycle) + { + DutyCycleInput = (s.Data.duty_cycle * 100.0).ToString("F1"); + IsDutyCycleInputInvalid = false; + } Plots.RecordDutyCycle(s.Data.timestamp, s.Data.duty_cycle); break; diff --git a/src/Dyno.App/Views/HomeView.axaml b/src/Dyno.App/Views/HomeView.axaml index 3778a94..3c9825b 100644 --- a/src/Dyno.App/Views/HomeView.axaml +++ b/src/Dyno.App/Views/HomeView.axaml @@ -10,6 +10,24 @@ + + + + + + + @@ -231,14 +249,27 @@ Text="{Binding GearRatio, StringFormat='{}{0:F3}'}" /> - + + + + The live console: connection toolbar, telemetry and task monitor. Pure markup — the -/// event log that used to sit at the foot of this page now belongs to the window, so it is shown on -/// every page (see ). +/// The live console: connection toolbar, telemetry and task monitor. +/// +/// Almost pure markup. The exception is the brake duty-cycle box, which needs three things XAML +/// bindings cannot express on their own: knowing when it has focus (so incoming telemetry stops +/// rewriting it mid-keystroke), treating Enter as "send it", and turning wheel notches into +/// adjustments. All three delegate straight to the view model. public partial class HomeView : UserControl { public HomeView() => InitializeComponent(); + + private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel; + + private void OnDutyCycleGotFocus(object? sender, GotFocusEventArgs e) + { + if (ViewModel is { } vm) + { + vm.IsEditingDutyCycle = true; + } + } + + private async void OnDutyCycleLostFocus(object? sender, RoutedEventArgs e) + { + if (ViewModel is { } vm) + { + await vm.CommitDutyCycleAsync(); + } + } + + private async void OnDutyCycleKeyDown(object? sender, KeyEventArgs e) + { + if (ViewModel is not { } vm) + { + return; + } + + switch (e.Key) + { + case Key.Enter: + e.Handled = true; + await vm.CommitDutyCycleAsync(); + break; + + // Abandon the edit and let the next telemetry sample put the real figure back. + case Key.Escape: + e.Handled = true; + vm.IsEditingDutyCycle = false; + vm.IsDutyCycleInputInvalid = false; + break; + } + } + + private async void OnDutyCycleWheel(object? sender, PointerWheelEventArgs e) + { + if (ViewModel is not { } vm || !vm.CanCommandDutyCycle) + { + return; + } + + // Handled unconditionally once the box can be commanded, so a notch adjusts the value + // instead of scrolling the page out from under the pointer. + e.Handled = true; + + int notches = Math.Sign(e.Delta.Y); + if (notches != 0) + { + await vm.NudgeDutyCycleAsync(notches); + } + } } diff --git a/src/Dyno.Core/DeviceClient.cs b/src/Dyno.Core/DeviceClient.cs index 379968e..e85276e 100644 --- a/src/Dyno.Core/DeviceClient.cs +++ b/src/Dyno.Core/DeviceClient.cs @@ -453,6 +453,38 @@ public Task SetSysConfigParamAsync( ); } + /// + /// Sets the commanded brake duty cycle (0.0 - 1.0), the same quantity the rig's rotary encoder + /// drives. + /// + /// The firmware honours this only while a session is running and clamps it to the + /// MIN/MAX_DUTY_CYCLE_PERCENT envelope, so a value sent outside a session is answered + /// USB_RSP_NOT_SUPPORTED and the brake is untouched. Not retried: unlike a sysconfig + /// write this commands an actuator, and re-sending a request whose ack was lost could drive the + /// brake to a figure the user has since moved away from. + /// + public Task SetBrakeDutyCycleAsync( + float dutyCycle, + TimeSpan? timeout = null, + CancellationToken cancellationToken = default + ) + { + byte[] body = new byte[4]; + BitConverter.TryWriteBytes(body.AsSpan(0, 4), dutyCycle); + + return SendCommandAsync( + task_offset_t.TASK_OFFSET_SESSION_CONTROLLER, + (ushort)session_controller_command_t.SESSION_CMD_SET_BRAKE_DUTY_CYCLE, + body, + description: $"brake duty cycle {dutyCycle:P1}", + type: usb_msg_type_t.USB_MSG_COMMAND, + throwOnError: true, + timeout: timeout, + retries: 0, + cancellationToken: cancellationToken + ); + } + /// Hands out the next host msg_id, skipping the firmware-reserved 0 on 16-bit wrap. private ushort NextMsgId() { diff --git a/src/Dyno.Core/Messages/Generated/Messages.cs b/src/Dyno.Core/Messages/Generated/Messages.cs index 6ecd58f..618f405 100644 --- a/src/Dyno.Core/Messages/Generated/Messages.cs +++ b/src/Dyno.Core/Messages/Generated/Messages.cs @@ -22,7 +22,7 @@ public static class MessageConstants public const uint USB_FRAME_CRC_INIT = 0xFFFFu; // 0xFFFFu public const uint USB_FRAME_CRC_POLY = 0x1021u; // 0x1021u public const uint USB_RX_MAX_PAYLOAD = 128u; // 128u - public const uint USB_PROTOCOL_VERSION = 7u; // 7u + public const uint USB_PROTOCOL_VERSION = 8u; // 8u public const uint SYSCFG_PARAM_COUNT = 34u; // 34u -- one past the highest sysconfig_param_t id; sizes the firmware store } @@ -256,6 +256,20 @@ public enum usb_controller_command_t : ushort USB_CMD_SET_SYSCONFIG = 1, // body = sysconfig_set_param_body; writes one runtime parameter into the sysconfig store. Applied by the USB task itself (the store is plain RAM), so the OK is still a full-path ack } +/// Session-controller-local commands: frames addressed to TASK_OFFSET_SESSION_CONTROLLER. +public enum session_controller_command_t : ushort +{ + SESSION_CMD_SET_BRAKE_DUTY_CYCLE = 0, // body = session_set_brake_duty_body; sets the commanded brake duty cycle, exactly as a rotary-encoder tick on the rig would. Honoured only while a session is running and clamped to the MIN/MAX_DUTY_CYCLE_PERCENT envelope -- the brake is never actuated outside a session, however the request arrives. Answered USB_RSP_OK when applied and USB_RSP_NOT_SUPPORTED when no session is running +} + +/// Body of SESSION_CMD_SET_BRAKE_DUTY_CYCLE. Fraction, not percent: 0.0 - 1.0, matching +/// MIN/MAX_DUTY_CYCLE_PERCENT and what the BPM task takes. +[StructLayout(LayoutKind.Sequential, Pack = 1)] +public struct session_set_brake_duty_body +{ + public float duty_cycle; +} + // Device-ready announcement (STM32 -> PC): emitted as USB_MSG_EVENT with task_offset // TASK_OFFSET_USB_CONTROLLER and repeated (~every 200ms) until the host answers with // USB_CMD_ACK. Carries the firmware's USB_PROTOCOL_VERSION so the host can confirm the @@ -429,6 +443,7 @@ public static class MessageContract (typeof(usb_msg_header_t), 12), (typeof(usb_cmd_header_t), 4), (typeof(usb_response_data_t), 8), + (typeof(session_set_brake_duty_body), 4), (typeof(usb_device_ready_event), 4), (typeof(session_state_event), 8), (typeof(usb_tx_batch_trailer), 8), diff --git a/src/Dyno.Core/Protocol/CommandOpcodes.cs b/src/Dyno.Core/Protocol/CommandOpcodes.cs index bb2d317..b4b1525 100644 --- a/src/Dyno.Core/Protocol/CommandOpcodes.cs +++ b/src/Dyno.Core/Protocol/CommandOpcodes.cs @@ -24,6 +24,10 @@ public static string Name(task_offset_t task, ushort opcode) => when Enum.IsDefined((usb_controller_command_t)opcode) => ( (usb_controller_command_t)opcode ).ToString(), + task_offset_t.TASK_OFFSET_SESSION_CONTROLLER + when Enum.IsDefined((session_controller_command_t)opcode) => ( + (session_controller_command_t)opcode + ).ToString(), // The force sensor no longer defines command opcodes (its ADS1115 config is sysconfig), // so anything addressed to it falls through to the generic name. _ => $"opcode {opcode}", diff --git a/tests/Dyno.Core.Tests/CommandOpcodeTests.cs b/tests/Dyno.Core.Tests/CommandOpcodeTests.cs index e13a13d..8b6ba29 100644 --- a/tests/Dyno.Core.Tests/CommandOpcodeTests.cs +++ b/tests/Dyno.Core.Tests/CommandOpcodeTests.cs @@ -30,7 +30,17 @@ public void AnUnknownOpcodeFallsBackToItsNumber() "opcode 99", CommandOpcodes.Name(task_offset_t.TASK_OFFSET_USB_CONTROLLER, 99) ); - Assert.Equal("opcode 1", CommandOpcodes.Name(task_offset_t.TASK_OFFSET_LUMEX_LCD, 1)); + Assert.Equal("opcode 1", CommandOpcodes.Name(task_offset_t.TASK_OFFSET_DISPLAY, 1)); + // The session controller does define commands, so a known opcode is named and an unknown + // one still falls back rather than being mislabelled as a defined command. + Assert.Equal( + "SESSION_CMD_SET_BRAKE_DUTY_CYCLE", + CommandOpcodes.Name(task_offset_t.TASK_OFFSET_SESSION_CONTROLLER, 0) + ); + Assert.Equal( + "opcode 77", + CommandOpcodes.Name(task_offset_t.TASK_OFFSET_SESSION_CONTROLLER, 77) + ); // The force sensor defines no command opcodes now (its ADS1115 config is sysconfig), so // anything addressed to it is a plain number too. Assert.Equal( @@ -52,6 +62,9 @@ public void EveryCommandEnumIsAccountedFor() .OrderBy(n => n, StringComparer.Ordinal) .ToArray(); - Assert.Equal(["usb_controller_command_t"], commandEnums); + Assert.Equal( + ["session_controller_command_t", "usb_controller_command_t"], + commandEnums + ); } } From 70e95b856b8a2d7db1091ed30568cb92574b7b3c Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 21:05:27 -0700 Subject: [PATCH 12/25] broken bpm --- firmware/Core/Inc/Config/debug.h | 4 +- .../Core/Src/Tasks/Display/ili9341_layout.c | 13 ++++- firmware/Core/Src/stm32h7xx_hal_msp.c | 4 +- firmware/stm32_dyno_firmware_v2.ioc | 9 ++-- firmware/tests/ili9341_layout_tests.cpp | 50 +++++++++++++++++++ 5 files changed, 71 insertions(+), 9 deletions(-) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index b415a24..f36ba6a 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -53,8 +53,8 @@ // Both panels consume the same session_controller_to_display message, so the SessionController // and its FSM are identical either way; only the driver linked in changes. There is no runtime // switch because there is no runtime question: a board has one panel soldered to it. -#define LUMEX_LCD_TASK_ENABLE 1 -#define ILI9341_LCD_TASK_ENABLE 0 +#define LUMEX_LCD_TASK_ENABLE 0 +#define ILI9341_LCD_TASK_ENABLE 1 #if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) != 1 #error "Exactly one display driver must be enabled: set one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1 and the other to 0." diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ili9341_layout.c index e5ed8a2..5c87223 100644 --- a/firmware/Core/Src/Tasks/Display/ili9341_layout.c +++ b/firmware/Core/Src/Tasks/Display/ili9341_layout.c @@ -133,7 +133,13 @@ static void layout_session(const session_controller_to_display *state, // Speed: label, big value, unit alongside. add_field(out, 12, 18, SIZE_SMALL, COLOUR_LABEL, "SPEED"); - uint32_t rpm = (uint32_t)roundf(state->rpm); + // Clamped for the same reason the detail row is, and it matters more here: these two sit + // beside unit labels ("rpm", "N") that a field grown by one character paints straight over, + // and the overhang is stranded when the reading comes back down. Braking is what drives + // force up, so the encoder is the control that reaches it. The clamp also has to happen + // before the cast -- (uint32_t)roundf() of a negative float is undefined, and in practice + // wraps to a ten-digit number that swamps the whole row. + const uint32_t rpm = (uint32_t)clamp_float(roundf(state->rpm), 0.0f, 99999.0f); snprintf(scratch, sizeof(scratch), "%5lu", (unsigned long)rpm); add_field(out, 12, 40, SIZE_VALUE, COLOUR_VALUE, scratch); @@ -142,7 +148,10 @@ static void layout_session(const session_controller_to_display *state, // Force, the same shape one row down. add_field(out, 12, 100, SIZE_SMALL, COLOUR_LABEL, "FORCE"); - float force = roundf(state->force * 100.0f) / 100.0f; + // -99.99 to 999.99 is what "%6.2f" is six characters wide for; either end past that grows a + // seventh. The lower bound keeps the sign, which a load cell sitting slightly below zero on + // offset alone still needs to show. + const float force = clamp_float(roundf(state->force * 100.0f) / 100.0f, -99.99f, 999.99f); snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); add_field(out, 12, 122, SIZE_VALUE, COLOUR_VALUE, scratch); diff --git a/firmware/Core/Src/stm32h7xx_hal_msp.c b/firmware/Core/Src/stm32h7xx_hal_msp.c index 7355002..14a593f 100644 --- a/firmware/Core/Src/stm32h7xx_hal_msp.c +++ b/firmware/Core/Src/stm32h7xx_hal_msp.c @@ -387,14 +387,14 @@ void HAL_SPI_MspInit(SPI_HandleTypeDef* hspi) GPIO_InitStruct.Pin = ILI_SPI1_MOSI_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(ILI_SPI1_MOSI_GPIO_Port, &GPIO_InitStruct); GPIO_InitStruct.Pin = ILI_SPI1_MISO_Pin|ILI_SPI1_SCK_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index 9b64ae5..c610c18 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -261,8 +261,9 @@ PD6.GPIO_Speed=GPIO_SPEED_FREQ_LOW PD6.Locked=true PD6.PinState=GPIO_PIN_SET PD6.Signal=GPIO_Output -PD7.GPIOParameters=GPIO_Label +PD7.GPIOParameters=GPIO_Speed,GPIO_Label PD7.GPIO_Label=ILI_SPI1_MOSI +PD7.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH PD7.Mode=Full_Duplex_Master PD7.Signal=SPI1_MOSI PE3.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -305,8 +306,9 @@ PG10.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM PG10.Locked=true PG10.PinState=GPIO_PIN_SET PG10.Signal=GPIO_Output -PG11.GPIOParameters=GPIO_Label +PG11.GPIOParameters=GPIO_Speed,GPIO_Label PG11.GPIO_Label=ILI_SPI1_SCK +PG11.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH PG11.Mode=Full_Duplex_Master PG11.Signal=SPI1_SCK PG14.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -315,8 +317,9 @@ PG14.GPIO_ModeDefaultEXTI=GPIO_MODE_IT_FALLING PG14.GPIO_PuPd=GPIO_PULLUP PG14.Locked=true PG14.Signal=GPXTI14 -PG9.GPIOParameters=GPIO_Label +PG9.GPIOParameters=GPIO_Speed,GPIO_Label PG9.GPIO_Label=ILI_SPI1_MISO +PG9.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH PG9.Mode=Full_Duplex_Master PG9.Signal=SPI1_MISO PH0-OSC_IN\ (PH0).Mode=HSE-External-Oscillator diff --git a/firmware/tests/ili9341_layout_tests.cpp b/firmware/tests/ili9341_layout_tests.cpp index 16dff8f..52d7191 100644 --- a/firmware/tests/ili9341_layout_tests.cpp +++ b/firmware/tests/ili9341_layout_tests.cpp @@ -325,6 +325,56 @@ TEST(Ili9341SessionDetail, ShowsAccelerationPeakForceAndElapsedTime) EXPECT_EQ(std::string(DetailField(frame, 2).text), "T 87s"); } +// The same invariant for the two primary readouts. They are the ones the rig actually drives to +// extremes -- braking hard is what sends force up -- and unlike the detail row they sit beside a +// unit label, so a field that grows a character paints straight over it and the tail is stranded +// when the reading comes back down. +TEST(Ili9341Session, PrimaryReadoutWidthsDoNotMoveWithTheValues) +{ + session_controller_to_display quiet = State(DISPLAY_SCREEN_SESSION); + quiet.rpm = 0.0f; + quiet.force = 0.0f; + + session_controller_to_display extreme = State(DISPLAY_SCREEN_SESSION); + extreme.rpm = 1e9f; + extreme.force = 1e9f; + + const ili9341_frame small = Layout(quiet); + const ili9341_frame large = Layout(extreme); + + ASSERT_EQ(small.count, large.count); + + // Field 1 is the rpm value, field 4 the force value. + for (int i : {1, 4}) + { + EXPECT_EQ(small.fields[i].length, large.fields[i].length) + << "primary field " << i << " changed width: \"" << small.fields[i].text + << "\" vs \"" << large.fields[i].text << "\""; + } +} + +// A negative reading must stay inside the format too. The load cell can sit slightly below zero +// on offset alone, and (uint32_t)roundf() of a negative float is undefined -- in practice it +// wraps to a ten-digit number that swamps the row. +TEST(Ili9341Session, NegativeReadingsStayInsideTheirFormat) +{ + session_controller_to_display quiet = State(DISPLAY_SCREEN_SESSION); + + session_controller_to_display negative = State(DISPLAY_SCREEN_SESSION); + negative.rpm = -5.0f; + negative.force = -1e9f; + + const ili9341_frame small = Layout(quiet); + const ili9341_frame large = Layout(negative); + + for (int i : {1, 4}) + { + EXPECT_EQ(small.fields[i].length, large.fields[i].length) + << "primary field " << i << " changed width: \"" << small.fields[i].text + << "\" vs \"" << large.fields[i].text << "\""; + } +} + TEST(Ili9341SessionDetail, WidthsDoNotMoveWithTheValues) { const ili9341_frame small = Layout(State(DISPLAY_SCREEN_SESSION), Detail(0.0f, 0.0f, 0)); From a9311e8354dd622141ff3c39cb4cf02813341c79 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 21:53:08 -0700 Subject: [PATCH 13/25] fix: stop the brake duty cycle dropping to 0%, and restore the display'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 --- firmware/Core/Inc/Config/debug.h | 7 +++++++ .../Inc/Tasks/SessionController/SessionController.hpp | 2 +- .../Tasks/SessionController/FiniteStateMachine.cpp | 11 +++++++++++ .../Src/Tasks/SessionController/SessionController.cpp | 8 +++++++- firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp | 4 ++-- firmware/Core/Src/stm32h7xx_hal_msp.c | 4 ++-- firmware/stm32_dyno_firmware_v2.ioc | 6 +++--- 7 files changed, 33 insertions(+), 9 deletions(-) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index f36ba6a..d181e18 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -60,6 +60,13 @@ #error "Exactly one display driver must be enabled: set one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1 and the other to 0." #endif +// "A display task exists", which is what everything outside the two drivers actually wants to +// know: null checks on the display queue and thread id, and the task monitor's stack-usage +// report. Those must not be gated on one panel's own enable -- selecting the other panel then +// silently compiles them out, which is exactly what happened when this board moved to the +// ILI9341 and took the display task's stack high-water mark off the USB stream with it. +#define DISPLAY_TASK_ENABLE (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) + // USB Controller task settings // The mock-message stream used to live here as DEBUG_USB_CONTROLLER_MOCK_MESSAGES. It is now the // runtime parameter SYSCFG_USB_MOCK_MESSAGES (schema: sysconfig_params), so exercising the link diff --git a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp index 316acbf..16794cb 100644 --- a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp @@ -18,7 +18,7 @@ || !defined(FORCE_SENSOR_ADS1115_TASK_ENABLE) || !defined(FORCE_SENSOR_ADC_TASK_ENABLE) \ || !defined(OPTICAL_ENCODER_TASK_ENABLE) || !defined(BPM_CONTROLLER_TASK_ENABLE) \ || !defined(PID_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE) \ - || !defined(ILI9341_LCD_TASK_ENABLE) + || !defined(ILI9341_LCD_TASK_ENABLE) || !defined(DISPLAY_TASK_ENABLE) #error "A *_TASK_ENABLE macro is not visible here; SessionController's #if-gated queue posts would silently compile out (include Config/debug.h)" #endif diff --git a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp index e78d6d2..9908d42 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -231,6 +231,17 @@ void FSM::HandleButtonBrakeInput(bool isEnabled) { return; } + + // A press while a session is already running is not a request to start one -- it is a + // second edge from a bouncing contact, or noise coupled into the line. ShowSessionScreen + // is destructive (it zeroes the commanded duty cycle, wipes the peak force and restarts + // the session clock), so re-entering it mid-run drops the brake to 0% under the user's + // hand. Ignore it: only a real IDLE -> IN_SESSION transition may reset those. + if (_state.mainState == State::MainDynoState::IN_SESSION) + { + return; + } + ShowSessionScreen(); } else diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index 2f75f7a..bf0be1b 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -60,9 +60,15 @@ bool SessionController::CheckTaskQueuesValid() || _task_queues->pid_controller == nullptr || _task_queues->pid_controller_ack == nullptr #endif - #if LUMEX_LCD_TASK_ENABLE + #if DISPLAY_TASK_ENABLE || _task_queues->display == nullptr #endif + #if USB_CONTROLLER_TASK_ENABLE + // The host command route: without these the SessionController silently swallows every + // command the USB task forwards, rather than reporting the bad wiring. + || _task_queues->usb_command == nullptr + || _task_queues->task_completion == nullptr + #endif ) { ReportError(ERROR_SESSION_CONTROLLER_INVALID_TASK_QUEUE_POINTER); diff --git a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp index eaedc2d..afc6647 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp +++ b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp @@ -36,7 +36,7 @@ bool TaskMonitor::Init() #if PID_CONTROLLER_TASK_ENABLE || _osThreadIdPtrs->pid_controller == nullptr #endif - #if LUMEX_LCD_TASK_ENABLE + #if DISPLAY_TASK_ENABLE || _osThreadIdPtrs->display == nullptr #endif ) @@ -94,7 +94,7 @@ void TaskMonitor::Run() #if PID_CONTROLLER_TASK_ENABLE GetTaskDataAndSendToUsbController(TASK_OFFSET_PID_CONTROLLER, _osThreadIdPtrs->pid_controller); #endif - #if LUMEX_LCD_TASK_ENABLE + #if DISPLAY_TASK_ENABLE GetTaskDataAndSendToUsbController(TASK_OFFSET_DISPLAY, _osThreadIdPtrs->display); #endif diff --git a/firmware/Core/Src/stm32h7xx_hal_msp.c b/firmware/Core/Src/stm32h7xx_hal_msp.c index 14a593f..f1acdbf 100644 --- a/firmware/Core/Src/stm32h7xx_hal_msp.c +++ b/firmware/Core/Src/stm32h7xx_hal_msp.c @@ -387,14 +387,14 @@ void HAL_SPI_MspInit(SPI_HandleTypeDef* hspi) GPIO_InitStruct.Pin = ILI_SPI1_MOSI_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(ILI_SPI1_MOSI_GPIO_Port, &GPIO_InitStruct); GPIO_InitStruct.Pin = ILI_SPI1_MISO_Pin|ILI_SPI1_SCK_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index c610c18..4543db7 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -263,7 +263,7 @@ PD6.PinState=GPIO_PIN_SET PD6.Signal=GPIO_Output PD7.GPIOParameters=GPIO_Speed,GPIO_Label PD7.GPIO_Label=ILI_SPI1_MOSI -PD7.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH +PD7.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM PD7.Mode=Full_Duplex_Master PD7.Signal=SPI1_MOSI PE3.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -308,7 +308,7 @@ PG10.PinState=GPIO_PIN_SET PG10.Signal=GPIO_Output PG11.GPIOParameters=GPIO_Speed,GPIO_Label PG11.GPIO_Label=ILI_SPI1_SCK -PG11.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH +PG11.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM PG11.Mode=Full_Duplex_Master PG11.Signal=SPI1_SCK PG14.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -319,7 +319,7 @@ PG14.Locked=true PG14.Signal=GPXTI14 PG9.GPIOParameters=GPIO_Speed,GPIO_Label PG9.GPIO_Label=ILI_SPI1_MISO -PG9.GPIO_Speed=GPIO_SPEED_FREQ_VERY_HIGH +PG9.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM PG9.Mode=Full_Duplex_Master PG9.Signal=SPI1_MISO PH0-OSC_IN\ (PH0).Mode=HSE-External-Oscillator From 2ce042efdca6e3e67fe9872004b1e546935b28c0 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Mon, 27 Jul 2026 21:54:01 -0700 Subject: [PATCH 14/25] display: one directory per panel, and keep the float formatter and HAL_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 --- .../Display/{ => ILI9341}/ILI9341Display.hpp | 2 +- .../Display/{ => ILI9341}/ili9341_layout.h | 2 +- .../Display/{ => ILI9341}/ili9341_main.h | 0 .../Tasks/{LCD => Display/Lumex}/LumexLCD.hpp | 4 +- .../{LCD => Display/Lumex}/lumex_layout.h | 0 .../{LCD => Display/Lumex}/lumexlcd_main.h | 0 .../Core/Inc/Tasks/Display/display_common.h | 17 ++++ firmware/Core/README.md | 3 +- .../Display/{ => ILI9341}/ILI9341Display.cpp | 24 +++++- .../Display/{ => ILI9341}/ili9341_layout.c | 12 ++- .../Tasks/{LCD => Display/Lumex}/LumexLCD.cpp | 7 +- .../{LCD => Display/Lumex}/lumex_layout.c | 5 +- firmware/Core/Src/Tasks/Display/README.md | 77 +++++++++++++++---- .../Core/Src/Tasks/Display/display_common.c | 29 +++++++ firmware/Core/Src/Tasks/LCD/README.md | 62 --------------- firmware/Core/Src/main.c | 4 +- firmware/Drivers/ILI9341/ILI9341.cpp | 12 +-- firmware/Drivers/ILI9341/ILI9341.hpp | 16 +++- firmware/tests/CMakeLists.txt | 4 +- firmware/tests/ili9341_layout_tests.cpp | 2 +- firmware/tests/lumex_layout_tests.cpp | 72 ++++++++++++++++- 21 files changed, 246 insertions(+), 108 deletions(-) rename firmware/Core/Inc/Tasks/Display/{ => ILI9341}/ILI9341Display.hpp (97%) rename firmware/Core/Inc/Tasks/Display/{ => ILI9341}/ili9341_layout.h (96%) rename firmware/Core/Inc/Tasks/Display/{ => ILI9341}/ili9341_main.h (100%) rename firmware/Core/Inc/Tasks/{LCD => Display/Lumex}/LumexLCD.hpp (95%) rename firmware/Core/Inc/Tasks/{LCD => Display/Lumex}/lumex_layout.h (100%) rename firmware/Core/Inc/Tasks/{LCD => Display/Lumex}/lumexlcd_main.h (100%) rename firmware/Core/Src/Tasks/Display/{ => ILI9341}/ILI9341Display.cpp (83%) rename firmware/Core/Src/Tasks/Display/{ => ILI9341}/ili9341_layout.c (94%) rename firmware/Core/Src/Tasks/{LCD => Display/Lumex}/LumexLCD.cpp (97%) rename firmware/Core/Src/Tasks/{LCD => Display/Lumex}/lumex_layout.c (97%) delete mode 100644 firmware/Core/Src/Tasks/LCD/README.md diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp similarity index 97% rename from firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp rename to firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp index 9253a58..7a0f97c 100644 --- a/firmware/Core/Inc/Tasks/Display/ILI9341Display.hpp +++ b/firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp @@ -10,7 +10,7 @@ #include "MessagePassing/messages_private.h" #include "MessagePassing/messages_public.h" -#include "Tasks/Display/ili9341_layout.h" +#include "Tasks/Display/ILI9341/ili9341_layout.h" // The ILI9341's side of the display split: turns screen state into painted pixels. // diff --git a/firmware/Core/Inc/Tasks/Display/ili9341_layout.h b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h similarity index 96% rename from firmware/Core/Inc/Tasks/Display/ili9341_layout.h rename to firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h index 3374c58..015acad 100644 --- a/firmware/Core/Inc/Tasks/Display/ili9341_layout.h +++ b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h @@ -3,7 +3,7 @@ // The ILI9341 panel's share of the display split: screen state in, positioned text fields out. // -// The counterpart to Tasks/LCD/lumex_layout.h, and deliberately a different shape. Both take +// The counterpart to Tasks/Display/Lumex/lumex_layout.h, and deliberately a different shape. Both take // the same session_controller_to_display and neither constrains the other -- that is the point // of sending screen state rather than draw commands. This one lays out a 320x240 landscape // panel with several text sizes; the Lumex one lays out a 2x16 character grid. diff --git a/firmware/Core/Inc/Tasks/Display/ili9341_main.h b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_main.h similarity index 100% rename from firmware/Core/Inc/Tasks/Display/ili9341_main.h rename to firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_main.h diff --git a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp similarity index 95% rename from firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp rename to firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp index a8a98db..c3c23f6 100644 --- a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp +++ b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp @@ -15,7 +15,7 @@ #include "MessagePassing/messages_public.h" #include "MessagePassing/osqueue_helpers.h" -#include "Tasks/LCD/lumex_layout.h" +#include "Tasks/Display/Lumex/lumex_layout.h" #include "TimeKeeping/timestamps.h" @@ -23,7 +23,7 @@ // // Satisfies the DisplayDriver concept (Tasks/Display/DisplayDriver.hpp) without inheriting // anything: the panel choice is fixed at link time, so the contract is checked at compile time -// and there is no vtable. See Core/Src/Tasks/LCD/README.md for the display split. +// and there is no vtable. See Core/Src/Tasks/Display/README.md for the display split. class LumexLCD { public: diff --git a/firmware/Core/Inc/Tasks/LCD/lumex_layout.h b/firmware/Core/Inc/Tasks/Display/Lumex/lumex_layout.h similarity index 100% rename from firmware/Core/Inc/Tasks/LCD/lumex_layout.h rename to firmware/Core/Inc/Tasks/Display/Lumex/lumex_layout.h diff --git a/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h b/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h similarity index 100% rename from firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h rename to firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h diff --git a/firmware/Core/Inc/Tasks/Display/display_common.h b/firmware/Core/Inc/Tasks/Display/display_common.h index 167683b..2f20a6d 100644 --- a/firmware/Core/Inc/Tasks/Display/display_common.h +++ b/firmware/Core/Inc/Tasks/Display/display_common.h @@ -4,6 +4,7 @@ // The parts of reading a display message that are the message's business rather than any one // panel's. Both layouts include this; neither includes the other. +#include #include #include "MessagePassing/messages_private.h" @@ -19,6 +20,22 @@ extern "C" { // number use this. uint32_t display_rpm_digit_increment(display_rpm_digit digit); +// Formats `value` to two decimal places, right-aligned in `width` columns -- what "%*.2f" +// would produce, without the float. +// +// snprintf("%f") drags in newlib's floating-point formatter, which needs ~400 bytes of stack +// (measured: _svfiprintf_r 152 + _printf_float 88 + _dtoa_r 108 + _Balloc/_malloc_r ~48) and +// is the one call in this path whose cost cannot be read off the -fstack-usage output. A +// display task runs on a kilobyte, so it stays out. Rendering a force reading was the only +// float conversion in the firmware, and it overflowed the stack: the overflow hook disables +// interrupts and spins, which looks exactly like a dead board. It is also the immediate +// neighbour of the SessionController's stack in the heap, whose outermost frame holds the FSM +// -- so an overflow that does not trip the canary lands on the commanded brake duty cycle. +// +// Rounds half away from zero, like printf. Values wider than `width` are not truncated, again +// matching printf. +void display_format_fixed2(char *out, size_t out_size, float value, int width); + #ifdef __cplusplus } #endif diff --git a/firmware/Core/README.md b/firmware/Core/README.md index f9f7e18..52993d0 100644 --- a/firmware/Core/README.md +++ b/firmware/Core/README.md @@ -21,8 +21,7 @@ never by calling into another task directly. | PID | `Core/Src/Tasks/PID/README.md` | Closed-loop brake control from encoder feedback | | ForceSensor | `Core/Src/Tasks/ForceSensor/README.md` | On-board force: i2c (ADS1115) and internal ADC | | OpticalSensor | `Core/Src/Tasks/OpticalSensor/README.md` | Angular velocity / acceleration from an optical encoder | -| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex 16x2 character display | -| Display | `Core/Src/Tasks/Display/README.md` | The display seam; ILI9341 320x240 TFT | +| Display | `Core/Src/Tasks/Display/README.md` | The display seam, plus both panels: `Lumex/` 16x2 character, `ILI9341/` 320x240 TFT | | USB | `Core/Src/Tasks/USB/README.md` | Streams data + errors to the PC over USB CDC | | TaskMonitor | `Core/Src/Tasks/TaskMonitor/README.md` | Per-task state and stack usage | | MessagePassing | `Core/Src/MessagePassing/README.md` | Queue helpers, circular buffers, USB wire protocol | diff --git a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp similarity index 83% rename from firmware/Core/Src/Tasks/Display/ILI9341Display.cpp rename to firmware/Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp index 2312c02..c852653 100644 --- a/firmware/Core/Src/Tasks/Display/ILI9341Display.cpp +++ b/firmware/Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp @@ -1,12 +1,14 @@ -#include "Tasks/Display/ILI9341Display.hpp" +#include "Tasks/Display/ILI9341/ILI9341Display.hpp" #include +#include "FreeRTOS.h" // configTICK_RATE_HZ, for the static_assert below + #include "Config/config.h" #include "Config/sysconfig.h" #include "Tasks/Display/DisplayDriver.hpp" -#include "Tasks/Display/ili9341_main.h" +#include "Tasks/Display/ILI9341/ili9341_main.h" #include "TimeKeeping/timestamps.h" @@ -18,12 +20,28 @@ extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZ // The panel is painted on black; every field carries its own foreground. #define ILI9341_DISPLAY_BACKGROUND ILI9341_BLACK +// The wait the panel driver uses for its reset and power-on timings, ~325 ms of them. Handed +// in so the driver itself stays free of the RTOS (see ILI9341::DelayMs): here, inside a task, +// the right answer is to yield rather than spin, which HAL_Delay would do. +// +// osDelay counts ticks and returns a status; at configTICK_RATE_HZ one tick is one +// millisecond, so the only adaptation is discarding the status. The static_assert is what +// makes a change of tick rate a build error instead of a panel that misses its timings. +static_assert(configTICK_RATE_HZ == 1000, + "osDelay is being called with milliseconds; that only holds at a 1 kHz tick."); + +static void DisplayDelayMs(uint32_t milliseconds) +{ + osDelay(milliseconds); +} + ILI9341Display::ILI9341Display() : _panel(&hspi1, ILI_SPI1_LCD_CS_GPIO_Port, ILI_SPI1_LCD_CS_Pin, ILI_LCD_DC_GPIO_Port, ILI_LCD_DC_Pin, - ILI_LCD_RST_GPIO_Port, ILI_LCD_RST_Pin), + ILI_LCD_RST_GPIO_Port, ILI_LCD_RST_Pin, + DisplayDelayMs), _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), diff --git a/firmware/Core/Src/Tasks/Display/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c similarity index 94% rename from firmware/Core/Src/Tasks/Display/ili9341_layout.c rename to firmware/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c index 5c87223..e223749 100644 --- a/firmware/Core/Src/Tasks/Display/ili9341_layout.c +++ b/firmware/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c @@ -1,4 +1,4 @@ -#include "Tasks/Display/ili9341_layout.h" +#include "Tasks/Display/ILI9341/ili9341_layout.h" #include #include @@ -116,8 +116,12 @@ static void layout_session_detail(const ili9341_session_detail *detail, ili9341_ snprintf(scratch, sizeof(scratch), "A%6ld", accel); add_field(out, 12, 168, SIZE_SMALL, COLOUR_LABEL, scratch); + // Formatted straight after the label rather than through a second buffer, and through + // display_format_fixed2 rather than "%6.2f" -- see display_common.h for why no display + // path may reach newlib's float formatter. const float peak = clamp_float(detail->peak_force, 0.0f, 999.99f); - snprintf(scratch, sizeof(scratch), "P%6.2f", (double)peak); + scratch[0] = 'P'; + display_format_fixed2(scratch + 1, sizeof(scratch) - 1, peak, 6); add_field(out, 108, 168, SIZE_SMALL, COLOUR_LABEL, scratch); const long seconds = clamp_long((long)detail->session_seconds, 0, 9999); @@ -151,8 +155,8 @@ static void layout_session(const session_controller_to_display *state, // -99.99 to 999.99 is what "%6.2f" is six characters wide for; either end past that grows a // seventh. The lower bound keeps the sign, which a load cell sitting slightly below zero on // offset alone still needs to show. - const float force = clamp_float(roundf(state->force * 100.0f) / 100.0f, -99.99f, 999.99f); - snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + const float force = clamp_float(state->force, -99.99f, 999.99f); + display_format_fixed2(scratch, sizeof(scratch), force, 6); add_field(out, 12, 122, SIZE_VALUE, COLOUR_VALUE, scratch); add_field(out, 200, 146, SIZE_SMALL, COLOUR_LABEL, "N"); diff --git a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp similarity index 97% rename from firmware/Core/Src/Tasks/LCD/LumexLCD.cpp rename to firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp index 4eabffb..887425d 100644 --- a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp @@ -1,5 +1,5 @@ -#include -#include +#include +#include #include #include "Tasks/Display/DisplayDriver.hpp" @@ -221,7 +221,8 @@ bool LumexLCD::ClearDisplay() return false; } - HAL_Delay(20); + // osDelay, not HAL_Delay: this runs in a task, and HAL_Delay spins rather than yielding. + osDelay(20); return true; } diff --git a/firmware/Core/Src/Tasks/LCD/lumex_layout.c b/firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c similarity index 97% rename from firmware/Core/Src/Tasks/LCD/lumex_layout.c rename to firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c index 7243621..2b0723a 100644 --- a/firmware/Core/Src/Tasks/LCD/lumex_layout.c +++ b/firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c @@ -1,4 +1,4 @@ -#include "Tasks/LCD/lumex_layout.h" +#include "Tasks/Display/Lumex/lumex_layout.h" #include #include @@ -60,8 +60,7 @@ static void render_session(const session_controller_to_display *state, lumex_fra // Six characters at cols 2-7, clear of the "F:" label and of the drive-mode field at // col 12 however large the reading gets. - float force = roundf(state->force * 100.0f) / 100.0f; - snprintf(scratch, sizeof(scratch), "%6.2f", (double)force); + display_format_fixed2(scratch, sizeof(scratch), state->force, 6); put_field(out, 1, 2, 6, scratch); // The drive-mode field. Which of the two appears is the menu option, not the live PID diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md index b264568..0ba5594 100644 --- a/firmware/Core/Src/Tasks/Display/README.md +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -1,26 +1,46 @@ --- module: Display -summary: The display seam — screen state in, whichever panel is fitted out. Holds the ILI9341 driver. +summary: The display task — screen state in, whichever panel is fitted out. Holds both panel drivers. code: - Core/Inc/Tasks/Display/DisplayDriver.hpp - Core/Inc/Tasks/Display/display_common.h - Core/Src/Tasks/Display/display_common.c - - Core/Inc/Tasks/Display/ILI9341Display.hpp - - Core/Src/Tasks/Display/ILI9341Display.cpp - - Core/Inc/Tasks/Display/ili9341_layout.h - - Core/Src/Tasks/Display/ili9341_layout.c - - Core/Inc/Tasks/Display/ili9341_main.h -entry_point: ili9341_lcd_main() + - Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp + - Core/Src/Tasks/Display/Lumex/LumexLCD.cpp + - Core/Inc/Tasks/Display/Lumex/lumex_layout.h + - Core/Src/Tasks/Display/Lumex/lumex_layout.c + - Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h + - Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp + - Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp + - Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h + - Core/Src/Tasks/Display/ILI9341/ili9341_layout.c + - Core/Inc/Tasks/Display/ILI9341/ili9341_main.h +entry_point: lumex_lcd_main() / ili9341_lcd_main() task_offset: TASK_OFFSET_DISPLAY consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] -related: [LumexLCD, SessionController, MessagePassing] +related: [SessionController, MessagePassing] --- # Display — the panel-independent seam -Two panels are supported and exactly one is compiled in: the Lumex 16x2 character LCD -([[LumexLCD]]) and an ILI9341 320x240 TFT. Both read the same queue and the same message. +Two panels are supported and exactly one is compiled in: the Lumex 16x2 character LCD and an +ILI9341 320x240 TFT. Both read the same queue and the same message. + +## Layout + +``` +Tasks/Display/ + DisplayDriver.hpp the concept every panel satisfies + the shared task loop + display_common.{h,c} helpers neither panel owns (cursor step, fixed-point formatting) + Lumex/ the 16x2 character driver and its layout + ILI9341/ the 320x240 TFT driver and its layout +``` + +Both panels used to live apart, under `Tasks/LCD/` and `Tasks/Display/`, which read as two +modules; they are one task with one `task_offset` reading one queue, so they are one +directory. Neither panel subdirectory includes the other — the only shared code is the two +files at this level. ## The contract @@ -66,7 +86,24 @@ A `#error` catches both or neither. `lcdDisplayTaskEntryFunction` in `main.c` di `lumex_lcd_main()` or `ili9341_lcd_main()`. Both drivers are always compiled; `--gc-sections` drops the unused one. -## ILI9341 rendering +## Lumex rendering (`Lumex/`) + +- `lumex_lcd_main()` → construct, `Init()` (8-bit / 2-line / 5x8 font, display on, clear), + then `RunDisplayTask`. +- `lumex_render()` is pure: screen state in, a full 2x16 `lumex_frame` out, every cell + written. No HAL, no RTOS, no driver state — `tests/lumex_layout_tests.cpp` pins all six + screens cell-for-cell on the host. +- `Render()` diffs that frame against `_lastFrame` and writes only the runs that differ. The + common in-session update moves one field: five cells out of thirty-two. +- A change of `screen` forces a physical `ClearDisplay()`. That reproduces the old behaviour + exactly — every `Show*Screen` used to clear, and the one redraw that deliberately did not + (a tick inside the RPM editor) is also the one that does not change screen id. +- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer + (`StartTimer`), which is microsecond-scale. Millisecond waits use `osDelay`, never + `HAL_Delay` — this runs in a task, and spinning there burns CPU that other tasks want. +- `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. + +## ILI9341 rendering (`ILI9341/`) - `ili9341_layout()` is pure: screen state in, up to `ILI9341_MAX_FIELDS` positioned text fields out. No HAL, no RTOS — `tests/ili9341_layout_tests.cpp` checks it host-side. @@ -79,9 +116,19 @@ A `#error` catches both or neither. `lcdDisplayTaskEntryFunction` in `main.c` di and repaints in full. This is not an optimisation: a full frame is ~98 ms at 12.5 MHz, against ~1-2 ms for one field. -## Errors -`ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → -`task_error_circular_buffer`. +- `ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → + `task_error_circular_buffer`. + +## Units + +`session_controller_to_display.rpm` is RPM. The optical encoder measures rad/s, and the FSM +converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]) — so a driver renders the +number it is given and no panel repeats the conversion. + +## Key constants + +`SYSCFG_LCD_TASK_OSDELAY` (sysconfig) · `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` / +`ILI9341_DISPLAY_ROTATION` (config.h) ## Related -[[LumexLCD]] · [[ILI9341 driver]] · [[SessionController]] · [[MessagePassing]] +[[ILI9341 driver]] · [[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] diff --git a/firmware/Core/Src/Tasks/Display/display_common.c b/firmware/Core/Src/Tasks/Display/display_common.c index 2f97697..979e4b2 100644 --- a/firmware/Core/Src/Tasks/Display/display_common.c +++ b/firmware/Core/Src/Tasks/Display/display_common.c @@ -1,5 +1,9 @@ #include "Tasks/Display/display_common.h" +#include +#include +#include + uint32_t display_rpm_digit_increment(display_rpm_digit digit) { switch (digit) @@ -12,3 +16,28 @@ uint32_t display_rpm_digit_increment(display_rpm_digit digit) default: return 0; } } + +void display_format_fixed2(char *out, size_t out_size, float value, int width) +{ + // One rounding, into hundredths, and integer formatting from there. + const long hundredths = lroundf(value * 100.0f); + + const long whole = hundredths / 100; + const long frac = labs(hundredths % 100); + + // Wide enough for a 64-bit long on the host test build; on the target it is 32-bit and a + // force reading uses a handful of digits. + char body[32]; + + // Truncating toward zero loses the sign for -0.99 .. -0.01, where the whole part is 0. + if (hundredths < 0 && whole == 0) + { + snprintf(body, sizeof(body), "-0.%02ld", frac); + } + else + { + snprintf(body, sizeof(body), "%ld.%02ld", whole, frac); + } + + snprintf(out, out_size, "%*s", width, body); +} diff --git a/firmware/Core/Src/Tasks/LCD/README.md b/firmware/Core/Src/Tasks/LCD/README.md deleted file mode 100644 index 485dc59..0000000 --- a/firmware/Core/Src/Tasks/LCD/README.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -module: LumexLCD -summary: Drives the Lumex character LCD; lays the SessionController's screen state onto a 2x16 grid. -code: - - Core/Src/Tasks/LCD/LumexLCD.cpp - - Core/Src/Tasks/LCD/lumex_layout.c - - Core/Inc/Tasks/LCD/LumexLCD.hpp - - Core/Inc/Tasks/LCD/lumex_layout.h - - Core/Inc/Tasks/LCD/lumexlcd_main.h -entry_point: lumex_lcd_main() -task_offset: TASK_OFFSET_DISPLAY -consumes: [session_controller_to_display (SessionController)] -produces: [task_error_circular_buffer] -related: [Display, SessionController, MessagePassing] ---- - -# LumexLCD — character display task - -Bit-bangs a Lumex parallel LCD over GPIO and renders the screen state the -[[SessionController]] FSM sends. - -## The display seam -The FSM sends **what it is showing**, not how to draw it: `session_controller_to_display` -carries a `display_screen_id` plus every value any screen displays. Turning that into -characters is this task's job. See [[Display]] for why the seam sits there, and for the -`DisplayDriver` concept this class satisfies. - -## Flow -1. `lumex_lcd_main()` → construct, `Init()`, `Run()`. -2. `Init()`: 8-bit / 2-line / 5x8 font, display on (no cursor/blink), clear. -3. `Run()` blocks on the display queue, **drains to the newest message**, then `Render()`s it. - Intermediate messages are skipped: each one is the whole screen state, so the ones behind - the newest are already stale. Then delays `SYSCFG_LCD_TASK_OSDELAY`. - -## Rendering -- `lumex_render()` (`lumex_layout.c`) is pure: screen state in, a full 2x16 `lumex_frame` out, - every cell written. No HAL, no RTOS, no driver state — so `tests/lumex_layout_tests.cpp` - pins all six screens cell-for-cell on the host. -- `Render()` diffs that frame against `_lastFrame` and writes only the runs that differ. The - common in-session update moves one field: five cells out of thirty-two. -- A change of `screen` forces a physical `ClearDisplay()`. That reproduces the old behaviour - exactly — every `Show*Screen` used to clear, and the one redraw that deliberately did not - (a tick inside the RPM editor) is also the one that does not change screen id. - -## Internals -- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer - (`StartTimer`). -- `WriteData` / `WriteCommand` / `SetCursor` / `DisplayChar` / `DisplayString` / `ToggleBlink`. - -## Units -`session_controller_to_display.rpm` is RPM. The optical encoder measures rad/s, and the FSM -converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]) — so a driver renders the -number it is given and no panel repeats the conversion. - -## Errors -- `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. - -## Key constants -- `SYSCFG_LCD_TASK_OSDELAY` (sysconfig), `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` (config.h) - -## Related -[[Display]] · [[SessionController]] · [[MessagePassing]] diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index d3f23dc..c07dff7 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -26,8 +26,8 @@ #include #include #include -#include -#include +#include +#include #include #include diff --git a/firmware/Drivers/ILI9341/ILI9341.cpp b/firmware/Drivers/ILI9341/ILI9341.cpp index 14c3da2..737a622 100644 --- a/firmware/Drivers/ILI9341/ILI9341.cpp +++ b/firmware/Drivers/ILI9341/ILI9341.cpp @@ -68,8 +68,10 @@ static uint8_t ili9341_scratch[ILI9341_SCRATCH_PIXELS * 2]; ILI9341::ILI9341(SPI_HandleTypeDef* spi, GPIO_TypeDef* csPort, uint16_t csPin, GPIO_TypeDef* dcPort, uint16_t dcPin, - GPIO_TypeDef* rstPort, uint16_t rstPin) : + GPIO_TypeDef* rstPort, uint16_t rstPin, + DelayMs delay) : _spi(spi), + _delay(delay != nullptr ? delay : HAL_Delay), _csPort(csPort), _dcPort(dcPort), _rstPort(rstPort), _csPin(csPin), _dcPin(dcPin), _rstPin(rstPin), _rotation(ILI9341_ROTATION_LANDSCAPE) @@ -143,11 +145,11 @@ bool ILI9341::Init(uint8_t rotation) // Reset is active low and must be held well past the controller's 10 us minimum; the panel // then needs time before it will accept commands. HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_SET); - HAL_Delay(5); + _delay(5); HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_RESET); - HAL_Delay(20); + _delay(20); HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_SET); - HAL_Delay(150); + _delay(150); const uint8_t* command = ILI9341_INIT_COMMANDS; @@ -168,7 +170,7 @@ bool ILI9341::Init(uint8_t rotation) if (delayAfter) { - HAL_Delay(150); + _delay(150); } } diff --git a/firmware/Drivers/ILI9341/ILI9341.hpp b/firmware/Drivers/ILI9341/ILI9341.hpp index fa2b755..deeaf17 100644 --- a/firmware/Drivers/ILI9341/ILI9341.hpp +++ b/firmware/Drivers/ILI9341/ILI9341.hpp @@ -25,10 +25,22 @@ class ILI9341 { public: + // How the driver waits out the panel's reset and power-on timings (5 + 20 + 150 + 150 ms). + // + // Injected rather than hardcoded. This is a driver, not a task, so it must not reach for + // cmsis_os2.h -- that is what keeps it host-testable and reusable. But HAL_Delay is the + // wrong call under an RTOS: it spins instead of yielding, so Init() burns ~325 ms of CPU + // at the display task's priority, and it never returns at all inside a FreeRTOS critical + // section, because HAL's tick comes from a TIM at TICK_INT_PRIORITY 15 which is masked + // there. So the caller supplies the wait: the display task passes osDelay, and bare-metal + // bring-up gets HAL_Delay by default. + using DelayMs = void (*)(uint32_t milliseconds); + ILI9341(SPI_HandleTypeDef* spi, GPIO_TypeDef* csPort, uint16_t csPin, GPIO_TypeDef* dcPort, uint16_t dcPin, - GPIO_TypeDef* rstPort, uint16_t rstPin); + GPIO_TypeDef* rstPort, uint16_t rstPin, + DelayMs delay = HAL_Delay); ~ILI9341() = default; // Hardware reset pulse, then the vendored power/gamma sequence, then `rotation`. Leaves @@ -68,6 +80,8 @@ class ILI9341 SPI_HandleTypeDef* _spi; + DelayMs _delay; + GPIO_TypeDef* _csPort; GPIO_TypeDef* _dcPort; GPIO_TypeDef* _rstPort; diff --git a/firmware/tests/CMakeLists.txt b/firmware/tests/CMakeLists.txt index a7821b3..8b5ec37 100644 --- a/firmware/tests/CMakeLists.txt +++ b/firmware/tests/CMakeLists.txt @@ -35,9 +35,9 @@ add_executable(fw_tests ${FIRMWARE_DIR}/Core/Src/Tasks/USB/usb_rx_ring.c ${FIRMWARE_DIR}/Core/Src/Tasks/USB/usb_framer.cpp ${FIRMWARE_DIR}/Core/Src/Tasks/OpticalSensor/encoder_math.c - ${FIRMWARE_DIR}/Core/Src/Tasks/LCD/lumex_layout.c + ${FIRMWARE_DIR}/Core/Src/Tasks/Display/Lumex/lumex_layout.c ${FIRMWARE_DIR}/Core/Src/Tasks/Display/display_common.c - ${FIRMWARE_DIR}/Core/Src/Tasks/Display/ili9341_layout.c + ${FIRMWARE_DIR}/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c ${FIRMWARE_DIR}/Drivers/ILI9341/ILI9341_font.c usb_rx_ring_tests.cpp usb_framer_tests.cpp diff --git a/firmware/tests/ili9341_layout_tests.cpp b/firmware/tests/ili9341_layout_tests.cpp index 52d7191..906af08 100644 --- a/firmware/tests/ili9341_layout_tests.cpp +++ b/firmware/tests/ili9341_layout_tests.cpp @@ -15,7 +15,7 @@ #include extern "C" { -#include "Tasks/Display/ili9341_layout.h" +#include "Tasks/Display/ILI9341/ili9341_layout.h" } #include "ILI9341_font.h" diff --git a/firmware/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp index c7d7dcb..6aee459 100644 --- a/firmware/tests/lumex_layout_tests.cpp +++ b/firmware/tests/lumex_layout_tests.cpp @@ -16,7 +16,8 @@ #include extern "C" { -#include "Tasks/LCD/lumex_layout.h" +#include "Tasks/Display/display_common.h" +#include "Tasks/Display/Lumex/lumex_layout.h" } namespace @@ -246,3 +247,72 @@ TEST(LumexLayout, SessionScreenShowsBrakeDutyWhenTheOptionIsNot) } } // namespace + +// --------------------------------------------------------- fixed-point force formatting + +// display_format_fixed2 replaced snprintf("%6.2f"). That call was the only floating-point +// conversion in the firmware and it overflowed the display task's 1 KB stack on the session +// screen -- newlib's float formatter needs ~400 bytes on top of the ~180 the render path +// already used, and the overflow hook disables interrupts and spins, so the board looked dead +// the moment the brake button was pressed. +// +// It was reintroduced by the revert in dacdd1f and is removed again here, this time from both +// the force reading and the peak-force readout that was added after the original fix. +// +// These pin the replacement against what %6.2f produced, so the fix cannot quietly change the +// reading. Expectations are what printf gives for the same inputs. +namespace +{ + +std::string Fixed2(float value, int width = 6) +{ + char buffer[32]; + display_format_fixed2(buffer, sizeof(buffer), value, width); + return std::string(buffer); +} + +TEST(DisplayFormatFixed2, MatchesPrintfForOrdinaryValues) +{ + EXPECT_EQ(Fixed2(0.0f), " 0.00"); + EXPECT_EQ(Fixed2(12.34f), " 12.34"); + EXPECT_EQ(Fixed2(1.5f), " 1.50"); + EXPECT_EQ(Fixed2(999.99f), "999.99"); + EXPECT_EQ(Fixed2(100.0f), "100.00"); +} + +TEST(DisplayFormatFixed2, RoundsHalfAwayFromZeroLikePrintf) +{ + EXPECT_EQ(Fixed2(1.005f), " 1.01"); + EXPECT_EQ(Fixed2(1.004f), " 1.00"); + EXPECT_EQ(Fixed2(-1.005f), " -1.01"); +} + +TEST(DisplayFormatFixed2, KeepsTheSignWhenTheWholePartIsZero) +{ + // Truncating toward zero makes the whole part 0 for these, so the sign has to be put back + // by hand -- "-0.50" must not come out as "0.50". + EXPECT_EQ(Fixed2(-0.5f), " -0.50"); + EXPECT_EQ(Fixed2(-0.01f), " -0.01"); + EXPECT_EQ(Fixed2(-0.99f), " -0.99"); +} + +TEST(DisplayFormatFixed2, HandlesNegativesGenerally) +{ + EXPECT_EQ(Fixed2(-1.5f), " -1.50"); + EXPECT_EQ(Fixed2(-12.34f), "-12.34"); +} + +TEST(DisplayFormatFixed2, DoesNotTruncateOversizedValues) +{ + // printf lets a value wider than the field push past it rather than clipping; the callers + // clip to their own field width afterwards. + EXPECT_EQ(Fixed2(12345.67f), "12345.67"); +} + +TEST(DisplayFormatFixed2, RespectsTheRequestedWidth) +{ + EXPECT_EQ(Fixed2(1.5f, 8), " 1.50"); + EXPECT_EQ(Fixed2(1.5f, 4), "1.50"); +} + +} // namespace From 0c16734b91f4f6073e9a29965894d393b38962d6 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 18:31:43 -0700 Subject: [PATCH 15/25] app: make the duty-cycle box a setpoint, not a readout that fights back 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 --- .../ViewModels/MainWindowViewModel.cs | 40 +++++++++++-------- src/Dyno.App/Views/HomeView.axaml | 12 +++++- src/Dyno.App/Views/HomeView.axaml.cs | 18 ++------- 3 files changed, 39 insertions(+), 31 deletions(-) diff --git a/src/Dyno.App/ViewModels/MainWindowViewModel.cs b/src/Dyno.App/ViewModels/MainWindowViewModel.cs index b55981a..09a30c1 100644 --- a/src/Dyno.App/ViewModels/MainWindowViewModel.cs +++ b/src/Dyno.App/ViewModels/MainWindowViewModel.cs @@ -229,12 +229,6 @@ private void Navigate(AppPage page) [ObservableProperty] private string _dutyCycleInput = "0.0"; - /// True while the duty-cycle box has focus. Telemetry stops writing to the box then: - /// the device streams a BPM sample several times a second, and without this the box would - /// overwrite whatever was being typed between one keystroke and the next. - [ObservableProperty] - private bool _isEditingDutyCycle; - /// Set when the last duty-cycle command was refused or failed, so the box can show /// that the brake is not at what it says. [ObservableProperty] @@ -247,12 +241,22 @@ private void Navigate(AppPage page) /// How far one scroll-wheel notch moves the duty cycle, in percent. private const double DutyCycleWheelStepPercent = 1.0; + /// Puts the brake's current figure into the setpoint box. Called when a session + /// starts and when a link goes away -- never from telemetry, which is the whole point. + private void SyncDutyCycleInputToDevice() + { + DutyCycleInput = (DutyCycle * 100.0).ToString("F1"); + IsDutyCycleInputInvalid = false; + } + + /// Abandons an edit and puts the brake's actual figure back. Escape, in other + /// words -- the way out of a half-typed value without commanding it. + public void RevertDutyCycleInput() => SyncDutyCycleInputToDevice(); + /// Sends whatever is in the box. Called when the box loses focus or Enter is pressed -- /// not on every keystroke, which would command the brake to "4" on the way to typing "45". public async Task CommitDutyCycleAsync() { - IsEditingDutyCycle = false; - if (!double.TryParse(DutyCycleInput, out double percent)) { IsDutyCycleInputInvalid = true; @@ -864,6 +868,11 @@ private void OnSessionStateChanged(bool active) => IsSessionActive = active; if (active) { + // Seed the setpoint from the brake's current figure, once, at the start of a run. + // Only here: a setpoint that kept re-syncing to the measurement would drag + // whatever you had dialled in back to where the brake happened to be. + SyncDutyCycleInputToDevice(); + // Plots keep the *finished* run on screen (unlike the readouts, a frozen trace // still reads as history, not as a live value) — so the moment to drop it is when // the next run starts, not when this one ends. @@ -894,6 +903,7 @@ private void ClearTelemetry() AngularAcceleration = 0; Force = 0; DutyCycle = 0; + SyncDutyCycleInputToDevice(); // The derived three as well as the measured ones. They were missed here, so a disconnect // blanked the sensor readouts while leaving torque and power lit at their last values — // the half of the panel most likely to be read as still current. @@ -1033,14 +1043,12 @@ private void Apply(DeviceMessage message) break; case BpmSample s: DutyCycle = s.Data.duty_cycle; - // Follow the device only when the box is not being typed in. BPM samples arrive - // several times a second, so without the gate the box would rewrite itself - // between keystrokes and the caret would jump to the end each time. - if (!IsEditingDutyCycle) - { - DutyCycleInput = (s.Data.duty_cycle * 100.0).ToString("F1"); - IsDutyCycleInputInvalid = false; - } + // Deliberately does NOT touch DutyCycleInput. That box is a setpoint -- what you + // have asked the brake for -- and this is a measurement of what it is doing. They + // are different numbers, and having telemetry write the box made it jump: a BPM + // sample lands several times a second, so every scroll notch was overwritten by + // the device's reading a fraction of a second later. Commanded and actual are + // shown side by side instead. Plots.RecordDutyCycle(s.Data.timestamp, s.Data.duty_cycle); break; diff --git a/src/Dyno.App/Views/HomeView.axaml b/src/Dyno.App/Views/HomeView.axaml index 3c9825b..9e43a91 100644 --- a/src/Dyno.App/Views/HomeView.axaml +++ b/src/Dyno.App/Views/HomeView.axaml @@ -263,12 +263,22 @@ Classes.invalid="{Binding IsDutyCycleInputInvalid}" Text="{Binding DutyCycleInput}" IsEnabled="{Binding CanCommandDutyCycle}" - GotFocus="OnDutyCycleGotFocus" LostFocus="OnDutyCycleLostFocus" KeyDown="OnDutyCycleKeyDown" PointerWheelChanged="OnDutyCycleWheel" /> + + diff --git a/src/Dyno.App/Views/HomeView.axaml.cs b/src/Dyno.App/Views/HomeView.axaml.cs index abfbf9f..c254229 100644 --- a/src/Dyno.App/Views/HomeView.axaml.cs +++ b/src/Dyno.App/Views/HomeView.axaml.cs @@ -9,23 +9,14 @@ namespace Dyno.App.Views; /// The live console: connection toolbar, telemetry and task monitor. /// /// Almost pure markup. The exception is the brake duty-cycle box, which needs three things XAML -/// bindings cannot express on their own: knowing when it has focus (so incoming telemetry stops -/// rewriting it mid-keystroke), treating Enter as "send it", and turning wheel notches into -/// adjustments. All three delegate straight to the view model. +/// bindings cannot express on their own: committing on Enter, committing on focus loss, and +/// turning wheel notches into adjustments. All three delegate straight to the view model. public partial class HomeView : UserControl { public HomeView() => InitializeComponent(); private MainWindowViewModel? ViewModel => DataContext as MainWindowViewModel; - private void OnDutyCycleGotFocus(object? sender, GotFocusEventArgs e) - { - if (ViewModel is { } vm) - { - vm.IsEditingDutyCycle = true; - } - } - private async void OnDutyCycleLostFocus(object? sender, RoutedEventArgs e) { if (ViewModel is { } vm) @@ -48,11 +39,10 @@ private async void OnDutyCycleKeyDown(object? sender, KeyEventArgs e) await vm.CommitDutyCycleAsync(); break; - // Abandon the edit and let the next telemetry sample put the real figure back. + // Abandon the edit: put the brake's actual figure back in the box. case Key.Escape: e.Handled = true; - vm.IsEditingDutyCycle = false; - vm.IsDutyCycleInputInvalid = false; + vm.RevertDutyCycleInput(); break; } } From ad08fd1edb70ea0f941162034936289a25ff8d4e Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 18:50:48 -0700 Subject: [PATCH 16/25] TEMPORARY: run with no display, to isolate SPI1 from the brake fault 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 --- firmware/Core/Inc/Config/debug.h | 24 ++++++++++++++++++++---- firmware/Core/Src/main.c | 13 ++++++++----- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index d181e18..3b775df 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -48,16 +48,32 @@ // BPM Controller Task #define BPM_CONTROLLER_TASK_ENABLE 1 -// Display task -- exactly one driver, chosen here and flashed. +// Display task -- at most one driver, chosen here and flashed. // // Both panels consume the same session_controller_to_display message, so the SessionController // and its FSM are identical either way; only the driver linked in changes. There is no runtime // switch because there is no runtime question: a board has one panel soldered to it. +// +// TEMPORARY DIAGNOSTIC: both are 0, so no display runs and nothing drives SPI1. +// +// The brake misbehaves when the rotary encoder is turned, and only on this branch. Everything +// in that path -- AdjustBrakeDutyCycle, the BPM and PID tasks, every ISR, the input manager, +// and the encoder and brake pin configuration -- is byte-identical to main. The one functional +// difference is that a display task now drives SPI1 at 12.5 MHz on PD7/PG9/PG10/PG11 while the +// encoder is being turned, so this isolates that single variable: the host command path, the +// session-detail message and the app's control all stay exactly as they are, and only the +// panel traffic goes away. +// +// If the brake behaves with this build, the display's SPI activity is implicated and the next +// step is which part of it (clock rate, edge rate, wiring). If it still misbehaves, the +// display is exonerated and the cause is elsewhere on the branch. +// +// Put ILI9341_LCD_TASK_ENABLE back to 1 when the question is answered. #define LUMEX_LCD_TASK_ENABLE 0 -#define ILI9341_LCD_TASK_ENABLE 1 +#define ILI9341_LCD_TASK_ENABLE 0 -#if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) != 1 -#error "Exactly one display driver must be enabled: set one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1 and the other to 0." +#if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) > 1 +#error "At most one display driver may be enabled: set at most one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1." #endif // "A display task exists", which is what everything outside the two drivers actually wants to diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index c07dff7..2ad448d 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -1316,8 +1316,6 @@ void sessionControllerTaskEntryFunction(void* argument) #error "SESSION_CONTROLLER_TASK_ENABLE or the display driver enables are not defined. Please define them as 0 or 1 in the configuration header." #elif SESSION_CONTROLLER_TASK_ENABLE == 0 osThreadSuspend(osThreadGetId()); - #elif (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) == 0 - #error "A display is a hard dependency of the Session Controller task. Enable one display driver." #else session_controller_os_task_queues tasks = { .usb_controller = sessionControllertoUsbControllerHandle, @@ -1349,14 +1347,19 @@ void opticalSensorTaskEntryFunction(void *argument) void lcdDisplayTaskEntryFunction(void *argument) { - /* Config/debug.h enforces that exactly one of these is 1, so there is no "neither" case - here -- both panels read the same queue and the same message. */ + /* Config/debug.h allows at most one of these. Both panels read the same queue and the same + message; with neither enabled the task parks and nothing drives a panel. */ #if (!defined(LUMEX_LCD_TASK_ENABLE) || !defined(ILI9341_LCD_TASK_ENABLE)) #error "LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE are not defined. Please define them in the configuration header." #elif ILI9341_LCD_TASK_ENABLE == 1 ili9341_lcd_main(sessionControllerToDisplayHandle); - #else + #elif LUMEX_LCD_TASK_ENABLE == 1 lumex_lcd_main(sessionControllerToDisplayHandle); + #else + /* No panel compiled in. Suspend rather than return: returning from a task function lands in + prvTaskExitError, which disables interrupts and spins. The FSM still posts its screen + state; nothing drains the queue, and the non-blocking puts simply fail. */ + osThreadSuspend(osThreadGetId()); #endif } From 178cdbf3ec7bfb326fe35b4f0f4d558222ad3cf8 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 18:57:11 -0700 Subject: [PATCH 17/25] fix: pull ROT_EN_B up -- the encoder's direction bit was read off a floating 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 --- firmware/Core/Inc/Config/debug.h | 21 +++++---------------- firmware/Core/Src/main.c | 2 +- firmware/stm32_dyno_firmware_v2.ioc | 3 ++- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index 3b775df..b966d3c 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -54,23 +54,12 @@ // and its FSM are identical either way; only the driver linked in changes. There is no runtime // switch because there is no runtime question: a board has one panel soldered to it. // -// TEMPORARY DIAGNOSTIC: both are 0, so no display runs and nothing drives SPI1. -// -// The brake misbehaves when the rotary encoder is turned, and only on this branch. Everything -// in that path -- AdjustBrakeDutyCycle, the BPM and PID tasks, every ISR, the input manager, -// and the encoder and brake pin configuration -- is byte-identical to main. The one functional -// difference is that a display task now drives SPI1 at 12.5 MHz on PD7/PG9/PG10/PG11 while the -// encoder is being turned, so this isolates that single variable: the host command path, the -// session-detail message and the app's control all stay exactly as they are, and only the -// panel traffic goes away. -// -// If the brake behaves with this build, the display's SPI activity is implicated and the next -// step is which part of it (clock rate, edge rate, wiring). If it still misbehaves, the -// display is exonerated and the cause is elsewhere on the branch. -// -// Put ILI9341_LCD_TASK_ENABLE back to 1 when the question is answered. +// ROT_EN_B (PI8) had no pull resistor while every other user input had one, so the direction +// bit the encoder ISR samples came off a floating pin. It read correctly while SPI1 was idle +// and randomly once the panel drove it, which is why the brake random-walked to 0% only on this +// branch and only while the encoder was turning. Fixed in the .ioc; the panel is back on. #define LUMEX_LCD_TASK_ENABLE 0 -#define ILI9341_LCD_TASK_ENABLE 0 +#define ILI9341_LCD_TASK_ENABLE 1 #if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) > 1 #error "At most one display driver may be enabled: set at most one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1." diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index 2ad448d..b50371d 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -1130,7 +1130,7 @@ static void MX_GPIO_Init(void) /*Configure GPIO pin : ROT_EN_B_Pin */ GPIO_InitStruct.Pin = ROT_EN_B_Pin; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; - GPIO_InitStruct.Pull = GPIO_NOPULL; + GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(ROT_EN_B_GPIO_Port, &GPIO_InitStruct); /*Configure GPIO pin : ADS1115_ALERT_Pin */ diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index 4543db7..8f6f2da 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -375,8 +375,9 @@ PI7.GPIO_ModeDefaultEXTI=GPIO_MODE_IT_RISING_FALLING PI7.GPIO_PuPd=GPIO_PULLDOWN PI7.Locked=true PI7.Signal=GPXTI7 -PI8.GPIOParameters=GPIO_Label +PI8.GPIOParameters=GPIO_PuPd,GPIO_Label PI8.GPIO_Label=ROT_EN_B +PI8.GPIO_PuPd=GPIO_PULLUP PI8.Locked=true PI8.Signal=GPIO_Input PinOutPanel.RotationAngle=0 From cb19aa300df41ffe66b5527945e55e38f931dd81 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 19:03:39 -0700 Subject: [PATCH 18/25] encoder: filter the rotary input, and quieten SPI1 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 --- firmware/Core/Inc/Config/config.h | 16 ++++++ .../input_manager_interrupts.c | 53 ++++++++++++++++++- firmware/Core/Src/main.c | 2 +- firmware/Core/Src/stm32h7xx_hal_msp.c | 4 +- firmware/stm32_dyno_firmware_v2.ioc | 10 ++-- 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/firmware/Core/Inc/Config/config.h b/firmware/Core/Inc/Config/config.h index 220b33c..a14d756 100644 --- a/firmware/Core/Inc/Config/config.h +++ b/firmware/Core/Inc/Config/config.h @@ -32,6 +32,22 @@ // User Input Config (like buttons) #define USER_INPUT_CIRCULAR_BUFFER_SIZE 100u +// Rotary encoder input conditioning. +// +// The encoder is decoded the cheap way: an EXTI on ROT_EN_A, reading ROT_EN_B's level to get +// the direction. That has no filtering of any kind, so a bounced contact gives extra ticks and +// a disturbed read of B gives a tick in the wrong direction -- and a wrong direction is worse +// than a missed tick, because the brake setpoint then random-walks instead of merely lagging. +// +// Two guards, both cheap enough for interrupt context: +// DEBOUNCE_US -- ignore an A edge that lands within this long of the last accepted one. +// A hand-turned encoder produces edges milliseconds apart; contact bounce +// and coupled noise are microseconds. 0 disables. +// DIRECTION_SAMPLES -- read B this many times and take the majority, so a single disturbed +// sample cannot decide which way the knob went. Must be odd. +#define ROTARY_ENCODER_DEBOUNCE_US 1000u +#define ROTARY_ENCODER_DIRECTION_SAMPLES 3u + // Session Controller Config // 10ms = 100 Hz torque/power. Task delays below are tuned as a set: at the old rates the four // streams totalled ~38 kB/s, which saturated the USB TX path once a session started (rising diff --git a/firmware/Core/Src/Tasks/SessionController/input_manager_interrupts.c b/firmware/Core/Src/Tasks/SessionController/input_manager_interrupts.c index 82a3c3f..f8ff703 100644 --- a/firmware/Core/Src/Tasks/SessionController/input_manager_interrupts.c +++ b/firmware/Core/Src/Tasks/SessionController/input_manager_interrupts.c @@ -6,6 +6,9 @@ #include +#include "Config/config.h" +#include "TimeKeeping/timestamps.h" + // Where the ISRs will write next; the FSM reads it to know how far to drain. Declared in the // header because that handshake is the FSM's business. @@ -41,12 +44,58 @@ static void register_button(GPIO_TypeDef* button_port, uint16_t button_pin, } +// How many A edges have been rejected as too close to the last accepted one, and how many +// direction reads were not unanimous. Neither is an error -- a mechanical contact bounces -- +// but both climbing while the knob is still says something is being coupled in. Not reported +// over USB; read them in a debugger. +volatile uint32_t rotary_encoder_rejected_edges = 0; +volatile uint32_t rotary_encoder_split_direction_reads = 0; + +// Timestamp of the last accepted edge, in the 1 us ticks get_timestamp() counts. Unsigned +// subtraction gives the right answer across the counter's 71-minute wrap. +static volatile uint32_t last_accepted_edge = 0; + +// Majority vote on ROT_EN_B. A single read is one sample of a line that has been shown to be +// disturbed while the panel drives SPI1, and getting it wrong reverses the tick. +static bool read_encoder_direction(void) +{ + uint32_t high = 0; + + for (uint32_t i = 0; i < ROTARY_ENCODER_DIRECTION_SAMPLES; i++) + { + if (HAL_GPIO_ReadPin(ROT_EN_B_GPIO_Port, ROT_EN_B_Pin) != GPIO_PIN_RESET) + { + high++; + } + } + + if (high != 0 && high != ROTARY_ENCODER_DIRECTION_SAMPLES) + { + rotary_encoder_split_direction_reads++; + } + + return high * 2u > ROTARY_ENCODER_DIRECTION_SAMPLES; +} + // Called on an edge of ROT_EN_A; ROT_EN_B's level at that moment gives the direction. void register_rotary_encoder_input(void) { - const bool positive = (HAL_GPIO_ReadPin(ROT_EN_B_GPIO_Port, ROT_EN_B_Pin) != GPIO_PIN_RESET); +#if ROTARY_ENCODER_DEBOUNCE_US > 0 + // Before the timestamp timer is started (SessionController::Init) this reads a frozen + // counter, so every edge is rejected. That is the right answer: nothing before the FSM + // exists is input to it, and the FSM starts from the ISRs' current index anyway. + const uint32_t now = get_timestamp(); + + if ((uint32_t)(now - last_accepted_edge) < ROTARY_ENCODER_DEBOUNCE_US) + { + rotary_encoder_rejected_edges++; + return; + } + + last_accepted_edge = now; +#endif - add_to_circular_buffer(ROT_EN_TICKS, positive); + add_to_circular_buffer(ROT_EN_TICKS, read_encoder_direction()); } // The encoder's push switch. Reported on release only, like the other buttons, and it has no diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index b50371d..e16959c 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -751,7 +751,7 @@ static void MX_SPI1_Init(void) hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; - hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; + hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_32; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; diff --git a/firmware/Core/Src/stm32h7xx_hal_msp.c b/firmware/Core/Src/stm32h7xx_hal_msp.c index f1acdbf..7355002 100644 --- a/firmware/Core/Src/stm32h7xx_hal_msp.c +++ b/firmware/Core/Src/stm32h7xx_hal_msp.c @@ -387,14 +387,14 @@ void HAL_SPI_MspInit(SPI_HandleTypeDef* hspi) GPIO_InitStruct.Pin = ILI_SPI1_MOSI_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(ILI_SPI1_MOSI_GPIO_Port, &GPIO_InitStruct); GPIO_InitStruct.Pin = ILI_SPI1_MISO_Pin|ILI_SPI1_SCK_Pin; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; - GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM; + GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; GPIO_InitStruct.Alternate = GPIO_AF5_SPI1; HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index 8f6f2da..b0784e6 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -263,7 +263,7 @@ PD6.PinState=GPIO_PIN_SET PD6.Signal=GPIO_Output PD7.GPIOParameters=GPIO_Speed,GPIO_Label PD7.GPIO_Label=ILI_SPI1_MOSI -PD7.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM +PD7.GPIO_Speed=GPIO_SPEED_FREQ_LOW PD7.Mode=Full_Duplex_Master PD7.Signal=SPI1_MOSI PE3.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -308,7 +308,7 @@ PG10.PinState=GPIO_PIN_SET PG10.Signal=GPIO_Output PG11.GPIOParameters=GPIO_Speed,GPIO_Label PG11.GPIO_Label=ILI_SPI1_SCK -PG11.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM +PG11.GPIO_Speed=GPIO_SPEED_FREQ_LOW PG11.Mode=Full_Duplex_Master PG11.Signal=SPI1_SCK PG14.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -319,7 +319,7 @@ PG14.Locked=true PG14.Signal=GPXTI14 PG9.GPIOParameters=GPIO_Speed,GPIO_Label PG9.GPIO_Label=ILI_SPI1_MISO -PG9.GPIO_Speed=GPIO_SPEED_FREQ_MEDIUM +PG9.GPIO_Speed=GPIO_SPEED_FREQ_LOW PG9.Mode=Full_Duplex_Master PG9.Signal=SPI1_MISO PH0-OSC_IN\ (PH0).Mode=HSE-External-Oscillator @@ -523,8 +523,8 @@ SH.S_TIM16_CH1.0=TIM16_CH1,PWM Generation1 CH1 SH.S_TIM16_CH1.ConfNb=1 SH.S_TIM4_CH1.0=TIM4_CH1,TriggerSource_TI1FP1 SH.S_TIM4_CH1.ConfNb=1 -SPI1.BaudRatePrescaler=SPI_BAUDRATEPRESCALER_16 -SPI1.CalculateBaudRate=12.5 MBits/s +SPI1.BaudRatePrescaler=SPI_BAUDRATEPRESCALER_32 +SPI1.CalculateBaudRate=6.25 MBits/s SPI1.DataSize=SPI_DATASIZE_8BIT SPI1.Direction=SPI_DIRECTION_2LINES SPI1.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate,DataSize,BaudRatePrescaler From 0b2120c647ef872a47a295921cc3ccfe6da160aa Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 19:23:35 -0700 Subject: [PATCH 19/25] docs: write down how a value becomes lit pixels, and the SPI wire format 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 --- firmware/Core/Src/Tasks/Display/README.md | 313 +++++++++++++---- firmware/Drivers/ILI9341/ILI9341.cpp | 4 +- firmware/Drivers/ILI9341/README.md | 387 +++++++++++++++++++--- 3 files changed, 595 insertions(+), 109 deletions(-) diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md index 0ba5594..ac8a745 100644 --- a/firmware/Core/Src/Tasks/Display/README.md +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -1,6 +1,6 @@ --- module: Display -summary: The display task — screen state in, whichever panel is fitted out. Holds both panel drivers. +summary: The display task — how a measured value becomes lit pixels, and why the seam sits where it does. code: - Core/Inc/Tasks/Display/DisplayDriver.hpp - Core/Inc/Tasks/Display/display_common.h @@ -19,116 +19,311 @@ entry_point: lumex_lcd_main() / ili9341_lcd_main() task_offset: TASK_OFFSET_DISPLAY consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] -related: [SessionController, MessagePassing] +related: [SessionController, MessagePassing, ILI9341 driver] --- -# Display — the panel-independent seam +# Display — from a measured value to lit pixels -Two panels are supported and exactly one is compiled in: the Lumex 16x2 character LCD and an +Two panels are supported and at most one is compiled in: a Lumex 16x2 character LCD and an ILI9341 320x240 TFT. Both read the same queue and the same message. -## Layout +--- + +## The whole path, end to end + +Follow a force reading from the sensor to the glass. Every stage discards work the next one +does not need, and that is the point of having five of them. ``` -Tasks/Display/ - DisplayDriver.hpp the concept every panel satisfies + the shared task loop - display_common.{h,c} helpers neither panel owns (cursor step, fixed-point formatting) - Lumex/ the 16x2 character driver and its layout - ILI9341/ the 320x240 TFT driver and its layout + ForceSensor task + | forcesensor_output_data -> circular buffer + v + SessionController::UpdateMeasurementDisplay() [1] only on change + | _fsm.DisplayForce(12.34f) + v + FSM::PostDisplayState() [2] meaning, not pixels + | session_controller_to_display { screen, rpm, force, ... } + | osMessageQueuePut(..., timeout 0) <- never blocks the SessionController + v + RunDisplayTask() (DisplayDriver.hpp) [3] drain to newest + | display.Render(state) + v + ili9341_layout() / lumex_render() [4] state -> positions + text + | ili9341_frame { fields[], count } (pure function, host-tested) + v + ILI9341Display::Render() / LumexLCD::Render() [5] diff, then paint the movers + | _panel.DrawString(x, y, " 12.34", 6, WHITE, BLACK, 5) + v + ILI9341 driver [6] pixels on the wire + CASET / PASET / RAMWR + 14,400 bytes of RGB565 +``` + +### [1] The SessionController posts only what moved + +`UpdateMeasurementDisplay()` compares against `_prevForce` / `_prevAngularVelocity` and calls +the FSM only when a reading actually changes. First filter. + +### [2] The FSM sends meaning, never pixels + +`PostDisplayState()` fills a `session_controller_to_display` — a `display_screen_id` plus +**every value any screen shows** — and posts it: + +```c +osMessageQueuePut(_toDisplayHandle, &msg, 0, 0); +``` + +Two deliberate choices: + +- **Whole state every time**, not deltas. A driver that misses a message is still correct on + the next one; there is no incremental state to get out of step. +- **Timeout 0.** A full queue drops the message rather than blocking. The display is the + least important thing on this board and must never stall the task that drives the brake. + +The FSM formats nothing. It used to: the layout lived here as +`WriteText(row, column, "n: 0 rpm ")` with hand-counted padding, which is a 16x2 +character grid baked into a state machine. + +### [3] The task loop takes only the newest message + +`RunDisplayTask` (`DisplayDriver.hpp`) blocks on the queue, then **drains to the newest** +before drawing anything: + +```cpp +while (osMessageQueueGet(queue, &state, 0, 0) == osOK); ``` -Both panels used to live apart, under `Tasks/LCD/` and `Tasks/Display/`, which read as two -modules; they are one task with one `task_offset` reading one queue, so they are one -directory. Neither panel subdirectory includes the other — the only shared code is the two -files at this level. +Each message is the whole screen state, so anything behind the newest is already stale. +Rendering them in turn would paint values nobody will ever see — and on a panel where a +field costs ~18 ms, that is the difference between keeping up and falling behind. + +It is `[[noreturn]]`, and that is load-bearing: **a FreeRTOS task function that returns lands +in `prvTaskExitError()`, which fails a `configASSERT`, disables interrupts and spins.** The +whole rig dies — buttons, brake and all — with no LED and no fault report. An early version +did `if (!Render(state)) return;`, and one failed SPI write took the entire dynamometer down. +A failed render is now recorded in the error buffer and the loop carries on. + +### [4] Layout: screen state in, positioned text out -## The contract +Each panel has its own layout function, and they are **pure**: no HAL, no RTOS, no driver +state, so `tests/ili9341_layout_tests.cpp` and `tests/lumex_layout_tests.cpp` check every +screen on the build machine. + +- `lumex_render()` → a full 2x16 `lumex_frame`, every cell written, blanks as spaces. +- `ili9341_layout()` → up to `ILI9341_MAX_FIELDS` `ili9341_field`s, each `{x, y, colour, + size, length, text}`. + +**Nothing measures available space.** Every coordinate is a number typed into the layout: + +```c +add_field(out, 12, 40, SIZE_VALUE, COLOUR_VALUE, scratch); // x=12, y=40, size 5 +``` + +`centred()` does the arithmetic for centred rows, and that is the extent of it. There is no +reflow and no auto-fit, because the driver below clips rather than shrinks. + +Two properties the driver **depends on**, asserted by tests rather than assumed: + +- **Positionally stable** — for a given screen, the same field count, order, positions and + widths whatever the values are. `AScreensFieldListIsPositionallyStable` renders every + screen with zeroed and with extreme values and compares. +- **Fixed width, space-padded** — `"ENABLED "` is padded to eight so it covers `"DISABLED"` + exactly, and the detail readouts **clamp** (`A 99999`, `P999.99`, `T9999s`) so a big + reading cannot outgrow its slot and shove its neighbours. + +Both exist for the same reason: there is no read-modify-write on either panel, so a field is +erased only by being repainted, background and all. A field that changed width would leave +the tail of the old one on screen forever. + +### [5] Diff, then paint only what moved + +`Render()` compares field *i* against field *i* of the last frame and redraws only the +movers. A change of `screen` clears and repaints in full. + +```cpp +const bool screenChanged = !_hasRendered || state.screen != _lastScreen; +if (screenChanged && !Clear()) { _hasRendered = false; return false; } + +for (i...) { + if (!screenChanged && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) + continue; + DrawField(_frame.fields[i]); +} +``` + +This is not an optimisation. A full ILI9341 frame is 153,600 bytes, ~197 ms at 6.25 MHz — a +repaint per sensor sample is impossible. One field is ~18 ms. + +Details that matter: + +- **Field equality includes colour.** The drive-mode field keeps its width but changes + green↔red; a text-only comparison would leave it the wrong colour. +- **On failure, `_hasRendered = false`.** The panel no longer matches the shadow copy, so the + diff would skip cells that were never actually painted. Forcing a full clear and repaint on + the next pass is what makes a glitch self-correcting. +- The Lumex diffs **runs of changed cells** rather than fields, for the same reason in a + different shape: the common in-session update moves five cells out of thirty-two. + +### [6] The driver puts pixels on the wire + +`DrawString` → `DrawChar` per cell → one address window + streamed RGB565. The panel knows +nothing about text; every glyph pixel is computed here. See [[ILI9341 driver]] for the wire +format, the glyph bitmaps and the timing. + +--- + +## The contract between the two halves `session_controller_to_display` carries a `display_screen_id` plus every value any screen -shows. The FSM says **what it is displaying**; each driver decides **how**. +shows. **The FSM says what it is displaying; each driver decides how.** + +There is deliberately **no common drawing API**, and that is the central design decision: -There is deliberately no common *drawing* API. The intersection of a character grid and a -320x240 TFT (`WriteText(row, column, string)`) caps the TFT at 16x2; the union -(`DrawRect`, `SetFont`, `DrawBitmap`) is meaningless on the LCD. Putting the seam at what -the values *mean* leaves each panel free: everything the TFT can do that the LCD cannot -lives inside its `Render()` and never appears in the contract. +- an *intersection* API (`WriteText(row, column, string)`) caps the TFT at 16x2 — a 320x240 + panel pretending to be a character LCD; +- a *union* API (`DrawRect`, `SetFont`, `DrawBitmap`) is meaningless on the Lumex, which + would no-op most of it. -## DisplayDriver — a concept, not a base class +Putting the seam at what the values *mean* leaves each panel free. Everything the TFT can do +that the Lumex cannot lives inside its `Render()` and never surfaces in the contract. + +### `DisplayDriver` — a concept, not a base class ```cpp template concept DisplayDriver = requires(T d, const session_controller_to_display& s) { - { d.Init() } -> std::same_as; - { d.Clear() } -> std::same_as; - { d.Render(s) } -> std::same_as; + { d.Init() } -> std::same_as; + { d.Clear() } -> std::same_as; + { d.Render(s) } -> std::same_as; + + // Extended session detail: only the TFT has room for these. + { d.ShowAngularAcceleration(float{}) } -> std::same_as; + { d.ShowPeakForce(float{}) } -> std::same_as; + { d.ShowSessionElapsed(uint32_t{}) } -> std::same_as; }; ``` -Virtual dispatch would cost a vtable pointer and an indirect call per draw for a choice -fixed at link time. The concept checks the same contract at compile time and inlines -through it. Each driver's `.cpp` carries `static_assert(DisplayDriver<...>)`, so a -signature mismatch is an error at the driver rather than at the call site. +The panel is fixed at link time, so virtual dispatch would buy nothing and cost a vtable +pointer plus an indirect call per draw. The concept checks the same contract at compile time +and inlines through it. Each driver carries `static_assert(DisplayDriver<...>)`, so a +signature mismatch is an error **at the driver**, not a link failure later. + +The three `Show*` methods are the one deliberate asymmetry. They are extra in-session +readouts that need room a 2x16 grid does not have, so `LumexLCD` implements them as one-line +no-ops that discard the argument: -`RunDisplayTask` is the shared queue-drain loop. It **drains to the newest -message** before drawing: each one is the whole screen state, so the ones behind it are -already stale. +```cpp +bool ShowPeakForce(float newtons) { (void)newtons; return true; } +``` + +Inline and empty — they emit no code at all in the Lumex build; they do not even appear in +its `-fstack-usage` output. 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 quietly stopped implementing one is a compile error. Adding a fourth readout is one real +implementation and one `(void)` line. + +--- ## Choosing a panel -`Core/Inc/Config/debug.h`, exactly one set to 1: +`Core/Inc/Config/debug.h`, **at most one** enabled: ```c -#define LUMEX_LCD_TASK_ENABLE 1 -#define ILI9341_LCD_TASK_ENABLE 0 +#define LUMEX_LCD_TASK_ENABLE 0 +#define ILI9341_LCD_TASK_ENABLE 1 ``` -A `#error` catches both or neither. `lcdDisplayTaskEntryFunction` in `main.c` dispatches to -`lumex_lcd_main()` or `ili9341_lcd_main()`. Both drivers are always compiled; -`--gc-sections` drops the unused one. +`DISPLAY_TASK_ENABLE` is derived from the pair and is what code outside the two drivers +should test. Both drivers are always compiled; `--gc-sections` drops the unused one. + +**Neither enabled is legal**, and useful: the display task parks and nothing drives SPI1, +which is how the panel gets ruled in or out of a fault elsewhere on the board. It is not the +same as switching to the Lumex, which would change two variables at once. Verify the +isolation with `arm-none-eabi-nm` — with no panel, `HAL_SPI_Transmit` is not linked in at +all. + +--- + +## Layout reference + +``` +Tasks/Display/ + 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 only shared code is the two files at this +level: + +- `display_rpm_digit_increment()` — the cursor position the message carries means the same + step size on any panel. +- `display_format_fixed2()` — two-decimal formatting **without** `snprintf("%f")`. That call + drags in newlib's floating-point formatter, several hundred bytes of stack in a task that + has a kilobyte, and it is the one call in the path whose cost cannot be read off + `-fstack-usage` output. + +--- ## Lumex rendering (`Lumex/`) - `lumex_lcd_main()` → construct, `Init()` (8-bit / 2-line / 5x8 font, display on, clear), then `RunDisplayTask`. -- `lumex_render()` is pure: screen state in, a full 2x16 `lumex_frame` out, every cell - written. No HAL, no RTOS, no driver state — `tests/lumex_layout_tests.cpp` pins all six - screens cell-for-cell on the host. -- `Render()` diffs that frame against `_lastFrame` and writes only the runs that differ. The - common in-session update moves one field: five cells out of thirty-two. +- `lumex_render()` writes every one of the 32 cells; unset cells are spaces. +- `Render()` writes only the runs that differ from `_lastFrame`. - A change of `screen` forces a physical `ClearDisplay()`. That reproduces the old behaviour exactly — every `Show*Screen` used to clear, and the one redraw that deliberately did not (a tick inside the RPM editor) is also the one that does not change screen id. - `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer - (`StartTimer`), which is microsecond-scale. Millisecond waits use `osDelay`, never - `HAL_Delay` — this runs in a task, and spinning there burns CPU that other tasks want. + (`StartTimer`), microsecond-scale. Millisecond waits use `osDelay`, never `HAL_Delay` — + this runs in a task, and spinning there burns CPU other tasks want. +- Known artifact: none. The force field used to strand two digits of its own label; fixed by + making the row literal labels and units only. - `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. ## ILI9341 rendering (`ILI9341/`) -- `ili9341_layout()` is pure: screen state in, up to `ILI9341_MAX_FIELDS` positioned text - fields out. No HAL, no RTOS — `tests/ili9341_layout_tests.cpp` checks it host-side. -- For a given screen the field list is **positionally stable**: same count, order, - positions and widths whatever the values. That is what makes the driver's index-wise diff - valid, and it is asserted in the tests rather than assumed. -- Fields are fixed-width and space-padded. Drawing paints foreground *and* background, so a - redraw erases the previous value — there is no read-modify-write on this bus. -- `Render()` repaints only fields whose text or colour moved. A change of `screen` clears - and repaints in full. This is not an optimisation: a full frame is ~98 ms at 12.5 MHz, - against ~1-2 ms for one field. +- `ili9341_lcd_main()` constructs the driver as a **function-local static**: the display task + runs on 1 KB and this object carries two frames of layout state, so it belongs in `.bss`. + `-fno-threadsafe-statics` is set and the function runs once, so there is no guard variable. +- Session screen field order, which the index-wise diff depends on: + | # | field | position | size | + |---|---|---|---| + | 0 | `SPEED` label | (12, 18) | 2 | + | 1 | RPM value | (12, 40) | 5 | + | 2 | `rpm` unit | (172, 64) | 2 | + | 3 | `FORCE` label | (12, 100) | 2 | + | 4 | force value | (12, 122) | 5 | + | 5 | `N` unit | (200, 146) | 2 | + | 6 | `A` angular acceleration | (12, 168) | 2 | + | 7 | `P` peak force | (108, 168) | 2 | + | 8 | `T` session elapsed | (216, 168) | 2 | + | 9 | drive mode | (12, 196) | 3 | + +- Everything is painted on `ILI9341_BLACK`; each field carries its own foreground. +- `ILI9341_DISPLAY_ROTATION` (`config.h`) says which way up the panel is fitted — a property + of the enclosure, not of the driver. - `ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → `task_error_circular_buffer`. +--- + ## Units -`session_controller_to_display.rpm` is RPM. The optical encoder measures rad/s, and the FSM -converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]) — so a driver renders the -number it is given and no panel repeats the conversion. +`session_controller_to_display.rpm` is **RPM**. The optical encoder measures rad/s and the +FSM converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]), so a driver renders +the number it is given and no panel repeats the conversion. This was a real bug: the readout +was labelled "rpm" while showing rad/s, so 3000 RPM displayed as 314. + +`angular_acceleration` is rad/s², `force` and `peak_force` are newtons, `bpm_duty_cycle` is a +0–1 fraction, `session_seconds` is seconds. ## Key constants `SYSCFG_LCD_TASK_OSDELAY` (sysconfig) · `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` / -`ILI9341_DISPLAY_ROTATION` (config.h) +`ILI9341_DISPLAY_ROTATION` (config.h) · `ILI9341_MAX_FIELDS` / `ILI9341_FIELD_TEXT_MAX` +(ili9341_layout.h) · `ILI9341_MAX_TEXT_SIZE` (ILI9341_main.h) ## Related [[ILI9341 driver]] · [[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] diff --git a/firmware/Drivers/ILI9341/ILI9341.cpp b/firmware/Drivers/ILI9341/ILI9341.cpp index 737a622..a2c412a 100644 --- a/firmware/Drivers/ILI9341/ILI9341.cpp +++ b/firmware/Drivers/ILI9341/ILI9341.cpp @@ -56,8 +56,8 @@ static uint8_t ili9341_scratch[ILI9341_SCRATCH_PIXELS * 2]; // How long HAL_SPI_Transmit may block. // -// Deliberately far longer than any transfer here needs -- the largest is 96 bytes, ~61 us at -// 12.5 MHz. The timeout is wall-clock, and it keeps counting while the caller is preempted: +// Deliberately far longer than any transfer here needs -- the largest is 96 bytes, ~123 us at +// 6.25 MHz. The timeout is wall-clock, and it keeps counting while the caller is preempted: // this runs in the lowest-priority task on the board, so a burst of sensor, PID and USB work // during session start can stall it for a long time between HAL's polls. A timeout tuned to // the transfer would fire on scheduling latency rather than on a real bus fault, which is a diff --git a/firmware/Drivers/ILI9341/README.md b/firmware/Drivers/ILI9341/README.md index 4e71627..bfea49b 100644 --- a/firmware/Drivers/ILI9341/README.md +++ b/firmware/Drivers/ILI9341/README.md @@ -1,6 +1,6 @@ --- module: ILI9341 driver -summary: SPI driver for the ILI9341 240x320 TFT, used by the ILI9341 display task. +summary: SPI driver for the ILI9341 240x320 TFT — the wire protocol, the drawing model, and why it is written rather than vendored. code: - Drivers/ILI9341/ILI9341.hpp - Drivers/ILI9341/ILI9341.cpp @@ -16,7 +16,307 @@ related: [Display, Config] C++ driver for the ILI9341, written against the STM32 HAL. Same shape as the [[ADS1115 driver]]: a plain class, no base class, no virtuals, HAL handles passed in. -## Why this is not the Adafruit library +**The one thing to understand before anything else: the panel has no idea what text is.** +The ILI9341 is a dumb framebuffer with a cursor. It knows no fonts, no characters, no +lines, no rectangles. It knows exactly one useful trick — you give it a rectangle and then +stream raw pixels into it. Everything above that, including every glyph, is computed here +and sent as pixels. + +--- + +## 1. The wire + +### Signals + +Four-wire SPI plus a fifth line the ILI9341 adds, all on SPI1, which is the panel's own bus: + +| signal | pin | direction | what it does | +|---|---|---|---| +| `ILI_SPI1_SCK` | PG11 | out | clock | +| `ILI_SPI1_MOSI` | PD7 | out | the only line that carries anything we send | +| `ILI_SPI1_MISO` | PG9 | in | wired, never read — the driver never reads back | +| `ILI_SPI1_LCD_CS` | PG10 | out | chip select, **active low**, driven by GPIO not SPI_NSS | +| `ILI_LCD_DC` | PD5 | out | **data / command**: low = this byte is a command, high = data | +| `ILI_LCD_RST` | PD6 | out | hardware reset, **active low**, idles high | + +`ILI_LCD_DC` is the part that is not ordinary SPI. There are no addresses, no registers and +no headers on this bus: **the D/C pin is the entire framing mechanism.** A byte is a command +if D/C was low when it was clocked, and an argument or a pixel if D/C was high. That is why +`WriteCommand` and `WriteData` differ only in which way they set that pin. + +### Bus settings (`main.c`, generated from the `.ioc`) + +``` +Mode master, 2 lines (full duplex, though MISO is unused) +DataSize 8 bit +CLKPolarity low ) SPI mode 0: idle low, sample on the rising edge +CLKPhase 1 edge) +FirstBit MSB +NSS soft -- CS is a plain GPIO, so it can stay low across several transfers +Prescaler 32 on a 200 MHz SPI123 kernel clock -> 6.25 MHz SCK +``` + +`NSS_SOFT` matters more than it looks. Because CS is an ordinary GPIO, the driver can hold +it low across a command *and* the megabyte of pixels that follows, which is what makes a +streamed write possible at all. + +### The message format + +There is no packet structure. A transaction is nothing more than a CS window with D/C +toggling inside it: + +``` +CS ‾‾‾\_______________________________________________________/‾‾‾ +D/C \__ 0 __/‾‾‾‾‾‾‾‾‾‾‾ 1 ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾ +MOSI [opcode] [arg] [arg] ... [arg] + 1 byte 0..n bytes +``` + +In code that is exactly `SendCommand`: + +```cpp +Select(); // CS low +WriteCommand(opcode); // D/C low, 1 byte +WriteData(args, length); // D/C high, n bytes +Deselect(); // CS high +``` + +### Worked example 1 — `FillRect(12, 40, 30, 40, ILI9341_WHITE)` + +A 30x40 white block at (12, 40). `SetAddrWindow` computes the inclusive far corner +(x1 = 12+30-1 = 41, y1 = 40+40-1 = 79) and sends three commands, then the pixels: + +``` +CS low + D/C=0 2A CASET, "set column range" + D/C=1 00 0C 00 29 x0 = 0x000C (12), x1 = 0x0029 (41) -- 16-bit, big-endian + D/C=0 2B PASET, "set page (row) range" + D/C=1 00 28 00 4F y0 = 0x0028 (40), y1 = 0x004F (79) + D/C=0 2C RAMWR, "everything after this is pixels" + D/C=1 FF FF FF FF ... FF FF 30*40 = 1200 pixels, 2400 bytes +CS high +``` + +Note `SetAddrWindow` deliberately **leaves CS asserted** and returns. The caller streams +straight into the open `RAMWR` and calls `Deselect()` when it is done. + +### Worked example 2 — one character + +`DrawChar` at size 3 opens a window over the whole 18x24 cell and then streams it row by +row, so the *entire glyph is a single command sequence*: + +``` +CS low + D/C=0 2A ; D/C=1 + D/C=0 2B ; D/C=1 + D/C=0 2C + D/C=1 <36 bytes> glyph row 0, repeated 3x down ) 24 writes + D/C=1 <36 bytes> ) of 18 pixels + ... ) = 864 bytes +CS high +``` + +### Pixel format + +`PIXFMT` (0x3A) is set to `0x55` in the init table: **16 bits per pixel, RGB565**, sent +**high byte first**. + +``` +bit 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0 + R R R R R G G G G G G B B B B B +``` + +So `ILI9341_WHITE` = `0xFFFF` → `FF FF`, `ILI9341_RED` = `0xF800` → `F8 00`. The byte order +is why the scratch fill is written the way it is: + +```cpp +ili9341_scratch[i * 2] = (uint8_t)(colour >> 8); +ili9341_scratch[i * 2 + 1] = (uint8_t)colour; +``` + +The colour constants in `ILI9341_main.h` are already RGB565 literals; nothing converts from +24-bit RGB anywhere. + +### The cursor, and why it is the whole design + +After `RAMWR`, every pixel written advances an internal cursor left-to-right then +top-to-bottom **inside the window**, wrapping at the window's right edge, not the panel's. +That is the entire reason this driver is fast enough to use: + +- set a window once, stream N pixels — **one** command sequence; +- set a window per pixel — **N** command sequences, each 11 bytes of overhead for 2 bytes of + payload. + +Hence the rule the whole driver is built on: **blit rectangles, never pixels.** There is no +`DrawPixel` in this class, on purpose. + +--- + +## 2. Drawing model + +### There is no read-modify-write + +The driver never reads the panel back. MISO is wired but unused, so there is no way to ask +"what is currently at (x, y)". Everything drawn is therefore *opaque*: `DrawChar` paints the +glyph's foreground **and** its background, all 48 pixels of a 6x8 cell. + +That single fact drives the layout rules one level up ([[Display]]): fields are fixed-width +and space-padded, because overwriting `"12345"` with `" 1235"` only erases the old digits if +those cells are repainted background and all. + +### Glyphs + +`ILI9341_font.c` is 1280 bytes of pure data: 256 glyphs x 5 bytes. **One byte per column**, +bit *n* of that byte being row *n* from the top: + +``` +'A' = 0x7C, 0x12, 0x11, 0x12, 0x7C + + . . # . . bit0 of each of the 5 bytes + . # . # . bit1 + # . . . # bit2 + # . . . # bit3 + # # # # # bit4 + # . . . # bit5 + # . . . # bit6 +``` + +`ili9341_font_pixel(c, column, row)` is just `(font[c * 5 + column] >> row) & 1`. + +The glyph is 5x7 drawn inside a **6x8 cell** — the sixth column and eighth row are the +inter-character and inter-line gap, and they are painted as background like everything else. + +### `size` is replication, not a font + +`size` scales by repeating pixels: at size 3 each font dot becomes a 3x3 block. It is *not* +a different typeface, so size 5 is not a nicer-looking font than size 1 — it is the same 35 +dots, five times blockier. Cell geometry is `6 * size` by `8 * size`, and text advances by +exactly `6 * size` per character whatever the character is (a fixed advance: `'i'` and `'W'` +occupy the same width). + +`ILI9341_MAX_TEXT_SIZE` (8) is the cap, and it is not arbitrary — it sizes the scratch +buffer: + +``` +ili9341_scratch = ILI9341_FONT_CELL_WIDTH (6) * ILI9341_MAX_TEXT_SIZE (8) * 2 bytes = 96 B +``` + +which is one glyph row at the largest allowed scale. `DrawChar` rejects a larger `size` +rather than overrun it. + +### Clipping + +Nothing here reflows, shrinks or wraps. Text that does not fit is **lost**: + +- `DrawString` stops as soon as a cell would *start* past `Width()`; +- `SetAddrWindow` and `FillRect` clip a rectangle's overhang to the panel edge — clipping + rather than rejecting, so a field near the edge loses its tail instead of vanishing, and + an unclipped window is never handed to the controller (which would wrap it onto the next + row). + +Fitting is the layout's job, done up front with fixed positions and clamped values. See +[[Display]]. + +--- + +## 3. Bring-up sequence + +`Init(rotation)` does three things in order. + +**1. Hardware reset.** Active low, and generously timed — the controller needs far longer +after reset than its 10 us minimum pulse before it will accept commands: + +``` +RST high, 5 ms -> RST low, 20 ms -> RST high, 150 ms +``` + +**2. Walk the init table.** `ILI9341_INIT_COMMANDS` is a flat byte array in a +self-describing format: + +``` +opcode, count, arg0 .. arg(count-1), +opcode, count, ... +0x00 <- terminator +``` + +with one wrinkle: **if the high bit of `count` is set, wait 150 ms after that command.** +`count & 0x7F` is the real argument count. Only `SLPOUT` (exit sleep) and `DISPON` use it, +and both genuinely need the wait. + +This table is the one part of the Adafruit library genuinely worth having. The gamma curves +(`GMCTRP1`/`GMCTRN1`, 15 bytes each) are tuned values, not derivations — you would not +reconstruct them from the datasheet in an afternoon. + +**3. Apply the rotation** via `MADCTL` (0x36). + +### Rotation and `MADCTL` + +| index | constant | bits | panel | +|---|---|---|---| +| 0 | `ILI9341_ROTATION_PORTRAIT` | `MX \| BGR` = 0x48 | 240x320 | +| 1 | `ILI9341_ROTATION_LANDSCAPE` | `MV \| BGR` = 0x28 | 320x240 | +| 2 | `ILI9341_ROTATION_PORTRAIT_FLIP` | `MY \| BGR` = 0x88 | 240x320 | +| 3 | `ILI9341_ROTATION_LANDSCAPE_FLIP` | `MX \| MY \| MV \| BGR` = 0xE8 | 320x240 | + +`MV` is the row/column exchange — that bit alone is what makes it landscape, and `Width()` / +`Height()` swap based on it. `MX`/`MY` mirror the axes, so 1 and 3 differ by exactly 0xC0: a +180-degree flip, same geometry, same colours. + +`BGR` is set in all four because these modules wire the panel that way. **A correct image in +wrong colours is that bit and nothing else.** + +Which rotation this board uses is `ILI9341_DISPLAY_ROTATION` in `config.h`, not a constant +here — which way up the panel is fitted is a property of the enclosure. + +--- + +## 4. Performance + +At the configured 6.25 MHz SCK, 16 bits per pixel: + +| operation | pixels | bytes | time | +|---|---|---|---| +| full screen (320x240) | 76,800 | 153,600 | ~197 ms | +| one size-5 field, 6 chars (180x40) | 7,200 | 14,400 | ~18 ms | +| one size-3 character (18x24) | 432 | 864 | ~1.1 ms | + +**A full repaint per sensor sample is impossible**, which is the whole reason the layer above +diffs and repaints only changed fields. That is not an optimisation; it is what makes the +panel usable. + +### Blocking `HAL_SPI_Transmit`, not DMA + +The display task is `osPriorityBelowNormal`, so a polling wait is preempted by anything that +matters and costs only idle time. + +DMA would also be real work here rather than a flag: **DMA1/DMA2 cannot reach DTCM on the +STM32H7**, and `STM32H743XX_FLASH.ld` puts `.data`, `.bss`, the FreeRTOS heap and every task +stack there. A DMA transfer would need a scratch buffer in a new linker section in AXI SRAM +(0x24000000, currently completely unused) plus a completion semaphore to make yielding — not +spinning — the point. Worth doing if a framebuffer or a live graph ever streams full frames; +not before. + +`ILI9341_SPI_TIMEOUT_MS` is 1000, far longer than any transfer here needs, because the HAL's +timeout is **wall clock** and keeps counting while the caller is preempted. A timeout tuned +to the transfer would fire on scheduling latency rather than a real bus fault. + +### Where the buffers live + +`ili9341_scratch` is a file-scope `static` in `.bss`, not a local. The display task runs on +1 KB of stack and there is exactly one panel, so a shared buffer is both cheaper and safer. + +### `DelayMs` is injected + +`Init` needs ~325 ms of waits. The constructor takes a `DelayMs` callback rather than calling +`HAL_Delay` directly, defaulting to `HAL_Delay` for bare-metal bring-up. The display task +passes `osDelay` instead, because under an RTOS `HAL_Delay` spins rather than yields — and +inside a FreeRTOS critical section it would never return at all, since HAL's tick comes from +a TIM at priority 15 which is masked there. This is also what keeps the driver free of +`cmsis_os2.h`. + +--- + +## 5. Why this is not the Adafruit library The obvious move is to submodule `Adafruit_ILI9341`. That would actually be **three** submodules — it depends on `Adafruit-GFX-Library` (which ships `Adafruit_SPITFT`), which @@ -26,60 +326,51 @@ depends on `Adafruit_BusIO` — and none of them would work here: 18 `virtual` in `Adafruit_GFX.h`, 2 more in `Adafruit_SPITFT.h`, plus Arduino's `Print` base. No build configuration removes them, and the firmware builds `-fno-rtti -fno-exceptions` precisely to avoid paying for that. -- **No STM32 branch to switch on.** `Adafruit_SPITFT.cpp` is 2621 lines of per-MCU - `#ifdef` — 24 `__AVR` sites, 21 `digitalPinToPort`, 19 `digitalWrite`, 13 - `portOutputRegister`, `SPIClass`/`SPISettings`. Porting means adding a whole new - architecture arm to someone else's dispatch tree, which upstream will never merge, so - the fork is permanent. +- **No STM32 branch to switch on.** `Adafruit_SPITFT.cpp` is 2621 lines of per-MCU `#ifdef` + — 24 `__AVR` sites, 21 `digitalPinToPort`, 19 `digitalWrite`, 13 `portOutputRegister`, + `SPIClass`/`SPISettings`. Porting means adding a whole new architecture arm to someone + else's dispatch tree, which upstream will never merge, so the fork is permanent. - **The payload is tiny.** What is genuinely ILI9341-specific is the init table, the - address-window command, and the MADCTL rotation values — about 30 lines. + address-window command and the MADCTL values — about 30 lines. So: vendor the constants and the data, write the transport. Exactly what `Drivers/ADS1115/README.md` describes for the force sensor's ADC. **Vendored, with Adafruit's BSD notice kept** (`ILI9341_main.h`): -- the init/power/gamma command table (`ILI9341_INIT_COMMANDS` in `ILI9341.cpp`) — tuned - values, not derivations, and the one thing worth taking; +- the init/power/gamma table (`ILI9341_INIT_COMMANDS`); - the command codes, MADCTL bits and RGB565 colour constants; -- `ILI9341_font.c`, the classic 5x7 GFX font: 256 glyphs x 5 column-bytes = 1280 bytes of - pure data. Its only Arduino dependency was a `PROGMEM` attribute that is defined away on - every non-AVR target. - -## Wiring -SPI1 is the display's own bus. `ILI_SPI1_MOSI` (PD7), `_MISO` (PG9), `_SCK` (PG11), -`ILI_SPI1_LCD_CS` (PG10), `ILI_LCD_DC` (PD5), `ILI_LCD_RST` (PD6) — all in `main.h`, all -owned by the `.ioc`. - -SPI1 runs 8-bit at `SPI_BAUDRATEPRESCALER_16`: SPI123 is clocked at 200 MHz, so that is a -12.5 MHz SCK. The datasheet allows about 10 MHz for writes and real modules take ~40 MHz, -so prescaler 8 (25 MHz) is the next thing to try once the panel is proven. - -## Key methods -- `Init(rotation)` — reset pulse, walk the init table, apply the rotation. Defaults to - landscape (320x240). -- `FillRect` / `FillScreen`, `DrawChar`, `DrawString`, `SetRotation`, `InvertDisplay`. -- `Width()` / `Height()` follow the active rotation. - -## Performance notes -- **Blocking `HAL_SPI_Transmit`, not DMA.** The display task is `osPriorityBelowNormal`, so - a polling wait is preempted by anything that matters and costs only idle time. DMA would - also be real work here rather than a flag: DMA1/DMA2 cannot reach DTCM on the STM32H7, and - `STM32H743XX_FLASH.ld` puts `.data`, `.bss`, the FreeRTOS heap and every task stack there — - so a transfer would need a scratch buffer in a new linker section in AXI SRAM. Worth doing - if a framebuffer or live graph ever streams full frames; not before. -- **Rectangles, never pixels.** A full frame is 320x240x16bpp = 153,600 bytes, ~98 ms at - 12.5 MHz — far too slow per sensor sample, which is why the display task repaints only - changed fields. `DrawChar` sets one address window per cell and streams the rows into the - open `RAMWR`; drawn pixel by pixel the same cell would be hundreds of command sequences. -- Pixel scratch lives in `.bss`, not on the caller's stack: the display task's stack is - 1 KB and there is exactly one panel. - -## Bring-up order -1. Reset pulse + `SLPOUT` + `FillScreen(WHITE)` → panel and backlight alive. -2. Fill red / green / blue → SPI, D/C and colour order. A blank screen is usually CS or - reset polarity; a correct image in wrong colours is the `MADCTL` BGR bit. +- `ILI9341_font.c` — 1280 bytes of glyph data whose only Arduino dependency was a `PROGMEM` + attribute that is defined away on every non-AVR target. + +--- + +## 6. API + +| method | notes | +|---|---| +| `Init(rotation)` | reset, init table, rotation. Defaults to landscape | +| `SetRotation(r)` | `MADCTL` write; changes what `Width()`/`Height()` report | +| `InvertDisplay(b)` | `INVON` / `INVOFF` | +| `FillRect(x,y,w,h,c)` | clipped; off-panel is success-with-nothing-drawn, not an error | +| `FillScreen(c)` | `FillRect` over the whole panel | +| `DrawChar(x,y,c,fg,bg,size)` | one cell, opaque, one command sequence | +| `DrawString(x,y,text,len,fg,bg,size)` | fixed advance, clipped at the right edge. **Not NUL-aware** — callers pass fixed-width fields | +| `Width()` / `Height()` | follow the active rotation | + +Every method returns `bool`: false means a HAL SPI call failed. Callers must not treat that +as fatal — see [[Display]] for why a failed write must never take the board down. + +--- + +## 7. Bring-up order on new hardware + +1. Reset pulse + `SLPOUT` + `FillScreen(WHITE)` → panel and backlight alive. A blank screen + here is almost always CS or RST polarity (both idle **high**), or a backlight pin that is + switched rather than strapped to 3V3. +2. Fill red / green / blue → SPI, D/C and colour order. Wrong colours with a correct image is + the `MADCTL` BGR bit; garbage is usually `DataSize` not being 8-bit, or the clock too fast. 3. `DrawString` of a literal → font path. -4. Landscape rotation → 320x240 origin and orientation. +4. Rotation → origin corner and orientation. Upside down is rotation 1 vs 3. ## Related [[Display]] · [[Config]] · upstream: https://github.com/adafruit/Adafruit_ILI9341 From f3b216b2a4ce657d25ef88c54730d8b0c26ebc1e Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 19:42:49 -0700 Subject: [PATCH 20/25] lumex: split the panel driver out to Drivers/, and delete TIM13 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 --- firmware/CMakeLists.txt | 2 + firmware/Core/Inc/Config/debug.h | 1 - .../Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp | 25 +- .../Inc/Tasks/Display/Lumex/lumexlcd_main.h | 1 - firmware/Core/Inc/stm32h7xx_it.h | 1 - firmware/Core/README.md | 3 +- .../Core/Src/Tasks/Display/Lumex/LumexLCD.cpp | 312 +++++------------- firmware/Core/Src/Tasks/Display/README.md | 4 +- firmware/Core/Src/main.c | 42 --- firmware/Core/Src/stm32h7xx_hal_msp.c | 28 -- firmware/Core/Src/stm32h7xx_it.c | 15 - firmware/Drivers/Lumex/LumexPanel.cpp | 161 +++++++++ firmware/Drivers/Lumex/LumexPanel.hpp | 88 +++++ firmware/Drivers/Lumex/LumexPanel_main.h | 72 ++++ firmware/Drivers/Lumex/README.md | 250 ++++++++++++++ firmware/stm32_dyno_firmware_v2.ioc | 26 +- 16 files changed, 675 insertions(+), 356 deletions(-) create mode 100644 firmware/Drivers/Lumex/LumexPanel.cpp create mode 100644 firmware/Drivers/Lumex/LumexPanel.hpp create mode 100644 firmware/Drivers/Lumex/LumexPanel_main.h create mode 100644 firmware/Drivers/Lumex/README.md diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 027b343..48053d2 100644 --- a/firmware/CMakeLists.txt +++ b/firmware/CMakeLists.txt @@ -65,6 +65,7 @@ file(GLOB_RECURSE APP_SOURCES CONFIGURE_DEPENDS target_sources(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/Core/Src/interrupts.c ${CMAKE_SOURCE_DIR}/Drivers/ADS1115/ADS1115.cpp + ${CMAKE_SOURCE_DIR}/Drivers/Lumex/LumexPanel.cpp ${CMAKE_SOURCE_DIR}/Drivers/ILI9341/ILI9341.cpp ${CMAKE_SOURCE_DIR}/Drivers/ILI9341/ILI9341_font.c ${APP_SOURCES} @@ -79,6 +80,7 @@ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/Middlewares/CircularBuffer/Inc ${CMAKE_SOURCE_DIR}/Drivers/ADS1115 ${CMAKE_SOURCE_DIR}/Drivers/ILI9341 + ${CMAKE_SOURCE_DIR}/Drivers/Lumex ) # Add project symbols (macros) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index b966d3c..b9fc04b 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -8,7 +8,6 @@ // ===== TIMERS ===== #define STM32_PERIPHERAL_TIM1_ENABLE 1 #define STM32_PERIPHERAL_TIM2_ENABLE 1 -#define STM32_PERIPHERAL_TIM13_ENABLE 1 #define STM32_PERIPHERAL_TIM14_ENABLE 1 #define STM32_PERIPHERAL_TIM16_ENABLE 1 diff --git a/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp index c3c23f6..10ed24d 100644 --- a/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp +++ b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp @@ -1,5 +1,5 @@ -#ifndef INC_TASKS_LCD_LUMEXLCD_HPP_ -#define INC_TASKS_LCD_LUMEXLCD_HPP_ +#ifndef INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ +#define INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ #include "main.h" @@ -11,6 +11,8 @@ #include "CircularBufferWriter.hpp" +#include "LumexPanel.hpp" + #include "MessagePassing/messages_private.h" #include "MessagePassing/messages_public.h" #include "MessagePassing/osqueue_helpers.h" @@ -19,7 +21,12 @@ #include "TimeKeeping/timestamps.h" -// Lumex 16x2 character LCD, bit-banged over GPIO. +// The Lumex panel's side of the display split: screen state in, changed cells out. +// +// Owns a LumexPanel (Drivers/Lumex) and adds everything the panel itself has no business +// knowing -- what the screens look like, which cells moved since the last frame, and the +// FreeRTOS task around it. The same division as ILI9341Display over ILI9341, and the reason +// this class no longer contains a line of HD44780 protocol. // // Satisfies the DisplayDriver concept (Tasks/Display/DisplayDriver.hpp) without inheriting // anything: the panel choice is fixed at link time, so the contract is checked at compile time @@ -69,15 +76,7 @@ class LumexLCD private: - bool StartTimer(uint8_t microseconds); - bool SendByte(uint8_t byte); - bool WriteData(uint8_t data); - bool WriteCommand(uint8_t command); - bool ClearDisplay(); - bool SetCursor(uint8_t row, uint8_t column); - bool DisplayChar(uint8_t row, uint8_t column, uint8_t character); - bool DisplayString(uint8_t row, uint8_t column, const char* string, size_t size); - bool ToggleBlink(bool enable); + LumexPanel _panel; CircularBufferWriter _task_error_buffer_writer; @@ -89,4 +88,4 @@ class LumexLCD bool _hasRendered; }; -#endif /* INC_TASKS_LCD_LUMEXLCD_HPP_ */ +#endif /* INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h b/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h index 12f1bb7..f65ef6e 100644 --- a/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h +++ b/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h @@ -10,7 +10,6 @@ extern "C" { #endif -void lumex_lcd_timer_interrupt(); void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayqHandle); #ifdef __cplusplus diff --git a/firmware/Core/Inc/stm32h7xx_it.h b/firmware/Core/Inc/stm32h7xx_it.h index 31d64e6..c7ffe7d 100644 --- a/firmware/Core/Inc/stm32h7xx_it.h +++ b/firmware/Core/Inc/stm32h7xx_it.h @@ -58,7 +58,6 @@ void ADC_IRQHandler(void); void EXTI9_5_IRQHandler(void); void TIM4_IRQHandler(void); void EXTI15_10_IRQHandler(void); -void TIM8_UP_TIM13_IRQHandler(void); void OTG_FS_IRQHandler(void); void TIM17_IRQHandler(void); /* USER CODE BEGIN EFP */ diff --git a/firmware/Core/README.md b/firmware/Core/README.md index 52993d0..17cbfa9 100644 --- a/firmware/Core/README.md +++ b/firmware/Core/README.md @@ -30,9 +30,10 @@ never by calling into another task directly. | CircularBuffer | `Middlewares/CircularBuffer/README.md` | Heap-free single-writer / multi-reader buffers | | ADS1115 driver | `Drivers/ADS1115/README.md` | I2C 16-bit ADC driver used by the force sensor | | ILI9341 driver | `Drivers/ILI9341/README.md` | SPI TFT driver used by the ILI9341 display task | +| Lumex panel driver | `Drivers/Lumex/README.md` | HD44780 character LCD driver used by the Lumex display task | ## main.c conventions -- Timer handles are renamed for clarity: `timestampTimer`, `lumexLcdTimer`, `bpmTimer`. +- Timer handles are renamed for clarity: `timestampTimer`, `bpmTimer`. - Peripheral and queue handles are passed into task entry points; handles also needed by ISRs are declared `extern` in the consuming file. - CubeMX owns everything outside the `USER CODE BEGIN/END` markers — regenerating from diff --git a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp index 887425d..eb1f6a4 100644 --- a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp @@ -2,78 +2,107 @@ #include #include -#include "Tasks/Display/DisplayDriver.hpp" +#include "FreeRTOS.h" // configTICK_RATE_HZ, for the static_assert below -extern TIM_HandleTypeDef* lumexLcdTimer; +#include "Tasks/Display/DisplayDriver.hpp" extern size_t task_error_circular_buffer_index_writer; extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZE]; -static volatile bool timerCallbackFlag = false; -LumexLCD::LumexLCD() : - _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), - _lastScreen(DISPLAY_SCREEN_IDLE), - _hasRendered(false) -{ - memset(_lastFrame.cells, ' ', sizeof(_lastFrame.cells)); -} - -bool LumexLCD::Init() +// --- The two waits the panel driver needs, supplied here so the driver itself stays free of +// both the RTOS and any particular timer. Same arrangement as ILI9341::DelayMs. + +// Microseconds, for the ~40 us enable pulse. Busy-waits on the free-running timestamp counter +// -- the one every sample is stamped from -- rather than a timer of its own. +// +// This used to be TIM13: a whole peripheral, an NVIC line, an ISR and a volatile flag, whose +// only job was to drop E and set the flag that this task was *already spinning on*. It cost a +// timer and saved no CPU, because the spin was there either way. Spinning 40 us directly is the +// same behaviour with none of the machinery, and TIM13 is now free. +// +// osDelay cannot do this job: at a 1 kHz tick its floor is 1 ms, which would stretch every byte +// 25x and a full 32-cell repaint from ~2.6 ms to ~64 ms. +// +// The loop is bounded as well as timed. get_timestamp() reads a counter that SessionController +// starts, and SESSION_CONTROLLER_TASK_ENABLE 0 is a legal configuration -- with the counter +// frozen the elapsed time would never advance and this would hang the display task forever. +// LumexLCD::Init starts the counter itself for that reason; the bound is what makes a failure +// there produce a mistimed panel rather than a wedged task. +static void PanelDelayUs(uint32_t microseconds) { + const uint32_t start = get_timestamp(); - // Enable to GND to tell that we are in command mode, not data mode - HAL_GPIO_WritePin(LUMEX_LCD_EN_GPIO_Port, LUMEX_LCD_EN_Pin, GPIO_PIN_RESET); + // Generous: at 1 us per tick this is ~40x the longest wait ever asked for. + uint32_t guard = 0; + const uint32_t guardLimit = 100000u; - osDelay(40); - - - // Proper 8-bit mode initialization sequence - // Function set: 8-bit mode, 2-line, 5x8 font - if (!WriteCommand(0x38)) + while ((uint32_t)(get_timestamp() - start) < microseconds && ++guard < guardLimit) { - return false; + // Spin. Nothing else can usefully happen in 40 us. } +} - osDelay(5); +// Milliseconds, for power-on and the clear instruction. Long enough to be worth yielding for, +// so this is osDelay -- never HAL_Delay, which spins and burns CPU other tasks want. +static_assert(configTICK_RATE_HZ == 1000, + "osDelay is being called with milliseconds; that only holds at a 1 kHz tick."); +static void PanelDelayMs(uint32_t milliseconds) +{ + osDelay(milliseconds); +} - // needs to be done twice - if (!WriteCommand(0x38)) - { - return false; - } +// Board wiring. The driver takes this rather than reaching for the LUMEX_LCD_* macros, so it +// depends on nothing but the pins it is handed. +static const LumexPanel::Pins LUMEX_PINS = { + { + { LUMEX_LCD_D0_GPIO_Port, LUMEX_LCD_D0_Pin }, + { LUMEX_LCD_D1_GPIO_Port, LUMEX_LCD_D1_Pin }, + { LUMEX_LCD_D2_GPIO_Port, LUMEX_LCD_D2_Pin }, + { LUMEX_LCD_D3_GPIO_Port, LUMEX_LCD_D3_Pin }, + { LUMEX_LCD_D4_GPIO_Port, LUMEX_LCD_D4_Pin }, + { LUMEX_LCD_D5_GPIO_Port, LUMEX_LCD_D5_Pin }, + { LUMEX_LCD_D6_GPIO_Port, LUMEX_LCD_D6_Pin }, + { LUMEX_LCD_D7_GPIO_Port, LUMEX_LCD_D7_Pin }, + }, + { LUMEX_LCD_RS_GPIO_Port, LUMEX_LCD_RS_Pin }, + { LUMEX_LCD_EN_GPIO_Port, LUMEX_LCD_EN_Pin }, +}; - osDelay(5); - // just to make sure it works - if (!WriteCommand(0x38)) - { - return false; - } - - osDelay(5); +LumexLCD::LumexLCD() : + _panel(LUMEX_PINS, PanelDelayUs, PanelDelayMs), + _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), + _lastScreen(DISPLAY_SCREEN_IDLE), + _hasRendered(false) +{ + memset(_lastFrame.cells, ' ', sizeof(_lastFrame.cells)); +} - // Display ON, Cursor OFF, Blink OFF - if (!WriteCommand(0x0c)) +bool LumexLCD::Init() +{ + // PanelDelayUs measures against this counter, so it has to be running before the panel is + // touched. SessionController starts it too and starting twice is harmless -- doing it here + // as well is what keeps this task working when the session controller is compiled out. + if (start_timestamp_timer() != HAL_OK) { - return false; - } - - osDelay(5); + task_error_data error_data = PopulateTaskErrorDataStruct( + get_timestamp(), + TASK_OFFSET_DISPLAY, + static_cast(ERROR_DISPLAY_INIT_FAILURE) + ); - // Clear Display - if (!ClearDisplay()) - { - return false; + _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); + return false; } - return true; + return _panel.Init(); } bool LumexLCD::Clear() { - if (!ClearDisplay()) + if (!_panel.ClearDisplay()) { return false; } @@ -121,7 +150,7 @@ bool LumexLCD::Render(const session_controller_to_display& state) column++; } - if (!DisplayString(row, start, &frame.cells[row][start], column - start)) + if (!_panel.DisplayString(row, start, &frame.cells[row][start], column - start)) { // The panel no longer matches _lastFrame, so the diff would skip cells that // were never written. Force a full clear and repaint next pass. @@ -138,187 +167,6 @@ bool LumexLCD::Render(const session_controller_to_display& state) return true; } - -bool LumexLCD::StartTimer(uint8_t microseconds) -{ - __HAL_TIM_SET_COUNTER(lumexLcdTimer, 0); - __HAL_TIM_SET_AUTORELOAD(lumexLcdTimer, microseconds); - if (HAL_TIM_Base_Start_IT(lumexLcdTimer) != HAL_OK) - { - task_error_data error_data = PopulateTaskErrorDataStruct( - get_timestamp(), - TASK_OFFSET_DISPLAY, - static_cast(ERROR_LUMEX_LCD_TIMER_START_FAILURE) - ); - - _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); - return false; - } - - return true; -} - - - -bool LumexLCD::SendByte(uint8_t byte) -{ - // Very Inefficient Way of Toggling Pins but may decide to use registers instead in the future - HAL_GPIO_WritePin(LUMEX_LCD_D7_GPIO_Port, LUMEX_LCD_D7_Pin, static_cast((byte >> 7) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D6_GPIO_Port, LUMEX_LCD_D6_Pin, static_cast((byte >> 6) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D5_GPIO_Port, LUMEX_LCD_D5_Pin, static_cast((byte >> 5) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D4_GPIO_Port, LUMEX_LCD_D4_Pin, static_cast((byte >> 4) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D3_GPIO_Port, LUMEX_LCD_D3_Pin, static_cast((byte >> 3) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D2_GPIO_Port, LUMEX_LCD_D2_Pin, static_cast((byte >> 2) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D1_GPIO_Port, LUMEX_LCD_D1_Pin, static_cast((byte >> 1) & 0x01)); - HAL_GPIO_WritePin(LUMEX_LCD_D0_GPIO_Port, LUMEX_LCD_D0_Pin, static_cast((byte >> 0) & 0x01)); - - - // Set EN Pin and start timer - HAL_GPIO_WritePin(LUMEX_LCD_EN_GPIO_Port, LUMEX_LCD_EN_Pin, GPIO_PIN_SET); - - timerCallbackFlag = false; - - if (!StartTimer(40)) - { - return false; - } - - while(!timerCallbackFlag); - - return true; - -} - -bool LumexLCD::WriteData(uint8_t data) -{ - HAL_GPIO_WritePin(LUMEX_LCD_RS_GPIO_Port, LUMEX_LCD_RS_Pin, GPIO_PIN_SET); - - if (!SendByte(data)) - { - return false; - } - return true; - -} - - -bool LumexLCD::WriteCommand(uint8_t command) -{ - HAL_GPIO_WritePin(LUMEX_LCD_RS_GPIO_Port, LUMEX_LCD_RS_Pin, GPIO_PIN_RESET); - - if (!SendByte(command)) - { - return false; - } - - return true; -} - -bool LumexLCD::ClearDisplay() -{ - if (!WriteCommand(0x01)) - { - return false; - } - - // osDelay, not HAL_Delay: this runs in a task, and HAL_Delay spins rather than yielding. - osDelay(20); - - return true; -} - - -bool LumexLCD::SetCursor(uint8_t row, uint8_t column) { - - uint8_t address = (row == 0) ? 0x00 : 0x40; - address += column; - if (!WriteCommand(0x80 | address)) - { - return false; - } - - return true; - -} - -bool LumexLCD::DisplayChar(uint8_t row, uint8_t column, uint8_t character) -{ - if (!SetCursor(row, column)) - { - return false; - } - - if (!WriteData(character)) - { - return false; - } - - return true; -} - -bool LumexLCD::DisplayString(uint8_t row, uint8_t column, const char* string, size_t size) -{ - assert_param(row < LUMEX_LCD_ROWS); - - for (uint8_t i = 0; i < size; i++) - { - // Clamp instead of wrapping: drop any chars past the last column so an - // overflow fails visibly in one cell rather than corrupting another row. - if (column >= LUMEX_LCD_COLUMNS) - { - break; - } - - if (!SetCursor(row, column)) - { - return false; - } - - if (!WriteData(string[i])) - { - return false; - } - - column++; - } - - return true; - - -} - -bool LumexLCD::ToggleBlink(bool enable) -{ - if (enable) - { - // Binary: 00001 1 1 1 = 0x0F - // Display ON, Cursor ON, Blink ON - if (!WriteCommand(0x0F)) - { - return false; - } - } - else - { - // Display ON, Cursor OFF, Blink OFF - if (!WriteCommand(0x0C)) - { - return false; - } - } - - return true; -} - - -extern "C" void lumex_lcd_timer_interrupt() -{ - HAL_TIM_Base_Stop_IT(lumexLcdTimer); - HAL_GPIO_WritePin(LUMEX_LCD_EN_GPIO_Port, LUMEX_LCD_EN_Pin, GPIO_PIN_RESET); - timerCallbackFlag = true; - -} - static_assert(DisplayDriver, "LumexLCD must satisfy DisplayDriver -- see Tasks/Display/DisplayDriver.hpp"); @@ -337,9 +185,3 @@ extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayHand osThreadSuspend(osThreadGetId()); } - - - - - - diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md index ac8a745..d02e76e 100644 --- a/firmware/Core/Src/Tasks/Display/README.md +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -19,7 +19,7 @@ entry_point: lumex_lcd_main() / ili9341_lcd_main() task_offset: TASK_OFFSET_DISPLAY consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] -related: [SessionController, MessagePassing, ILI9341 driver] +related: [SessionController, MessagePassing, ILI9341 driver, Lumex panel driver] --- # Display — from a measured value to lit pixels @@ -326,4 +326,4 @@ was labelled "rpm" while showing rad/s, so 3000 RPM displayed as 314. (ili9341_layout.h) · `ILI9341_MAX_TEXT_SIZE` (ILI9341_main.h) ## Related -[[ILI9341 driver]] · [[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] +[[ILI9341 driver]] · [[Lumex panel driver]] · [[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index e16959c..9227359 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -76,7 +76,6 @@ SPI_HandleTypeDef hspi2; TIM_HandleTypeDef htim1; TIM_HandleTypeDef htim2; TIM_HandleTypeDef htim4; -TIM_HandleTypeDef htim13; TIM_HandleTypeDef htim16; /* Definitions for usbTask */ @@ -220,9 +219,7 @@ TIM_HandleTypeDef* timestampTimer = &htim2; // runs in external clock mode 1, so CNT *is* the pulse count and no interrupt fires per edge. TIM_HandleTypeDef* opticalCounterTimer = &htim4; -TIM_HandleTypeDef* lumexLcdTimer = &htim13; -TIM_TypeDef* lumexLcdTimInstance = TIM13; TIM_HandleTypeDef* bpmTimer = &htim16; @@ -238,7 +235,6 @@ static void MX_SDMMC1_SD_Init(void); static void MX_SPI1_Init(void); static void MX_SPI2_Init(void); static void MX_TIM1_Init(void); -static void MX_TIM13_Init(void); static void MX_ADC2_Init(void); static void MX_TIM2_Init(void); static void MX_ADC3_Init(void); @@ -303,7 +299,6 @@ int main(void) MX_SPI1_Init(); MX_SPI2_Init(); MX_TIM1_Init(); - MX_TIM13_Init(); MX_ADC2_Init(); MX_TIM2_Init(); MX_ADC3_Init(); @@ -970,39 +965,6 @@ static void MX_TIM4_Init(void) } -/** - * @brief TIM13 Initialization Function - * @param None - * @retval None - */ -static void MX_TIM13_Init(void) -{ - - /* USER CODE BEGIN TIM13_Init 0 */ - #if STM32_PERIPHERAL_TIM13_ENABLE == 0 - return; - #endif - /* USER CODE END TIM13_Init 0 */ - - /* USER CODE BEGIN TIM13_Init 1 */ - - /* USER CODE END TIM13_Init 1 */ - htim13.Instance = TIM13; - htim13.Init.Prescaler = 400-1; - htim13.Init.CounterMode = TIM_COUNTERMODE_UP; - htim13.Init.Period = 40-1; - htim13.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; - htim13.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; - if (HAL_TIM_Base_Init(&htim13) != HAL_OK) - { - Error_Handler(); - } - /* USER CODE BEGIN TIM13_Init 2 */ - - /* USER CODE END TIM13_Init 2 */ - -} - /** * @brief TIM16 Initialization Function * @param None @@ -1477,10 +1439,6 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) HAL_IncTick(); } /* USER CODE BEGIN Callback 1 */ - else if (htim->Instance == lumexLcdTimInstance) - { - lumex_lcd_timer_interrupt(htim); - } else if (htim->Instance == TIM4) { // TIM4's counter is 16 bits, so it wraps every 65536 encoder pulses. Counting the wraps diff --git a/firmware/Core/Src/stm32h7xx_hal_msp.c b/firmware/Core/Src/stm32h7xx_hal_msp.c index 7355002..adf7ef1 100644 --- a/firmware/Core/Src/stm32h7xx_hal_msp.c +++ b/firmware/Core/Src/stm32h7xx_hal_msp.c @@ -558,20 +558,6 @@ void HAL_TIM_Base_MspInit(TIM_HandleTypeDef* htim_base) /* USER CODE END TIM4_MspInit 1 */ } - else if(htim_base->Instance==TIM13) - { - /* USER CODE BEGIN TIM13_MspInit 0 */ - - /* USER CODE END TIM13_MspInit 0 */ - /* Peripheral clock enable */ - __HAL_RCC_TIM13_CLK_ENABLE(); - /* TIM13 interrupt Init */ - HAL_NVIC_SetPriority(TIM8_UP_TIM13_IRQn, 5, 0); - HAL_NVIC_EnableIRQ(TIM8_UP_TIM13_IRQn); - /* USER CODE BEGIN TIM13_MspInit 1 */ - - /* USER CODE END TIM13_MspInit 1 */ - } else if(htim_base->Instance==TIM16) { /* USER CODE BEGIN TIM16_MspInit 0 */ @@ -661,20 +647,6 @@ void HAL_TIM_Base_MspDeInit(TIM_HandleTypeDef* htim_base) /* USER CODE END TIM4_MspDeInit 1 */ } - else if(htim_base->Instance==TIM13) - { - /* USER CODE BEGIN TIM13_MspDeInit 0 */ - - /* USER CODE END TIM13_MspDeInit 0 */ - /* Peripheral clock disable */ - __HAL_RCC_TIM13_CLK_DISABLE(); - - /* TIM13 interrupt DeInit */ - HAL_NVIC_DisableIRQ(TIM8_UP_TIM13_IRQn); - /* USER CODE BEGIN TIM13_MspDeInit 1 */ - - /* USER CODE END TIM13_MspDeInit 1 */ - } else if(htim_base->Instance==TIM16) { /* USER CODE BEGIN TIM16_MspDeInit 0 */ diff --git a/firmware/Core/Src/stm32h7xx_it.c b/firmware/Core/Src/stm32h7xx_it.c index 7a8c5e8..b15e3c8 100644 --- a/firmware/Core/Src/stm32h7xx_it.c +++ b/firmware/Core/Src/stm32h7xx_it.c @@ -58,7 +58,6 @@ extern PCD_HandleTypeDef hpcd_USB_OTG_FS; extern ADC_HandleTypeDef hadc2; extern TIM_HandleTypeDef htim4; -extern TIM_HandleTypeDef htim13; extern TIM_HandleTypeDef htim17; /* USER CODE BEGIN EV */ @@ -250,20 +249,6 @@ void EXTI15_10_IRQHandler(void) /* USER CODE END EXTI15_10_IRQn 1 */ } -/** - * @brief This function handles TIM8 update interrupt and TIM13 global interrupt. - */ -void TIM8_UP_TIM13_IRQHandler(void) -{ - /* USER CODE BEGIN TIM8_UP_TIM13_IRQn 0 */ - - /* USER CODE END TIM8_UP_TIM13_IRQn 0 */ - HAL_TIM_IRQHandler(&htim13); - /* USER CODE BEGIN TIM8_UP_TIM13_IRQn 1 */ - - /* USER CODE END TIM8_UP_TIM13_IRQn 1 */ -} - /** * @brief This function handles USB On The Go FS global interrupt. */ diff --git a/firmware/Drivers/Lumex/LumexPanel.cpp b/firmware/Drivers/Lumex/LumexPanel.cpp new file mode 100644 index 0000000..bb66a2c --- /dev/null +++ b/firmware/Drivers/Lumex/LumexPanel.cpp @@ -0,0 +1,161 @@ +#include "LumexPanel.hpp" + +#include "Config/config.h" + + +LumexPanel::LumexPanel(const Pins& pins, DelayUs delayUs, DelayMs delayMs) : + _pins(pins), + _delayUs(delayUs), + _delayMs(delayMs) +{} + + +// ---------------------------------------------------------------------------- transport + +void LumexPanel::Write(const Pin& pin, GPIO_PinState state) +{ + HAL_GPIO_WritePin(pin.port, pin.pin, state); +} + +// The whole wire protocol, in one function. +// +// 1. put the byte on D0..D7 +// 2. raise E +// 3. hold it for LUMEX_ENABLE_PULSE_US +// 4. drop E -- the panel latches on this edge +// +// RS is set by the caller and must already be stable, which it is: WriteCommand and WriteData +// set it before calling here, and the eight GPIO writes below take longer than the controller's +// setup time on their own. +// +// The hold doubles as the instruction-execution wait, which is why the next byte can follow +// immediately with no further delay. +bool LumexPanel::SendByte(uint8_t byte) +{ + for (uint8_t bit = 0; bit < 8; bit++) + { + Write(_pins.data[bit], static_cast((byte >> bit) & 0x01u)); + } + + Write(_pins.en, GPIO_PIN_SET); + _delayUs(LUMEX_ENABLE_PULSE_US); + Write(_pins.en, GPIO_PIN_RESET); + + return true; +} + +bool LumexPanel::WriteCommand(uint8_t command) +{ + Write(_pins.rs, GPIO_PIN_RESET); // RS low: this byte is an instruction + + return SendByte(command); +} + +bool LumexPanel::WriteData(uint8_t data) +{ + Write(_pins.rs, GPIO_PIN_SET); // RS high: this byte is a character + + return SendByte(data); +} + + +// ---------------------------------------------------------------------------- setup + +bool LumexPanel::Init() +{ + Write(_pins.en, GPIO_PIN_RESET); + + // The controller ignores everything until its internal power-on reset finishes. + _delayMs(LUMEX_POWER_ON_DELAY_MS); + + // Function set, three times. This is the documented way out of an unknown state: the + // controller may come up in 4-bit mode -- after a warm reset that did not cycle its power, + // say -- where a single 8-bit function set is read as half of a 4-bit pair. Repeating it + // lands the part in 8-bit mode from any starting state. + const uint8_t functionSet = + LUMEX_CMD_FUNCTION_SET | LUMEX_FUNCTION_8BIT | LUMEX_FUNCTION_2LINE; + + for (uint8_t attempt = 0; attempt < 3; attempt++) + { + if (!WriteCommand(functionSet)) + { + return false; + } + + _delayMs(LUMEX_SETTLE_DELAY_MS); + } + + if (!WriteCommand(LUMEX_CMD_DISPLAY_CONTROL | LUMEX_DISPLAY_ON)) + { + return false; + } + + _delayMs(LUMEX_SETTLE_DELAY_MS); + + return ClearDisplay(); +} + + +// ---------------------------------------------------------------------------- drawing + +bool LumexPanel::ClearDisplay() +{ + if (!WriteCommand(LUMEX_CMD_CLEAR)) + { + return false; + } + + // One of the two instructions the enable pulse's 40 us does not cover. + _delayMs(LUMEX_CLEAR_DELAY_MS); + + return true; +} + +// Moves the cursor. The two rows are not contiguous in DDRAM -- row 1 starts at 0x40 -- so the +// address is a base plus the column, not a linear offset. +bool LumexPanel::SetCursor(uint8_t row, uint8_t column) +{ + const uint8_t base = (row == 0) ? LUMEX_DDRAM_ROW0_BASE : LUMEX_DDRAM_ROW1_BASE; + + return WriteCommand(LUMEX_CMD_SET_DDRAM_ADDR | (uint8_t)(base + column)); +} + +bool LumexPanel::DisplayChar(uint8_t row, uint8_t column, uint8_t character) +{ + return SetCursor(row, column) && WriteData(character); +} + +bool LumexPanel::DisplayString(uint8_t row, uint8_t column, const char* string, size_t size) +{ + assert_param(row < LUMEX_LCD_ROWS); + + for (size_t i = 0; i < size; i++) + { + // Clamp instead of wrapping: drop any characters past the last column so an overflow + // fails visibly in one cell rather than corrupting the other row. + if (column >= LUMEX_LCD_COLUMNS) + { + break; + } + + // The cursor auto-increments, so a run could be written with one SetCursor and then + // characters. It is set per character anyway: this costs one extra byte per cell and + // removes any dependence on the entry mode the controller happens to be in. + if (!DisplayChar(row, column, (uint8_t)string[i])) + { + return false; + } + + column++; + } + + return true; +} + +bool LumexPanel::ToggleBlink(bool enable) +{ + const uint8_t control = LUMEX_CMD_DISPLAY_CONTROL | LUMEX_DISPLAY_ON + | (enable ? (LUMEX_CURSOR_ON | LUMEX_BLINK_ON) : 0u); + + return WriteCommand(control); +} diff --git a/firmware/Drivers/Lumex/LumexPanel.hpp b/firmware/Drivers/Lumex/LumexPanel.hpp new file mode 100644 index 0000000..b2372bf --- /dev/null +++ b/firmware/Drivers/Lumex/LumexPanel.hpp @@ -0,0 +1,88 @@ +#ifndef DRIVERS_LUMEX_LUMEXPANEL_HPP_ +#define DRIVERS_LUMEX_LUMEXPANEL_HPP_ + +// Lumex 16x2 character LCD (HD44780 controller), bit-banged over eight data lines plus RS and +// E. The counterpart to Drivers/ILI9341: the panel's own protocol and nothing above it. What +// to put on the screen, and which cells changed since last time, belong to +// Tasks/Display/Lumex/LumexLCD. +// +// Write-only. This board wires no R/W pin, so the busy flag can never be read and every wait +// is a fixed delay -- see LumexPanel_main.h for which and why. +// +// Same shape as the ILI9341 driver: a plain class, no base, no virtuals, board wiring and +// timing handed in rather than reached for. + +#include +#include +#include + +#include "LumexPanel_main.h" + +#include "main.h" + +class LumexPanel +{ +public: + // One GPIO. The eight data lines are not required to share a port, and on this board they + // happen to but nothing here relies on it. + struct Pin + { + GPIO_TypeDef* port; + uint16_t pin; + }; + + // Board wiring, D0 first. Passing this as one struct keeps a ten-argument constructor from + // existing. + struct Pins + { + Pin data[8]; + Pin rs; // low = instruction, high = character data + Pin en; // the strobe; the panel latches on its falling edge + }; + + // Two waits, because they are two different problems and one mechanism cannot do both. + // + // DelayUs is the ~40 us between bytes. osDelay cannot express it -- at a 1 kHz tick its + // floor is 1 ms, which would stretch every byte 25x and a full repaint from ~2.6 ms to + // ~64 ms -- so this one busy-waits, and 40 us of spinning is not worth an RTOS call. + // + // DelayMs is the millisecond-scale waits: power-on and the clear instruction. Those are + // long enough to be worth yielding for, so the task passes osDelay, exactly as it passes + // osDelay to the ILI9341 driver. + using DelayUs = void (*)(uint32_t microseconds); + using DelayMs = void (*)(uint32_t milliseconds); + + LumexPanel(const Pins& pins, DelayUs delayUs, DelayMs delayMs); + ~LumexPanel() = default; + + // Power-on reset sequence: 8-bit / 2-line / 5x8, display on, cursor and blink off, clear. + bool Init(); + + bool ClearDisplay(); + bool SetCursor(uint8_t row, uint8_t column); + + // Writes `size` characters from `column` along `row`, clipping at the last column rather + // than wrapping onto the other row -- an overlong field then fails visibly in its own cells + // instead of corrupting its neighbour. + bool DisplayString(uint8_t row, uint8_t column, const char* string, size_t size); + bool DisplayChar(uint8_t row, uint8_t column, uint8_t character); + + bool ToggleBlink(bool enable); + + // Raw instruction / character writes, public because the instruction set is the driver's + // whole surface and a caller may legitimately want one this class does not wrap. + bool WriteCommand(uint8_t command); + bool WriteData(uint8_t data); + +private: + // Puts a byte on the data lines and strobes E. Everything above goes through here. + bool SendByte(uint8_t byte); + + void Write(const Pin& pin, GPIO_PinState state); + + Pins _pins; + DelayUs _delayUs; + DelayMs _delayMs; +}; + +#endif /* DRIVERS_LUMEX_LUMEXPANEL_HPP_ */ diff --git a/firmware/Drivers/Lumex/LumexPanel_main.h b/firmware/Drivers/Lumex/LumexPanel_main.h new file mode 100644 index 0000000..64a60b0 --- /dev/null +++ b/firmware/Drivers/Lumex/LumexPanel_main.h @@ -0,0 +1,72 @@ +// HD44780 instruction set, as used by the Lumex character LCD. +// +// The controller has eight instructions, distinguished by the position of the highest set bit. +// Everything below that bit is options for that instruction, which is why the codes are built +// by OR-ing rather than listed as magic numbers: +// +// 0x01 clear +// 0x02 home +// 0x04 entry mode | cursor direction | display shift +// 0x08 display control | display on | cursor on | blink on +// 0x10 cursor/display shift +// 0x20 function set | bus width | line count | font +// 0x40 set CGRAM address (user-defined glyphs -- unused here) +// 0x80 set DDRAM address (this is how the cursor is moved) +// +// Sent with RS low. Anything sent with RS high is a character for the current DDRAM address. + +#ifndef DRIVERS_LUMEX_LUMEXPANEL_MAIN_H_ +#define DRIVERS_LUMEX_LUMEXPANEL_MAIN_H_ + +// --- Instructions +#define LUMEX_CMD_CLEAR 0x01u // blanks DDRAM and homes the cursor. Slow: ~1.5 ms +#define LUMEX_CMD_HOME 0x02u // cursor to 0,0 without clearing. Also slow +#define LUMEX_CMD_ENTRY_MODE 0x04u +#define LUMEX_CMD_DISPLAY_CONTROL 0x08u +#define LUMEX_CMD_CURSOR_SHIFT 0x10u +#define LUMEX_CMD_FUNCTION_SET 0x20u +#define LUMEX_CMD_SET_CGRAM_ADDR 0x40u +#define LUMEX_CMD_SET_DDRAM_ADDR 0x80u + +// --- Options for LUMEX_CMD_ENTRY_MODE +#define LUMEX_ENTRY_INCREMENT 0x02u // advance the cursor after each character +#define LUMEX_ENTRY_SHIFT_DISPLAY 0x01u // scroll the whole display instead of the cursor + +// --- Options for LUMEX_CMD_DISPLAY_CONTROL +#define LUMEX_DISPLAY_ON 0x04u +#define LUMEX_CURSOR_ON 0x02u // the underline +#define LUMEX_BLINK_ON 0x01u // the blinking block + +// --- Options for LUMEX_CMD_FUNCTION_SET +#define LUMEX_FUNCTION_8BIT 0x10u // all eight data lines wired (this board) +#define LUMEX_FUNCTION_2LINE 0x08u +#define LUMEX_FUNCTION_5X10_FONT 0x04u // clear for the usual 5x8 + +// --- DDRAM layout. +// +// The two rows are NOT contiguous: row 0 starts at 0x00 and row 1 at 0x40, with the gap +// unused on a 16-column part. So moving to (row, column) is SET_DDRAM_ADDR | base | column, +// which is all SetCursor does. +#define LUMEX_DDRAM_ROW0_BASE 0x00u +#define LUMEX_DDRAM_ROW1_BASE 0x40u + +// --- Timing, microseconds. +// +// This panel is write-only on this board: there is no R/W pin, so the busy flag can never be +// read and every wait has to be a fixed delay long enough for the worst case. +// +// ENABLE_PULSE_US doubles as the instruction-execution wait. The controller needs ~37 us to +// retire an ordinary instruction, far longer than the ~450 ns the enable pulse itself must be +// held, so holding E for 40 us covers both and lets the next byte follow immediately. +#define LUMEX_ENABLE_PULSE_US 40u + +// CLEAR and HOME are the two slow instructions, ~1.52 ms; 20 ms is generous and only ever +// costs on a screen change. +#define LUMEX_CLEAR_DELAY_MS 20u + +// Power-on: the controller wants >40 ms after Vcc rises before it will accept anything, then +// a settle between the instructions of the reset sequence. +#define LUMEX_POWER_ON_DELAY_MS 40u +#define LUMEX_SETTLE_DELAY_MS 5u + +#endif /* DRIVERS_LUMEX_LUMEXPANEL_MAIN_H_ */ diff --git a/firmware/Drivers/Lumex/README.md b/firmware/Drivers/Lumex/README.md new file mode 100644 index 0000000..fd62c0c --- /dev/null +++ b/firmware/Drivers/Lumex/README.md @@ -0,0 +1,250 @@ +--- +module: Lumex panel driver +summary: Driver for the Lumex 16x2 character LCD (HD44780) — the instruction set, the bit-banged wire protocol, and the timing. +code: + - Drivers/Lumex/LumexPanel.hpp + - Drivers/Lumex/LumexPanel.cpp + - Drivers/Lumex/LumexPanel_main.h +used_by: Display (Lumex variant) +related: [Display, ILI9341 driver, Config] +--- + +# Lumex — HD44780 character LCD driver + +Driver for the Lumex 16x2 character LCD, bit-banged over GPIO. The counterpart to +[[ILI9341 driver]] and deliberately the same shape: a plain class, no base, no virtuals, +board wiring and timing handed in rather than reached for. + +**The contrast with the ILI9341 is the point of having both.** That panel is a framebuffer — +you send pixels and it knows nothing about text. This one is the opposite: it contains a +character generator and 80 bytes of display RAM, so you send **`'A'`** and it draws an A. +There is no way to address a pixel at all. + +| | Lumex (HD44780) | ILI9341 | +|---|---|---| +| bus | 8 parallel data lines + RS + E | SPI + D/C | +| you send | characters and instructions | raw RGB565 pixels | +| fonts | in the panel's ROM | in our flash (`ILI9341_font.c`) | +| addressable unit | a character cell (32 of them) | a pixel (76,800 of them) | +| full repaint | ~2.6 ms | ~197 ms | + +--- + +## 1. The wire + +### Signals + +| signal | pins | what it does | +|---|---|---| +| `LUMEX_LCD_D0..D7` | PA0–PA7 | the byte being written | +| `LUMEX_LCD_RS` | PC5 | **register select**: low = instruction, high = character | +| `LUMEX_LCD_EN` | PC4 | **enable strobe**: the panel latches on its *falling* edge | +| R/W | *not wired* | tied low on the board — see below | + +**There is no R/W pin on this board.** The panel is write-only, which means the busy flag can +never be read, which means every wait has to be a fixed delay long enough for the worst case. +That single fact explains all the timing constants in `LumexPanel_main.h`. + +`RS` is the entire framing mechanism, exactly as `D/C` is on the ILI9341: a byte is an +instruction or a character purely because of what `RS` was when `E` fell. + +### The write cycle + +Every byte, without exception, goes through `SendByte`: + +``` +D0..D7 ──< byte >──────────────────────── +RS ──< 0 = instruction / 1 = data >── (set by the caller, already stable) +E _________/‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾\_________ + |<-- 40 us -->| ^ + latched here +``` + +```cpp +for (bit = 0; bit < 8; bit++) Write(data[bit], (byte >> bit) & 1); +Write(en, HIGH); +_delayUs(LUMEX_ENABLE_PULSE_US); // 40 us +Write(en, LOW); // panel latches on this edge +``` + +Data setup time needs no explicit wait: the eight `HAL_GPIO_WritePin` calls take longer on +their own than the controller requires. + +### Why the pulse is 40 µs and not 450 ns + +The datasheet's minimum enable-high time is ~450 ns. The 40 µs here is not that — **it is the +instruction-execution wait wearing the pulse's clothes.** The controller needs ~37 µs to retire +an ordinary instruction, and with no busy flag to poll there is nowhere else to put that wait. +Holding `E` high for 40 µs covers both, which is why the next byte can follow immediately with +no further delay. + +The two exceptions are `CLEAR` and `HOME`, which take ~1.52 ms. `ClearDisplay` waits +`LUMEX_CLEAR_DELAY_MS` (20 ms, generous) after them. + +--- + +## 2. The instruction set — what each command does + +The HD44780 has eight instructions, told apart by **the position of the highest set bit**. +Everything below that bit is options, which is why `LumexPanel_main.h` defines them as pieces +to OR together rather than as magic numbers. + +| code | instruction | what it does | +|---|---|---| +| `0x01` | `CLEAR` | blanks DDRAM to spaces, cursor home. **Slow (~1.5 ms)** | +| `0x02` | `HOME` | cursor to 0,0, contents untouched. Also slow | +| `0x04` | `ENTRY_MODE` | which way the cursor moves after a character, and whether the display scrolls | +| `0x08` | `DISPLAY_CONTROL` | display / cursor / blink on or off | +| `0x10` | `CURSOR_SHIFT` | nudge cursor or display without writing | +| `0x20` | `FUNCTION_SET` | bus width, line count, font | +| `0x40` | `SET_CGRAM_ADDR` | point at user-defined glyph RAM (unused here) | +| `0x80` | `SET_DDRAM_ADDR` | **point at a screen cell — this is how the cursor moves** | + +### Option bits + +``` +ENTRY_MODE | 0x02 INCREMENT advance cursor after each character + | 0x01 SHIFT_DISPLAY scroll the display instead of the cursor + +DISPLAY_CONTROL | 0x04 DISPLAY_ON + | 0x02 CURSOR_ON the underline + | 0x01 BLINK_ON the blinking block + +FUNCTION_SET | 0x10 8BIT all eight data lines wired (this board) + | 0x08 2LINE + | 0x04 5X10_FONT clear for the usual 5x8 +``` + +### The four codes this driver actually sends + +| built from | code | meaning | +|---|---|---| +| `FUNCTION_SET \| 8BIT \| 2LINE` | `0x38` | 8-bit bus, two lines, 5x8 font | +| `DISPLAY_CONTROL \| DISPLAY_ON` | `0x0C` | display on, cursor off, blink off | +| `DISPLAY_CONTROL \| DISPLAY_ON \| CURSOR_ON \| BLINK_ON` | `0x0F` | `ToggleBlink(true)` | +| `CLEAR` | `0x01` | blank the screen | +| `SET_DDRAM_ADDR \| base \| column` | `0x80`… | every cursor move | + +### DDRAM addressing — the one real trap + +**The two rows are not contiguous.** Row 0 starts at `0x00` and row 1 at `0x40`, with the +addresses in between unused on a 16-column part. So a cell is not a linear offset: + +``` +row 0, column 0 -> 0x80 | 0x00 | 0 = 0x80 +row 0, column 15 -> 0x80 | 0x00 | 15 = 0x8F +row 1, column 0 -> 0x80 | 0x40 | 0 = 0xC0 <- not 0x90 +row 1, column 15 -> 0x80 | 0x40 | 15 = 0xCF +``` + +That is all `SetCursor` does. Writing past column 15 does not wrap onto row 1 — it walks into +the unused gap and the characters vanish, which is why `DisplayString` clips at +`LUMEX_LCD_COLUMNS` instead. + +### Worked example — putting `Hi` at row 1, column 3 + +``` +RS=0 0xC3 SET_DDRAM_ADDR | 0x40 | 3 (E pulse, 40 us) +RS=1 0x48 'H' (E pulse, 40 us) +RS=0 0xC4 SET_DDRAM_ADDR | 0x40 | 4 (E pulse, 40 us) +RS=1 0x69 'i' (E pulse, 40 us) +``` + +Eight GPIO writes and one strobe per line above; ~160 µs for the pair. + +The cursor auto-increments, so `SetCursor` could be sent once and the characters streamed +after it. `DisplayString` re-addresses every cell anyway: it costs one extra byte per +character and removes any dependence on whatever entry mode the controller happens to be in. + +--- + +## 3. Power-on + +`Init()` runs the documented reset sequence: + +``` +E low, wait 40 ms controller ignores everything until its own reset finishes +FUNCTION_SET (0x38), 5 ms ) three times +FUNCTION_SET (0x38), 5 ms ) +FUNCTION_SET (0x38), 5 ms ) +DISPLAY_CONTROL (0x0C), 5 ms +CLEAR (0x01), 20 ms +``` + +**Why `FUNCTION_SET` three times** — this is not superstition. The controller may come up in +4-bit mode, for instance after a warm reset that never cycled its power. In 4-bit mode a single +8-bit write is read as *half* of a 4-bit pair, so one function set cannot be trusted to land. +Repeating it reaches 8-bit mode from any starting state. + +--- + +## 4. Timing + +At 40 µs per byte, and two bytes per character cell (address + character): + +| operation | bytes | time | +|---|---|---| +| one character cell | 2 | ~80 µs | +| one 5-cell field (e.g. the RPM readout) | 10 | ~400 µs | +| full 32-cell repaint | 64 | ~2.6 ms | +| `CLEAR` | 1 | ~20 ms | + +Two orders of magnitude faster to repaint than the ILI9341, because there are 32 cells rather +than 76,800 pixels. The layer above still diffs and writes only changed runs — see +[[Display]] — but here that is an economy rather than a necessity. + +--- + +## 5. The two delays, and why there is no timer + +`LumexPanel` takes **two** callbacks, because the waits are two different problems: + +```cpp +using DelayUs = void (*)(uint32_t microseconds); // the 40 us enable pulse +using DelayMs = void (*)(uint32_t milliseconds); // power-on, CLEAR +``` + +`DelayMs` is `osDelay` — yields, exactly as the ILI9341 driver's injected delay does. + +`DelayUs` **cannot** be `osDelay`: at `configTICK_RATE_HZ` 1000 its floor is 1 ms, which would +stretch every byte 25x and a full repaint from ~2.6 ms to ~64 ms. So it busy-waits on the +free-running microsecond timestamp counter — the same one every sensor sample is stamped from. + +**This used to be TIM13**, with an NVIC line, an ISR, and a `volatile bool` the task spun on: + +```cpp +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. Spinning 40 µs directly is the same behaviour with +none of the machinery, and TIM13 is now free for something else. + +The task's `PanelDelayUs` is bounded as well as timed. `get_timestamp()` reads a counter that +`SessionController::Init` starts, and `SESSION_CONTROLLER_TASK_ENABLE 0` is a legal +configuration — with the counter frozen, a purely time-based loop would never exit and would +wedge the display task. `LumexLCD::Init` starts the counter itself for that reason, and the +iteration bound is what turns a failure there into a mistimed panel rather than a hung task. + +--- + +## 6. API + +| method | notes | +|---|---| +| `Init()` | power-on reset sequence; leaves the display on and cleared | +| `ClearDisplay()` | `CLEAR` + the 20 ms wait | +| `SetCursor(row, col)` | `SET_DDRAM_ADDR` with the row base folded in | +| `DisplayChar(row, col, c)` | address then character | +| `DisplayString(row, col, s, n)` | `n` characters, clipped at the last column. **Not NUL-aware** — callers pass fixed-width fields | +| `ToggleBlink(on)` | `DISPLAY_CONTROL` with the cursor and blink bits | +| `WriteCommand(b)` / `WriteData(b)` | raw instruction / character, public because the instruction set is this driver's whole surface | + +Every method returns `bool`. `SendByte` cannot currently fail — there is nothing to fail +against on a write-only bus with no busy flag — but the signatures keep the shape the ILI9341 +driver has, and a caller must not treat `false` as fatal. See [[Display]] for why a failed +write must never take the board down. + +## Related +[[Display]] · [[ILI9341 driver]] · [[Config]] diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index b0784e6..0192a3d 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -47,10 +47,9 @@ Mcu.IP11=SYS Mcu.IP12=TIM1 Mcu.IP13=TIM2 Mcu.IP14=TIM4 -Mcu.IP15=TIM13 -Mcu.IP16=TIM16 -Mcu.IP17=USB_DEVICE -Mcu.IP18=USB_OTG_FS +Mcu.IP15=TIM16 +Mcu.IP16=USB_DEVICE +Mcu.IP17=USB_OTG_FS Mcu.IP2=CORTEX_M7 Mcu.IP3=FREERTOS Mcu.IP4=I2C4 @@ -59,7 +58,7 @@ Mcu.IP6=NVIC Mcu.IP7=RCC Mcu.IP8=SDMMC1 Mcu.IP9=SPI1 -Mcu.IPNb=19 +Mcu.IPNb=18 Mcu.Name=STM32H743IITx Mcu.Package=LQFP176 Mcu.Pin0=PE3 @@ -115,15 +114,14 @@ Mcu.Pin53=VP_SYS_VS_tim17 Mcu.Pin54=VP_TIM1_VS_ClockSourceINT Mcu.Pin55=VP_TIM2_VS_ClockSourceINT Mcu.Pin56=VP_TIM4_VS_ControllerModeClock -Mcu.Pin57=VP_TIM13_VS_ClockSourceINT -Mcu.Pin58=VP_TIM16_VS_ClockSourceINT -Mcu.Pin59=VP_USB_DEVICE_VS_USB_DEVICE_CDC_FS +Mcu.Pin57=VP_TIM16_VS_ClockSourceINT +Mcu.Pin58=VP_USB_DEVICE_VS_USB_DEVICE_CDC_FS +Mcu.Pin59=VP_MEMORYMAP_VS_MEMORYMAP Mcu.Pin6=PC15-OSC32_OUT (OSC32_OUT) -Mcu.Pin60=VP_MEMORYMAP_VS_MEMORYMAP Mcu.Pin7=PI11 Mcu.Pin8=PF6 Mcu.Pin9=PF7 -Mcu.PinsNb=61 +Mcu.PinsNb=60 Mcu.ThirdPartyNb=0 Mcu.UserConstants= Mcu.UserName=STM32H743IITx @@ -150,7 +148,6 @@ NVIC.SavedSystickIrqHandlerGenerated=true NVIC.SysTick_IRQn=true\:15\:0\:false\:false\:false\:true\:false\:true\:false NVIC.TIM17_IRQn=true\:15\:0\:false\:false\:true\:false\:false\:true\:true NVIC.TIM4_IRQn=true\:5\:0\:false\:false\:true\:true\:true\:true\:true -NVIC.TIM8_UP_TIM13_IRQn=true\:5\:0\:false\:false\:true\:true\:true\:true\:true NVIC.TimeBase=TIM17_IRQn NVIC.TimeBaseIP=TIM17 NVIC.UsageFault_IRQn=true\:0\:0\:false\:false\:true\:false\:false\:false\:false @@ -412,7 +409,7 @@ ProjectManager.ToolChainLocation= ProjectManager.UAScriptAfterPath= ProjectManager.UAScriptBeforePath= ProjectManager.UnderRoot=false -ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_TIM16_Init-TIM16-false-HAL-true,4-MX_SDMMC1_SD_Init-SDMMC1-false-HAL-true,5-MX_SPI1_Init-SPI1-false-HAL-true,6-MX_SPI2_Init-SPI2-false-HAL-true,7-MX_TIM1_Init-TIM1-false-HAL-true,8-MX_TIM13_Init-TIM13-false-HAL-true,9-MX_ADC2_Init-ADC2-false-HAL-true,10-MX_TIM2_Init-TIM2-false-HAL-true,11-MX_ADC3_Init-ADC3-false-HAL-true,12-MX_I2C4_Init-I2C4-false-HAL-true,13-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false,14-MX_TIM4_Init-TIM4-false-HAL-true,0-MX_CORTEX_M7_Init-CORTEX_M7-false-HAL-true +ProjectManager.functionlistsort=1-SystemClock_Config-RCC-false-HAL-false,2-MX_GPIO_Init-GPIO-false-HAL-true,3-MX_TIM16_Init-TIM16-false-HAL-true,4-MX_SDMMC1_SD_Init-SDMMC1-false-HAL-true,5-MX_SPI1_Init-SPI1-false-HAL-true,6-MX_SPI2_Init-SPI2-false-HAL-true,7-MX_TIM1_Init-TIM1-false-HAL-true,8-MX_ADC2_Init-ADC2-false-HAL-true,9-MX_TIM2_Init-TIM2-false-HAL-true,10-MX_ADC3_Init-ADC3-false-HAL-true,11-MX_I2C4_Init-I2C4-false-HAL-true,12-MX_USB_DEVICE_Init-USB_DEVICE-false-HAL-false,13-MX_TIM4_Init-TIM4-false-HAL-true,0-MX_CORTEX_M7_Init-CORTEX_M7-false-HAL-true RCC.ADCCLockSelection=RCC_ADCCLKSOURCE_PLL3 RCC.ADCFreq_Value=50000000 RCC.AHB12Freq_Value=200000000 @@ -535,9 +532,6 @@ SPI2.Direction=SPI_DIRECTION_2LINES SPI2.IPParameters=VirtualType,Mode,Direction,CalculateBaudRate SPI2.Mode=SPI_MODE_MASTER SPI2.VirtualType=VM_MASTER -TIM13.IPParameters=Prescaler,Period -TIM13.Period=40-1 -TIM13.Prescaler=400-1 TIM16.AutoReloadPreload=TIM_AUTORELOAD_PRELOAD_ENABLE TIM16.Channel=TIM_CHANNEL_1 TIM16.IPParameters=Channel,Prescaler,Period,AutoReloadPreload,Pulse,OCPolarity_1 @@ -560,8 +554,6 @@ VP_MEMORYMAP_VS_MEMORYMAP.Mode=CurAppReg VP_MEMORYMAP_VS_MEMORYMAP.Signal=MEMORYMAP_VS_MEMORYMAP VP_SYS_VS_tim17.Mode=TIM17 VP_SYS_VS_tim17.Signal=SYS_VS_tim17 -VP_TIM13_VS_ClockSourceINT.Mode=Enable_Timer -VP_TIM13_VS_ClockSourceINT.Signal=TIM13_VS_ClockSourceINT VP_TIM16_VS_ClockSourceINT.Mode=Enable_Timer VP_TIM16_VS_ClockSourceINT.Signal=TIM16_VS_ClockSourceINT VP_TIM1_VS_ClockSourceINT.Mode=Internal From ca52f4ebb05658e925d384566cad98d699c8a253 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 19:49:52 -0700 Subject: [PATCH 21/25] docs: split the Display README so each panel's docs sit beside its code 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 --- firmware/Core/README.md | 6 +- .../Core/Src/Tasks/Display/ILI9341/README.md | 178 +++++++++++++++ .../Core/Src/Tasks/Display/Lumex/README.md | 160 +++++++++++++ firmware/Core/Src/Tasks/Display/README.md | 216 +++++------------- 4 files changed, 396 insertions(+), 164 deletions(-) create mode 100644 firmware/Core/Src/Tasks/Display/ILI9341/README.md create mode 100644 firmware/Core/Src/Tasks/Display/Lumex/README.md diff --git a/firmware/Core/README.md b/firmware/Core/README.md index 17cbfa9..b2a809c 100644 --- a/firmware/Core/README.md +++ b/firmware/Core/README.md @@ -2,7 +2,7 @@ module: Core summary: Firmware application — FreeRTOS tasks, message passing, and STM32H743 hardware bring-up. entry: Core/Src/main.c -related: [MessagePassing, SessionController, USB, TaskMonitor, BPM, PID, LCD, Display, ForceSensor, OpticalSensor, Config, TimeKeeping] +related: [MessagePassing, SessionController, USB, TaskMonitor, BPM, PID, Display, ForceSensor, OpticalSensor, Config, TimeKeeping] --- # Core — application firmware @@ -21,7 +21,9 @@ never by calling into another task directly. | PID | `Core/Src/Tasks/PID/README.md` | Closed-loop brake control from encoder feedback | | ForceSensor | `Core/Src/Tasks/ForceSensor/README.md` | On-board force: i2c (ADS1115) and internal ADC | | OpticalSensor | `Core/Src/Tasks/OpticalSensor/README.md` | Angular velocity / acceleration from an optical encoder | -| Display | `Core/Src/Tasks/Display/README.md` | The display seam, plus both panels: `Lumex/` 16x2 character, `ILI9341/` 320x240 TFT | +| Display | `Core/Src/Tasks/Display/README.md` | The display seam: one message, either panel | +| Lumex display | `Core/Src/Tasks/Display/Lumex/README.md` | Rendering on the 16x2 character grid | +| ILI9341 display | `Core/Src/Tasks/Display/ILI9341/README.md` | Rendering on the 320x240 TFT | | USB | `Core/Src/Tasks/USB/README.md` | Streams data + errors to the PC over USB CDC | | TaskMonitor | `Core/Src/Tasks/TaskMonitor/README.md` | Per-task state and stack usage | | MessagePassing | `Core/Src/MessagePassing/README.md` | Queue helpers, circular buffers, USB wire protocol | diff --git a/firmware/Core/Src/Tasks/Display/ILI9341/README.md b/firmware/Core/Src/Tasks/Display/ILI9341/README.md new file mode 100644 index 0000000..6a2c081 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/ILI9341/README.md @@ -0,0 +1,178 @@ +--- +module: ILI9341 display +summary: Rendering for the ILI9341 320x240 TFT — the field model, the diff the panel's speed forces, and the session-detail readouts. +code: + - Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp + - Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp + - Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h + - Core/Src/Tasks/Display/ILI9341/ili9341_layout.c + - Core/Inc/Tasks/Display/ILI9341/ili9341_main.h +entry_point: ili9341_lcd_main() +related: [Display, ILI9341 driver] +--- + +# ILI9341 display — rendering on a 320x240 TFT + +Stages [4] to [6] of the path in [[Display]], for the TFT. The wire protocol underneath — +`CASET`/`PASET`/`RAMWR`, RGB565, glyph bitmaps — is [[ILI9341 driver]] in `Drivers/ILI9341`; +this file is what goes on the screen and when. + +Enabled with `ILI9341_LCD_TASK_ENABLE 1` in `Config/debug.h`. Landscape, 320x240; which way up +is `ILI9341_DISPLAY_ROTATION` in `config.h`, a property of the enclosure rather than of the +driver. + +--- + +## [4] Layout — `ili9341_layout.c` + +`ili9341_layout(state, detail, frame)` is **pure**: no HAL, no RTOS, no driver state. Screen +state in, up to `ILI9341_MAX_FIELDS` positioned runs of text out: + +```c +typedef struct { + uint16_t x, y; + uint16_t colour; + uint8_t size; // font scale; the cell is 6*size by 8*size pixels + uint8_t length; + char text[ILI9341_FIELD_TEXT_MAX]; +} ili9341_field; +``` + +`tests/ili9341_layout_tests.cpp` checks it on the build machine. + +**Nothing measures available space.** Every coordinate is a number typed into the layout: + +```c +add_field(out, 12, 40, SIZE_VALUE, COLOUR_VALUE, scratch); // x=12, y=40, size 5 +``` + +`centred()` does the arithmetic for centred rows and that is the extent of it. There is no +reflow and no auto-fit, because the driver below **clips rather than shrinks** — text that +does not fit is lost, not resized. Fitting is this file's job, done up front. + +### Two properties the driver depends on + +Asserted by tests rather than assumed, because both are invisible until they break: + +- **Positionally stable.** For a given screen: the same field count, order, positions and + widths whatever the values are. `AScreensFieldListIsPositionallyStable` lays out every screen + with zeroed and with extreme values and compares. This is what makes the index-wise diff in + [5] valid rather than accidental. +- **Fixed width, space-padded.** `"ENABLED "` is padded to eight so it covers `"DISABLED"` + exactly, and the detail readouts **clamp** (`A 99999`, `P999.99`, `T9999s`) so a large + reading cannot outgrow its slot and shift its neighbours. + +Both descend from the same fact as the character panel's rules: no read-modify-write, so a +field is erased only by being repainted, background and all. See [[Display]]. + +### Session screen field order + +The index-wise diff depends on this order, so it is written down: + +| # | field | position | size | colour | +|---|---|---|---|---| +| 0 | `SPEED` label | (12, 18) | 2 | grey | +| 1 | RPM value | (12, 40) | 5 | white | +| 2 | `rpm` unit | (172, 64) | 2 | grey | +| 3 | `FORCE` label | (12, 100) | 2 | grey | +| 4 | force value | (12, 122) | 5 | white | +| 5 | `N` unit | (200, 146) | 2 | grey | +| 6 | `A` angular acceleration | (12, 168) | 2 | grey | +| 7 | `P` peak force | (108, 168) | 2 | grey | +| 8 | `T` session elapsed | (216, 168) | 2 | grey | +| 9 | drive mode | (12, 196) | 3 | green / red / yellow | + +Fields 6–8 are the detail row — the readouts the character panel has no room for. The drive +mode is `PID ARMED` / `PID OFF` when the menu allows arming it, otherwise `BRAKE nnn%`; both +are ten characters so one paints over the other. + +Everything is drawn on `ILI9341_BLACK`; each field carries its own foreground. + +--- + +## [5] Render — `ILI9341Display::Render` + +```cpp +const bool screenChanged = !_hasRendered || state.screen != _lastScreen; +if (screenChanged && !Clear()) { _hasRendered = false; return false; } + +for (i...) { + if (!screenChanged && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) + continue; + DrawField(_frame.fields[i]); +} +``` + +**This is not an optimisation.** A full frame is 320x240x16bpp = 153,600 bytes, ~197 ms at +6.25 MHz — a repaint per sensor sample is impossible. One field is ~18 ms. Diffing is what +makes the panel usable at all. + +A change of `screen` clears and repaints in full: a different screen has a different set of +fields in different places, so there is nothing meaningful to diff against. + +Details that matter: + +- **Field equality includes colour.** The drive-mode field keeps its width but changes + green↔red; a text-only comparison would leave it the wrong colour. +- **On failure, `_hasRendered = false`.** The panel no longer matches the shadow copy, so the + diff would skip fields that were never actually painted. Forcing a full clear and repaint on + the next pass is what makes a glitch self-correcting. + +### The session detail readouts + +`ShowAngularAcceleration`, `ShowPeakForce` and `ShowSessionElapsed` **only record** into +`_detail`; drawing happens in `Render`. That is deliberate — everything on screen goes through +one layout pass and one diff, and these must not paint behind its back. + +The character panel discards the same three calls as no-ops. See [[Display]] for why they are +on the concept at all. + +--- + +## [6] The panel — the task's side of it + +`ili9341_lcd_main()` constructs the driver as a **function-local static**, not a local: + +```cpp +static ILI9341Display display; +``` + +The display task runs on 1 KB of stack and this object carries **two** `ili9341_frame`s — the +frame being built and the last one painted — which at `ILI9341_MAX_FIELDS` entries is several +hundred bytes. It belongs in `.bss`. `-fno-threadsafe-statics` is set and the function runs +exactly once, so there is no guard variable and no initialisation race. + +The class supplies the panel driver with the board wiring (`hspi1`, CS/DC/RST) and a +`DisplayDelayMs` that is `osDelay` — the driver takes the delay as a callback so it stays free +of `cmsis_os2.h`, and under an RTOS the right answer is to yield rather than spin through +`Init()`'s ~325 ms of waits. + +--- + +## Timing + +At 6.25 MHz, 16 bits per pixel: + +| operation | pixels | bytes | time | +|---|---|---|---| +| full screen | 76,800 | 153,600 | ~197 ms | +| one size-5 field, 6 chars | 7,200 | 14,400 | ~18 ms | +| one size-3 character | 432 | 864 | ~1.1 ms | + +Two orders of magnitude slower to repaint than the character panel, for 2,400 times as many +addressable dots. + +## Errors + +`ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → +`task_error_circular_buffer`, and from there to the host over USB. A transmit failure is +reported and survived, never fatal — see [[Display]]. + +## Key constants + +`ILI9341_MAX_FIELDS` / `ILI9341_FIELD_TEXT_MAX` / `ILI9341_LAYOUT_WIDTH` / +`ILI9341_LAYOUT_HEIGHT` (`ili9341_layout.h`) · `ILI9341_DISPLAY_ROTATION` (`config.h`) · +`ILI9341_MAX_TEXT_SIZE` (`Drivers/ILI9341/ILI9341_main.h`) + +## Related +[[Display]] · [[ILI9341 driver]] · [[SessionController]] diff --git a/firmware/Core/Src/Tasks/Display/Lumex/README.md b/firmware/Core/Src/Tasks/Display/Lumex/README.md new file mode 100644 index 0000000..e6f929d --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/Lumex/README.md @@ -0,0 +1,160 @@ +--- +module: Lumex display +summary: Rendering for the Lumex 16x2 character LCD — the six screens, the cell diff, and what it does with the readouts it cannot show. +code: + - Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp + - Core/Src/Tasks/Display/Lumex/LumexLCD.cpp + - Core/Inc/Tasks/Display/Lumex/lumex_layout.h + - Core/Src/Tasks/Display/Lumex/lumex_layout.c + - Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h +entry_point: lumex_lcd_main() +related: [Display, Lumex panel driver] +--- + +# Lumex display — rendering on a 2x16 character grid + +Stages [4] to [6] of the path in [[Display]], for the character panel. The HD44780 protocol +underneath — instruction codes, the RS/E write cycle, DDRAM addressing — is +[[Lumex panel driver]] in `Drivers/Lumex`; this file is what goes on the screen and when. + +Enabled with `LUMEX_LCD_TASK_ENABLE 1` in `Config/debug.h`. + +--- + +## [4] Layout — `lumex_layout.c` + +`lumex_render(state, frame)` is **pure**: no HAL, no RTOS, no driver state. Screen state in, a +full `lumex_frame` out — + +```c +typedef struct { char cells[LUMEX_LCD_ROWS][LUMEX_LCD_COLUMNS]; } lumex_frame; // 2 x 16 +``` + +— with **every one of the 32 cells written on every call**, blanks as spaces. Nothing is left +over from a previous frame, so the result depends only on `state`. That is what makes the diff +in [5] valid, and what lets `tests/lumex_layout_tests.cpp` pin all six screens cell-for-cell on +the build machine. + +### The six screens + +Written as whole 16-character rows in the tests, because the bugs worth catching are +off-by-one column errors that a field-level check steps straight over. + +``` +IDLE SD_LOGGING PID_ENABLE + DYNO SD LOGGING PID LOGGING + PRESS SELECT DISABLED DISABLED + +DESIRED_RPM DESIRED_RPM_EDIT SESSION + PID DES RPM PID DES RPM n: 1235 rpm + 5000 5000 100 F: 12.34 N B 45 +``` + +### Fixed-width fields + +Every value is written at a fixed width — `%5lu` for RPM, six characters for force, four for +the drive mode. Not cosmetic: [[Display]] explains that neither panel supports +read-modify-write, so a shorter value only erases a longer one if it repaints the same cells. +`"ENABLED "` is padded to eight so it covers `"DISABLED"` exactly. + +Values that could outgrow their field are clipped to it rather than allowed to shove their +neighbours. + +### Two fixed layout bugs, recorded so they are not reintroduced + +- **The force field used to strand two digits.** The row literal was + `"F: 0.00 N "`, carrying its own `0.00` at columns 6–9, while the force field wrote + columns 2–7. Columns 8–9 were never rewritten, so 12.34 N displayed as `12.3400`. The + literals now hold labels and units only, with each unit placed just past where its field + ends. +- **The RPM readout showed rad/s.** See Units in [[Display]]. + +--- + +## [5] Render — `LumexLCD::Render` + +Renders a frame, then writes only what differs from `_lastFrame`, in **runs of changed +cells**: + +```cpp +for each row: + walk the columns; where a cell differs, extend a run while cells keep differing; + _panel.DisplayString(row, start, &frame.cells[row][start], runLength); +``` + +Runs rather than whole rows because the common in-session update moves one field — five cells +out of thirty-two. + +**A change of `screen` forces a physical `ClearDisplay()`** before the repaint. That +reproduces the old FSM behaviour exactly: every `Show*Screen` used to clear, and the one +redraw that deliberately did not — a tick inside the RPM editor — is also the one that does +not change screen id. The rule is derived now rather than passed along as a flag. + +**On a failed write, `_hasRendered = false`.** The panel no longer matches the shadow copy, so +the diff would skip cells that were never actually written. Forcing a full clear and repaint +next pass is what makes a glitch self-correcting. + +### The three readouts it cannot show + +`DisplayDriver` requires `ShowAngularAcceleration`, `ShowPeakForce` and `ShowSessionElapsed` +of every panel. Thirty-two cells are fully spoken for by speed, force and drive mode, so this +one accepts and discards them: + +```cpp +bool ShowPeakForce(float newtons) { (void)newtons; return true; } +``` + +Inline and empty. They emit **no code at all** in this build — they do not even appear in its +`-fstack-usage` output — so the asymmetry costs nothing but the three lines. See [[Display]] +for why they sit on the concept rather than only on the TFT. + +--- + +## [6] The panel — the task's side of it + +`lumex_lcd_main()` constructs a `LumexLCD` on the task stack (it is small: one 32-byte frame +and a few flags), runs `Init()`, then hands off to `RunDisplayTask`. + +This class supplies the panel driver with two things it deliberately does not reach for +itself: + +- **the board wiring**, as a `LumexPanel::Pins` struct built from the `LUMEX_LCD_*` macros; +- **two delays**, because the waits are two different problems. `PanelDelayMs` is `osDelay`. + `PanelDelayUs` busy-waits on the free-running microsecond timestamp counter, because + `osDelay`'s floor at a 1 kHz tick is 1 ms and the enable pulse is 40 µs — rounding up would + stretch a full repaint from ~2.6 ms to ~64 ms. [[Lumex panel driver]] explains why that 40 µs + is not negotiable. + +`Init()` starts the timestamp counter itself. `SessionController` also starts it, and starting +twice is harmless — doing it here is what keeps this task working when +`SESSION_CONTROLLER_TASK_ENABLE` is 0, which would otherwise leave `PanelDelayUs` waiting on a +frozen counter forever. The busy-wait is bounded as well as timed for the same reason. + +--- + +## Timing + +Two bytes per character cell (address + character), ~40 µs each: + +| operation | time | +|---|---| +| one cell | ~80 µs | +| one 5-cell field | ~400 µs | +| full 32-cell repaint | ~2.6 ms | +| `CLEAR` (screen change) | ~20 ms | + +Fast enough that the run-diff is an economy rather than a necessity — unlike the TFT, where a +full repaint is ~197 ms and diffing is what makes the panel usable at all. + +## Errors + +`ERROR_LUMEX_LCD_TIMER_START_FAILURE` and `ERROR_DISPLAY_INIT_FAILURE` → +`task_error_circular_buffer`, and from there to the host over USB. + +## Key constants + +`LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` (`config.h`) — the character grid. +Timing constants are in `Drivers/Lumex/LumexPanel_main.h`. + +## Related +[[Display]] · [[Lumex panel driver]] · [[SessionController]] diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md index d02e76e..729f798 100644 --- a/firmware/Core/Src/Tasks/Display/README.md +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -1,35 +1,33 @@ --- module: Display -summary: The display task — how a measured value becomes lit pixels, and why the seam sits where it does. +summary: The display task — how a measured value reaches a panel, and why the seam sits where it does. Panel specifics live in the subdirectories. code: - Core/Inc/Tasks/Display/DisplayDriver.hpp - Core/Inc/Tasks/Display/display_common.h - Core/Src/Tasks/Display/display_common.c - - Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp - - Core/Src/Tasks/Display/Lumex/LumexLCD.cpp - - Core/Inc/Tasks/Display/Lumex/lumex_layout.h - - Core/Src/Tasks/Display/Lumex/lumex_layout.c - - Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h - - Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp - - Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp - - Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h - - Core/Src/Tasks/Display/ILI9341/ili9341_layout.c - - Core/Inc/Tasks/Display/ILI9341/ili9341_main.h entry_point: lumex_lcd_main() / ili9341_lcd_main() task_offset: TASK_OFFSET_DISPLAY consumes: [session_controller_to_display (SessionController)] produces: [task_error_circular_buffer] -related: [SessionController, MessagePassing, ILI9341 driver, Lumex panel driver] +related: [Lumex display, ILI9341 display, SessionController, MessagePassing] --- -# Display — from a measured value to lit pixels +# Display — the panel-independent half Two panels are supported and at most one is compiled in: a Lumex 16x2 character LCD and an ILI9341 320x240 TFT. Both read the same queue and the same message. +**This file is the part that is true of both.** How each one turns that message into something +on glass is in its own directory: + +| | rendering | panel protocol | +|---|---|---| +| Lumex | [[Lumex display]] (`Lumex/README.md`) | [[Lumex panel driver]] (`Drivers/Lumex`) | +| ILI9341 | [[ILI9341 display]] (`ILI9341/README.md`) | [[ILI9341 driver]] (`Drivers/ILI9341`) | + --- -## The whole path, end to end +## The path, end to end Follow a force reading from the sensor to the glass. Every stage discards work the next one does not need, and that is the point of having five of them. @@ -48,22 +46,24 @@ does not need, and that is the point of having five of them. RunDisplayTask() (DisplayDriver.hpp) [3] drain to newest | display.Render(state) v - ili9341_layout() / lumex_render() [4] state -> positions + text - | ili9341_frame { fields[], count } (pure function, host-tested) + layout [4] state -> what goes where + | a full frame, computed from the state alone v - ILI9341Display::Render() / LumexLCD::Render() [5] diff, then paint the movers - | _panel.DrawString(x, y, " 12.34", 6, WHITE, BLACK, 5) + Render() [5] diff, then paint what moved + | v - ILI9341 driver [6] pixels on the wire - CASET / PASET / RAMWR + 14,400 bytes of RGB565 + driver [6] bytes on the wire ``` +Stages [1] to [3] are shared and described below. [4] to [6] are per-panel — follow the +table above. + ### [1] The SessionController posts only what moved `UpdateMeasurementDisplay()` compares against `_prevForce` / `_prevAngularVelocity` and calls the FSM only when a reading actually changes. First filter. -### [2] The FSM sends meaning, never pixels +### [2] The FSM sends meaning, never drawing `PostDisplayState()` fills a `session_controller_to_display` — a `display_screen_id` plus **every value any screen shows** — and posts it: @@ -79,9 +79,9 @@ Two deliberate choices: - **Timeout 0.** A full queue drops the message rather than blocking. The display is the least important thing on this board and must never stall the task that drives the brake. -The FSM formats nothing. It used to: the layout lived here as -`WriteText(row, column, "n: 0 rpm ")` with hand-counted padding, which is a 16x2 -character grid baked into a state machine. +The FSM formats nothing. It used to: the layout lived there as +`WriteText(row, column, "n: 0 rpm ")` with hand-counted padding — a 16x2 character grid +baked into a state machine, which is exactly what made a second panel impossible. ### [3] The task loop takes only the newest message @@ -93,8 +93,7 @@ while (osMessageQueueGet(queue, &state, 0, 0) == osOK); ``` Each message is the whole screen state, so anything behind the newest is already stale. -Rendering them in turn would paint values nobody will ever see — and on a panel where a -field costs ~18 ms, that is the difference between keeping up and falling behind. +Rendering them in turn would paint values nobody will ever see. It is `[[noreturn]]`, and that is load-bearing: **a FreeRTOS task function that returns lands in `prvTaskExitError()`, which fails a `configASSERT`, disables interrupts and spins.** The @@ -102,73 +101,6 @@ whole rig dies — buttons, brake and all — with no LED and no fault report. A did `if (!Render(state)) return;`, and one failed SPI write took the entire dynamometer down. A failed render is now recorded in the error buffer and the loop carries on. -### [4] Layout: screen state in, positioned text out - -Each panel has its own layout function, and they are **pure**: no HAL, no RTOS, no driver -state, so `tests/ili9341_layout_tests.cpp` and `tests/lumex_layout_tests.cpp` check every -screen on the build machine. - -- `lumex_render()` → a full 2x16 `lumex_frame`, every cell written, blanks as spaces. -- `ili9341_layout()` → up to `ILI9341_MAX_FIELDS` `ili9341_field`s, each `{x, y, colour, - size, length, text}`. - -**Nothing measures available space.** Every coordinate is a number typed into the layout: - -```c -add_field(out, 12, 40, SIZE_VALUE, COLOUR_VALUE, scratch); // x=12, y=40, size 5 -``` - -`centred()` does the arithmetic for centred rows, and that is the extent of it. There is no -reflow and no auto-fit, because the driver below clips rather than shrinks. - -Two properties the driver **depends on**, asserted by tests rather than assumed: - -- **Positionally stable** — for a given screen, the same field count, order, positions and - widths whatever the values are. `AScreensFieldListIsPositionallyStable` renders every - screen with zeroed and with extreme values and compares. -- **Fixed width, space-padded** — `"ENABLED "` is padded to eight so it covers `"DISABLED"` - exactly, and the detail readouts **clamp** (`A 99999`, `P999.99`, `T9999s`) so a big - reading cannot outgrow its slot and shove its neighbours. - -Both exist for the same reason: there is no read-modify-write on either panel, so a field is -erased only by being repainted, background and all. A field that changed width would leave -the tail of the old one on screen forever. - -### [5] Diff, then paint only what moved - -`Render()` compares field *i* against field *i* of the last frame and redraws only the -movers. A change of `screen` clears and repaints in full. - -```cpp -const bool screenChanged = !_hasRendered || state.screen != _lastScreen; -if (screenChanged && !Clear()) { _hasRendered = false; return false; } - -for (i...) { - if (!screenChanged && ili9341_field_equal(&_frame.fields[i], &_lastFrame.fields[i])) - continue; - DrawField(_frame.fields[i]); -} -``` - -This is not an optimisation. A full ILI9341 frame is 153,600 bytes, ~197 ms at 6.25 MHz — a -repaint per sensor sample is impossible. One field is ~18 ms. - -Details that matter: - -- **Field equality includes colour.** The drive-mode field keeps its width but changes - green↔red; a text-only comparison would leave it the wrong colour. -- **On failure, `_hasRendered = false`.** The panel no longer matches the shadow copy, so the - diff would skip cells that were never actually painted. Forcing a full clear and repaint on - the next pass is what makes a glitch self-correcting. -- The Lumex diffs **runs of changed cells** rather than fields, for the same reason in a - different shape: the common in-session update moves five cells out of thirty-two. - -### [6] The driver puts pixels on the wire - -`DrawString` → `DrawChar` per cell → one address window + streamed RGB565. The panel knows -nothing about text; every glyph pixel is computed here. See [[ILI9341 driver]] for the wire -format, the glyph bitmaps and the timing. - --- ## The contract between the two halves @@ -207,19 +139,21 @@ pointer plus an indirect call per draw. The concept checks the same contract at and inlines through it. Each driver carries `static_assert(DisplayDriver<...>)`, so a signature mismatch is an error **at the driver**, not a link failure later. -The three `Show*` methods are the one deliberate asymmetry. They are extra in-session -readouts that need room a 2x16 grid does not have, so `LumexLCD` implements them as one-line -no-ops that discard the argument: +The three `Show*` methods are the one deliberate asymmetry — extra in-session readouts that +need room a 2x16 grid does not have. 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 quietly stopped implementing one is a compile error. Adding a fourth +readout is one real implementation and one `(void)` line. What each panel does with them is in +its own README. -```cpp -bool ShowPeakForce(float newtons) { (void)newtons; return true; } -``` +### The rule both panels obey -Inline and empty — they emit no code at all in the Lumex build; they do not even appear in -its `-fstack-usage` output. 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 quietly stopped implementing one is a compile error. Adding a fourth readout is one real -implementation and one `(void)` line. +**Neither panel supports read-modify-write.** Nothing can ask either one "what is currently +at this position", so a field is erased only by being *repainted*, background and all. + +Every layout rule in both subdirectories descends from that one fact: fixed-width fields, +space padding, clamped values, stable positions. They are the same requirement expressed in a +character grid and in pixels. --- @@ -238,8 +172,7 @@ should test. Both drivers are always compiled; `--gc-sections` drops the unused **Neither enabled is legal**, and useful: the display task parks and nothing drives SPI1, which is how the panel gets ruled in or out of a fault elsewhere on the board. It is not the same as switching to the Lumex, which would change two variables at once. Verify the -isolation with `arm-none-eabi-nm` — with no panel, `HAL_SPI_Transmit` is not linked in at -all. +isolation with `arm-none-eabi-nm` — with no panel, `HAL_SPI_Transmit` is not linked in at all. --- @@ -249,12 +182,15 @@ all. Tasks/Display/ 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 + Lumex/ the 16x2 character panel's rendering -> Lumex/README.md + ILI9341/ the 320x240 TFT's rendering -> ILI9341/README.md ``` -Neither panel subdirectory includes the other. The only shared code is the two files at this -level: +with the panel protocols one level out, in `Drivers/Lumex` and `Drivers/ILI9341`. Each +subdirectory renders; each driver talks to hardware. Neither panel subdirectory includes the +other. + +### The two shared helpers - `display_rpm_digit_increment()` — the cursor position the message carries means the same step size on any panel. @@ -265,65 +201,21 @@ level: --- -## Lumex rendering (`Lumex/`) - -- `lumex_lcd_main()` → construct, `Init()` (8-bit / 2-line / 5x8 font, display on, clear), - then `RunDisplayTask`. -- `lumex_render()` writes every one of the 32 cells; unset cells are spaces. -- `Render()` writes only the runs that differ from `_lastFrame`. -- A change of `screen` forces a physical `ClearDisplay()`. That reproduces the old behaviour - exactly — every `Show*Screen` used to clear, and the one redraw that deliberately did not - (a tick inside the RPM editor) is also the one that does not change screen id. -- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer - (`StartTimer`), microsecond-scale. Millisecond waits use `osDelay`, never `HAL_Delay` — - this runs in a task, and spinning there burns CPU other tasks want. -- Known artifact: none. The force field used to strand two digits of its own label; fixed by - making the row literal labels and units only. -- `ERROR_LUMEX_LCD_TIMER_START_FAILURE` → `task_error_circular_buffer`. - -## ILI9341 rendering (`ILI9341/`) - -- `ili9341_lcd_main()` constructs the driver as a **function-local static**: the display task - runs on 1 KB and this object carries two frames of layout state, so it belongs in `.bss`. - `-fno-threadsafe-statics` is set and the function runs once, so there is no guard variable. -- Session screen field order, which the index-wise diff depends on: - - | # | field | position | size | - |---|---|---|---| - | 0 | `SPEED` label | (12, 18) | 2 | - | 1 | RPM value | (12, 40) | 5 | - | 2 | `rpm` unit | (172, 64) | 2 | - | 3 | `FORCE` label | (12, 100) | 2 | - | 4 | force value | (12, 122) | 5 | - | 5 | `N` unit | (200, 146) | 2 | - | 6 | `A` angular acceleration | (12, 168) | 2 | - | 7 | `P` peak force | (108, 168) | 2 | - | 8 | `T` session elapsed | (216, 168) | 2 | - | 9 | drive mode | (12, 196) | 3 | - -- Everything is painted on `ILI9341_BLACK`; each field carries its own foreground. -- `ILI9341_DISPLAY_ROTATION` (`config.h`) says which way up the panel is fitted — a property - of the enclosure, not of the driver. -- `ERROR_DISPLAY_INIT_FAILURE`, `ERROR_DISPLAY_SPI_TRANSMIT_FAILURE` → - `task_error_circular_buffer`. - ---- - ## Units -`session_controller_to_display.rpm` is **RPM**. The optical encoder measures rad/s and the -FSM converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]), so a driver renders -the number it is given and no panel repeats the conversion. This was a real bug: the readout -was labelled "rpm" while showing rad/s, so 3000 RPM displayed as 314. +`session_controller_to_display.rpm` is **RPM**. The optical encoder measures rad/s and the FSM +converts once on the way in via `encoder_rpm()` ([[OpticalSensor]]), so a driver renders the +number it is given and no panel repeats the conversion. This was a real bug: the readout was +labelled "rpm" while showing rad/s, so 3000 RPM displayed as 314. `angular_acceleration` is rad/s², `force` and `peak_force` are newtons, `bpm_duty_cycle` is a 0–1 fraction, `session_seconds` is seconds. ## Key constants -`SYSCFG_LCD_TASK_OSDELAY` (sysconfig) · `LUMEX_LCD_ROWS` / `LUMEX_LCD_COLUMNS` / -`ILI9341_DISPLAY_ROTATION` (config.h) · `ILI9341_MAX_FIELDS` / `ILI9341_FIELD_TEXT_MAX` -(ili9341_layout.h) · `ILI9341_MAX_TEXT_SIZE` (ILI9341_main.h) +`SYSCFG_LCD_TASK_OSDELAY` (sysconfig) — the delay at the end of each pass of the task loop. +Panel-specific constants are listed in the panel READMEs. ## Related -[[ILI9341 driver]] · [[Lumex panel driver]] · [[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] +[[Lumex display]] · [[ILI9341 display]] · [[Lumex panel driver]] · [[ILI9341 driver]] · +[[SessionController]] · [[OpticalSensor]] · [[MessagePassing]] From 91a2d0785cc315aa983fbb568a8ca420fa6b99a0 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 20:04:35 -0700 Subject: [PATCH 22/25] lumex fix --- firmware/Core/Inc/Config/debug.h | 4 ++-- firmware/Core/Inc/TimeKeeping/timestamps.h | 20 ++++++++++++++++++- .../Core/Src/Tasks/Display/Lumex/LumexLCD.cpp | 17 +++++++++++++--- .../Core/Src/Tasks/Display/Lumex/README.md | 17 ++++++++++------ firmware/Core/Src/TimeKeeping/README.md | 14 ++++++++++++- firmware/Drivers/Lumex/LumexPanel_main.h | 12 ++++++++++- firmware/Drivers/Lumex/README.md | 9 +++++++++ 7 files changed, 79 insertions(+), 14 deletions(-) diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index b9fc04b..61e80a3 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -57,8 +57,8 @@ // bit the encoder ISR samples came off a floating pin. It read correctly while SPI1 was idle // and randomly once the panel drove it, which is why the brake random-walked to 0% only on this // branch and only while the encoder was turning. Fixed in the .ioc; the panel is back on. -#define LUMEX_LCD_TASK_ENABLE 0 -#define ILI9341_LCD_TASK_ENABLE 1 +#define LUMEX_LCD_TASK_ENABLE 1 +#define ILI9341_LCD_TASK_ENABLE 0 #if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) > 1 #error "At most one display driver may be enabled: set at most one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1." diff --git a/firmware/Core/Inc/TimeKeeping/timestamps.h b/firmware/Core/Inc/TimeKeeping/timestamps.h index 428a3b7..58c8fec 100644 --- a/firmware/Core/Inc/TimeKeeping/timestamps.h +++ b/firmware/Core/Inc/TimeKeeping/timestamps.h @@ -30,9 +30,27 @@ inline uint32_t get_timestamp() return __HAL_TIM_GET_COUNTER(timestampTimer); } +// Idempotent, which HAL_TIM_Base_Start underneath it is not: that returns HAL_ERROR whenever the +// handle is not in READY state, and a timer someone has already started is BUSY. So a second +// caller is told "failed" when the truth is "already running, nothing to do". +// +// Two callers legitimately need this counter: SessionController stamps samples from it, and the +// Lumex display measures its enable pulse against it. Neither can know which initialises first, +// and here SessionController does -- it runs at osPriorityHigh against the display's +// osPriorityBelowNormal -- so the display got HAL_ERROR, reported ERROR_DISPLAY_INIT_FAILURE and +// suspended itself. A blank panel, from a timer that was running perfectly. +// +// The fix is to ask the hardware whether the counter is running rather than asking the HAL +// whether it was this caller who started it. A handle that was never initialised still fails +// honestly: CEN stays clear, and HAL_ERROR comes back. inline HAL_StatusTypeDef start_timestamp_timer() { - return HAL_TIM_Base_Start(timestampTimer); + if ((timestampTimer->Instance->CR1 & TIM_CR1_CEN) == 0U) + { + (void)HAL_TIM_Base_Start(timestampTimer); + } + + return ((timestampTimer->Instance->CR1 & TIM_CR1_CEN) != 0U) ? HAL_OK : HAL_ERROR; } uint32_t get_timestamp_scale(void); diff --git a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp index eb1f6a4..c6dbe9d 100644 --- a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp @@ -21,6 +21,9 @@ extern task_error_data task_error_circular_buffer[TASK_ERROR_CIRCULAR_BUFFER_SIZ // timer and saved no CPU, because the spin was there either way. Spinning 40 us directly is the // same behaviour with none of the machinery, and TIM13 is now free. // +// Note that TIM13 counted at 500 kHz, so the pulse it produced was ~82 us rather than the 40 us +// its argument implied. See LUMEX_ENABLE_PULSE_US before adjusting this. +// // osDelay cannot do this job: at a 1 kHz tick its floor is 1 ms, which would stretch every byte // 25x and a full 32-cell repaint from ~2.6 ms to ~64 ms. // @@ -33,7 +36,10 @@ static void PanelDelayUs(uint32_t microseconds) { const uint32_t start = get_timestamp(); - // Generous: at 1 us per tick this is ~40x the longest wait ever asked for. + // Counts iterations, not microseconds -- it only exists for the case where the counter is + // frozen and the timed condition can never come true. Each pass is a volatile read of CNT + // and a compare, so a 40 us wait needs a couple of thousand of these and the limit is far + // enough above that to never end a healthy wait early. uint32_t guard = 0; const uint32_t guardLimit = 100000u; @@ -83,8 +89,13 @@ LumexLCD::LumexLCD() : bool LumexLCD::Init() { // PanelDelayUs measures against this counter, so it has to be running before the panel is - // touched. SessionController starts it too and starting twice is harmless -- doing it here - // as well is what keeps this task working when the session controller is compiled out. + // touched. SessionController starts it too, and here it always gets there first -- it runs + // at osPriorityHigh against this task's osPriorityBelowNormal. Doing it here as well is what + // keeps this task working when the session controller is compiled out. + // + // So this call is normally the *second* one, which is exactly what start_timestamp_timer was + // changed to tolerate: HAL_TIM_Base_Start underneath it reports an already-running timer as + // HAL_ERROR, and taking that at face value suspended this task and blanked the panel. if (start_timestamp_timer() != HAL_OK) { task_error_data error_data = PopulateTaskErrorDataStruct( diff --git a/firmware/Core/Src/Tasks/Display/Lumex/README.md b/firmware/Core/Src/Tasks/Display/Lumex/README.md index e6f929d..272a459 100644 --- a/firmware/Core/Src/Tasks/Display/Lumex/README.md +++ b/firmware/Core/Src/Tasks/Display/Lumex/README.md @@ -8,7 +8,7 @@ code: - Core/Src/Tasks/Display/Lumex/lumex_layout.c - Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h entry_point: lumex_lcd_main() -related: [Display, Lumex panel driver] +related: [Display, Lumex panel driver, TimeKeeping] --- # Lumex display — rendering on a 2x16 character grid @@ -125,10 +125,15 @@ itself: stretch a full repaint from ~2.6 ms to ~64 ms. [[Lumex panel driver]] explains why that 40 µs is not negotiable. -`Init()` starts the timestamp counter itself. `SessionController` also starts it, and starting -twice is harmless — doing it here is what keeps this task working when -`SESSION_CONTROLLER_TASK_ENABLE` is 0, which would otherwise leave `PanelDelayUs` waiting on a -frozen counter forever. The busy-wait is bounded as well as timed for the same reason. +`Init()` starts the timestamp counter itself, because `SESSION_CONTROLLER_TASK_ENABLE 0` is a +legal configuration and would otherwise leave `PanelDelayUs` waiting on a frozen counter forever. +The busy-wait is bounded as well as timed for the same reason. + +In every normal build this call is the **second** one — `SessionController` runs at +`osPriorityHigh` against this task's `osPriorityBelowNormal`, so it always gets there first. That +was a real bug for one commit: `HAL_TIM_Base_Start` reports an already-running timer as +`HAL_ERROR`, this `Init()` treated it as fatal, and the task suspended itself with the panel +blank. `start_timestamp_timer()` is now idempotent — see [[TimeKeeping]]. --- @@ -157,4 +162,4 @@ full repaint is ~197 ms and diffing is what makes the panel usable at all. Timing constants are in `Drivers/Lumex/LumexPanel_main.h`. ## Related -[[Display]] · [[Lumex panel driver]] · [[SessionController]] +[[Display]] · [[Lumex panel driver]] · [[SessionController]] · [[TimeKeeping]] diff --git a/firmware/Core/Src/TimeKeeping/README.md b/firmware/Core/Src/TimeKeeping/README.md index 8e77e5c..e89cd89 100644 --- a/firmware/Core/Src/TimeKeeping/README.md +++ b/firmware/Core/Src/TimeKeeping/README.md @@ -12,7 +12,11 @@ Provides the monotonic timestamp stamped onto every sensor / error / monitor rec ## API (timestamps.h) - `uint32_t get_timestamp()` — current tick, `0 .. UINT32_MAX`. -- `HAL_StatusTypeDef start_timestamp_timer()` — starts the hardware timer; called once by [[SessionController]] in `Init()`. +- `HAL_StatusTypeDef start_timestamp_timer()` — starts the hardware timer. **Idempotent**: safe to + call from more than one task's `Init()`, and returns `HAL_OK` if the counter is already running. + Called by [[SessionController]] and, when that panel is fitted, by [[Lumex display]] — whose + enable pulse is measured against this counter, so it cannot assume the session controller is + compiled in. See Behavior below for why the idempotence is not free. - `get_timestamp_scale()`, `get_apb1_timer_clock()`, `get_apb2_timer_clock()`, `get_timer_clock(TIMx)` — clock-rate helpers (used by OpticalSensor to convert ticks → seconds). ## Behavior @@ -30,6 +34,14 @@ Provides the monotonic timestamp stamped onto every sensor / error / monitor rec Anything new that measures across timestamps must do the same; a signed difference is the bug this note exists to prevent. +- **Starting twice:** `start_timestamp_timer()` checks `TIM2->CR1.CEN` and only calls + `HAL_TIM_Base_Start` if the counter is stopped. That wrapper is not decoration. The HAL call + returns `HAL_ERROR` whenever the handle is not in `READY` state, and a timer someone has already + started is `BUSY` — so it reports "already running" and "failed to start" with the same value. + The Lumex display took that at face value, logged `ERROR_DISPLAY_INIT_FAILURE` and suspended its + own task, leaving a blank panel driven by a timer that was working perfectly. Asking the hardware + whether the counter is running answers the question the callers are actually asking. A handle + that was never initialised still fails: `CEN` stays clear and `HAL_ERROR` comes back. - Clock-rate helpers may be inaccurate if the RCC tree gets more complex; revisit if clocks change. ## Related diff --git a/firmware/Drivers/Lumex/LumexPanel_main.h b/firmware/Drivers/Lumex/LumexPanel_main.h index 64a60b0..2e9fafe 100644 --- a/firmware/Drivers/Lumex/LumexPanel_main.h +++ b/firmware/Drivers/Lumex/LumexPanel_main.h @@ -57,7 +57,17 @@ // // ENABLE_PULSE_US doubles as the instruction-execution wait. The controller needs ~37 us to // retire an ordinary instruction, far longer than the ~450 ns the enable pulse itself must be -// held, so holding E for 40 us covers both and lets the next byte follow immediately. +// held. Nothing waits after E falls, so it is this hold that spaces one latch from the next and +// it has to cover the execution time on its own. 40 us covers it and lets the next byte follow +// immediately. +// +// If the panel is ever intermittent -- dropped or garbled characters, worse when warm -- raise +// this first. When TIM13 timed the pulse it counted at 500 kHz (200 MHz APB1 timer clock / 400), +// so the ARR of 40 it was handed was 41 ticks of 2 us: the wire saw ~82 us, and the 40 in that +// code was ticks wearing the units of microseconds. This is the value the datasheet asks for and +// the value the code always appeared to use, but it is half of what the board actually ran on +// for years, and the ~37 us it has to cover moves 20-30% with the controller's internal RC +// oscillator. With no R/W pin there is no busy flag to ask whether it was long enough. #define LUMEX_ENABLE_PULSE_US 40u // CLEAR and HOME are the two slow instructions, ~1.52 ms; 20 ms is generous and only ever diff --git a/firmware/Drivers/Lumex/README.md b/firmware/Drivers/Lumex/README.md index fd62c0c..6a532f3 100644 --- a/firmware/Drivers/Lumex/README.md +++ b/firmware/Drivers/Lumex/README.md @@ -81,6 +81,15 @@ no further delay. The two exceptions are `CLEAR` and `HOME`, which take ~1.52 ms. `ClearDisplay` waits `LUMEX_CLEAR_DELAY_MS` (20 ms, generous) after them. +**If the panel is ever intermittent, raise this first.** Dropped or garbled characters, worse +when the board is warm, is what too short a hold looks like. When TIM13 timed this pulse it +counted at 500 kHz — 200 MHz APB1 timer clock over a prescaler of 400 — so the `StartTimer(40)` +in that code meant 41 ticks of 2 µs and the wire saw **~82 µs**. The `40` was ticks wearing the +units of microseconds. 40 µs is what the datasheet asks for and what that code always appeared +to be doing, but it is half of what the board actually ran on for years, and the ~37 µs it has +to cover moves 20–30% with the controller's internal RC oscillator. With no R/W pin there is no +busy flag to ask whether it was long enough. + --- ## 2. The instruction set — what each command does From 7104c94663c4629ad8ade449580c1e224ffd5ceb Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 20:35:34 -0700 Subject: [PATCH 23/25] timekeeping: start the timestamp counter in main(), not in a task's Init 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 --- firmware/Core/Inc/TimeKeeping/timestamps.h | 28 ++++++++-------- .../Core/Src/Tasks/Display/Lumex/LumexLCD.cpp | 16 ++++----- .../Core/Src/Tasks/Display/Lumex/README.md | 19 ++++++----- .../Src/Tasks/SessionController/README.md | 5 +-- .../SessionController/SessionController.cpp | 8 ++--- firmware/Core/Src/TimeKeeping/README.md | 33 +++++++++++-------- firmware/Core/Src/main.c | 12 +++++++ firmware/Drivers/Lumex/README.md | 12 +++---- 8 files changed, 73 insertions(+), 60 deletions(-) diff --git a/firmware/Core/Inc/TimeKeeping/timestamps.h b/firmware/Core/Inc/TimeKeeping/timestamps.h index 58c8fec..96392f7 100644 --- a/firmware/Core/Inc/TimeKeeping/timestamps.h +++ b/firmware/Core/Inc/TimeKeeping/timestamps.h @@ -25,25 +25,25 @@ extern TIM_HandleTypeDef* timestampTimer; // from the live clock tree, so it survives a CubeMX clock or prescaler change that this comment // would not. -inline uint32_t get_timestamp() +// static inline, not plain inline, so this is callable from C and C++ alike at any optimisation +// level. 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 the definition is emitted -- +// and surfaced the moment main.c called one of these in a Debug build. +static inline uint32_t get_timestamp(void) { return __HAL_TIM_GET_COUNTER(timestampTimer); } -// Idempotent, which HAL_TIM_Base_Start underneath it is not: that returns HAL_ERROR whenever the -// handle is not in READY state, and a timer someone has already started is BUSY. So a second -// caller is told "failed" when the truth is "already running, nothing to do". +// Called once from main(), before the scheduler starts: this counter is shared by every task that +// stamps a sample or times a wait, and no task owns it. Tasks should assume it is already running. // -// Two callers legitimately need this counter: SessionController stamps samples from it, and the -// Lumex display measures its enable pulse against it. Neither can know which initialises first, -// and here SessionController does -- it runs at osPriorityHigh against the display's -// osPriorityBelowNormal -- so the display got HAL_ERROR, reported ERROR_DISPLAY_INIT_FAILURE and -// suspended itself. A blank panel, from a timer that was running perfectly. -// -// The fix is to ask the hardware whether the counter is running rather than asking the HAL -// whether it was this caller who started it. A handle that was never initialised still fails -// honestly: CEN stays clear, and HAL_ERROR comes back. -inline HAL_StatusTypeDef start_timestamp_timer() +// Idempotent anyway, which HAL_TIM_Base_Start underneath it is not -- that returns HAL_ERROR +// whenever the handle is not in READY state, and a timer someone has already started is BUSY, so +// it answers "already running" and "failed to start" with the same value. Asking the hardware +// whether the counter is running answers the question callers are actually asking. A handle that +// was never initialised still fails honestly: CEN stays clear, and HAL_ERROR comes back. +static inline HAL_StatusTypeDef start_timestamp_timer(void) { if ((timestampTimer->Instance->CR1 & TIM_CR1_CEN) == 0U) { diff --git a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp index c6dbe9d..576a52b 100644 --- a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp +++ b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp @@ -88,15 +88,11 @@ LumexLCD::LumexLCD() : bool LumexLCD::Init() { - // PanelDelayUs measures against this counter, so it has to be running before the panel is - // touched. SessionController starts it too, and here it always gets there first -- it runs - // at osPriorityHigh against this task's osPriorityBelowNormal. Doing it here as well is what - // keeps this task working when the session controller is compiled out. - // - // So this call is normally the *second* one, which is exactly what start_timestamp_timer was - // changed to tolerate: HAL_TIM_Base_Start underneath it reports an already-running timer as - // HAL_ERROR, and taking that at face value suspended this task and blanked the panel. - if (start_timestamp_timer() != HAL_OK) + // PanelDelayUs measures the enable pulse against the timestamp counter, which is already + // running: main() starts it before the scheduler, because SessionController reads it too and + // neither task owns it. This used to start it here, which meant reporting an already-started + // timer as an init failure and suspending the task -- a blank panel driven by a working timer. + if (!_panel.Init()) { task_error_data error_data = PopulateTaskErrorDataStruct( get_timestamp(), @@ -108,7 +104,7 @@ bool LumexLCD::Init() return false; } - return _panel.Init(); + return true; } bool LumexLCD::Clear() diff --git a/firmware/Core/Src/Tasks/Display/Lumex/README.md b/firmware/Core/Src/Tasks/Display/Lumex/README.md index 272a459..1c39189 100644 --- a/firmware/Core/Src/Tasks/Display/Lumex/README.md +++ b/firmware/Core/Src/Tasks/Display/Lumex/README.md @@ -125,15 +125,16 @@ itself: stretch a full repaint from ~2.6 ms to ~64 ms. [[Lumex panel driver]] explains why that 40 µs is not negotiable. -`Init()` starts the timestamp counter itself, because `SESSION_CONTROLLER_TASK_ENABLE 0` is a -legal configuration and would otherwise leave `PanelDelayUs` waiting on a frozen counter forever. -The busy-wait is bounded as well as timed for the same reason. - -In every normal build this call is the **second** one — `SessionController` runs at -`osPriorityHigh` against this task's `osPriorityBelowNormal`, so it always gets there first. That -was a real bug for one commit: `HAL_TIM_Base_Start` reports an already-running timer as -`HAL_ERROR`, this `Init()` treated it as fatal, and the task suspended itself with the panel -blank. `start_timestamp_timer()` is now idempotent — see [[TimeKeeping]]. +`PanelDelayUs` measures against the timestamp counter, which is **already running** by the time +this task exists: `main()` starts it before the scheduler, because [[SessionController]] reads it +too and neither task owns it. That also means the panel works with +`SESSION_CONTROLLER_TASK_ENABLE 0`, which is a legal configuration. + +This `Init()` used to start the counter itself, and it was a bug for one commit: +`HAL_TIM_Base_Start` reports an already-running timer as `HAL_ERROR`, `SessionController` always +got there first at `osPriorityHigh`, and this task took the error at face value and suspended +itself with the panel blank. See [[TimeKeeping]]. The busy-wait is bounded as well as timed, which +is the remaining defence if the counter is ever stopped. --- diff --git a/firmware/Core/Src/Tasks/SessionController/README.md b/firmware/Core/Src/Tasks/SessionController/README.md index 869e12e..5cd89c4 100644 --- a/firmware/Core/Src/Tasks/SessionController/README.md +++ b/firmware/Core/Src/Tasks/SessionController/README.md @@ -18,8 +18,9 @@ related: [BPM, PID, USB, LCD, ForceSensor, OpticalSensor, TimeKeeping] # SessionController — orchestrator -The top-level task. Starts the timestamp timer, validates every queue handle, runs the -UI/FSM, dispatches commands to all other tasks, and drives the LCD readout. +The top-level task. Validates every queue handle, runs the UI/FSM, dispatches commands to all +other tasks, and drives the LCD readout. It stamps samples from the [[TimeKeeping]] counter but +no longer starts it — that is shared with the display task and started in `main()`. ## Sub-modules - **input_manager_interrupts** (C) — button + rotary-encoder GPIO ISRs write `button_press_data` diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index bf0be1b..0f7ddda 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -78,14 +78,10 @@ bool SessionController::CheckTaskQueuesValid() return true; } +// The timestamp counter this task stamps every sample from is started in main(), before the +// scheduler runs -- it is shared with the display task and owned by neither, so neither starts it. bool SessionController::Init(void) { - if (start_timestamp_timer() != HAL_OK) - { - ReportError(ERROR_SESSION_CONTROLLER_TIMESTAMP_TIMER_START_FAILURE); - return false; - } - return CheckTaskQueuesValid(); } diff --git a/firmware/Core/Src/TimeKeeping/README.md b/firmware/Core/Src/TimeKeeping/README.md index e89cd89..7df85d0 100644 --- a/firmware/Core/Src/TimeKeeping/README.md +++ b/firmware/Core/Src/TimeKeeping/README.md @@ -12,11 +12,9 @@ Provides the monotonic timestamp stamped onto every sensor / error / monitor rec ## API (timestamps.h) - `uint32_t get_timestamp()` — current tick, `0 .. UINT32_MAX`. -- `HAL_StatusTypeDef start_timestamp_timer()` — starts the hardware timer. **Idempotent**: safe to - call from more than one task's `Init()`, and returns `HAL_OK` if the counter is already running. - Called by [[SessionController]] and, when that panel is fitted, by [[Lumex display]] — whose - enable pulse is measured against this counter, so it cannot assume the session controller is - compiled in. See Behavior below for why the idempotence is not free. +- `HAL_StatusTypeDef start_timestamp_timer()` — starts the hardware timer. **Called once from + `main()`**, in `USER CODE BEGIN 2`, before the scheduler starts. Tasks should assume the counter + is already running and must not start it themselves. Idempotent regardless — see Behavior. - `get_timestamp_scale()`, `get_apb1_timer_clock()`, `get_apb2_timer_clock()`, `get_timer_clock(TIMx)` — clock-rate helpers (used by OpticalSensor to convert ticks → seconds). ## Behavior @@ -34,14 +32,23 @@ Provides the monotonic timestamp stamped onto every sensor / error / monitor rec Anything new that measures across timestamps must do the same; a signed difference is the bug this note exists to prevent. -- **Starting twice:** `start_timestamp_timer()` checks `TIM2->CR1.CEN` and only calls - `HAL_TIM_Base_Start` if the counter is stopped. That wrapper is not decoration. The HAL call - returns `HAL_ERROR` whenever the handle is not in `READY` state, and a timer someone has already - started is `BUSY` — so it reports "already running" and "failed to start" with the same value. - The Lumex display took that at face value, logged `ERROR_DISPLAY_INIT_FAILURE` and suspended its - own task, leaving a blank panel driven by a timer that was working perfectly. Asking the hardware - whether the counter is running answers the question the callers are actually asking. A handle - that was never initialised still fails: `CEN` stays clear and `HAL_ERROR` comes back. +- **Who starts it:** `main()`, once, before any task exists. The counter is shared by + [[SessionController]] (which stamps every sample) and [[Lumex display]] (whose enable pulse is + timed against it), and owned by neither — so neither starts it. + + It used to be started by whichever task's `Init()` ran first, and that was a bug rather than a + tidy piece of laziness. `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 won; the display read the `HAL_ERROR`, + logged `ERROR_DISPLAY_INIT_FAILURE` and suspended its own task, leaving a blank panel driven by + a timer that was working perfectly. A shared resource started from a task's `Init()` makes + startup depend on a scheduling race, whatever the HAL returns. +- **Starting twice:** still safe. `start_timestamp_timer()` checks `TIM2->CR1.CEN` and only calls + `HAL_TIM_Base_Start` if the counter is stopped, so it asks the hardware whether the counter is + running rather than asking the HAL whether this caller is the one who started it. A handle that + was never initialised — `STM32_PERIPHERAL_TIM2_ENABLE 0` — still fails honestly: `CEN` stays + clear and `HAL_ERROR` comes back. - Clock-rate helpers may be inaccurate if the RCC tree gets more complex; revisit if clocks change. ## Related diff --git a/firmware/Core/Src/main.c b/firmware/Core/Src/main.c index 9227359..592a9b6 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -313,6 +313,18 @@ int main(void) HAL_GPIO_WritePin(LED_BRAKE_GPIO_Port, LED_BRAKE_Pin, GPIO_PIN_SET); /* Seed the runtime sysconfig store from the config.h defaults before any task runs. */ sysconfig_init(); + /* Start the free-running microsecond timestamp counter. Every task that stamps a sample or + times a wait reads it and no single task owns it, so it starts here rather than in whichever + Init() happens to run first -- which is how it used to work, and it made the display's + startup depend on a race it could only lose (SessionController runs at osPriorityHigh and + always claimed the timer first, leaving the display to read an already-started timer as a + failure and suspend itself). + The return is deliberately ignored. If TIM2 is configured, MX_TIM2_Init has already called + Error_Handler on anything that could go wrong, so by this line the handle is READY and the + start cannot fail. If STM32_PERIPHERAL_TIM2_ENABLE is 0 the timer is deliberately absent and + halting the board over it would defeat the point of the switch; timestamps read zero and the + Lumex panel falls back to its bounded spin. */ + (void)start_timestamp_timer(); /* USER CODE END 2 */ /* Init scheduler */ diff --git a/firmware/Drivers/Lumex/README.md b/firmware/Drivers/Lumex/README.md index 6a532f3..f26ea55 100644 --- a/firmware/Drivers/Lumex/README.md +++ b/firmware/Drivers/Lumex/README.md @@ -6,7 +6,7 @@ code: - Drivers/Lumex/LumexPanel.cpp - Drivers/Lumex/LumexPanel_main.h used_by: Display (Lumex variant) -related: [Display, ILI9341 driver, Config] +related: [Display, ILI9341 driver, Config, TimeKeeping] --- # Lumex — HD44780 character LCD driver @@ -231,10 +231,10 @@ it cost a whole peripheral and saved no CPU. Spinning 40 µs directly is the sam none of the machinery, and TIM13 is now free for something else. The task's `PanelDelayUs` is bounded as well as timed. `get_timestamp()` reads a counter that -`SessionController::Init` starts, and `SESSION_CONTROLLER_TASK_ENABLE 0` is a legal -configuration — with the counter frozen, a purely time-based loop would never exit and would -wedge the display task. `LumexLCD::Init` starts the counter itself for that reason, and the -iteration bound is what turns a failure there into a mistimed panel rather than a hung task. +`main()` starts before the scheduler — shared with the session controller, owned by neither, so +it is running whatever else is compiled in. The iteration bound covers the case where it is not: +with a frozen counter a purely time-based loop would never exit and would wedge the display task, +and the bound turns that into a mistimed panel instead. See [[TimeKeeping]]. --- @@ -256,4 +256,4 @@ driver has, and a caller must not treat `false` as fatal. See [[Display]] for wh write must never take the board down. ## Related -[[Display]] · [[ILI9341 driver]] · [[Config]] +[[Display]] · [[ILI9341 driver]] · [[Config]] · [[TimeKeeping]] From 75387fd5ea3c13d8f792b4bacab7f37cdc69572e Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 20:35:57 -0700 Subject: [PATCH 24/25] sysconfig: give the display its own sections, and make the panels exclude 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 --- firmware/Core/Inc/Config/config.h | 9 ++-- firmware/Core/Inc/Config/debug.h | 33 ++++++------ .../SessionController/SessionController.hpp | 2 +- firmware/Core/Src/Tasks/Display/README.md | 10 +++- .../SessionController/SessionController.cpp | 2 +- .../Src/Tasks/TaskMonitor/TaskMonitor.cpp | 4 +- src/Dyno.App/README.md | 8 +++ .../ViewModels/ConfigParameterViewModel.cs | 27 +++++++++- .../ViewModels/MainWindowViewModel.cs | 4 +- src/Dyno.App/ViewModels/SysConfigViewModel.cs | 24 +++++++++ src/Dyno.App/Views/HomeView.axaml | 8 ++- .../Firmware/ConfigExclusiveGroups.cs | 32 ++++++++++++ src/Dyno.Core/README.md | 18 +++++-- tests/Dyno.Core.Tests/CommandOpcodeTests.cs | 5 +- .../ConfigExclusiveGroupsTests.cs | 38 ++++++++++++++ .../FirmwareConfigFileTests.cs | 52 ++++++++++++++++++- 16 files changed, 239 insertions(+), 37 deletions(-) create mode 100644 src/Dyno.Core/Firmware/ConfigExclusiveGroups.cs create mode 100644 tests/Dyno.Core.Tests/ConfigExclusiveGroupsTests.cs diff --git a/firmware/Core/Inc/Config/config.h b/firmware/Core/Inc/Config/config.h index a14d756..cb1042c 100644 --- a/firmware/Core/Inc/Config/config.h +++ b/firmware/Core/Inc/Config/config.h @@ -120,9 +120,11 @@ // the old 5 gave up (and dropped the batch) during ordinary congestion, not just dead hosts. #define USB_TX_FLUSH_MAX_RETRIES 20 -// LCD config +// ===== Display ===== +// Applies whichever panel is fitted. Which one that is, is a task enable in debug.h. #define LCD_TASK_OSDELAY 20 +// ===== Display: Lumex 16x2 ===== // The Lumex panel's character grid. The display message no longer carries strings -- it // carries screen state, and the Lumex driver lays that out into a grid this size -- so these // describe the panel itself rather than a queue payload, which is what the old @@ -130,8 +132,9 @@ #define LUMEX_LCD_ROWS 2 #define LUMEX_LCD_COLUMNS 16 -// Which way up the ILI9341 panel is fitted. Both LANDSCAPE and LANDSCAPE_FLIP are 320x240, -// so this changes nothing but the origin corner -- the layout is unaffected either way. +// ===== Display: ILI9341 320x240 TFT ===== +// Which way up the panel is fitted. Both LANDSCAPE and LANDSCAPE_FLIP are 320x240, so this +// changes nothing but the origin corner -- the layout is unaffected either way. // // FLIP because the panel is mounted 180 degrees from the controller's default landscape: // LANDSCAPE rendered the screens upside down on the rig. This is a property of the enclosure, diff --git a/firmware/Core/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index 61e80a3..1e91d57 100644 --- a/firmware/Core/Inc/Config/debug.h +++ b/firmware/Core/Inc/Config/debug.h @@ -47,30 +47,29 @@ // BPM Controller Task #define BPM_CONTROLLER_TASK_ENABLE 1 -// Display task -- at most one driver, chosen here and flashed. +// ===== Display ===== +// At most one panel, chosen here and flashed. Both consume the same +// session_controller_to_display message, so the SessionController and its FSM are identical +// either way -- only the driver linked in changes. There is no runtime switch because there is +// no runtime question: a board has one panel soldered to it. // -// Both panels consume the same session_controller_to_display message, so the SessionController -// and its FSM are identical either way; only the driver linked in changes. There is no runtime -// switch because there is no runtime question: a board has one panel soldered to it. +// Neither enabled is legal, and useful: the display task parks and nothing drives SPI1, which is +// how the panel gets ruled in or out of a fault elsewhere on the board. // -// ROT_EN_B (PI8) had no pull resistor while every other user input had one, so the direction -// bit the encoder ISR samples came off a floating pin. It read correctly while SPI1 was idle -// and randomly once the panel drove it, which is why the brake random-walked to 0% only on this -// branch and only while the encoder was turning. Fixed in the .ioc; the panel is back on. -#define LUMEX_LCD_TASK_ENABLE 1 -#define ILI9341_LCD_TASK_ENABLE 0 +// Anything outside the two drivers that needs to know a display task exists -- the null checks on +// the display queue and thread id, the task monitor's stack-usage report -- must test BOTH of +// these, never one. Gating on a single panel's enable silently compiles the feature out when the +// other panel is selected: that is how the display task's stack high-water mark fell off the USB +// stream when this board moved to the ILI9341. There used to be a DISPLAY_TASK_ENABLE macro +// spelling that disjunction once, but a derived value has no business in a file the desktop app +// offers as a list of switches to override, so the disjunction is written out where it is used. +#define LUMEX_LCD_TASK_ENABLE 0 // Lumex 16x2 character LCD, bit-banged over GPIO +#define ILI9341_LCD_TASK_ENABLE 1 // ILI9341 320x240 SPI TFT #if (LUMEX_LCD_TASK_ENABLE + ILI9341_LCD_TASK_ENABLE) > 1 #error "At most one display driver may be enabled: set at most one of LUMEX_LCD_TASK_ENABLE / ILI9341_LCD_TASK_ENABLE to 1." #endif -// "A display task exists", which is what everything outside the two drivers actually wants to -// know: null checks on the display queue and thread id, and the task monitor's stack-usage -// report. Those must not be gated on one panel's own enable -- selecting the other panel then -// silently compiles them out, which is exactly what happened when this board moved to the -// ILI9341 and took the display task's stack high-water mark off the USB stream with it. -#define DISPLAY_TASK_ENABLE (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) - // USB Controller task settings // The mock-message stream used to live here as DEBUG_USB_CONTROLLER_MOCK_MESSAGES. It is now the // runtime parameter SYSCFG_USB_MOCK_MESSAGES (schema: sysconfig_params), so exercising the link diff --git a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp index 16794cb..316acbf 100644 --- a/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp +++ b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp @@ -18,7 +18,7 @@ || !defined(FORCE_SENSOR_ADS1115_TASK_ENABLE) || !defined(FORCE_SENSOR_ADC_TASK_ENABLE) \ || !defined(OPTICAL_ENCODER_TASK_ENABLE) || !defined(BPM_CONTROLLER_TASK_ENABLE) \ || !defined(PID_CONTROLLER_TASK_ENABLE) || !defined(LUMEX_LCD_TASK_ENABLE) \ - || !defined(ILI9341_LCD_TASK_ENABLE) || !defined(DISPLAY_TASK_ENABLE) + || !defined(ILI9341_LCD_TASK_ENABLE) #error "A *_TASK_ENABLE macro is not visible here; SessionController's #if-gated queue posts would silently compile out (include Config/debug.h)" #endif diff --git a/firmware/Core/Src/Tasks/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md index 729f798..bb3bb54 100644 --- a/firmware/Core/Src/Tasks/Display/README.md +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -166,8 +166,14 @@ character grid and in pixels. #define ILI9341_LCD_TASK_ENABLE 1 ``` -`DISPLAY_TASK_ENABLE` is derived from the pair and is what code outside the two drivers -should test. Both drivers are always compiled; `--gc-sections` drops the unused one. +Code outside the two drivers must test **both**, never one — `#if (LUMEX_LCD_TASK_ENABLE || +ILI9341_LCD_TASK_ENABLE)`. Gating on a single panel's enable silently compiles the feature out +when the other panel is selected, which is how the display task's stack high-water mark fell off +the USB stream when this board moved to the ILI9341. A `DISPLAY_TASK_ENABLE` macro used to spell +that disjunction once, but a derived value has no business in a header the desktop app offers as +a list of switches to override, so it is written out at the four sites that need it. + +Both drivers are always compiled; `--gc-sections` drops the unused one. **Neither enabled is legal**, and useful: the display task parks and nothing drives SPI1, which is how the panel gets ruled in or out of a fault elsewhere on the board. It is not the diff --git a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp index 0f7ddda..42b5798 100644 --- a/firmware/Core/Src/Tasks/SessionController/SessionController.cpp +++ b/firmware/Core/Src/Tasks/SessionController/SessionController.cpp @@ -60,7 +60,7 @@ bool SessionController::CheckTaskQueuesValid() || _task_queues->pid_controller == nullptr || _task_queues->pid_controller_ack == nullptr #endif - #if DISPLAY_TASK_ENABLE + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) || _task_queues->display == nullptr #endif #if USB_CONTROLLER_TASK_ENABLE diff --git a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp index afc6647..88df05b 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp +++ b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp @@ -36,7 +36,7 @@ bool TaskMonitor::Init() #if PID_CONTROLLER_TASK_ENABLE || _osThreadIdPtrs->pid_controller == nullptr #endif - #if DISPLAY_TASK_ENABLE + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) || _osThreadIdPtrs->display == nullptr #endif ) @@ -94,7 +94,7 @@ void TaskMonitor::Run() #if PID_CONTROLLER_TASK_ENABLE GetTaskDataAndSendToUsbController(TASK_OFFSET_PID_CONTROLLER, _osThreadIdPtrs->pid_controller); #endif - #if DISPLAY_TASK_ENABLE + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) GetTaskDataAndSendToUsbController(TASK_OFFSET_DISPLAY, _osThreadIdPtrs->display); #endif diff --git a/src/Dyno.App/README.md b/src/Dyno.App/README.md index c1398d8..d7ba952 100644 --- a/src/Dyno.App/README.md +++ b/src/Dyno.App/README.md @@ -53,6 +53,14 @@ sits idle. applying are separate. Every row behaves the same way whichever section it is in: an edit is staged (blue dot), its default and accepted range are stated under it, Reset stages that default, and nothing is written until Apply. + + One exception to "every row is independent": a few compile-time switches exclude each other, and + turning one on turns the others off in the page rather than staging a build the firmware's + `#error` would reject. The display panels are the case that exists — + `LUMEX_LCD_TASK_ENABLE` / `ILI9341_LCD_TASK_ENABLE`. They are not a radio group: **all off** is a + real configuration, and it is how the panel gets ruled in or out of a fault elsewhere on the + board. The list lives in `ConfigExclusiveGroups` ([[Dyno.Core]]), wired up once both headers are + parsed since a group may span `config.h` and `debug.h`. - **Firmware** (`FirmwareViewModel`) — build the firmware in the Docker toolchain, then flash it over SWD, USB DFU or UART. It runs `firmware/Scripts/` and shows their output verbatim. diff --git a/src/Dyno.App/ViewModels/ConfigParameterViewModel.cs b/src/Dyno.App/ViewModels/ConfigParameterViewModel.cs index 93cacdd..e8c38f2 100644 --- a/src/Dyno.App/ViewModels/ConfigParameterViewModel.cs +++ b/src/Dyno.App/ViewModels/ConfigParameterViewModel.cs @@ -21,6 +21,10 @@ public partial class ConfigParameterViewModel : ObservableObject private readonly string _searchHaystack; private string _savedValue; + /// Settings that must switch off when this one comes on. Wired after every parameter + /// exists, since a group's members are siblings in the same list. + private IReadOnlyList _exclusiveWith = []; + public string Name { get; } public string Category { get; } public string FileLabel { get; } @@ -124,7 +128,28 @@ private void Reset() partial void OnTextChanged(string value) => RefreshDirty(); - partial void OnIsOnChanged(bool value) => RefreshDirty(); + /// + /// Turning this on turns its group off. Turning it off does nothing to them, which is + /// what leaves "all off" reachable — see for why that has to + /// stay a legal state. + /// + /// Each sibling's own handler runs on assignment, but with false, so it takes + /// this branch no further and there is no need to guard against re-entry. + partial void OnIsOnChanged(bool value) + { + if (value) + { + foreach (var other in _exclusiveWith) + { + other.IsOn = false; + } + } + RefreshDirty(); + } + + /// Declares the settings this one excludes. Idempotent and one-way per call: the + /// caller wires every member of a group against the rest. + public void Excludes(IReadOnlyList others) => _exclusiveWith = others; private void RefreshDirty() { diff --git a/src/Dyno.App/ViewModels/MainWindowViewModel.cs b/src/Dyno.App/ViewModels/MainWindowViewModel.cs index 09a30c1..45987e3 100644 --- a/src/Dyno.App/ViewModels/MainWindowViewModel.cs +++ b/src/Dyno.App/ViewModels/MainWindowViewModel.cs @@ -278,7 +278,9 @@ public async Task NudgeDutyCycleAsync(int notches) // Start from what is in the box, so several notches in a row accumulate rather than each // one being applied to whatever the device last reported. - double percent = double.TryParse(DutyCycleInput, out double parsed) ? parsed : DutyCycle * 100.0; + double percent = double.TryParse(DutyCycleInput, out double parsed) + ? parsed + : DutyCycle * 100.0; await SendDutyCyclePercentAsync(percent + notches * DutyCycleWheelStepPercent) .ConfigureAwait(true); diff --git a/src/Dyno.App/ViewModels/SysConfigViewModel.cs b/src/Dyno.App/ViewModels/SysConfigViewModel.cs index 0ff5279..0658a60 100644 --- a/src/Dyno.App/ViewModels/SysConfigViewModel.cs +++ b/src/Dyno.App/ViewModels/SysConfigViewModel.cs @@ -694,6 +694,7 @@ private void Load() // are quantities, where a literal 0 or 1 would be an ordinary number. LoadFile(dir, "config.h", saved, binaryTogglesAreBool: false); LoadFile(dir, "debug.h", saved, binaryTogglesAreBool: true); + WireExclusiveGroups(); LoadFailed = false; StatusText = $"{_parameters.Count} compile-time settings"; } @@ -739,6 +740,29 @@ bool binaryTogglesAreBool } } + /// + /// Points each mutually exclusive switch at the others, so turning one on turns those off + /// rather than staging a build the firmware's #error would reject. + /// + /// Runs after both headers are parsed: a group may span files, and every member has to + /// exist before any of them can be wired. Saved values are left exactly as loaded — normalising + /// a stored combination here would silently rewrite the user's settings on open, and the header + /// check still catches one that got in by some other route. + private void WireExclusiveGroups() + { + foreach (var group in ConfigExclusiveGroups.Groups) + { + var members = _parameters + .Where(p => p.IsBool && group.Contains(p.Name, StringComparer.Ordinal)) + .ToList(); + + foreach (var member in members) + { + member.Excludes(members.Where(other => other != member).ToList()); + } + } + } + partial void OnSearchTextChanged(string value) => ApplyFilter(); private void ApplyFilter() diff --git a/src/Dyno.App/Views/HomeView.axaml b/src/Dyno.App/Views/HomeView.axaml index 9e43a91..09b4789 100644 --- a/src/Dyno.App/Views/HomeView.axaml +++ b/src/Dyno.App/Views/HomeView.axaml @@ -256,7 +256,13 @@ Text="BPM duty cycle" ToolTip.Tip="Commands the brake, exactly as the rig's rotary encoder does. Editable only during a session — the firmware refuses outside one — and clamped to the min/max duty cycle set on the SysConfig page. Scroll over the box to nudge it by 1%." /> - + +/// Sets of compile-time switches the firmware refuses to have on at the same time. +/// +/// The headers already enforce these with an #error, which is the authority — this list +/// exists so the SysConfig page can keep a user out of that build rather than let them save a +/// combination that fails at compile time, minutes later, with a message they have to go and read. +/// +/// Membership is exclusive, not required: every switch in a group may be off. That is a real +/// configuration rather than an oversight — with no display driver enabled the display task parks +/// and nothing drives SPI1, which is how the panel gets ruled in or out of a fault elsewhere on the +/// board. So this cannot be modelled as a radio group. +/// +public static class ConfigExclusiveGroups +{ + public static readonly IReadOnlyList> Groups = + [ + // debug.h: "At most one display driver may be enabled". One panel is soldered to a given + // board, and the two share SPI1 and its chip select. + ["LUMEX_LCD_TASK_ENABLE", "ILI9341_LCD_TASK_ENABLE"], + ]; + + /// The other switches that must go off when is turned on. + /// Empty for a setting that is in no group, which is nearly all of them. + public static IReadOnlyList Siblings(string name) => + Groups + .Where(group => group.Contains(name, StringComparer.Ordinal)) + .SelectMany(group => group.Where(member => member != name)) + .Distinct(StringComparer.Ordinal) + .ToList(); +} diff --git a/src/Dyno.Core/README.md b/src/Dyno.Core/README.md index 2063259..6f03aca 100644 --- a/src/Dyno.Core/README.md +++ b/src/Dyno.Core/README.md @@ -219,9 +219,21 @@ Three details the tests pin down: - **A value that could rewrite the header around it is refused.** The generated file is C that nobody reviews; a value carrying `//` or a newline could define anything it liked. -Bad *combinations* are not the app's business: the firmware already enforces them itself (`#error -"Cannot enable both ADS1115 and ADC Force Sensor modules at the same time!"` and ~19 others), and an -override that trips one fails the build with that message, which is the right one. +Bad *combinations* are, with one exception, not the app's business: the firmware already enforces +them itself (`#error "Cannot enable both ADS1115 and ADC Force Sensor modules at the same time!"` +and ~19 others), and an override that trips one fails the build with that message, which is the +right one. + +The exception is `ConfigExclusiveGroups`, which names sets of switches the page keeps the user out +of rather than letting them stage: turning one on turns the rest off. The firmware's `#error` is +still the authority — this only decides what a click does. It earns its place where the switches +are a *choice* rather than a mistake, which today means the two display panels: picking a panel is +a normal thing to do on this page, and the alternative is discovering minutes later that the build +you asked for was never going to compile. + +Deliberately not a radio group. Every member may be off — with no display driver enabled the +display task parks and nothing drives SPI1, which is how the panel gets ruled in or out of a fault +elsewhere on the board. Only turning a switch *on* touches its siblings. ## Session state The dyno streams sensor data **only while a session is running**, so the absence of samples means diff --git a/tests/Dyno.Core.Tests/CommandOpcodeTests.cs b/tests/Dyno.Core.Tests/CommandOpcodeTests.cs index 8b6ba29..e50e522 100644 --- a/tests/Dyno.Core.Tests/CommandOpcodeTests.cs +++ b/tests/Dyno.Core.Tests/CommandOpcodeTests.cs @@ -62,9 +62,6 @@ public void EveryCommandEnumIsAccountedFor() .OrderBy(n => n, StringComparer.Ordinal) .ToArray(); - Assert.Equal( - ["session_controller_command_t", "usb_controller_command_t"], - commandEnums - ); + Assert.Equal(["session_controller_command_t", "usb_controller_command_t"], commandEnums); } } diff --git a/tests/Dyno.Core.Tests/ConfigExclusiveGroupsTests.cs b/tests/Dyno.Core.Tests/ConfigExclusiveGroupsTests.cs new file mode 100644 index 0000000..6d9e58a --- /dev/null +++ b/tests/Dyno.Core.Tests/ConfigExclusiveGroupsTests.cs @@ -0,0 +1,38 @@ +using Dyno.Core.Firmware; +using Xunit; + +namespace Dyno.Core.Tests; + +public class ConfigExclusiveGroupsTests +{ + [Theory] + [InlineData("LUMEX_LCD_TASK_ENABLE", "ILI9341_LCD_TASK_ENABLE")] + [InlineData("ILI9341_LCD_TASK_ENABLE", "LUMEX_LCD_TASK_ENABLE")] + public void EachDisplayPanelExcludesTheOther(string turnedOn, string expectedOff) + { + Assert.Equal([expectedOff], ConfigExclusiveGroups.Siblings(turnedOn)); + } + + [Fact] + public void SettingsInNoGroupExcludeNothing() + { + Assert.Empty(ConfigExclusiveGroups.Siblings("PID_CONTROLLER_TASK_ENABLE")); + Assert.Empty(ConfigExclusiveGroups.Siblings("")); + } + + // Exclusion is a relation between members, so a one-member group is a typo that would silently + // do nothing rather than fail. + [Fact] + public void EveryGroupHasSomethingToExclude() + { + Assert.All(ConfigExclusiveGroups.Groups, group => Assert.True(group.Count >= 2)); + } + + // A name in two groups would make "turn the others off" depend on which group was consulted. + [Fact] + public void NoSettingBelongsToTwoGroups() + { + var all = ConfigExclusiveGroups.Groups.SelectMany(g => g).ToList(); + Assert.Equal(all.Count, all.Distinct(StringComparer.Ordinal).Count()); + } +} diff --git a/tests/Dyno.Core.Tests/FirmwareConfigFileTests.cs b/tests/Dyno.Core.Tests/FirmwareConfigFileTests.cs index 1f2845c..8ec0dd1 100644 --- a/tests/Dyno.Core.Tests/FirmwareConfigFileTests.cs +++ b/tests/Dyno.Core.Tests/FirmwareConfigFileTests.cs @@ -37,6 +37,15 @@ public class FirmwareConfigFileTests // LCD config #define SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE 16 + 1 + // ===== Display: Lumex 16x2 ===== + // The Lumex panel's character grid. + #define LUMEX_LCD_ROWS 2 + #define LUMEX_LCD_COLUMNS 16 + + // ===== Display: ILI9341 320x240 TFT ===== + // Which way up the panel is fitted. + #define ILI9341_DISPLAY_ROTATION ILI9341_ROTATION_LANDSCAPE_FLIP + // User Input Config (like buttons) #define USER_INPUT_CIRCULAR_BUFFER_SIZE 100u @@ -60,6 +69,11 @@ public class FirmwareConfigFileTests // Task enable/disables #define FORCE_SENSOR_ADS1115_TASK_ENABLE 1 + // ===== Display ===== + // At most one panel, chosen here and flashed. Neither enabled is legal. + #define LUMEX_LCD_TASK_ENABLE 0 // Lumex 16x2 character LCD + #define ILI9341_LCD_TASK_ENABLE 1 // ILI9341 320x240 SPI TFT + // USB Controller task settings #define USB_CONTROLLER_TASK_ENABLE 1 #define DEBUG_USB_CONTROLLER_MOCK_MESSAGES 0 @@ -81,7 +95,7 @@ public void IncludeGuardAndIncludeAreNotSettings() { var file = ParseConfig(); Assert.DoesNotContain(file.Defines, d => d.Name == "INC_CONFIG_CONFIG_H_"); - Assert.Equal(10, file.Defines.Count); + Assert.Equal(13, file.Defines.Count); } [Fact] @@ -155,4 +169,40 @@ public void DebugTogglesAreBool() var file = ParseDebug(); Assert.All(file.Defines, d => Assert.Equal(ConfigValueKind.Bool, d.Kind)); } + + // The SysConfig page groups by category, so these assertions are what put the two panel + // switches on one card and each panel's own settings on a card of its own. Before the headers + // carried banners here, the category was the first line of the prose above the define -- + // "The Lumex panel's character grid. The display message no longer carries strings..." was a + // section name on screen. + [Fact] + public void BothPanelSwitchesShareOneDisplaySection() + { + var file = ParseDebug(); + Assert.Equal("Display", Get(file, "LUMEX_LCD_TASK_ENABLE").Category); + Assert.Equal("Display", Get(file, "ILI9341_LCD_TASK_ENABLE").Category); + } + + [Fact] + public void PanelSpecificSettingsGetTheirOwnSection() + { + var file = ParseConfig(); + Assert.Equal("Display: Lumex 16x2", Get(file, "LUMEX_LCD_ROWS").Category); + Assert.Equal("Display: Lumex 16x2", Get(file, "LUMEX_LCD_COLUMNS").Category); + Assert.Equal( + "Display: ILI9341 320x240 TFT", + Get(file, "ILI9341_DISPLAY_ROTATION").Category + ); + } + + // A banner block sets the category and is otherwise discarded, so the only way a switch gets + // its own description is a trailing comment. Without these the Display card is two unlabelled + // toggles named after C macros. + [Fact] + public void PanelSwitchesKeepTheirTrailingDescriptions() + { + var file = ParseDebug(); + Assert.Equal("Lumex 16x2 character LCD", Get(file, "LUMEX_LCD_TASK_ENABLE").Description); + Assert.Equal("ILI9341 320x240 SPI TFT", Get(file, "ILI9341_LCD_TASK_ENABLE").Description); + } } From 1fe31f12da418a43b1be9365e3575693d3733ac5 Mon Sep 17 00:00:00 2001 From: Tomaz Zlindra Date: Tue, 28 Jul 2026 21:47:29 -0700 Subject: [PATCH 25/25] app: let the duty-cycle box be the readout, instead of pairing it with 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 --- .../ViewModels/MainWindowViewModel.cs | 115 +++++++++++++++--- src/Dyno.App/Views/HomeView.axaml | 13 +- 2 files changed, 102 insertions(+), 26 deletions(-) diff --git a/src/Dyno.App/ViewModels/MainWindowViewModel.cs b/src/Dyno.App/ViewModels/MainWindowViewModel.cs index 45987e3..c9770ba 100644 --- a/src/Dyno.App/ViewModels/MainWindowViewModel.cs +++ b/src/Dyno.App/ViewModels/MainWindowViewModel.cs @@ -221,14 +221,44 @@ private void Navigate(AppPage page) [ObservableProperty] private double _dutyCycle; - /// What is typed in the brake duty-cycle box, as a percentage (0 - 100) to match the - /// readout it replaced. The firmware takes a 0 - 1 fraction; the conversion happens on send. + /// The brake duty-cycle box, as a percentage (0 - 100). The firmware takes a 0 - 1 + /// fraction; the conversion happens on send. /// - /// Held as text rather than a number so a half-typed value ("4", "4.") is not repeatedly - /// reinterpreted and rewritten under the user's cursor. + /// Both a readout and an editor. It follows whatever the board reports — including a change + /// made at the rig's own rotary encoder, or the PID driving the brake — and stops following + /// only while the user is part-way through an edit or a command has yet to land. Held as text + /// rather than a number so a half-typed value ("4", "4.") is not repeatedly reinterpreted and + /// rewritten under the cursor. [ObservableProperty] private string _dutyCycleInput = "0.0"; + /// True while the box holds something the user typed and has not committed. Set by + /// for any change this class did not make itself, and + /// cleared by every write that goes through . + private bool _hasUncommittedDutyCycleEdit; + + /// Guards so the app's own writes are not + /// mistaken for typing. Without it, mirroring the board would mark the box edited and then + /// refuse to mirror it again. + private bool _writingDutyCycleInput; + + /// What was last commanded, and when. While this is set the board is still expected to + /// be catching up, so its readings are not mirrored — otherwise a scroll notch would be undone + /// by the sample that lands a fraction of a second later, which is the "jumping" this box had + /// before. + private double? _commandedDutyCyclePercent; + private DateTime _commandedDutyCycleAt; + + /// How long a commanded figure stands before the board's own reading wins anyway. + /// Reaching the commanded value clears it sooner; this is the other exit, and it is what + /// surfaces a value the firmware clamped to its MIN/MAX envelope — the board never reports what + /// was asked for, so nothing else would ever end the wait. + private static readonly TimeSpan DutyCycleSettleWindow = TimeSpan.FromSeconds(1); + + /// Half the box's display resolution: two figures that round to the same "F1" text are + /// the same figure as far as this is concerned. + private const double DutyCycleMatchTolerancePercent = 0.05; + /// Set when the last duty-cycle command was refused or failed, so the box can show /// that the brake is not at what it says. [ObservableProperty] @@ -241,11 +271,68 @@ private void Navigate(AppPage page) /// How far one scroll-wheel notch moves the duty cycle, in percent. private const double DutyCycleWheelStepPercent = 1.0; - /// Puts the brake's current figure into the setpoint box. Called when a session - /// starts and when a link goes away -- never from telemetry, which is the whole point. + /// Writes the box on the app's own behalf: no pending edit afterwards, and the change + /// is not mistaken for typing. Every programmatic write goes through here. + private void SetDutyCycleInput(double percent) + { + _writingDutyCycleInput = true; + DutyCycleInput = percent.ToString("F1"); + _writingDutyCycleInput = false; + _hasUncommittedDutyCycleEdit = false; + } + + /// Anything this class did not write is the user typing, and the box belongs to them + /// until they commit it or abandon it. + partial void OnDutyCycleInputChanged(string value) + { + if (!_writingDutyCycleInput) + { + _hasUncommittedDutyCycleEdit = true; + } + } + + /// + /// Follows the board's reported duty cycle, which is what makes the box a live readout as well + /// as an editor — a change made at the rig's rotary encoder, or by the PID, shows up here. + /// + /// Yields to the user in the two cases where the box is not the board's to write: a + /// half-typed value, and a command that has not landed yet. + private void MirrorDeviceDutyCycle(double percent) + { + if (_hasUncommittedDutyCycleEdit) + { + return; + } + + if (_commandedDutyCyclePercent is { } commanded) + { + if (Math.Abs(percent - commanded) < DutyCycleMatchTolerancePercent) + { + // The board is where it was asked to be; nothing is in flight any more. + _commandedDutyCyclePercent = null; + } + else if (DateTime.UtcNow - _commandedDutyCycleAt < DutyCycleSettleWindow) + { + return; + } + else + { + // It was never going to match -- the firmware clamped it, or refused. Show what the + // brake is actually doing rather than what was asked for. + _commandedDutyCyclePercent = null; + } + } + + SetDutyCycleInput(percent); + IsDutyCycleInputInvalid = false; + } + + /// Puts the brake's current figure into the box. Called when a session starts and when + /// a link goes away, where there is no telemetry to follow. private void SyncDutyCycleInputToDevice() { - DutyCycleInput = (DutyCycle * 100.0).ToString("F1"); + _commandedDutyCyclePercent = null; + SetDutyCycleInput(DutyCycle * 100.0); IsDutyCycleInputInvalid = false; } @@ -298,7 +385,12 @@ private async Task SendDutyCyclePercentAsync(double percent) } percent = Math.Clamp(percent, 0.0, 100.0); - DutyCycleInput = percent.ToString("F1"); + SetDutyCycleInput(percent); + + // Hold off mirroring until the board reports this figure, or the window expires. Set before + // the await, since samples arrive while the command is still in flight. + _commandedDutyCyclePercent = percent; + _commandedDutyCycleAt = DateTime.UtcNow; try { @@ -1045,12 +1137,7 @@ private void Apply(DeviceMessage message) break; case BpmSample s: DutyCycle = s.Data.duty_cycle; - // Deliberately does NOT touch DutyCycleInput. That box is a setpoint -- what you - // have asked the brake for -- and this is a measurement of what it is doing. They - // are different numbers, and having telemetry write the box made it jump: a BPM - // sample lands several times a second, so every scroll notch was overwritten by - // the device's reading a fraction of a second later. Commanded and actual are - // shown side by side instead. + MirrorDeviceDutyCycle(s.Data.duty_cycle * 100.0); Plots.RecordDutyCycle(s.Data.timestamp, s.Data.duty_cycle); break; diff --git a/src/Dyno.App/Views/HomeView.axaml b/src/Dyno.App/Views/HomeView.axaml index 09b4789..43bc78d 100644 --- a/src/Dyno.App/Views/HomeView.axaml +++ b/src/Dyno.App/Views/HomeView.axaml @@ -254,7 +254,7 @@ Grid.Column="0" Classes="label" Text="BPM duty cycle" - ToolTip.Tip="Commands the brake, exactly as the rig's rotary encoder does. Editable only during a session — the firmware refuses outside one — and clamped to the min/max duty cycle set on the SysConfig page. Scroll over the box to nudge it by 1%." + ToolTip.Tip="What the brake is running at, and the way to change it — exactly as the rig's rotary encoder does. It follows the board, so a change made at the rig shows up here. Editable only during a session — the firmware refuses outside one — and clamped to the min/max duty cycle set on the SysConfig page. Scroll over the box to nudge it by 1%." /> - -