[READ-ONLY] Mirror of https://github.com/jmrplens/kleidos. Kleidos — Hardware BLE password manager for ESP32-S3 (M5StickC Plus2, StickS3, M5Stack Gray, T-Deck, Cardputer) jmrplens.github.io/kleidos/
0

Configure Feed

Select the types of activity you want to include in your feed.

Tune the SSD1681 fast waveform to 5 fps and add 2-bit grayscale rendering

Optimization round on the CoreInk fast-animation mode, all measured on
silicon via the EINKLUT live-tuning command (sweep waveform variants over
serial without reflashing):
- Fast partials run 0x22 = 0x8C: skipping the enable-analog stage (its
HV-ready detection costs ~91 ms even with the rails already up) cuts the
per-update fixed cost to ~94 ms. The stage is mandatory after full
refreshes, whose sequence powers the analog down, so the bank-settle pass
keeps 0xCC and doubles as the analog power-up (fastSettleSequence).
- Built-in waveform is the owner-picked quality-ladder point: 4 drive
frames at +/-17 V source voltage (VCOM/VGH stay on the panel's OTP trim)
plus a 1-frame reinforcement poke, the only ladder variant whose
animation trail self-cleans: 194 ms/update, 5.1 fps (from 503 ms / 1.5
fps at the start of the effort). BUSY poll tightened to 1 ms; the
post-update RAM rewrite proved redundant (read-back-verified) and the
gate-scan-window experiment proved frame period is FR-fixed, so neither
ships.
- ssd1681_lut.h: pure constexpr builders for the 153-byte waveform layout
(fast differential LUT with kick/burst/rest/poke knobs, graded grayscale
LUT, source-voltage register codes), the single source of truth shared
by the controller tables, EINKLUT and EINKGRAY, with a native host test
suite pinning the layout against the datasheet.
- 2-bit grayscale rendering (EpaperPanelDriver::renderGrayImage +
display::renderEpaperGray + EINKGRAY): both RAM planes select one of
four LUT slots per pixel, so graded drive lengths yield 4 tones in one
update or 6 tones (white + 4 grays + black) in two stacked updates —
owner-validated on the panel. The command holds the tones until serial
input (returning lets the FSM repaint and collapse them).
- SSD1681 register read-back (readPanelRegister over the shared SDA line):
the 0x2F Status Bit Read chip-ID bits fingerprint the controller in
silicon, settling that this CoreInk unit carries the D67/SSD1681 panel
revision, and SPIRD now works on e-paper. ANIMTEST gains TAG plus an
on-panel firmware-revision letter so tuning-ladder observers always know
which build/step they are watching.

Claude-Session: https://claude.ai/code/session_01SV58JXhfxhhc9DC6vdjBo4

José M. Requena Plens (Jul 14, 2026, 11:51 PM +0200) 7499db75 fe160a03

+1029 -113
+1
platformio.ini
··· 425 425 vault/test_sd_vault_sync 426 426 ble/test_gatt_fuzz_ext 427 427 crypto/test_crypto_selftest 428 + drivers/test_ssd1681_lut 428 429 drivers/test_regmap_types 429 430 drivers/test_axp2101_regmap 430 431 drivers/test_axp192_regmap
+65 -71
src/drivers/display/gdew0154d67.cpp
··· 6 6 #include "drivers/display/gdew0154d67.h" 7 7 8 8 #include "drivers/display/i_epaper_controller.h" 9 + #include "drivers/display/ssd1681_lut.h" 9 10 10 11 #include <array> 11 12 #include <cstddef> ··· 14 15 15 16 namespace kleidos::drivers::display { 16 17 namespace { 18 + 19 + namespace ssd1681 = kleidos::drivers::display::ssd1681; 17 20 18 21 // Power-on initialization for the GDEW0154D67 (SSD1681), transcribed from the 19 22 // LovyanGFX / M5GFX Panel_GDEW0154D67 reference (getInitCommands list0), which ··· 67 70 // 68 71 // The OTP partial waveform drives ~17 frames of ghost compensation per update 69 72 // (~500 ms wall). For animations Kleidos trades that compensation for speed 70 - // with a custom differential LUT: only pixels whose value CHANGED get a short 71 - // drive burst; unchanged pixels are left untouched (VSS no-op), so a small 72 - // moving region animates without disturbing — or transiently re-developing 73 - // ghosts on — the rest of the panel. 73 + // with a custom differential LUT: pixels whose value CHANGED get a short 74 + // drive burst, and every pixel gets a 1-frame reinforcement nudge toward its 75 + // current value (the poke) that grinds residue and bloom away, so a moving 76 + // region animates cleanly without the OTP waveform's long compensation. 74 77 // Structure per the SSD1681 datasheet (§6.6-6.7): 5 voltage-selection LUTs 75 78 // (LUT0-3 indexed by the {RED RAM, BW RAM} bit pair, LUT4 = VCOM) x 12 groups 76 79 // x 4 phases (2-bit VS codes: 00=VSS 01=VSH1 10=VSL 11=VSH2), then 12 groups ··· 84 87 // driver bounds fast-mode streaks and consolidates with a full OTP refresh on 85 88 // exit. 86 89 87 - // Frame plan (values are SSD1681 LUT TP frame counts, ~20 ms each at FR 2). 88 - // The erase direction is delivered as burst-rest-burst: commercial fast-mono 89 - // waveforms (E Ink A2 for this GDEW panel family) chop the drive with rest 90 - // gaps because pigment re-mobilizes during the rest, improving completeness at 91 - // equal net charge. 92 - /// Inverse-kick frames opening the erase (unsticks freshly driven pigment). 93 - constexpr uint8_t kFastKickFrames = 1; 94 - /// First drive burst frames (both directions). 95 - constexpr uint8_t kFastBurstAFrames = 4; 96 - /// Rest frames between the two bursts (sources at VSS). 97 - constexpr uint8_t kFastRestFrames = 1; 98 - /// Second drive burst frames (both directions). 99 - constexpr uint8_t kFastBurstBFrames = 3; 90 + // Frame plan (built by the shared ssd1681_lut.h builder; ~20 ms/frame at FR 91 + // code 2). The owner-selected point from the on-device quality ladders: 4 92 + // drive frames at +/-17 V source voltage plus a 1-frame reinforcement poke — 93 + // 194 ms per update (~5.1 fps). The poke (group 1 phase A nudges EVERY pixel 94 + // toward its current value) was the only ladder variant whose trail 95 + // self-cleans instead of accumulating. Alternatives (inverse kick, chopped 96 + // bursts, other frame counts) remain sweepable live via the EINKLUT command. 97 + constexpr ssd1681::FastLutSpec kFastWaveform = { 98 + .kickFrames = 0, 99 + .driveFrames = 4, 100 + .restFrames = 1, // doubles as the poke duration 101 + .burstFrames = 0, 102 + .poke = true, 103 + .frameRate = 2, 104 + }; 100 105 101 - constexpr uint8_t kFastLut[153] = { 102 - // VS, LUT0 (old black -> black): unchanged pixel, never driven (A2 style: 103 - // no reinforcement poke — residue cleanup is the scheduled deghost pass). 104 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 105 - // VS, LUT1 (old black -> white): VSH1 kick + VSL burst, rest, VSL burst. 106 - 0x60, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 107 - // VS, LUT2 (old white -> black): VSH1 bursts in the same slots (no kick — 108 - // driving ink into white film needs less help than clearing it). 109 - 0x10, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 110 - // VS, LUT3 (old white -> white): unchanged pixel, never driven. 111 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 112 - // VS, LUT4 (VCOM): held at DC for every group. 113 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 114 - // Timing group 0: phase A = erase kick (rest for ink), phase B = burst 1. 115 - kFastKickFrames, kFastBurstAFrames, 0x00, 0x00, 0x00, 0x00, 0x00, 116 - // Timing group 1: phase A = rest gap, phase B = burst 2. 117 - kFastRestFrames, kFastBurstBFrames, 0x00, 0x00, 0x00, 0x00, 0x00, 118 - // Timing groups 2-11: unused. 119 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 120 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 121 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 122 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 123 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 124 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 125 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 126 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 127 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 128 - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 129 - // Frame-rate code 2 for every group (two 3-bit codes per byte, ~20 ms/frame 130 - // measured on-device; the ~150 ms/update sequencing cost is FR-independent). 131 - 0x22, 0x22, 0x22, 0x22, 0x22, 0x22, 132 - // XON gate-scan selection: unused. 133 - 0x00, 0x00, 0x00 }; 106 + constexpr auto kFastLut = ssd1681::buildFastLut( kFastWaveform ); 134 107 135 - // Fast-mode entry uploads ONLY the waveform LUT (plus the flicker-free border 136 - // setting). The analog operating point — gate/source voltages, VCOM and the 137 - // LUT-end option — is deliberately NOT overridden: those registers hold the 138 - // panel's per-unit factory calibration, loaded from OTP by every full update 139 - // (0x22 = 0xF7/0xF8), and VCOM in particular is individually trimmed per 140 - // panel. Driving with a generic VCOM skews every transition into a net-DC 141 - // pulse, producing exactly the incomplete-erase ghosting a custom LUT is 142 - // blamed for. 143 - constexpr uint8_t kFastBorderWaveform[] = { 0x80 }; // VBD follows VCOM (no flicker) 108 + // Fast-mode entry uploads the waveform LUT, the flicker-free border setting 109 + // and a raised source voltage. VSH1/VSL run at the +/-17 V datasheet maxima 110 + // (stock +/-15 V): the higher source voltage buys back transition contrast 111 + // lost to the short drive burst — the classic voltage-for-time trade for fast 112 + // e-paper waveforms. VCOM and the gate voltage are deliberately NOT touched: 113 + // they hold the panel's per-unit factory trim (reloaded from OTP by every full 114 + // update), and driving against a generic VCOM skews every transition into a 115 + // net-DC pulse with incomplete-erase ghosting. 116 + constexpr uint8_t kFastBorderWaveform[] = { 0x80 }; // VBD follows VCOM (no flicker) 117 + constexpr uint8_t kFastSourceVoltage[] = { 0x4B, 0xB0, 0x3A }; // VSH1 17 V, VSH2 5.8 V, VSL -17 V 144 118 145 119 constexpr auto kFastModeEnterSequence = std::to_array<EpaperCommand>( { 146 120 { 0x3C, kFastBorderWaveform, sizeof( kFastBorderWaveform ), 0, false }, 147 - { 0x32, kFastLut, sizeof( kFastLut ), 0, false }, 121 + { 0x32, kFastLut.data(), sizeof( kFastLut ), 0, false }, 122 + { 0x04, kFastSourceVoltage, sizeof( kFastSourceVoltage ), 0, false }, 148 123 } ); 149 124 150 125 // Exit powers the analog rails/oscillator down (fast partials leave them ··· 159 134 { 0x3C, kBorderWaveform, sizeof( kBorderWaveform ), 0, false }, 160 135 } ); 161 136 162 - // Fast partial refresh: 0xCC = enable clock + analog, display with DISPLAY 163 - // Mode 2 — critically WITHOUT the load-LUT/temperature steps of 0xFF (they 164 - // would overwrite the custom LUT with the OTP one) and WITHOUT the trailing 165 - // disable-analog/OSC steps of 0xCF: powering the analog rails down and back 166 - // up costs ~160 ms per update on this panel, so during an animation they stay 167 - // on between frames (the fast-mode exit sequence shuts them down once). 168 - // 0xCC is not one of the datasheet's enumerated 0x22 sequences but is the 169 - // documented 0xCF minus its two trailing disable stages; validated on-device. 170 - // A further ~150 ms/update of controller-internal sequencing is a fixed floor 171 - // (FR-independent, measured), capping fast partials at ~6 updates/s. 172 - constexpr uint8_t kUpdateControl2FastPartial[] = { 0xCC }; 137 + // Fast partial refresh (steady state): 0x8C = enable clock, display with 138 + // DISPLAY Mode 2 — critically WITHOUT the load-LUT/temperature steps of 0xFF 139 + // (they would overwrite the custom LUT with the OTP one), WITHOUT trailing 140 + // disable stages (powering the rails down and back up costs ~160 ms/update; an 141 + // animation keeps them on and the exit sequence shuts them down once), and 142 + // WITHOUT the enable-ANALOG stage of 0xCC, whose HV-ready detection costs a 143 + // further ~91 ms even with the rails already up. 0x8C therefore REQUIRES the 144 + // analog to have been enabled by a previous 0xCC update — that is the settle 145 + // sequence below, which runs after every full refresh (their sequence powers 146 + // the analog down) and on fast-mode entry. Neither byte is one of the 147 + // datasheet's enumerated 0x22 sequences; both decompose from the documented 148 + // stage bits and are validated on-device. The remaining ~94 ms/update of 149 + // controller-internal sequencing is a fixed floor (FR- and gate-count- 150 + // independent, measured), capping fast partials at ~10 updates/s. 151 + constexpr uint8_t kUpdateControl2FastPartial[] = { 0x8C }; 173 152 174 153 constexpr auto kFastPartialRefreshSequence = std::to_array<EpaperCommand>( { 175 154 { 0x22, kUpdateControl2FastPartial, sizeof( kUpdateControl2FastPartial ), 0, false }, 155 + { 0x20, nullptr, 0, 0, true }, // Master Activation, wait for BUSY 156 + } ); 157 + 158 + // Bank-settle update: 0xCC = enable clock + ANALOG, display with DISPLAY 159 + // Mode 2, no trailing disables. Used by the driver's alternate-bank settle 160 + // pass, which doubles as the analog power-up for the 0x8C steady state. 161 + constexpr uint8_t kUpdateControl2FastSettle[] = { 0xCC }; 162 + 163 + constexpr auto kFastSettleSequence = std::to_array<EpaperCommand>( { 164 + { 0x22, kUpdateControl2FastSettle, sizeof( kUpdateControl2FastSettle ), 0, false }, 176 165 { 0x20, nullptr, 0, 0, true }, // Master Activation, wait for BUSY 177 166 } ); 178 167 ··· 222 211 const EpaperCommand* Gdew0154D67::fastPartialRefreshSequence( size_t& count ) const { 223 212 count = std::size( kFastPartialRefreshSequence ); 224 213 return kFastPartialRefreshSequence.data(); 214 + } 215 + 216 + const EpaperCommand* Gdew0154D67::fastSettleSequence( size_t& count ) const { 217 + count = std::size( kFastSettleSequence ); 218 + return kFastSettleSequence.data(); 225 219 } 226 220 227 221 } // namespace kleidos::drivers::display
+1
src/drivers/display/gdew0154d67.h
··· 44 44 const EpaperCommand* fastModeEnterSequence( size_t& count ) const override; 45 45 const EpaperCommand* fastModeExitSequence( size_t& count ) const override; 46 46 const EpaperCommand* fastPartialRefreshSequence( size_t& count ) const override; 47 + const EpaperCommand* fastSettleSequence( size_t& count ) const override; 47 48 48 49 uint16_t panelWidth() const override { return kPanelWidth; } 49 50 uint16_t panelHeight() const override { return kPanelHeight; }
+16
src/drivers/display/i_epaper_controller.h
··· 202 202 } 203 203 204 204 /** 205 + * @brief Optional settle/power-up variant of the fast partial refresh. 206 + * 207 + * Run by the driver's alternate-bank settle pass (after full refreshes and 208 + * on fast-mode entry). Unlike @ref fastPartialRefreshSequence it must 209 + * include the enable-analog stage (e.g. 0x22 = 0xCC), because full-refresh 210 + * sequences power the analog rails down and the steady-state fast partial 211 + * may skip that stage for speed. Defaults to the fast partial sequence. 212 + * 213 + * @param[out] count Receives the number of entries (0 if unsupported). 214 + * @return Pointer to the settle command table, or nullptr if unsupported. 215 + */ 216 + virtual const EpaperCommand* fastSettleSequence( size_t& count ) const { 217 + return fastPartialRefreshSequence( count ); 218 + } 219 + 220 + /** 205 221 * @brief Native panel width in pixels. 206 222 * 207 223 * @return Native panel width in pixels.
+205
src/drivers/display/ssd1681_lut.h
··· 1 + /** 2 + * @file ssd1681_lut.h 3 + * @brief Pure constexpr builders for SSD1681 waveform LUTs (register 0x32). 4 + * 5 + * Single source of truth for the 153-byte SSD1681 waveform-setting layout 6 + * (datasheet §6.6-6.7): 5 voltage-selection LUTs (LUT0-3 indexed by the 7 + * {old, new} = {RED RAM, BW RAM} pixel bit pair, LUT4 = VCOM) x 12 groups x 4 8 + * phases, then 12 groups x 7 timing bytes (TP_A TP_B SR_AB TP_C TP_D SR_CD 9 + * RP), 6 packed frame-rate bytes and 3 XON bytes. Used by the GDEW0154D67 10 + * controller tables, the EINKLUT live-tuning command and the EINKGRAY 11 + * grayscale renderer; being pure and header-only it is exercised by the 12 + * native host tests. 13 + * 14 + * On-device timing anchor (CoreInk, FR code 2): one waveform frame is ~20 ms; 15 + * FR code 7 is ~5 ms. The controller adds a fixed ~94 ms of sequencing per 16 + * update on top of the programmed frames. 17 + * 18 + * @sa Gdew0154D67, IEpaperController 19 + */ 20 + #pragma once 21 + 22 + #include <array> 23 + #include <cstddef> 24 + #include <cstdint> 25 + 26 + namespace kleidos::drivers::display::ssd1681 { 27 + 28 + /** 29 + * @addtogroup drv_display 30 + * @{ 31 + */ 32 + 33 + /// Byte count of one SSD1681 waveform LUT (the 0x32 register payload). 34 + inline constexpr size_t kLutBytes = 153; 35 + 36 + /// 2-bit source-voltage codes for one waveform phase (datasheet Table 6-6). 37 + enum class PhaseVoltage : uint8_t { 38 + Vss = 0x0, ///< No drive (source at VSS). 39 + Vsh1 = 0x1, ///< Drive toward black. 40 + Vsl = 0x2, ///< Drive toward white. 41 + Vsh2 = 0x3, ///< Secondary positive source level. 42 + }; 43 + 44 + /// Byte layout anchors of the 153-byte waveform setting. 45 + inline constexpr size_t kVsBytesPerLut = 12; ///< One VS row: 12 group bytes. 46 + inline constexpr size_t kTimingOffset = 60; ///< First timing byte (group 0 TP_A). 47 + inline constexpr size_t kTimingBytesPerGrp = 7; ///< TP_A TP_B SR_AB TP_C TP_D SR_CD RP. 48 + inline constexpr size_t kFrameRateOffset = 144; ///< Six packed FR nibble bytes. 49 + inline constexpr size_t kFrameRateBytes = 6; ///< Two 3-bit FR codes per byte. 50 + 51 + /** 52 + * @brief Pack a voltage code into one 2-bit phase field of a VS byte. 53 + * 54 + * @param[in] voltage Source voltage for the phase. 55 + * @param[in] phase Phase slot 0 (A) .. 3 (D); phase A occupies the top bits. 56 + * @return The voltage code shifted into the requested field. 57 + */ 58 + constexpr uint8_t vsPhase( PhaseVoltage voltage, unsigned phase ) { 59 + return static_cast<uint8_t>( static_cast<uint8_t>( voltage ) << ( 6U - 2U * phase ) ); 60 + } 61 + 62 + /** 63 + * @brief Fast-animation waveform recipe (drive only what changed). 64 + * 65 + * Group 0 phase A is an optional inverse kick on the erase direction (a short 66 + * pulse toward black unsticks freshly driven pigment before the white drive), 67 + * group 0 phase B the main drive burst for both directions, and group 1 68 + * phase A an optional reinforcement poke that nudges EVERY pixel toward its 69 + * current value — the only ladder variant whose animation trail self-cleans. 70 + * All durations are in waveform frames; 0 skips the phase. 71 + */ 72 + struct FastLutSpec { 73 + uint8_t kickFrames = 0; ///< Inverse kick on the erase direction. 74 + uint8_t driveFrames = 4; ///< Main drive burst (both directions). 75 + uint8_t restFrames = 0; ///< Idle gap after the drive (group 1 phase A slot). 76 + uint8_t burstFrames = 0; ///< Optional second drive burst (group 1 phase B). 77 + bool poke = false; ///< Reinforce all pixels in the group 1 phase A slot. 78 + uint8_t frameRate = 2; ///< FR code 0-7 for every group (~20 ms/frame at 2). 79 + }; 80 + 81 + /** 82 + * @brief Build a fast-animation differential LUT from @p spec. 83 + * 84 + * LUT0/LUT3 (unchanged pixels) stay undriven unless @c spec.poke is set; 85 + * LUT1 (black to white) gets the kick + drive; LUT2 (white to black) gets the 86 + * drive in the same slots without a kick (driving ink into white film needs 87 + * less help than clearing it). 88 + * 89 + * @param[in] spec Frame counts and options. 90 + * @return The 153-byte 0x32 payload. 91 + */ 92 + constexpr std::array<uint8_t, kLutBytes> buildFastLut( const FastLutSpec& spec ) { 93 + std::array<uint8_t, kLutBytes> lut = {}; 94 + 95 + // VS rows: [0]=LUT0 (old black -> black), [12]=LUT1 (black -> white), 96 + // [24]=LUT2 (white -> black), [36]=LUT3 (white -> white). 97 + const uint8_t whiteDrive = vsPhase( PhaseVoltage::Vsl, 1 ); 98 + const uint8_t blackDrive = vsPhase( PhaseVoltage::Vsh1, 1 ); 99 + lut[12] = static_cast<uint8_t>( ( spec.kickFrames > 0 ? vsPhase( PhaseVoltage::Vsh1, 0 ) : 0U ) 100 + | whiteDrive ); 101 + lut[24] = blackDrive; 102 + if ( spec.burstFrames > 0 ) { 103 + lut[13] = whiteDrive; 104 + lut[25] = blackDrive; 105 + } 106 + if ( spec.poke ) { 107 + // Group 1 phase A: every pixel nudged toward its current value. 108 + lut[1] = vsPhase( PhaseVoltage::Vsh1, 0 ); 109 + lut[13] |= vsPhase( PhaseVoltage::Vsl, 0 ); 110 + lut[25] |= vsPhase( PhaseVoltage::Vsh1, 0 ); 111 + lut[37] = vsPhase( PhaseVoltage::Vsl, 0 ); 112 + } 113 + 114 + // Timing: group 0 = {kick, drive}, group 1 = {rest/poke, second burst}. 115 + lut[kTimingOffset] = spec.kickFrames; 116 + lut[kTimingOffset + 1] = spec.driveFrames; 117 + lut[kTimingOffset + kTimingBytesPerGrp] = spec.restFrames; 118 + lut[kTimingOffset + kTimingBytesPerGrp + 1] = spec.burstFrames; 119 + 120 + const auto frPacked = 121 + static_cast<uint8_t>( ( spec.frameRate << 4 ) | ( spec.frameRate & 0x0FU ) ); 122 + for ( size_t i = 0; i < kFrameRateBytes; ++i ) { 123 + lut[kFrameRateOffset + i] = frPacked; 124 + } 125 + return lut; 126 + } 127 + 128 + /** 129 + * @brief One grayscale render pass: graded black-ward drive per 2-bit index. 130 + * 131 + * Departures from a white base: index 3 (LUT0) drives for @c blackFrames, 132 + * index 2 (LUT2) for @c darkFrames, index 1 (LUT3) for @c lightFrames and 133 + * index 0 (LUT1) not at all. Stacking passes accumulates drive, splitting the 134 + * ladder into more tones (4 levels in one pass, 6 in two). 135 + */ 136 + struct GrayLutSpec { 137 + uint8_t lightFrames = 1; ///< Drive frames for the lightest driven tone. 138 + uint8_t darkFrames = 4; ///< Drive frames for the middle tone. 139 + uint8_t blackFrames = 10; ///< Drive frames for the darkest tone. 140 + uint8_t frameRate = 2; ///< FR code 0-7 for every group. 141 + }; 142 + 143 + /** 144 + * @brief Build one grayscale-pass LUT from @p spec. 145 + * 146 + * All driven indexes share phase A of groups 0-2 at VSH1 (black-ward); the 147 + * graded TP counts stop the light tone first, then the dark tone, leaving 148 + * only black driving to the end. Requires lightFrames < darkFrames < 149 + * blackFrames (the timing bytes store the differences). 150 + * 151 + * @param[in] spec Per-tone drive frames. 152 + * @return The 153-byte 0x32 payload. 153 + */ 154 + constexpr std::array<uint8_t, kLutBytes> buildGrayLut( const GrayLutSpec& spec ) { 155 + std::array<uint8_t, kLutBytes> lut = {}; 156 + 157 + const uint8_t blackPhaseA = vsPhase( PhaseVoltage::Vsh1, 0 ); 158 + lut[0] = blackPhaseA; // LUT0 (index 3, black), group 0 159 + lut[24] = blackPhaseA; // LUT2 (index 2, dark), group 0 160 + lut[36] = blackPhaseA; // LUT3 (index 1, light), group 0 161 + lut[1] = blackPhaseA; // LUT0, group 1 (black + dark continue) 162 + lut[25] = blackPhaseA; // LUT2, group 1 163 + lut[2] = blackPhaseA; // LUT0, group 2 (black continues alone) 164 + 165 + lut[kTimingOffset] = spec.lightFrames; 166 + lut[kTimingOffset + kTimingBytesPerGrp] = 167 + static_cast<uint8_t>( spec.darkFrames - spec.lightFrames ); 168 + lut[kTimingOffset + 2 * kTimingBytesPerGrp] = 169 + static_cast<uint8_t>( spec.blackFrames - spec.darkFrames ); 170 + 171 + const auto frPacked = 172 + static_cast<uint8_t>( ( spec.frameRate << 4 ) | ( spec.frameRate & 0x0FU ) ); 173 + for ( size_t i = 0; i < kFrameRateBytes; ++i ) { 174 + lut[kFrameRateOffset + i] = frPacked; 175 + } 176 + return lut; 177 + } 178 + 179 + /// Source-voltage register codes (0x04 parameters, datasheet p.21 tables). 180 + inline constexpr uint8_t kVsh1CodeFor9V = 0x23; ///< VSH1 9 V anchor; +0.2 V per code. 181 + inline constexpr uint8_t kVslCodeForNeg9V = 0x1A; ///< VSL -9 V anchor; -0.5 V per 2 codes. 182 + inline constexpr uint8_t kVsh2Code5V8 = 0xB0; ///< VSH2 5.8 V (stock operating point). 183 + 184 + /** 185 + * @brief Convert a positive VSH1 level in whole volts to its 0x04 code. 186 + * @param[in] volts VSH1 level, 9..17 V. 187 + * @return The 0x04 register code (0.2 V per step from the 9 V anchor). 188 + */ 189 + constexpr uint8_t vsh1Code( int volts ) { 190 + return static_cast<uint8_t>( kVsh1CodeFor9V + ( volts - 9 ) * 5 ); 191 + } 192 + 193 + /** 194 + * @brief Convert a negative VSL level in whole volts to its 0x04 code. 195 + * @param[in] volts VSL level, -9..-17 V. 196 + * @return The 0x04 register code (0.5 V per two codes from the -9 V anchor). 197 + */ 198 + constexpr uint8_t vslCode( int volts ) { 199 + return static_cast<uint8_t>( kVslCodeForNeg9V + ( -volts - 9 ) * 4 ); 200 + } 201 + 202 + /** 203 + * @} 204 + */ 205 + } // namespace kleidos::drivers::display::ssd1681
+41 -6
src/hal/common/display_hal.h
··· 70 70 /** 71 71 * @brief Switch e-paper partial refreshes between the quality and fast waveforms. 72 72 * 73 - * Only meaningful on e-paper: fast mode loads a custom short animation LUT so 74 - * region updates complete in the ~100 ms class instead of the OTP waveform's 75 - * ghost-compensated ~500 ms, trading contrast and ghosting hygiene for speed. 73 + * Only meaningful on e-paper: fast mode loads a custom short animation LUT and 74 + * keeps the analog rails on between updates, so region updates complete in 75 + * ~194 ms (vs the OTP waveform's ghost-compensated ~503 ms), trading contrast 76 + * headroom for speed. 76 77 * Callers must keep fast-mode streaks bounded (an animation, a live counter) 77 78 * and disable it afterwards — leaving fast mode consolidates with a full 78 79 * refresh on the next flush. Defined by the e-paper backend only: gate call ··· 88 89 * 89 90 * Plane selectors match @ref display::driver::EpaperPanelDriver::readPanelRam: 90 91 * 0 = controller B/W RAM, 1 = controller old-image RAM (both read over SPI — 91 - * the ground truth of what the panel controller holds), 2-4 = the driver's 92 - * CPU-side current/previous/older frames. Defined by the e-paper backend only: 92 + * the ground truth of what the panel controller holds), 2/3 = the driver's 93 + * CPU-side current/previous frames. Defined by the e-paper backend only: 93 94 * gate call sites on @c device::kDisplayIsEpaper (the @ref setInvert pattern). 94 95 * 95 - * @param[in] plane Plane selector (0-4). 96 + * @param[in] plane Plane selector (0-3). 96 97 * @param[out] out Buffer receiving the 1 bpp row-major plane. 97 98 * @param[in] outSize Capacity of @p out in bytes. 98 99 * @retval true Plane read/copied. 99 100 * @retval false Unsupported plane/controller, not ready, or SPI error. 100 101 */ 101 102 bool readEpaperRam( uint8_t plane, uint8_t* out, size_t outSize ); 103 + /** 104 + * @brief Override the e-paper fast-animation waveform at runtime (QA tuning). 105 + * 106 + * Mirrors @ref display::driver::EpaperPanelDriver::setFastTuning: replaces the 107 + * fast LUT (153 bytes), optionally the source-voltage parameters and the 108 + * Display Update Control 2 byte used by fast partials, until cleared with 109 + * all-null arguments. The live tuning seam for the EINKLUT waveform-sweep 110 + * workflow. Defined by the e-paper backend only: gate call sites on 111 + * @c device::kDisplayIsEpaper (the @ref setInvert pattern). 112 + * 113 + * @param[in] lut 153-byte LUT, or nullptr to restore the built-in. 114 + * @param[in] sourceVoltage Three 0x04 bytes (VSH1, VSH2, VSL), or nullptr for 115 + * the panel's OTP calibration. 116 + * @param[in] updateMode 0x22 byte for fast partials, or -1 for built-in. 117 + * @retval true Overrides stored (and pushed live if fast mode is active). 118 + * @retval false Panel not ready. 119 + */ 120 + bool setEpaperFastTuning( const uint8_t* lut, const uint8_t* sourceVoltage, int updateMode ); 121 + /** 122 + * @brief Render a 2-bit grayscale image on the e-paper (QA/exploration). 123 + * 124 + * Mirrors @ref display::driver::EpaperPanelDriver::renderGrayImage: the two 125 + * RAM planes select one of four LUTs per pixel, so a graded-drive LUT yields 126 + * black / dark gray / light gray / white in a single update. Defined by the 127 + * e-paper backend only: gate call sites on @c device::kDisplayIsEpaper (the 128 + * @ref setInvert pattern). 129 + * 130 + * @param[in] msbPlane Gray-index MSB plane (1 bpp row-major, RED RAM). 131 + * @param[in] lsbPlane Gray-index LSB plane (1 bpp row-major, B/W RAM). 132 + * @param[in] lut 153-byte waveform LUT with per-index drive lengths. 133 + * @retval true Image rendered; the next flush resynchronizes with a full. 134 + * @retval false Panel not ready or bad arguments. 135 + */ 136 + bool renderEpaperGray( const uint8_t* msbPlane, const uint8_t* lsbPlane, const uint8_t* lut ); 102 137 /** 103 138 * @brief Put the active display into its low-power sleep state. 104 139 *
+14 -7
src/hal/common/display_hal_epaper.cpp
··· 290 290 return s_panel.readPanelRam( plane, out, outSize ); 291 291 } 292 292 293 + bool setEpaperFastTuning( const uint8_t* lut, const uint8_t* sourceVoltage, int updateMode ) { 294 + flushPending(); 295 + return s_panel.setFastTuning( lut, sourceVoltage, updateMode ); 296 + } 297 + 298 + bool renderEpaperGray( const uint8_t* msbPlane, const uint8_t* lsbPlane, const uint8_t* lut ) { 299 + flushPending(); 300 + return s_panel.renderGrayImage( msbPlane, lsbPlane, lut ); 301 + } 302 + 293 303 bool canReadRect() { 294 304 return s_panel.canReadRect(); 295 305 } ··· 300 310 } 301 311 302 312 bool readPanelRegister( uint8_t cmd, uint8_t* out, size_t len ) { 303 - // E-paper controllers are driven write-only here; there is no SPI panel 304 - // register read-back path. Report unavailable so the console prints a clean 305 - // "no panel readback on this board" instead of returning junk. 306 - (void)cmd; 307 - (void)out; 308 - (void)len; 309 - return false; 313 + // SSD1681 registers read back over the shared SDA line (half-duplex 314 + // three-wire), e.g. 0x2F Status Bit Read with the chip-ID bits. 315 + flushPending(); 316 + return s_panel.readPanelRegister( cmd, out, len ); 310 317 } 311 318 312 319 uint32_t frameSequence() {
+158 -24
src/hal/display/epaper_panel_driver.cpp
··· 23 23 24 24 constexpr const char* kTag = "EpaperPanel"; 25 25 constexpr size_t kSpiChunkBytes = 4096; 26 - constexpr uint16_t kBusyPollMs = 5; 27 - constexpr uint8_t kInkThreshold = 48; ///< RGB luminance below this maps to ink. 26 + // 1 ms BUSY poll: each update pays one poll-granularity slack on the release 27 + // edge, and at animation rates (several updates per second) a coarser poll 28 + // wastes a visible slice of the frame budget. 29 + constexpr uint16_t kBusyPollMs = 1; 30 + constexpr uint8_t kInkThreshold = 48; ///< RGB luminance below this maps to ink. 28 31 /// Partial refreshes between forced full refreshes (SSD1681 ghosting cleanup). 29 32 /// Tuned on-device; lower = cleaner but more full-flash, higher = less flash but 30 33 /// more residual ghosting. ··· 494 497 const kleidos::drivers::display::EpaperCommand* enterSeq = 495 498 controller_->fastModeEnterSequence( enterCount ); 496 499 runSequence( enterSeq, enterCount ); 500 + applyFastTuning(); 497 501 settleAlternateRamBank(); 498 502 } 499 503 } else { ··· 508 512 // partial-vs-full decision. 509 513 writeWindowSsd1681(); 510 514 writeRamChunks( controller_->currentFrameCommand(), currentFrame_ ); 511 - runSequence( partialSeq, partialSeqCount ); 512 - // The mode-2 update swapped the RAM banks: rewrite the frame so the 513 - // newly exposed bank also holds the image just displayed, keeping both 514 - // banks coherent for the next diff (~2 ms at 20 MHz). 515 - writeWindowSsd1681(); 516 - writeRamChunks( controller_->currentFrameCommand(), currentFrame_ ); 515 + // No post-update RAM rewrite: the mode-2 update swaps the banks, but 516 + // the next update's full-frame preload lands in the re-exposed bank 517 + // anyway (read-back-verified), and the alternate bank being one frame 518 + // stale is exactly the old image the differential diff needs. 519 + if ( fastPartial_ ) { 520 + runFastPartialSequence( partialSeq, partialSeqCount ); 521 + } else { 522 + runSequence( partialSeq, partialSeqCount ); 523 + } 517 524 ++partialCount_; 518 525 } 519 526 waitUntilIdle( controller_->refreshDelayMs() ); ··· 568 575 fastPartial_ = false; // deep sleep dropped the custom LUT; back to quality mode. 569 576 } 570 577 578 + kleidos::platform::spi::Device EpaperPanelDriver::makePanelReadDevice() const { 579 + // Reads are clocked far below the 20 MHz write rate and the panel answers 580 + // on the shared SDA line, so they run on a temporary half-duplex 3-wire 581 + // device; CS stays this driver's manual GPIO, shared by both devices. 582 + constexpr uint32_t kPanelReadHz = 1000000; 583 + 584 + spi::DeviceConfig readConfig = {}; 585 + readConfig.host = config_.host; 586 + readConfig.chipSelect = -1; 587 + readConfig.clockHz = kPanelReadHz; 588 + readConfig.mode = 0; 589 + readConfig.queueSize = 1; 590 + readConfig.halfDuplex = true; 591 + readConfig.threeWire = true; 592 + return spi::addDevice( readConfig ); 593 + } 594 + 571 595 bool EpaperPanelDriver::readPanelRam( uint8_t plane, uint8_t* out, size_t outSize ) { 572 596 if ( !ready_ || out == nullptr || outSize < frameBytes_ ) { 573 597 return false; ··· 589 613 590 614 // SSD1681 RAM read-back: select the plane (0x41, A0: 0 = B/W RAM 0x24, 591 615 // 1 = RED RAM 0x26), rewind the window/counters, then stream via 0x27. 592 - // Reads are clocked far below the 20 MHz write rate and the panel answers 593 - // on the shared SDA line, so they run on a temporary half-duplex 3-wire 594 - // device; CS stays this driver's manual GPIO, shared by both devices. 595 - constexpr uint32_t kPanelReadHz = 1000000; 596 - constexpr uint8_t kCommandReadRam = 0x27; 597 - constexpr uint8_t kDummyBitsPerRead = 8; // 0x27's first byte is dummy. 616 + constexpr uint8_t kCommandReadRam = 0x27; 617 + constexpr uint8_t kDummyBitsPerRead = 8; // 0x27's first byte is dummy. 598 618 599 619 waitWhileBusy( controller_->refreshDelayMs() ); 600 620 writeCommand( 0x41, &plane, sizeof( plane ) ); 601 621 writeWindowSsd1681(); 602 622 603 - spi::DeviceConfig readConfig = {}; 604 - readConfig.host = config_.host; 605 - readConfig.chipSelect = -1; 606 - readConfig.clockHz = kPanelReadHz; 607 - readConfig.mode = 0; 608 - readConfig.queueSize = 1; 609 - readConfig.halfDuplex = true; 610 - readConfig.threeWire = true; 611 - spi::Device readDevice = spi::addDevice( readConfig ); 623 + spi::Device readDevice = makePanelReadDevice(); 612 624 if ( !readDevice.valid() ) { 613 625 return false; 614 626 } ··· 624 636 return ok; 625 637 } 626 638 639 + bool EpaperPanelDriver::readPanelRegister( uint8_t command, uint8_t* out, size_t len ) { 640 + if ( !ready_ || out == nullptr || len == 0 ) { 641 + return false; 642 + } 643 + if ( controller_->refreshModel() 644 + != kleidos::drivers::display::EpaperRefreshModel::SingleRamSsd1681 ) { 645 + return false; 646 + } 647 + // Generic SSD1681 register read (e.g. 0x2F Status Bit Read with the chip 648 + // ID in A[1:0], 0x1B temperature): same read path as the RAM read-back, 649 + // but with no leading dummy byte. 650 + waitWhileBusy( controller_->refreshDelayMs() ); 651 + 652 + spi::Device readDevice = makePanelReadDevice(); 653 + if ( !readDevice.valid() ) { 654 + return false; 655 + } 656 + 657 + assertChipSelect(); 658 + gpio::setLevel( config_.dc, false ); 659 + spi::transmit( device_, &command, sizeof( command ) ); 660 + gpio::setLevel( config_.dc, true ); 661 + const bool ok = spi::readWithDummy( readDevice, out, len, 0, /*keepCsActive=*/false ); 662 + deassertChipSelect(); 663 + readDevice.reset(); 664 + return ok; 665 + } 666 + 667 + bool EpaperPanelDriver::renderGrayImage( const uint8_t* msbPlane, const uint8_t* lsbPlane, 668 + const uint8_t* lut ) { 669 + if ( !ready_ || msbPlane == nullptr || lsbPlane == nullptr || lut == nullptr ) { 670 + return false; 671 + } 672 + if ( controller_->refreshModel() 673 + != kleidos::drivers::display::EpaperRefreshModel::SingleRamSsd1681 ) { 674 + return false; 675 + } 676 + // One-shot 2-bit grayscale render (QA/exploration): the {RED, BW} plane 677 + // pair gives four per-pixel LUT slots, so a custom LUT with graded drive 678 + // lengths yields black / dark gray / light gray / white from a single 679 + // update — beyond the panel's nominal 2 levels. Both planes are loaded 680 + // with the gray index (RED = MSB via 0x26, B/W = LSB via 0x24), the gray 681 + // LUT replaces the register LUT, and one 0xCC update (clock + analog 682 + // enable, no trailing disables like the settle sequence) drives it. 683 + constexpr uint8_t kCommandWriteRamRed = 0x26; 684 + constexpr uint8_t kCommandWriteLut = 0x32; 685 + constexpr uint8_t kCommandUpdateControl = 0x22; 686 + constexpr uint8_t kCommandActivate = 0x20; 687 + constexpr uint8_t kUpdateModeSettle = 0xCC; 688 + 689 + waitWhileBusy( controller_->refreshDelayMs() ); 690 + writeWindowSsd1681(); 691 + writeRamChunks( controller_->currentFrameCommand(), lsbPlane ); 692 + writeWindowSsd1681(); 693 + writeRamChunks( kCommandWriteRamRed, msbPlane ); 694 + writeCommand( kCommandWriteLut, lut, kFastLutBytes ); 695 + writeCommand( kCommandUpdateControl, &kUpdateModeSettle, sizeof( kUpdateModeSettle ) ); 696 + writeCommand( kCommandActivate ); 697 + waitUntilIdle( controller_->refreshDelayMs() ); 698 + // The panel RAM now holds gray indexes, not the driver's 1-bit frame: 699 + // resynchronize with a full quality refresh on the next flush. 700 + forceFullNext_ = true; 701 + fastPartial_ = false; // the gray LUT replaced any fast LUT. 702 + return true; 703 + } 704 + 705 + bool EpaperPanelDriver::setFastTuning( const uint8_t* lut, const uint8_t* sourceVoltage, 706 + int updateMode ) { 707 + if ( !ready_ ) { 708 + return false; 709 + } 710 + tuningLutActive_ = lut != nullptr; 711 + if ( tuningLutActive_ ) { 712 + std::memcpy( tuningLut_, lut, kFastLutBytes ); 713 + } 714 + tuningVoltageActive_ = sourceVoltage != nullptr; 715 + if ( tuningVoltageActive_ ) { 716 + std::memcpy( tuningSourceVoltage_, sourceVoltage, sizeof( tuningSourceVoltage_ ) ); 717 + } 718 + tuningUpdateMode_ = static_cast<int16_t>( updateMode ); 719 + if ( fastPartial_ ) { 720 + // Fast mode is live: push the new waveform to the panel immediately. 721 + waitWhileBusy( controller_->refreshDelayMs() ); 722 + applyFastTuning(); 723 + } 724 + return true; 725 + } 726 + 727 + void EpaperPanelDriver::applyFastTuning() const { 728 + constexpr uint8_t kCommandWriteLut = 0x32; 729 + constexpr uint8_t kCommandSourceVoltage = 0x04; 730 + if ( tuningLutActive_ ) { 731 + writeCommand( kCommandWriteLut, tuningLut_, kFastLutBytes ); 732 + } 733 + if ( tuningVoltageActive_ ) { 734 + writeCommand( kCommandSourceVoltage, tuningSourceVoltage_, sizeof( tuningSourceVoltage_ ) ); 735 + } 736 + } 737 + 738 + void EpaperPanelDriver::runFastPartialSequence( 739 + const kleidos::drivers::display::EpaperCommand* sequence, size_t count ) const { 740 + if ( tuningUpdateMode_ < 0 ) { 741 + runSequence( sequence, count ); 742 + return; 743 + } 744 + // Update-mode override (QA tuning): replace the controller's 0x22 byte for 745 + // fast partials — e.g. to test stripping the enable-clock/enable-analog 746 + // stages once the rails are held on — keeping the 0x20 + BUSY handshake. 747 + constexpr uint8_t kCommandUpdateControl2 = 0x22; 748 + constexpr uint8_t kCommandMasterActivate = 0x20; 749 + const auto mode = static_cast<uint8_t>( tuningUpdateMode_ ); 750 + writeCommand( kCommandUpdateControl2, &mode, sizeof( mode ) ); 751 + writeCommand( kCommandMasterActivate ); 752 + waitUntilIdle( controller_->refreshDelayMs() ); 753 + } 754 + 627 755 void EpaperPanelDriver::settleAlternateRamBank() { 628 756 // Mode-2 display updates ping-pong the controller's B/W RAM banks (full 629 757 // mode-1 refreshes do not), and each mode-2 update diffs against the ··· 635 763 // rewritten with that frame, leaving both banks coherent. 636 764 size_t fastSeqCount = 0; 637 765 const kleidos::drivers::display::EpaperCommand* fastSeq = 638 - controller_->fastPartialRefreshSequence( fastSeqCount ); 766 + controller_->fastSettleSequence( fastSeqCount ); 639 767 if ( fastSeq == nullptr ) { 640 768 return; 641 769 } 642 770 writeWindowSsd1681(); 643 771 writeRamChunks( controller_->currentFrameCommand(), currentFrame_ ); 772 + // Always the controller's settle sequence here (0xCC: enable clock + 773 + // analog, no trailing disables), never a tuning override: the settle runs 774 + // right after full refreshes, whose sequence powers the analog rails down — 775 + // the steady-state fast partial (0x8C) skips the enable-analog stage for 776 + // speed and would otherwise drive without source power. 644 777 runSequence( fastSeq, fastSeqCount ); 645 778 writeWindowSsd1681(); 646 779 writeRamChunks( controller_->currentFrameCommand(), currentFrame_ ); ··· 665 798 runSequence( seq, count ); 666 799 fastPartial_ = enable; 667 800 if ( enable ) { 801 + applyFastTuning(); 668 802 // Entering fast mode: the currently displayed frame is already in the 669 803 // addressable bank, but the alternate bank may hold anything — settle 670 804 // it now so the first animation update diffs against the truth.
+79 -4
src/hal/display/epaper_panel_driver.h
··· 91 91 /** 92 92 * @brief Switch the panel's partial-refresh waveform between quality and fast. 93 93 * 94 - * Fast mode uploads the controller's custom short animation LUT, so partial 95 - * flushes complete in a few drive frames (~100 ms class) instead of the OTP 96 - * waveform's ghost-compensated cycle (~500 ms class), at the cost of lower 97 - * contrast and faster ghosting build-up. Entering raises the periodic 94 + * Fast mode uploads the controller's custom short animation LUT and keeps 95 + * the analog rails on between updates, so partial flushes complete in 96 + * ~194 ms (measured on the CoreInk: 4 drive frames + reinforcement poke at 97 + * +/-17 V) instead of the OTP waveform's ghost-compensated ~503 ms, at the 98 + * cost of lower contrast headroom. Entering raises the periodic 98 99 * full-refresh interval so an animation is not interrupted by the cleanup 99 100 * flash; leaving restores the quality waveform and forces a consolidating 100 101 * full refresh on the next flush. Callers must keep fast-mode streaks ··· 116 117 static constexpr uint8_t kPlaneCpuCurrent = 2; ///< CPU current frame buffer. 117 118 static constexpr uint8_t kPlaneCpuPrevious = 3; ///< CPU previous (last-flushed) frame. 118 119 120 + /// Byte count of an SSD1681 waveform LUT (register 0x32 payload). 121 + static constexpr size_t kFastLutBytes = 153; 122 + 123 + /** 124 + * @brief Override the fast-animation waveform at runtime (QA tuning). 125 + * 126 + * Replaces the controller's built-in fast LUT (and optionally the source 127 + * voltages and the Display Update Control 2 sequence byte used by fast 128 + * partials) until cleared, surviving the LUT re-uploads that follow full 129 + * refreshes. This is the live tuning seam for the waveform sweep workflow 130 + * (EINKLUT): iterate frame counts / voltages / update stages over serial 131 + * without reflashing. Pass all-null to clear every override. 132 + * 133 + * @param[in] lut 153-byte LUT to upload instead of the built-in 134 + * fast LUT, or nullptr to keep/restore built-in. 135 + * @param[in] sourceVoltage Three 0x04 parameter bytes (VSH1, VSH2, VSL) to 136 + * program after each LUT upload, or nullptr to 137 + * keep the panel's OTP calibration. 138 + * @param[in] updateMode 0x22 sequence byte for fast partial updates 139 + * (e.g. 0xCC), or -1 to keep the controller's. 140 + * @retval true Overrides stored (applied on the next fast-mode entry). 141 + * @retval false Panel not ready. 142 + */ 143 + bool setFastTuning( const uint8_t* lut, const uint8_t* sourceVoltage, int updateMode ); 144 + 145 + /** 146 + * @brief Read an SSD1681 register over the shared SDA line (QA/debug). 147 + * 148 + * Unlike @ref readPanelRam this issues an arbitrary read command with no 149 + * leading dummy byte — e.g. 0x2F Status Bit Read, whose A[1:0] chip-ID 150 + * bits fingerprint the controller silicon. 151 + * 152 + * @param[in] command Read command byte. 153 + * @param[out] out Buffer receiving @p len bytes. 154 + * @param[in] len Bytes to read (must be non-zero). 155 + * @retval true Register read. 156 + * @retval false Not ready, unsupported model, or SPI error. 157 + */ 158 + bool readPanelRegister( uint8_t command, uint8_t* out, size_t len ); 159 + 160 + /** 161 + * @brief Render a 2-bit grayscale image in one update (QA/exploration). 162 + * 163 + * The {RED, BW} RAM plane pair selects one of four LUTs per pixel, so a 164 + * custom LUT with graded drive lengths produces black / dark gray / light 165 + * gray / white — beyond the panel's nominal 2 levels. Callers supply the 166 + * two 1 bpp planes (gray index MSB / LSB, row-major) and the 153-byte 167 + * gray LUT, and should render onto a freshly full-refreshed white panel 168 + * (the gray drive lengths are tuned as departures from white). The next 169 + * flush runs a full quality refresh to resynchronize the panel RAM. 170 + * 171 + * @param[in] msbPlane Gray-index MSB plane (written to the RED RAM). 172 + * @param[in] lsbPlane Gray-index LSB plane (written to the B/W RAM). 173 + * @param[in] lut 153-byte waveform LUT with per-index drive lengths. 174 + * @retval true Image rendered. 175 + * @retval false Not ready, bad arguments, or unsupported model. 176 + */ 177 + bool renderGrayImage( const uint8_t* msbPlane, const uint8_t* lsbPlane, const uint8_t* lut ); 178 + 119 179 /** 120 180 * @brief Read back one panel RAM plane or CPU frame buffer (QA/debug). 121 181 * ··· 224 284 void writeRamChunks( uint8_t command, const uint8_t* frame ) const; 225 285 void writeWindowSsd1681() const; 226 286 void writeWindowSsd1681Region( uint8_t x0Byte, uint8_t x1Byte, uint16_t y0, uint16_t y1 ) const; 287 + /// Create the temporary half-duplex three-wire SPI device panel reads run 288 + /// on (the SSD1681 answers on the shared SDA line, far below write speed). 289 + kleidos::platform::spi::Device makePanelReadDevice() const; 227 290 /// Run one dummy mode-2 update of the currently displayed frame and rewrite 228 291 /// it, so BOTH ping-ponged B/W RAM banks hold that frame (see the fast-mode 229 292 /// bank-coherency notes in flushSingleRam). 230 293 void settleAlternateRamBank(); 294 + /// Re-apply the active QA waveform overrides (custom LUT / source voltages) 295 + /// after a fast-mode LUT upload; no-op when no override is set. 296 + void applyFastTuning() const; 297 + /// Run the fast partial update sequence, honoring an update-mode override. 298 + void runFastPartialSequence( const kleidos::drivers::display::EpaperCommand* sequence, 299 + size_t count ) const; 231 300 /// Diff current vs previous frame; returns the changed byte-column / row box. 232 301 /// @return False when the two frames are identical (nothing to flush). 233 302 bool computeDamageBox( uint8_t& x0Byte, uint8_t& x1Byte, uint16_t& y0, uint16_t& y1 ) const; ··· 259 328 mutable bool forceFullNext_ = true; ///< Force a full refresh on the next flush. 260 329 mutable uint32_t partialCount_ = 0; ///< Partial refreshes since the last full one. 261 330 bool fastPartial_ = false; ///< Fast-animation custom LUT is loaded. 331 + // QA waveform-tuning overrides (see setFastTuning). Inactive by default. 332 + uint8_t tuningLut_[kFastLutBytes] = {}; ///< Override LUT payload. 333 + uint8_t tuningSourceVoltage_[3] = {}; ///< Override 0x04 parameters. 334 + bool tuningLutActive_ = false; ///< tuningLut_ holds a LUT. 335 + bool tuningVoltageActive_ = false; ///< tuningSourceVoltage_ is set. 336 + int16_t tuningUpdateMode_ = -1; ///< 0x22 byte for fast partials, or -1. 262 337 // Optional software clip window in logical pixels. Inactive by default. When 263 338 // armed, fillRect() fast-rejects against it and drawPixel() — the per-pixel 264 339 // choke every primitive funnels through — skips out-of-window pixels, so all
+12 -1
src/ui/debug/debug_console.cpp
··· 596 596 { "ANIMTEST", nullptr, nullptr, nullptr }, 597 597 []( const char* cmd ) { return handleDebugAnimTestCommand( cmd ); }, 598 598 "Disp", 599 - "ANIMTEST [frames|LOOP] [FAST] [HOLD] [DUMP] [FILM] — bouncing-ball animation; LOOP " 599 + "ANIMTEST [frames|LOOP] [FAST] [HOLD] [DUMP] [FILM] [TAG s] — bouncing-ball animation; LOOP " 600 600 "runs until serial input (10 s cleans), HOLD skips exit consolidation, DUMP/FILM " 601 601 "stream panel RAM at the end / per frame" }, 602 602 { Match::Prefix, ··· 609 609 []( const char* /*cmd*/ ) { return handleDebugEinkDumpCommand(); }, 610 610 "Disp", 611 611 "EINKDUMP — base64 dump of panel BW/old RAM + CPU frame planes" }, 612 + { Match::Prefix, 613 + { "EINKGRAY", nullptr, nullptr, nullptr }, 614 + []( const char* cmd ) { return handleDebugEinkGrayCommand( cmd ); }, 615 + "Disp", 616 + "EINKGRAY [4|6] — grayscale demo bands; holds until serial input" }, 617 + { Match::Prefix, 618 + { "EINKLUT", nullptr, nullptr, nullptr }, 619 + []( const char* cmd ) { return handleDebugEinkLutCommand( cmd ); }, 620 + "Disp", 621 + "EINKLUT <kick> <burstA> <rest> <burstB> [FR n] [V vsh1 vsl] [U mode] | OFF — live " 622 + "fast-waveform tuning" }, 612 623 { Match::Prefix, 613 624 { "PBKDF2BENCH", nullptr, nullptr, nullptr }, 614 625 []( const char* cmd ) { return handleDebugPbkdf2BenchCommand( cmd ); },
+246
src/ui/debug/debug_diagnostics.cpp
··· 23 23 #include "crypto/sha256_soft.h" 24 24 #include "crypto/vault_crypto.h" 25 25 #include "diag/runtime_stats.h" 26 + #include "drivers/display/ssd1681_lut.h" 26 27 #include "drivers/gps/gps_config_map.h" 27 28 #include "drivers/gps/gps_factory.h" 28 29 #include "drivers/gps/gps_time_sync.h" ··· 1910 1911 /// Canvas-clean period for `ANIMTEST LOOP`: every cycle ends with a quality 1911 1912 /// refresh so the animation restarts on a clean canvas. 1912 1913 constexpr uint32_t kAnimTestCleanPeriodMs = 10000; 1914 + /// Firmware revision letter ANIMTEST prints on the panel corner, so the 1915 + /// observer always knows which flashed build they are looking at. Bump on 1916 + /// every tuning flash. 1917 + constexpr char kAnimTestFirmwareTag = 'G'; 1913 1918 /// Default and maximum full-panel flash cycles for one EINKCLEAN run. 1914 1919 constexpr long kEinkCleanDefaultCycles = 2; 1915 1920 constexpr long kEinkCleanMaxCycles = 10; ··· 2053 2058 } 2054 2059 2055 2060 /** 2061 + * @brief Handle the `EINKLUT` debug command: live fast-waveform tuning. 2062 + * 2063 + * Builds a fast-animation LUT from the given frame counts and pushes it (plus 2064 + * optional source-voltage / update-mode overrides) through 2065 + * @ref display::setEpaperFastTuning, so waveform variants can be swept over 2066 + * serial without reflashing. The LUT keeps the shipped shape — erase gets an 2067 + * inverse kick, both directions drive in two bursts separated by a rest — 2068 + * with every duration adjustable; FR sets the internal frame rate code 2069 + * (2 ≈ 20 ms/frame, 7 ≈ 5 ms/frame). 2070 + * Usage: `EINKLUT <kick> <burstA> <rest> <burstB> [FR <0-7>] [V <vsh1> <vsl>] 2071 + * [U <0x22 byte>] [P]` (P = reinforcement poke in the rest slot), or 2072 + * `EINKLUT OFF` to restore the built-in waveform. 2073 + * 2074 + * @param[in] cmd Full command string (caller matched the EINKLUT prefix). 2075 + * @retval true Always (the command was consumed). 2076 + */ 2077 + bool handleDebugEinkLutCommand( const char* cmd ) { 2078 + if constexpr ( !device::kDisplayIsEpaper ) { 2079 + static_cast<void>( cmd ); 2080 + serial::printf( "EINKLUT_ERR: not an e-paper panel\n" ); 2081 + return true; 2082 + } else { 2083 + const char* cursor = argsAfter( cmd, "EINKLUT" ); 2084 + char token[12]; 2085 + if ( !readDebugToken( cursor, token, sizeof( token ) ) ) { 2086 + serial::printf( 2087 + "EINKLUT_ERR: usage EINKLUT <kick> <burstA> <rest> <burstB> " 2088 + "[FR n] [V vsh1 vsl] [U mode] [P] | OFF\n" ); 2089 + return true; 2090 + } 2091 + if ( str::equalsIgnoreCase( token, "OFF" ) ) { 2092 + display::setEpaperFastTuning( nullptr, nullptr, -1 ); 2093 + serial::printf( "EINKLUT_OFF: built-in fast waveform restored\n" ); 2094 + return true; 2095 + } 2096 + 2097 + long frames[4] = { 0, 0, 0, 0 }; // kick, burstA, rest, burstB 2098 + for ( int i = 0; i < 4; ++i ) { 2099 + char* end = nullptr; 2100 + frames[i] = strtol( token, &end, 0 ); 2101 + if ( end == token || frames[i] < 0 || frames[i] > 255 2102 + || ( i < 3 && !readDebugToken( cursor, token, sizeof( token ) ) ) ) { 2103 + serial::printf( "EINKLUT_ERR: four frame counts (0-255) required\n" ); 2104 + return true; 2105 + } 2106 + } 2107 + 2108 + long frameRate = 2; // FR code (~20 ms/frame). 2109 + long updateMode = -1; // keep the controller's 0x22 byte. 2110 + long vsh1 = 0; // volts; 0 = keep OTP calibration. 2111 + long vsl = 0; 2112 + bool poke = false; // reinforce EVERY pixel toward its value in the rest slot. 2113 + while ( readDebugToken( cursor, token, sizeof( token ) ) ) { 2114 + char* end = nullptr; 2115 + if ( str::equalsIgnoreCase( token, "FR" ) 2116 + && readDebugToken( cursor, token, sizeof( token ) ) ) { 2117 + frameRate = strtol( token, &end, 0 ); 2118 + } else if ( str::equalsIgnoreCase( token, "V" ) 2119 + && readDebugToken( cursor, token, sizeof( token ) ) ) { 2120 + vsh1 = strtol( token, &end, 0 ); 2121 + if ( readDebugToken( cursor, token, sizeof( token ) ) ) { 2122 + vsl = strtol( token, &end, 0 ); 2123 + } 2124 + } else if ( str::equalsIgnoreCase( token, "U" ) 2125 + && readDebugToken( cursor, token, sizeof( token ) ) ) { 2126 + updateMode = strtol( token, &end, 16 ); 2127 + } else if ( str::equalsIgnoreCase( token, "P" ) ) { 2128 + poke = true; 2129 + } 2130 + } 2131 + if ( frameRate < 0 || frameRate > 7 || updateMode > 0xFF ) { 2132 + serial::printf( "EINKLUT_ERR: FR must be 0-7, U a byte\n" ); 2133 + return true; 2134 + } 2135 + 2136 + namespace lutb = kleidos::drivers::display::ssd1681; 2137 + const lutb::FastLutSpec spec = { 2138 + .kickFrames = static_cast<uint8_t>( frames[0] ), 2139 + .driveFrames = static_cast<uint8_t>( frames[1] ), 2140 + .restFrames = static_cast<uint8_t>( frames[2] ), 2141 + .burstFrames = static_cast<uint8_t>( frames[3] ), 2142 + .poke = poke, 2143 + .frameRate = static_cast<uint8_t>( frameRate ), 2144 + }; 2145 + const auto lut = lutb::buildFastLut( spec ); 2146 + 2147 + // Optional source-voltage override in whole volts (VSH2 kept at 5.8 V). 2148 + uint8_t voltage[3] = { 0, lutb::kVsh2Code5V8, 0 }; 2149 + uint8_t* voltagePtr = nullptr; 2150 + if ( vsh1 >= 9 && vsh1 <= 17 && vsl <= -9 && vsl >= -17 ) { 2151 + voltage[0] = lutb::vsh1Code( static_cast<int>( vsh1 ) ); 2152 + voltage[2] = lutb::vslCode( static_cast<int>( vsl ) ); 2153 + voltagePtr = voltage; 2154 + } 2155 + 2156 + const bool ok = 2157 + display::setEpaperFastTuning( lut.data(), voltagePtr, static_cast<int>( updateMode ) ); 2158 + const long driveTotal = frames[0] + frames[1] + frames[2] + frames[3]; 2159 + serial::printf( 2160 + "EINKLUT_%s kick=%ld burstA=%ld rest=%ld burstB=%ld fr=%ld v=%ld/%ld " 2161 + "u=%ld p=%d frames=%ld\n", 2162 + ok ? "OK" : "ERR", frames[0], frames[1], frames[2], frames[3], frameRate, vsh1, vsl, 2163 + updateMode, poke ? 1 : 0, driveTotal ); 2164 + return true; 2165 + } 2166 + } 2167 + 2168 + 2169 + /** 2170 + * @brief Handle the `EINKGRAY` debug command: 2-bit grayscale demo bands. 2171 + * 2172 + * Renders vertical tone bands using both RAM planes as a 2-bit gray index 2173 + * and a custom LUT whose drive length is graded per index (departures from a 2174 + * fresh white base): 4 levels in one update, or 6 levels (white + 4 grays + 2175 + * black) in two stacked updates whose drives accumulate. The command then 2176 + * HOLDS until any serial input arrives — once it returns, the FSM repaints 2177 + * its screen and the next update re-drives every pixel from the 1-bit planes, 2178 + * collapsing the tones. Exploration seam for grayscale icons / fills / soft 2179 + * antialiasing on the nominally 2-level panel. 2180 + * Usage: `EINKGRAY [levels]` (4 [default] or 6). 2181 + * 2182 + * @param[in] cmd Full command string (caller matched the EINKGRAY prefix). 2183 + * @retval true Always (the command was consumed). 2184 + */ 2185 + bool handleDebugEinkGrayCommand( const char* cmd ) { 2186 + if constexpr ( device::kDisplayIsEpaper ) { 2187 + namespace mem = kleidos::platform::memory; 2188 + 2189 + const char* cursor = argsAfter( cmd, "EINKGRAY" ); 2190 + long levels = 4; 2191 + char token[12]; 2192 + if ( readDebugToken( cursor, token, sizeof( token ) ) ) { 2193 + char* end = nullptr; 2194 + levels = strtol( token, &end, 0 ); 2195 + } 2196 + if ( levels != 4 && levels != 6 ) { 2197 + serial::printf( "EINKGRAY_ERR: levels must be 4 or 6\n" ); 2198 + return true; 2199 + } 2200 + 2201 + // Per-pass recipe: each pass tags every band with a 2-bit drive index 2202 + // (0 none, 1 light, 2 dark, 3 black) and drives it for the pass's 2203 + // {light, dark, black} frame counts. Stacked passes accumulate drive: 2204 + // 4 levels need one pass (totals {10,4,1,0} frames left to right), 2205 + // 6 levels two passes (totals {10,6,4,2,1,0} = black + 4 grays + 2206 + // white). 2207 + namespace lutb = kleidos::drivers::display::ssd1681; 2208 + struct GrayPass { 2209 + uint8_t index[6]; ///< Per-band drive index, band 0 = leftmost. 2210 + lutb::GrayLutSpec spec; ///< Drive frames for indexes 1/2/3 (+ FR code). 2211 + }; 2212 + constexpr GrayPass kFourLevel[1] = { { { 3, 2, 1, 0, 0, 0 }, { 1, 4, 10, 2 } } }; 2213 + constexpr GrayPass kSixLevel[2] = { { { 3, 3, 3, 2, 1, 0 }, { 1, 2, 4, 2 } }, 2214 + { { 3, 2, 0, 0, 0, 0 }, { 1, 2, 6, 2 } } }; 2215 + const GrayPass* passes = levels == 4 ? kFourLevel : kSixLevel; 2216 + const int passCount = levels == 4 ? 1 : 2; 2217 + 2218 + // Fresh white base: the gray drive lengths are departures from white. 2219 + ui::RasterCanvas raster; 2220 + ui::Canvas& canvas = raster; 2221 + canvas.frameBegin(); 2222 + canvas.fillScreen( 0x0000 ); // renders as paper under the e-paper light theme 2223 + canvas.frameCommit(); 2224 + 2225 + const int16_t w = display::width(); 2226 + const int16_t h = display::height(); 2227 + const size_t rowBytes = ( static_cast<size_t>( w ) + 7U ) / 8U; 2228 + const size_t planeBytes = rowBytes * static_cast<size_t>( h ); 2229 + auto* msb = static_cast<uint8_t*>( mem::allocLarge( planeBytes ) ); 2230 + auto* lsb = static_cast<uint8_t*>( mem::allocLarge( planeBytes ) ); 2231 + if ( msb == nullptr || lsb == nullptr ) { 2232 + mem::freeLarge( msb ); 2233 + mem::freeLarge( lsb ); 2234 + serial::printf( "EINKGRAY_ERR: plane alloc failed\n" ); 2235 + return true; 2236 + } 2237 + 2238 + bool ok = true; 2239 + for ( int pass = 0; pass < passCount && ok; ++pass ) { 2240 + const GrayPass& p = passes[pass]; 2241 + // Drive index -> {RED, BW} plane bits selecting LUT slot 2242 + // red*2 + bw: none {0,1} = LUT1 (no drive), light {1,1} = LUT3, 2243 + // dark {1,0} = LUT2, black {0,0} = LUT0. 2244 + std::memset( msb, 0, planeBytes ); 2245 + std::memset( lsb, 0, planeBytes ); 2246 + for ( int16_t y = 0; y < h; ++y ) { 2247 + for ( int16_t x = 0; x < w; ++x ) { 2248 + auto band = static_cast<int16_t>( x / ( w / levels ) ); 2249 + if ( band >= levels ) { 2250 + band = static_cast<int16_t>( levels - 1 ); 2251 + } 2252 + const uint8_t driveIdx = p.index[band]; 2253 + const size_t idx = 2254 + static_cast<size_t>( y ) * rowBytes + static_cast<size_t>( x ) / 8U; 2255 + const auto bit = static_cast<uint8_t>( 0x80U >> ( x % 8 ) ); 2256 + if ( driveIdx == 1 || driveIdx == 2 ) { 2257 + msb[idx] |= bit; 2258 + } 2259 + if ( driveIdx <= 1 ) { 2260 + lsb[idx] |= bit; 2261 + } 2262 + } 2263 + } 2264 + 2265 + const auto lut = lutb::buildGrayLut( p.spec ); 2266 + ok = display::renderEpaperGray( msb, lsb, lut.data() ); 2267 + } 2268 + mem::freeLarge( msb ); 2269 + mem::freeLarge( lsb ); 2270 + 2271 + // Hold the tones on the glass: the moment this handler returns, the 2272 + // FSM repaints its screen and the next update re-drives every pixel 2273 + // from the 1-bit planes, collapsing the grays. 2274 + serial::printf( "EINKGRAY_%s levels=%ld (holding; send anything to release)\n", 2275 + ok ? "OK" : "ERR", levels ); 2276 + while ( serial::available() == 0 ) { 2277 + rtos::delayMs( 50 ); 2278 + kleidos::platform::watchdog::resetCurrentTask(); 2279 + } 2280 + while ( serial::available() > 0 && serial::read() >= 0 ) {} 2281 + serial::printf( "EINKGRAY_RELEASED\n" ); 2282 + } else { 2283 + static_cast<void>( cmd ); 2284 + serial::printf( "EINKGRAY_ERR: not an e-paper panel\n" ); 2285 + } 2286 + return true; 2287 + } 2288 + 2289 + /** 2056 2290 * @brief Handle the `ANIMTEST` debug command: timed bouncing-ball animation. 2057 2291 * 2058 2292 * Clears the panel, then bounces a filled disc diagonally across the screen, ··· 2082 2316 bool dump = false; 2083 2317 bool film = false; 2084 2318 char token[12]; 2319 + char tag[8] = { 0 }; 2085 2320 while ( readDebugToken( cursor, token, sizeof( token ) ) ) { 2086 2321 if ( str::equalsIgnoreCase( token, "FAST" ) ) { 2087 2322 wantFast = true; ··· 2101 2336 } 2102 2337 if ( str::equalsIgnoreCase( token, "FILM" ) ) { 2103 2338 film = true; 2339 + continue; 2340 + } 2341 + if ( str::equalsIgnoreCase( token, "TAG" ) ) { 2342 + readDebugToken( cursor, tag, sizeof( tag ) ); 2104 2343 continue; 2105 2344 } 2106 2345 char* end = nullptr; ··· 2138 2377 constexpr uint16_t kMarkerBg = 0x0000; 2139 2378 constexpr uint16_t kMarkerBall = 0xFFFF; 2140 2379 2380 + // On-panel version label (firmware letter + optional TAG argument): the 2381 + // observer of a tuning ladder can always tell which build/step is shown. 2382 + char label[12]; 2383 + str::format( label, sizeof( label ), "%c%s%s", kAnimTestFirmwareTag, tag[0] != 0 ? "-" : "", 2384 + tag ); 2385 + 2141 2386 // Baseline clear (a full-panel change: on e-paper a full refresh that also 2142 2387 // resets accumulated ghosting), timed separately from the animation frames. 2143 2388 const uint32_t clearStartMs = platformClock::millis32(); ··· 2189 2434 canvas.frameBegin(); 2190 2435 canvas.fillCircle( prevX, prevY, radius, kMarkerBg ); // erase the old disc 2191 2436 canvas.fillCircle( posX, posY, radius, kMarkerBall ); 2437 + canvas.drawText( label, 2, 2, display::Font::MONO, kMarkerBall, 0, false, 0.0F ); 2192 2438 const uint32_t frameStartMs = platformClock::millis32(); 2193 2439 canvas.frameCommit(); 2194 2440 const uint32_t frameMs = platformClock::millis32() - frameStartMs;
+2
src/ui/debug/debug_internal.h
··· 150 150 bool handleDebugAnimTestCommand( const char* cmd ); // ANIMTEST: flush-latency animation 151 151 bool handleDebugEinkCleanCommand( const char* cmd ); // EINKCLEAN: deep deghosting flashes 152 152 bool handleDebugEinkDumpCommand(); // EINKDUMP: panel RAM + CPU plane dump 153 + bool handleDebugEinkLutCommand( const char* cmd ); // EINKLUT: live fast-waveform tuning 154 + bool handleDebugEinkGrayCommand( const char* cmd ); // EINKGRAY: 2-bit grayscale demo 153 155 bool handleDebugPbkdf2BenchCommand( const char* cmd ); 154 156 bool handleDebugKdfBenchCommand( const char* cmd ); 155 157 bool handleDebugCryptoBenchCommand();
+189
test/drivers/test_ssd1681_lut/test_ssd1681_lut.cpp
··· 1 + // Kleidos — Native test: SSD1681 waveform LUT builders 2 + // Build: pio test -e native -f drivers/test_ssd1681_lut 3 + // 4 + // Pins the pure constexpr LUT builders in drivers/display/ssd1681_lut.h — the 5 + // single source of truth for the 153-byte SSD1681 waveform-setting layout 6 + // shared by the GDEW0154D67 fast-animation tables, the EINKLUT live-tuning 7 + // command and the EINKGRAY grayscale renderer. Covers the VS phase packing, 8 + // the byte-layout anchors (timing groups, frame-rate nibbles), the shipped 9 + // fast waveform recipe (drive + reinforcement poke), the graded grayscale 10 + // pass, and the 0x04 source-voltage code conversions against the datasheet 11 + // anchor values. 12 + 13 + // Group 1: module under test 14 + #include "drivers/display/ssd1681_lut.h" 15 + 16 + // Group 3: stdlib + Unity 17 + #include <cstddef> 18 + #include <cstdint> 19 + 20 + #include <unity.h> 21 + 22 + namespace lut = kleidos::drivers::display::ssd1681; 23 + 24 + // --- Constants ---------------------------------------------------------------- 25 + 26 + /// VS row offsets for the four {old, new} transition LUTs. 27 + static constexpr size_t kLut0 = 0; // old black -> black 28 + static constexpr size_t kLut1 = 12; // old black -> white 29 + static constexpr size_t kLut2 = 24; // old white -> black 30 + static constexpr size_t kLut3 = 36; // old white -> white 31 + static constexpr size_t kLut4 = 48; // VCOM 32 + 33 + void setUp() {} 34 + void tearDown() {} 35 + 36 + // --- VS phase packing --------------------------------------------------------- 37 + 38 + static void test_vsPhase_packsVoltageIntoRequestedField() { 39 + // ARRANGE / ACT / ASSERT: 2-bit fields, phase A in the top bits. 40 + TEST_ASSERT_EQUAL_UINT8( 0x40, lut::vsPhase( lut::PhaseVoltage::Vsh1, 0 ) ); 41 + TEST_ASSERT_EQUAL_UINT8( 0x80, lut::vsPhase( lut::PhaseVoltage::Vsl, 0 ) ); 42 + TEST_ASSERT_EQUAL_UINT8( 0x10, lut::vsPhase( lut::PhaseVoltage::Vsh1, 1 ) ); 43 + TEST_ASSERT_EQUAL_UINT8( 0x20, lut::vsPhase( lut::PhaseVoltage::Vsl, 1 ) ); 44 + TEST_ASSERT_EQUAL_UINT8( 0x03, lut::vsPhase( lut::PhaseVoltage::Vsh2, 3 ) ); 45 + TEST_ASSERT_EQUAL_UINT8( 0x00, lut::vsPhase( lut::PhaseVoltage::Vss, 2 ) ); 46 + } 47 + 48 + // --- Fast-animation LUT ------------------------------------------------------- 49 + 50 + static void test_buildFastLut_shippedRecipe_placesDrivePokeAndTiming() { 51 + // ARRANGE: the waveform baked into the GDEW0154D67 controller tables. 52 + constexpr lut::FastLutSpec spec = { 53 + .kickFrames = 0, 54 + .driveFrames = 4, 55 + .restFrames = 1, 56 + .burstFrames = 0, 57 + .poke = true, 58 + .frameRate = 2, 59 + }; 60 + 61 + // ACT 62 + constexpr auto bytes = lut::buildFastLut( spec ); 63 + 64 + // ASSERT: no kick -> group 0 carries only the phase-B drive; the poke 65 + // lands in group 1 phase A of every transition row. 66 + TEST_ASSERT_EQUAL_UINT8( 0x20, bytes[kLut1] ); // erase: VSL drive 67 + TEST_ASSERT_EQUAL_UINT8( 0x10, bytes[kLut2] ); // ink: VSH1 drive 68 + TEST_ASSERT_EQUAL_UINT8( 0x80, bytes[kLut1 + 1] ); // erase poke toward white 69 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut2 + 1] ); // ink poke toward black 70 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut0 + 1] ); // unchanged black reinforced 71 + TEST_ASSERT_EQUAL_UINT8( 0x80, bytes[kLut3 + 1] ); // unchanged white reinforced 72 + 73 + // Timing: group 0 = {kick, drive}, group 1 phase A = poke duration. 74 + TEST_ASSERT_EQUAL_UINT8( 0, bytes[lut::kTimingOffset] ); 75 + TEST_ASSERT_EQUAL_UINT8( 4, bytes[lut::kTimingOffset + 1] ); 76 + TEST_ASSERT_EQUAL_UINT8( 1, bytes[lut::kTimingOffset + lut::kTimingBytesPerGrp] ); 77 + 78 + // Frame-rate nibbles packed twice per byte across all six bytes. 79 + for ( size_t i = 0; i < lut::kFrameRateBytes; ++i ) { 80 + TEST_ASSERT_EQUAL_UINT8( 0x22, bytes[lut::kFrameRateOffset + i] ); 81 + } 82 + 83 + // VCOM row stays at DC. 84 + for ( size_t i = 0; i < lut::kVsBytesPerLut; ++i ) { 85 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut4 + i] ); 86 + } 87 + } 88 + 89 + static void test_buildFastLut_kick_addsInversePulseOnEraseOnly() { 90 + // ARRANGE / ACT: 1-frame kick ahead of the drive, no poke. 91 + constexpr auto bytes = lut::buildFastLut( { 1, 4, 0, 0, false, 2 } ); 92 + 93 + // ASSERT: erase direction gets VSH1 kick (A) + VSL drive (B); ink 94 + // direction drives without a kick; unchanged rows stay untouched. 95 + TEST_ASSERT_EQUAL_UINT8( 0x60, bytes[kLut1] ); 96 + TEST_ASSERT_EQUAL_UINT8( 0x10, bytes[kLut2] ); 97 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut0] ); 98 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut0 + 1] ); 99 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut3 + 1] ); 100 + TEST_ASSERT_EQUAL_UINT8( 1, bytes[lut::kTimingOffset] ); 101 + } 102 + 103 + static void test_buildFastLut_secondBurst_populatesGroupOnePhaseB() { 104 + // ARRANGE / ACT: chopped drive — burst, rest, burst. 105 + constexpr auto bytes = lut::buildFastLut( { 1, 4, 1, 3, false, 2 } ); 106 + 107 + // ASSERT: group 1 phase B carries the second burst for both directions. 108 + TEST_ASSERT_EQUAL_UINT8( 0x20, bytes[kLut1 + 1] ); 109 + TEST_ASSERT_EQUAL_UINT8( 0x10, bytes[kLut2 + 1] ); 110 + TEST_ASSERT_EQUAL_UINT8( 1, bytes[lut::kTimingOffset + lut::kTimingBytesPerGrp] ); 111 + TEST_ASSERT_EQUAL_UINT8( 3, bytes[lut::kTimingOffset + lut::kTimingBytesPerGrp + 1] ); 112 + } 113 + 114 + static void test_buildFastLut_frameRate_packsBothNibbles() { 115 + // ARRANGE / ACT / ASSERT: FR code 7 lands in both nibbles of every byte. 116 + constexpr auto bytes = lut::buildFastLut( { 0, 3, 0, 0, false, 7 } ); 117 + for ( size_t i = 0; i < lut::kFrameRateBytes; ++i ) { 118 + TEST_ASSERT_EQUAL_UINT8( 0x77, bytes[lut::kFrameRateOffset + i] ); 119 + } 120 + } 121 + 122 + // --- Grayscale LUT ------------------------------------------------------------ 123 + 124 + static void test_buildGrayLut_gradedStops_encodeDifferences() { 125 + // ARRANGE / ACT: the 4-level demo ladder (1 / 4 / 10 frames). 126 + constexpr auto bytes = lut::buildGrayLut( { 1, 4, 10, 2 } ); 127 + 128 + // ASSERT: timing groups hold light, dark-light, black-dark. 129 + TEST_ASSERT_EQUAL_UINT8( 1, bytes[lut::kTimingOffset] ); 130 + TEST_ASSERT_EQUAL_UINT8( 3, bytes[lut::kTimingOffset + lut::kTimingBytesPerGrp] ); 131 + TEST_ASSERT_EQUAL_UINT8( 6, bytes[lut::kTimingOffset + 2 * lut::kTimingBytesPerGrp] ); 132 + 133 + // Drive assignments: black spans groups 0-2, dark 0-1, light group 0 134 + // only; the undriven index (LUT1, white) never drives. 135 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut0] ); 136 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut0 + 1] ); 137 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut0 + 2] ); 138 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut2] ); 139 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut2 + 1] ); 140 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut2 + 2] ); 141 + TEST_ASSERT_EQUAL_UINT8( 0x40, bytes[kLut3] ); 142 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut3 + 1] ); 143 + for ( size_t i = 0; i < lut::kVsBytesPerLut; ++i ) { 144 + TEST_ASSERT_EQUAL_UINT8( 0x00, bytes[kLut1 + i] ); 145 + } 146 + } 147 + 148 + // --- Source-voltage register codes --------------------------------------------- 149 + 150 + static void test_voltageCodes_matchDatasheetAnchors() { 151 + // ARRANGE / ACT / ASSERT: 0x04 parameter tables (datasheet p.21): VSH1 152 + // steps 0.2 V per code from 9 V = 0x23; VSL steps 0.5 V per two codes 153 + // from -9 V = 0x1A. 154 + TEST_ASSERT_EQUAL_UINT8( 0x41, lut::vsh1Code( 15 ) ); // stock operating point 155 + TEST_ASSERT_EQUAL_UINT8( 0x46, lut::vsh1Code( 16 ) ); 156 + TEST_ASSERT_EQUAL_UINT8( 0x4B, lut::vsh1Code( 17 ) ); // datasheet maximum 157 + TEST_ASSERT_EQUAL_UINT8( 0x23, lut::vsh1Code( 9 ) ); 158 + TEST_ASSERT_EQUAL_UINT8( 0x32, lut::vslCode( -15 ) ); // stock operating point 159 + TEST_ASSERT_EQUAL_UINT8( 0x3A, lut::vslCode( -17 ) ); // datasheet maximum 160 + TEST_ASSERT_EQUAL_UINT8( 0x1A, lut::vslCode( -9 ) ); 161 + } 162 + 163 + // --- Layout invariants ---------------------------------------------------------- 164 + 165 + static void test_lutLayout_isConstexprAndSized() { 166 + // The payload size and layout anchors are compile-time facts shared with 167 + // the device driver; a change here must be a conscious datasheet review. 168 + static_assert( lut::kLutBytes == 153 ); 169 + static_assert( lut::kTimingOffset == 5 * lut::kVsBytesPerLut ); 170 + static_assert( lut::kFrameRateOffset == lut::kTimingOffset + 12 * lut::kTimingBytesPerGrp ); 171 + static_assert( lut::kFrameRateOffset + lut::kFrameRateBytes + 3 == lut::kLutBytes ); 172 + constexpr auto bytes = lut::buildFastLut( {} ); 173 + TEST_ASSERT_EQUAL_size_t( lut::kLutBytes, bytes.size() ); 174 + } 175 + 176 + // --- main --------------------------------------------------------------------- 177 + 178 + int main( int /*argc*/, char** /*argv*/ ) { 179 + UNITY_BEGIN(); 180 + RUN_TEST( test_vsPhase_packsVoltageIntoRequestedField ); 181 + RUN_TEST( test_buildFastLut_shippedRecipe_placesDrivePokeAndTiming ); 182 + RUN_TEST( test_buildFastLut_kick_addsInversePulseOnEraseOnly ); 183 + RUN_TEST( test_buildFastLut_secondBurst_populatesGroupOnePhaseB ); 184 + RUN_TEST( test_buildFastLut_frameRate_packsBothNibbles ); 185 + RUN_TEST( test_buildGrayLut_gradedStops_encodeDifferences ); 186 + RUN_TEST( test_voltageCodes_matchDatasheetAnchors ); 187 + RUN_TEST( test_lutLayout_isConstexprAndSized ); 188 + return UNITY_END(); 189 + }