diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt index 22ffd45..48053d2 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) @@ -59,15 +65,22 @@ 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} ) # 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 + ${CMAKE_SOURCE_DIR}/Drivers/Lumex ) # Add project symbols (macros) diff --git a/firmware/Core/Inc/Config/config.h b/firmware/Core/Inc/Config/config.h index e0f4bee..cb1042c 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 @@ -31,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 @@ -103,9 +120,26 @@ // 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 -#define SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE 16 + 1 + +// ===== 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 +// SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE was really doing. +#define LUMEX_LCD_ROWS 2 +#define LUMEX_LCD_COLUMNS 16 + +// ===== 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, +// 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/Inc/Config/debug.h b/firmware/Core/Inc/Config/debug.h index df44cf7..1e91d57 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 @@ -48,8 +47,28 @@ // BPM Controller Task #define BPM_CONTROLLER_TASK_ENABLE 1 -// Lumex LCD Task -#define LUMEX_LCD_TASK_ENABLE 1 +// ===== 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. +// +// 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. +// +// 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 // USB Controller task settings // The mock-message stream used to live here as DEBUG_USB_CONTROLLER_MOCK_MESSAGES. It is now the @@ -59,8 +78,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 diff --git a/firmware/Core/Inc/MessagePassing/messages_private.h b/firmware/Core/Inc/MessagePassing/messages_private.h index 00a4c3f..8968f72 100644 --- a/firmware/Core/Inc/MessagePassing/messages_private.h +++ b/firmware/Core/Inc/MessagePassing/messages_private.h @@ -27,25 +27,59 @@ 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 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 + 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 + 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) <= 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/MessagePassing/messages_public.h b/firmware/Core/Inc/MessagePassing/messages_public.h index 3bc5ac0..c630921 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 { @@ -248,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. @@ -314,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/Display/DisplayDriver.hpp b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp new file mode 100644 index 0000000..5ab859b --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/DisplayDriver.hpp @@ -0,0 +1,94 @@ +#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; + + // --- 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. +// +// 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 +[[noreturn]] void RunDisplayTask(Display& display, osMessageQueueId_t queue) +{ + session_controller_to_display state; + memset(&state, 0, sizeof(state)); + + for (;;) + { + if (osMessageQueueGet(queue, &state, 0, osWaitForever) == osOK) + { + 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); + } + + osDelay(sysconfig_get_u32(SYSCFG_LCD_TASK_OSDELAY)); + } +} + +#endif /* INC_TASKS_DISPLAY_DISPLAYDRIVER_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp b/firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp new file mode 100644 index 0000000..7a0f97c --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ILI9341/ILI9341Display.hpp @@ -0,0 +1,62 @@ +#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/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); + + // --- 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. + 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; + + // 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; +}; + +#endif /* INC_TASKS_DISPLAY_ILI9341DISPLAY_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h new file mode 100644 index 0000000..015acad --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_layout.h @@ -0,0 +1,78 @@ +#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/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. +// +// 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 + +// 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. +// +// `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; + +// 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. +// `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. +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/ili9341_main.h b/firmware/Core/Inc/Tasks/Display/ILI9341/ili9341_main.h new file mode 100644 index 0000000..18a951a --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/ILI9341/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/Display/Lumex/LumexLCD.hpp b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp new file mode 100644 index 0000000..10ed24d --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/Lumex/LumexLCD.hpp @@ -0,0 +1,91 @@ +#ifndef INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ +#define INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ + +#include "main.h" + +#include "cmsis_os2.h" + +#include "string.h" + +#include "Config/config.h" + +#include "CircularBufferWriter.hpp" + +#include "LumexPanel.hpp" + +#include "MessagePassing/messages_private.h" +#include "MessagePassing/messages_public.h" +#include "MessagePassing/osqueue_helpers.h" + +#include "Tasks/Display/Lumex/lumex_layout.h" + +#include "TimeKeeping/timestamps.h" + +// 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 +// and there is no vtable. See Core/Src/Tasks/Display/README.md for the display split. +class LumexLCD +{ + public: + LumexLCD(); + ~LumexLCD() = default; + + bool Init(); + + // 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); + + // --- 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: + LumexPanel _panel; + + CircularBufferWriter _task_error_buffer_writer; + + // 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; +}; + +#endif /* INC_TASKS_DISPLAY_LUMEX_LUMEXLCD_HPP_ */ diff --git a/firmware/Core/Inc/Tasks/Display/Lumex/lumex_layout.h b/firmware/Core/Inc/Tasks/Display/Lumex/lumex_layout.h new file mode 100644 index 0000000..8014436 --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/Lumex/lumex_layout.h @@ -0,0 +1,38 @@ +#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" +#include "Tasks/Display/display_common.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); + +#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/Display/Lumex/lumexlcd_main.h similarity index 68% rename from firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h rename to firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h index 953a5b9..f65ef6e 100644 --- a/firmware/Core/Inc/Tasks/LCD/lumexlcd_main.h +++ b/firmware/Core/Inc/Tasks/Display/Lumex/lumexlcd_main.h @@ -10,8 +10,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/Display/display_common.h b/firmware/Core/Inc/Tasks/Display/display_common.h new file mode 100644 index 0000000..2f20a6d --- /dev/null +++ b/firmware/Core/Inc/Tasks/Display/display_common.h @@ -0,0 +1,43 @@ +#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 + +#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); + +// 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 + +#endif /* INC_TASKS_DISPLAY_DISPLAY_COMMON_H_ */ diff --git a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp b/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp deleted file mode 100644 index d63989e..0000000 --- a/firmware/Core/Inc/Tasks/LCD/LumexLCD.hpp +++ /dev/null @@ -1,59 +0,0 @@ -#ifndef INC_TASKS_LCD_LUMEXLCD_HPP_ -#define INC_TASKS_LCD_LUMEXLCD_HPP_ - -#include "main.h" - -#include "cmsis_os2.h" - -#include "string.h" - - -#include "Config/config.h" - - -#include "CircularBufferWriter.hpp" - -#include "MessagePassing/messages_private.h" -#include "MessagePassing/messages_public.h" -#include "MessagePassing/osqueue_helpers.h" - -#include "TimeKeeping/timestamps.h" - -#ifdef __cplusplus -extern "C" { -#endif - -class LumexLCD -{ - public: - LumexLCD(osMessageQueueId_t lumexLcdToSessionControllerqHandle); - ~LumexLCD() = default; - - bool Init(); - void Run(); - - - 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); - - CircularBufferWriter _task_error_buffer_writer; - - osMessageQueueId_t _fromSCqHandle; -}; - - -#ifdef __cplusplus -} -#endif - - - -#endif /* INC_TASKS_LCD_LUMEXLCD_HPP_ */ 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 f1b531d..4d7066c 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 @@ -75,21 +77,28 @@ 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. - void DisplayRpm(float rpm); + // 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 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(); + // 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; @@ -104,14 +113,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 +139,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(); + + // Maps the FSM's own state pair onto the screen id the drivers switch on. + display_screen_id CurrentScreen() const; - osMessageQueueId_t _sessionControllerToLumexLcdHandle; + osMessageQueueId_t _toDisplayHandle; State _state; @@ -154,6 +162,20 @@ 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. Stored as + // RPM: the conversion from the encoder's rad/s happens on the way in. + 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/Inc/Tasks/SessionController/SessionController.hpp b/firmware/Core/Inc/Tasks/SessionController/SessionController.hpp index 9da2c21..316acbf 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 @@ -46,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 bc59f29..1984867 100644 --- a/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h +++ b/firmware/Core/Inc/Tasks/SessionController/sessioncontroller_main.h @@ -17,7 +17,12 @@ 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; + // 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/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/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/Inc/TimeKeeping/timestamps.h b/firmware/Core/Inc/TimeKeeping/timestamps.h index 428a3b7..96392f7 100644 --- a/firmware/Core/Inc/TimeKeeping/timestamps.h +++ b/firmware/Core/Inc/TimeKeeping/timestamps.h @@ -25,14 +25,32 @@ 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); } -inline HAL_StatusTypeDef start_timestamp_timer() +// 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. +// +// 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) { - 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/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 fffbe92..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, 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 | -| LCD | `Core/Src/Tasks/LCD/README.md` | Lumex character display | +| 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 | @@ -29,9 +31,11 @@ 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 | +| 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/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/Display/ILI9341/ILI9341Display.cpp b/firmware/Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp new file mode 100644 index 0000000..c852653 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/ILI9341/ILI9341Display.cpp @@ -0,0 +1,183 @@ +#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/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 + +// 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, + DisplayDelayMs), + _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)); + 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() +{ + if (!_panel.Init(ILI9341_DISPLAY_ROTATION)) + { + 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_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 + // positionally stable, which is what makes the index-wise comparison below valid. + const bool screenChanged = !_hasRendered || state.screen != _lastScreen; + + if (screenChanged && !Clear()) + { + _hasRendered = false; + 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); + + // 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; + } + } + + _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()) + { + // 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/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/ILI9341/ili9341_layout.c b/firmware/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c new file mode 100644 index 0000000..e223749 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/ILI9341/ili9341_layout.c @@ -0,0 +1,235 @@ +#include "Tasks/Display/ILI9341/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"); +} + +// 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); + + // 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); + 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); + 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]; + + // Speed: label, big value, unit alongside. + add_field(out, 12, 18, SIZE_SMALL, COLOUR_LABEL, "SPEED"); + + // 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); + + 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"); + + // -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(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"); + + 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. + 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, + const ili9341_session_detail *detail, + 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, detail, out); + break; + + default: + break; + } +} diff --git a/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp new file mode 100644 index 0000000..576a52b --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/Lumex/LumexLCD.cpp @@ -0,0 +1,194 @@ +#include +#include +#include + +#include "FreeRTOS.h" // configTICK_RATE_HZ, for the static_assert below + +#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]; + + +// --- 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. +// +// 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. +// +// 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(); + + // 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; + + while ((uint32_t)(get_timestamp() - start) < microseconds && ++guard < guardLimit) + { + // Spin. Nothing else can usefully happen in 40 us. + } +} + +// 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); +} + +// 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 }, +}; + + +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)); +} + +bool LumexLCD::Init() +{ + // 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(), + TASK_OFFSET_DISPLAY, + static_cast(ERROR_DISPLAY_INIT_FAILURE) + ); + + _task_error_buffer_writer.WriteElementAndIncrementIndex(error_data); + return false; + } + + return true; +} + +bool LumexLCD::Clear() +{ + if (!_panel.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()) + { + _hasRendered = false; + 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 (!_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. + _hasRendered = false; + return false; + } + } + } + + _lastFrame = frame; + _lastScreen = state.screen; + _hasRendered = true; + + return true; +} + +static_assert(DisplayDriver, + "LumexLCD must satisfy DisplayDriver -- see Tasks/Display/DisplayDriver.hpp"); + +extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToDisplayHandle) +{ + LumexLCD lcd; + + if (!lcd.Init()) + { + // 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/Core/Src/Tasks/Display/Lumex/README.md b/firmware/Core/Src/Tasks/Display/Lumex/README.md new file mode 100644 index 0000000..1c39189 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/Lumex/README.md @@ -0,0 +1,166 @@ +--- +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, TimeKeeping] +--- + +# 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. + +`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. + +--- + +## 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]] · [[TimeKeeping]] diff --git a/firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c b/firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c new file mode 100644 index 0000000..2b0723a --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/Lumex/lumex_layout.c @@ -0,0 +1,135 @@ +#include "Tasks/Display/Lumex/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); +} + +// 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) +{ + // 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: rpm "); + PUT_LITERAL(out, 1, 0, "F: N "); + + char scratch[SCRATCH_SIZE]; + + 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, 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); + 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)display_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/Display/README.md b/firmware/Core/Src/Tasks/Display/README.md new file mode 100644 index 0000000..bb3bb54 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/README.md @@ -0,0 +1,227 @@ +--- +module: Display +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 +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: [Lumex display, ILI9341 display, SessionController, MessagePassing] +--- + +# 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 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. + +``` + 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 + layout [4] state -> what goes where + | a full frame, computed from the state alone + v + Render() [5] diff, then paint what moved + | + v + 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 drawing + +`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 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 + +`RunDisplayTask` (`DisplayDriver.hpp`) blocks on the queue, then **drains to the newest** +before drawing anything: + +```cpp +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. + +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. + +--- + +## 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.** + +There is deliberately **no common drawing API**, and that is the central design decision: + +- 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. + +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; + + // 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; +}; +``` + +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 — 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. + +### The rule both panels obey + +**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. + +--- + +## Choosing a panel + +`Core/Inc/Config/debug.h`, **at most one** enabled: + +```c +#define LUMEX_LCD_TASK_ENABLE 0 +#define ILI9341_LCD_TASK_ENABLE 1 +``` + +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 +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 panel's rendering -> Lumex/README.md + ILI9341/ the 320x240 TFT's rendering -> ILI9341/README.md +``` + +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. +- `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. + +--- + +## 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. + +`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) — the delay at the end of each pass of the task loop. +Panel-specific constants are listed in the panel READMEs. + +## Related +[[Lumex display]] · [[ILI9341 display]] · [[Lumex panel driver]] · [[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 new file mode 100644 index 0000000..979e4b2 --- /dev/null +++ b/firmware/Core/Src/Tasks/Display/display_common.c @@ -0,0 +1,43 @@ +#include "Tasks/Display/display_common.h" + +#include +#include +#include + +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; + } +} + +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/LumexLCD.cpp b/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp deleted file mode 100644 index e30e737..0000000 --- a/firmware/Core/Src/Tasks/LCD/LumexLCD.cpp +++ /dev/null @@ -1,312 +0,0 @@ -#include -#include -#include - -extern TIM_HandleTypeDef* lumexLcdTimer; - -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(osMessageQueueId_t sessionControllerToLumexLcdHandle) : - _task_error_buffer_writer(task_error_circular_buffer, &task_error_circular_buffer_index_writer, TASK_ERROR_CIRCULAR_BUFFER_SIZE), - _fromSCqHandle(sessionControllerToLumexLcdHandle) -{} - -bool LumexLCD::Init() -{ - - // 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); - - osDelay(40); - - - // Proper 8-bit mode initialization sequence - // Function set: 8-bit mode, 2-line, 5x8 font - if (!WriteCommand(0x38)) - { - return false; - } - - osDelay(5); - - - // needs to be done twice - if (!WriteCommand(0x38)) - { - return false; - } - - osDelay(5); - - // just to make sure it works - if (!WriteCommand(0x38)) - { - return false; - } - - osDelay(5); - - // Display ON, Cursor OFF, Blink OFF - if (!WriteCommand(0x0c)) - { - return false; - } - - osDelay(5); - - // Clear Display - if (!ClearDisplay()) - { - return false; - } - - return true; -} - - void LumexLCD::Run(void) - { - - session_controller_to_lumex_lcd msg; - memset(&msg, 0, sizeof(msg)); - - while (1) - { - // Block until a message arrives - if (osMessageQueueGet(_fromSCqHandle, &msg, 0, osWaitForever) == osOK) - { - // Drain any remaining messages to ensure we process all pending commands - do - { - 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; - } - } - 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::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_LUMEX_LCD, - 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; - } - - HAL_Delay(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(size < SESSION_CONTROLLER_TO_LUMEX_LCD_MSG_STRING_SIZE); - - for (uint8_t i = 0; i < size; i++) - { - if (!SetCursor(row, column)) - { - return false; - } - - if (!WriteData(string[i])) - { - return false; - } - - 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; - - -} - -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; - -} - -extern "C" void lumex_lcd_main(osMessageQueueId_t sessionControllerToLumexLcdHandle) -{ - LumexLCD lcd = LumexLCD(sessionControllerToLumexLcdHandle); - - if (!lcd.Init()) - { - osThreadSuspend(osThreadGetId());; - } - - - lcd.Run(); -} - - - - - - diff --git a/firmware/Core/Src/Tasks/LCD/README.md b/firmware/Core/Src/Tasks/LCD/README.md deleted file mode 100644 index 4628094..0000000 --- a/firmware/Core/Src/Tasks/LCD/README.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -module: LumexLCD -summary: Drives the Lumex character LCD; renders strings the SessionController FSM sends. -code: - - Core/Src/Tasks/LCD/LumexLCD.cpp - - Core/Inc/Tasks/LCD/LumexLCD.hpp - - 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)] -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. - -## 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`. - -## Internals -- `SendByte` toggles the data GPIO lines; enable-pin timing is gated by a hardware timer (`StartTimer`). -- `WriteData` / `WriteCommand` / `SetCursor` / `DisplayChar` / `DisplayString` / `ToggleBlink`. - -## 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` - -## Related -[[SessionController]] · [[MessagePassing]] 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 1617b83..9908d42 100644 --- a/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp +++ b/firmware/Core/Src/Tasks/SessionController/FiniteStateMachine.cpp @@ -2,8 +2,10 @@ #include "Config/sysconfig.h" -FSM::FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle) : - _sessionControllerToLumexLcdHandle(sessionControllerToLumexLcdHandle), +#include "TimeKeeping/timestamps.h" + +FSM::FSM(osMessageQueueId_t sessionControllerToDisplayHandle) : + _toDisplayHandle(sessionControllerToDisplayHandle), _state{ State::MainDynoState::INIT_STATE, State::SettingsState::INIT_STATE, @@ -14,6 +16,11 @@ FSM::FSM(osMessageQueueId_t sessionControllerToLumexLcdHandle) : _desiredRpm(5000), _pidEnabled(false), _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. @@ -25,7 +32,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 +106,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 +149,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 +197,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). @@ -222,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 @@ -253,6 +273,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 { @@ -295,10 +336,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 +344,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 +352,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,112 +361,138 @@ 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() { _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 // 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 -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)); +// 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. - WriteText(0, 3, buf); +// 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) +{ + _rpm = encoder_rpm(angularVelocity); + 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); + _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; + } - // %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); + PostDisplayState(); +} + +void FSM::DisplayAngularAcceleration(float angularAcceleration) +{ + _angularAcceleration = angularAcceleration; + 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)); + + msg.screen = CurrentScreen(); + msg.rpm = _rpm; + 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; + msg.angular_acceleration = _angularAcceleration; + msg.peak_force = _peakForce; - strncpy(msg.display_string, display_string, sizeof(msg.display_string) - 1); - msg.display_string[sizeof(msg.display_string) - 1] = '\0'; // Ensure null termination + // 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(_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..5cd89c4 100644 --- a/firmware/Core/Src/Tasks/SessionController/README.md +++ b/firmware/Core/Src/Tasks/SessionController/README.md @@ -12,14 +12,15 @@ 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] --- # 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` @@ -28,7 +29,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 +56,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..42b5798 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), @@ -60,8 +60,14 @@ bool SessionController::CheckTaskQueuesValid() || _task_queues->pid_controller == nullptr || _task_queues->pid_controller_ack == nullptr #endif - #if LUMEX_LCD_TASK_ENABLE - || _task_queues->lumex_lcd == nullptr + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_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 ) { @@ -72,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(); } @@ -136,7 +138,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 +221,8 @@ void SessionController::UpdateMeasurementDisplay() if (_prevAngularVelocity != _opticalData.angular_velocity) { - _fsm.DisplayRpm(_opticalData.angular_velocity); + _fsm.DisplayAngularVelocity(_opticalData.angular_velocity); + _fsm.DisplayAngularAcceleration(_opticalData.angular_acceleration); _prevAngularVelocity = _opticalData.angular_velocity; } @@ -230,6 +233,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(); @@ -237,6 +293,7 @@ void SessionController::Run() while (1) { _fsm.HandleUserInputs(); + DrainHostCommands(); PublishSdLoggingChange(); 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/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..88df05b 100644 --- a/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp +++ b/firmware/Core/Src/Tasks/TaskMonitor/TaskMonitor.cpp @@ -36,8 +36,8 @@ bool TaskMonitor::Init() #if PID_CONTROLLER_TASK_ENABLE || _osThreadIdPtrs->pid_controller == nullptr #endif - #if LUMEX_LCD_TASK_ENABLE - || _osThreadIdPtrs->lumex_lcd == nullptr + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) + || _osThreadIdPtrs->display == nullptr #endif ) { @@ -94,8 +94,8 @@ void TaskMonitor::Run() #if PID_CONTROLLER_TASK_ENABLE GetTaskDataAndSendToUsbController(TASK_OFFSET_PID_CONTROLLER, _osThreadIdPtrs->pid_controller); #endif - #if LUMEX_LCD_TASK_ENABLE - GetTaskDataAndSendToUsbController(TASK_OFFSET_LUMEX_LCD, _osThreadIdPtrs->lumex_lcd); + #if (LUMEX_LCD_TASK_ENABLE || ILI9341_LCD_TASK_ENABLE) + GetTaskDataAndSendToUsbController(TASK_OFFSET_DISPLAY, _osThreadIdPtrs->display); #endif GetTaskDataAndSendToUsbController(TASK_OFFSET_TASK_MONITOR, osThreadGetId()); 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/TimeKeeping/README.md b/firmware/Core/Src/TimeKeeping/README.md index 8e77e5c..7df85d0 100644 --- a/firmware/Core/Src/TimeKeeping/README.md +++ b/firmware/Core/Src/TimeKeeping/README.md @@ -12,7 +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; called once by [[SessionController]] in `Init()`. +- `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 @@ -30,6 +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. +- **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 fad461f..592a9b6 100644 --- a/firmware/Core/Src/main.c +++ b/firmware/Core/Src/main.c @@ -26,7 +26,8 @@ #include #include #include -#include +#include +#include #include #include @@ -75,7 +76,6 @@ SPI_HandleTypeDef hspi2; TIM_HandleTypeDef htim1; TIM_HandleTypeDef htim2; TIM_HandleTypeDef htim4; -TIM_HandleTypeDef htim13; TIM_HandleTypeDef htim16; /* Definitions for usbTask */ @@ -124,7 +124,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 */ @@ -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; @@ -191,6 +191,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 = { @@ -214,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; @@ -232,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); @@ -297,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(); @@ -312,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 */ @@ -330,8 +343,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); @@ -360,6 +373,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); @@ -738,11 +754,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_32; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; @@ -961,39 +977,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 @@ -1095,10 +1078,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_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_RESET); + 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); @@ -1118,7 +1104,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 */ @@ -1300,12 +1286,10 @@ 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." #else session_controller_os_task_queues tasks = { .usb_controller = sessionControllertoUsbControllerHandle, @@ -1315,7 +1299,9 @@ void sessionControllerTaskEntryFunction(void* argument) .bpm_controller = sessionControllerToBpmHandle, .pid_controller = sessionControllerToPidControllerHandle, .pid_controller_ack = pidControllerToSessionControllerAckHandle, - .lumex_lcd = sessionControllerToLumexLcdHandle + .display = sessionControllerToDisplayHandle, + .usb_command = usbToSessionControllerCommandHandle, + .task_completion = taskToUsbControllerResponseHandle }; sessioncontroller_main(&tasks); #endif @@ -1335,12 +1321,19 @@ 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 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); + #elif LUMEX_LCD_TASK_ENABLE == 1 + lumex_lcd_main(sessionControllerToDisplayHandle); #else - lumex_lcd_main(sessionControllerToLumexLcdHandle); + /* 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 } @@ -1382,7 +1375,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 @@ -1406,7 +1399,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 */ } @@ -1458,10 +1451,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/ILI9341/ILI9341.cpp b/firmware/Drivers/ILI9341/ILI9341.cpp new file mode 100644 index 0000000..a2c412a --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341.cpp @@ -0,0 +1,362 @@ +#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. +// +// 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 +// display glitch reported as hardware failure. +#define ILI9341_SPI_TIMEOUT_MS 1000 + + +ILI9341::ILI9341(SPI_HandleTypeDef* spi, + GPIO_TypeDef* csPort, uint16_t csPin, + GPIO_TypeDef* dcPort, uint16_t dcPin, + 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) +{} + + +// ---------------------------------------------------------------------------- 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); + _delay(5); + HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_RESET); + _delay(20); + HAL_GPIO_WritePin(_rstPort, _rstPin, GPIO_PIN_SET); + _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) + { + _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..deeaf17 --- /dev/null +++ b/firmware/Drivers/ILI9341/ILI9341.hpp @@ -0,0 +1,96 @@ +#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: + // 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, + DelayMs delay = HAL_Delay); + ~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; + + DelayMs _delay; + + 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..bfea49b --- /dev/null +++ b/firmware/Drivers/ILI9341/README.md @@ -0,0 +1,376 @@ +--- +module: ILI9341 driver +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 + - 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. + +**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 +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 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 table (`ILI9341_INIT_COMMANDS`); +- the command codes, MADCTL bits and RGB565 colour constants; +- `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. Rotation → origin corner and orientation. Upside down is rotation 1 vs 3. + +## Related +[[Display]] · [[Config]] · upstream: https://github.com/adafruit/Adafruit_ILI9341 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..2e9fafe --- /dev/null +++ b/firmware/Drivers/Lumex/LumexPanel_main.h @@ -0,0 +1,82 @@ +// 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. 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 +// 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..f26ea55 --- /dev/null +++ b/firmware/Drivers/Lumex/README.md @@ -0,0 +1,259 @@ +--- +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, TimeKeeping] +--- + +# 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. + +**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 + +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 +`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]]. + +--- + +## 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]] · [[TimeKeeping]] diff --git a/firmware/stm32_dyno_firmware_v2.ioc b/firmware/stm32_dyno_firmware_v2.ioc index b7be50d..0192a3d 100644 --- a/firmware/stm32_dyno_firmware_v2.ioc +++ b/firmware/stm32_dyno_firmware_v2.ioc @@ -22,8 +22,8 @@ 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.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.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 FREERTOS.configMAX_TASK_NAME_LEN=32 @@ -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 @@ -255,13 +252,15 @@ 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.GPIOParameters=GPIO_Speed,GPIO_Label PD7.GPIO_Label=ILI_SPI1_MOSI +PD7.GPIO_Speed=GPIO_SPEED_FREQ_LOW PD7.Mode=Full_Duplex_Master PD7.Signal=SPI1_MOSI PE3.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -298,13 +297,15 @@ 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.GPIOParameters=GPIO_Speed,GPIO_Label PG11.GPIO_Label=ILI_SPI1_SCK +PG11.GPIO_Speed=GPIO_SPEED_FREQ_LOW PG11.Mode=Full_Duplex_Master PG11.Signal=SPI1_SCK PG14.GPIOParameters=GPIO_PuPd,GPIO_Label,GPIO_ModeDefaultEXTI @@ -313,8 +314,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_LOW PG9.Mode=Full_Duplex_Master PG9.Signal=SPI1_MISO PH0-OSC_IN\ (PH0).Mode=HSE-External-Oscillator @@ -370,8 +372,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 @@ -406,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 @@ -517,9 +520,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_32 +SPI1.CalculateBaudRate=6.25 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 @@ -527,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 @@ -552,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 diff --git a/firmware/tests/CMakeLists.txt b/firmware/tests/CMakeLists.txt index c6155df..8b5ec37 100644 --- a/firmware/tests/CMakeLists.txt +++ b/firmware/tests/CMakeLists.txt @@ -35,18 +35,25 @@ 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/Display/Lumex/lumex_layout.c + ${FIRMWARE_DIR}/Core/Src/Tasks/Display/display_common.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 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/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/ili9341_layout_tests.cpp b/firmware/tests/ili9341_layout_tests.cpp new file mode 100644 index 0000000..906af08 --- /dev/null +++ b/firmware/tests/ili9341_layout_tests.cpp @@ -0,0 +1,429 @@ +// 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/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, + const ili9341_session_detail &detail = {}) +{ + ili9341_frame frame{}; + ili9341_layout(&state, &detail, &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 + +// ------------------------------------------------- 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"); +} + +// 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)); + 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/tests/lumex_layout_tests.cpp b/firmware/tests/lumex_layout_tests.cpp new file mode 100644 index 0000000..6aee459 --- /dev/null +++ b/firmware/tests/lumex_layout_tests.cpp @@ -0,0 +1,318 @@ +// 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. 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. + +#include + +#include + +extern "C" { +#include "Tasks/Display/display_common.h" +#include "Tasks/Display/Lumex/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.rpm = 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(DisplayCommon, EveryCursorPositionMapsToItsStep) +{ + 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 + +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 "); + 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.rpm = 1234.6f; + // 0123456789012345 + EXPECT_EQ(Row(Render(state), 0), "n: 1235 rpm "); + + state.rpm = 7.0f; + EXPECT_EQ(Row(Render(state), 0), "n: 7 rpm "); +} + +TEST(LumexLayout, SessionScreenForceFieldEndsWhereItsUnitBegins) +{ + // 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.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) +{ + 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 + +// --------------------------------------------------------- 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 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..7b78b45 100644 --- a/firmware/tools/message_gen/schema/messages_private.yaml +++ b/firmware/tools/message_gen/schema/messages_private.yaml @@ -22,30 +22,67 @@ 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: 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" } + - { 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" } + # 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 diff --git a/firmware/tools/message_gen/schema/messages_public.yaml b/firmware/tools/message_gen/schema/messages_public.yaml index 0b617c7..fb449a2 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 @@ -366,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: |- @@ -449,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/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 36d7fe6..c9770ba 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,194 @@ private void Navigate(AppPage page) [ObservableProperty] private double _dutyCycle; + /// The brake duty-cycle box, as a percentage (0 - 100). The firmware takes a 0 - 1 + /// fraction; the conversion happens on send. + /// + /// 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] + 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; + + /// 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() + { + _commandedDutyCyclePercent = null; + SetDutyCycleInput(DutyCycle * 100.0); + 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() + { + 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); + 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 + { + 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; @@ -772,6 +962,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. @@ -802,6 +997,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. @@ -941,6 +1137,7 @@ private void Apply(DeviceMessage message) break; case BpmSample s: DutyCycle = s.Data.duty_cycle; + MirrorDeviceDutyCycle(s.Data.duty_cycle * 100.0); Plots.RecordDutyCycle(s.Data.timestamp, s.Data.duty_cycle); break; 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 3778a94..43bc78d 100644 --- a/src/Dyno.App/Views/HomeView.axaml +++ b/src/Dyno.App/Views/HomeView.axaml @@ -10,6 +10,24 @@ + + + + + + + @@ -231,14 +249,32 @@ Text="{Binding GearRatio, StringFormat='{}{0:F3}'}" /> - + + Spacing="4" + > + + + 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: 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 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: put the brake's actual figure back in the box. + case Key.Escape: + e.Handled = true; + vm.RevertDutyCycleInput(); + 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/Firmware/ConfigExclusiveGroups.cs b/src/Dyno.Core/Firmware/ConfigExclusiveGroups.cs new file mode 100644 index 0000000..58b910b --- /dev/null +++ b/src/Dyno.Core/Firmware/ConfigExclusiveGroups.cs @@ -0,0 +1,32 @@ +namespace Dyno.Core.Firmware; + +/// +/// 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/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..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 } @@ -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 @@ -254,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 @@ -417,7 +433,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), @@ -427,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/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 e13a13d..e50e522 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,6 @@ 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); } } 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); + } }