Skip to main content

epd_datafuri/displays/
adafruit_thinkink_290_t5.rs

1//! Driver and graphics buffer for the Adafruit MagTag 2.9" e-ink display (IL0373 controller).
2//!
3//! This module targets the **original** Adafruit MagTag board revision which
4//! uses the IL0373 e-paper controller. The 2025 edition uses an SSD1680
5//! instead; see [`adafruit_thinkink_290_mfgn`](self::adafruit_thinkink_290_mfgn).
6//!
7//! ## Key differences from the SSD1680 variant
8//!
9//! | Property | SSD1680 | IL0373 |
10//! |----------|---------|--------|
11//! | Busy pin polarity | Active-high (HIGH = busy) | Active-low (LOW = busy) |
12//! | LUT registers | Single register (0x32) | Five registers (0x20–0x24) |
13//! | RAM address counters | Required | Not used |
14//! | Gray2 encoding | Identical two-plane scheme | Identical two-plane scheme |
15//!
16//! Because the panel dimensions (296×128) and Gray2 encoding are identical,
17//! - [`adafruit_thinkink_290_t5::ThinkInk2in9Gray2`](self::adafruit_thinkink_290_t5::ThinkInk2in9Gray2): 2-bit, 4-level grayscale rendering using the Gray2 LUT
18//! - [`adafruit_thinkink_290_t5::ThinkInk2in9Mono`](self::adafruit_thinkink_290_t5::ThinkInk2in9Mono): black/white rendering using the mono full LUT
19
20use crate::color::Color;
21use crate::driver::il0373::Il0373Cmd;
22use crate::driver::EpdDriver;
23use crate::interface::SpiDisplayInterface;
24use display_interface::DisplayError;
25use embedded_hal::delay::DelayNs;
26use embedded_hal::digital::{InputPin, OutputPin};
27use embedded_hal::spi::SpiDevice;
28use log::debug;
29
30pub use crate::graphics::display290_gray4_t5::Display2in9Gray2;
31pub use crate::graphics::display290_mono::Display2in9Mono;
32
33/// Display width of the MagTag 2.9" IL0373 panel in pixels.
34pub const WIDTH: u16 = 296;
35/// Display height of the MagTag 2.9" IL0373 panel in pixels.
36pub const HEIGHT: u16 = 128;
37
38// ---------------------------------------------------------------------------
39// Gray4 waveform LUTs (5 separate tables, each 42 bytes).
40// Ported from Adafruit's ThinkInk_290_Grayscale4_T5.h (ti_290t5_gray4_lut_code).
41// ---------------------------------------------------------------------------
42
43#[rustfmt::skip]
44/// IL0373 Gray4 LUT1 (no-update waveform), register 0x20, 42 bytes.
45///
46/// Applied when a pixel does not need to change state. Keeping the pixel at
47/// its current level without unnecessary voltage transitions reduces flicker.
48const TI_290T5_GRAY4_LUT1: [u8; 42] = [
49    0x00, 0x0A, 0x00, 0x00, 0x00, 0x01,
50    0x60, 0x14, 0x14, 0x00, 0x00, 0x01,
51    0x00, 0x14, 0x00, 0x00, 0x00, 0x01,
52    0x00, 0x13, 0x0A, 0x01, 0x00, 0x01,
53    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
54    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
55    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
56];
57
58#[rustfmt::skip]
59/// IL0373 Gray4 LUTWW (white-to-white waveform), register 0x21, 42 bytes.
60///
61/// Applied when a pixel stays white across the update, ensuring the panel
62/// maintains proper white-level driving voltage.
63const TI_290T5_GRAY4_LUTWW: [u8; 42] = [
64    0x40, 0x0A, 0x00, 0x00, 0x00, 0x01,
65    0x90, 0x14, 0x14, 0x00, 0x00, 0x01,
66    0x10, 0x14, 0x0A, 0x00, 0x00, 0x01,
67    0xA0, 0x13, 0x01, 0x00, 0x00, 0x01,
68    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
69    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
70    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
71];
72
73#[rustfmt::skip]
74/// IL0373 Gray4 LUTBW (black-to-white waveform), register 0x22, 42 bytes.
75///
76/// Applied when a pixel transitions from black to white, driving the pixel
77/// through the correct voltage sequence to fully clear the e-ink capsules.
78const TI_290T5_GRAY4_LUTBW: [u8; 42] = [
79    0x40, 0x0A, 0x00, 0x00, 0x00, 0x01,
80    0x90, 0x14, 0x14, 0x00, 0x00, 0x01,
81    0x00, 0x14, 0x0A, 0x00, 0x00, 0x01,
82    0x99, 0x0C, 0x01, 0x03, 0x04, 0x01,
83    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
84    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
85    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
86];
87
88#[rustfmt::skip]
89/// IL0373 Gray4 LUTWB (white-to-black waveform), register 0x23, 42 bytes.
90///
91/// Applied when a pixel transitions from white to black, driving the e-ink
92/// capsules to their fully actuated (dark) state.
93const TI_290T5_GRAY4_LUTWB: [u8; 42] = [
94    0x40, 0x0A, 0x00, 0x00, 0x00, 0x01,
95    0x90, 0x14, 0x14, 0x00, 0x00, 0x01,
96    0x00, 0x14, 0x0A, 0x00, 0x00, 0x01,
97    0x99, 0x0B, 0x04, 0x04, 0x01, 0x01,
98    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
99    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
100    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
101];
102
103#[rustfmt::skip]
104/// IL0373 Gray4 LUTBB (black-to-black waveform), register 0x24, 42 bytes.
105///
106/// Applied when a pixel stays black across the update, maintaining the dark
107/// state without unnecessary voltage transitions.
108const TI_290T5_GRAY4_LUTBB: [u8; 42] = [
109    0x80, 0x0A, 0x00, 0x00, 0x00, 0x01,
110    0x90, 0x14, 0x14, 0x00, 0x00, 0x01,
111    0x20, 0x14, 0x0A, 0x00, 0x00, 0x01,
112    0x50, 0x13, 0x01, 0x00, 0x00, 0x01,
113    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
114    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
115    0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
116];
117
118/// Driver for the Adafruit MagTag 2.9" monochrome e-ink display (IL0373 controller).
119///
120/// The IL0373 busy pin is active-low (LOW = busy, HIGH = ready).
121///
122/// Full refreshes use the factory OTP waveform (no custom LUT is loaded).
123/// Use [`Display2in9Mono`] as the graphics buffer.
124pub struct ThinkInk2in9Mono<SPI, BSY, DC, RST>
125where
126    SPI: SpiDevice,
127    BSY: InputPin,
128    DC: OutputPin,
129    RST: OutputPin,
130{
131    interface: SpiDisplayInterface<SPI, BSY, DC, RST>,
132}
133
134impl<SPI, BSY, DC, RST> ThinkInk2in9Mono<SPI, BSY, DC, RST>
135where
136    SPI: SpiDevice,
137    BSY: InputPin,
138    DC: OutputPin,
139    RST: OutputPin,
140{
141    /// Create a new MagTag 2.9" IL0373 monochrome display driver.
142    pub fn new(spi: SPI, busy: BSY, dc: DC, rst: RST) -> Result<Self, DisplayError> {
143        let interface = SpiDisplayInterface::new(spi, busy, dc, rst);
144        Ok(Self { interface })
145    }
146
147    fn write_dtm1(&mut self, buffer: &[u8]) -> Result<(), DisplayError> {
148        self.interface.cmd_with_data(Il0373Cmd::DTM1, buffer)
149    }
150
151    fn display(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
152        self.interface.cmd(Il0373Cmd::DISPLAY_REFRESH)?;
153        delay.delay_ms(100);
154        self.interface.wait_until_idle_active_low(delay);
155        Ok(())
156    }
157
158    /// Write the black/white buffer to the display and trigger a full refresh.
159    ///
160    /// This method calls [`EpdDriver::init`] internally, so there is no need
161    /// to call it separately before invoking this method.
162    pub fn update_and_display(
163        &mut self,
164        bw_buffer: &[u8],
165        delay: &mut impl DelayNs,
166    ) -> Result<(), DisplayError> {
167        self.init(delay)?;
168        self.update_bw(bw_buffer, delay)?;
169        self.display(delay)?;
170        self.sleep(delay)
171    }
172}
173
174impl<SPI, BSY, DC, RST> EpdDriver for ThinkInk2in9Mono<SPI, BSY, DC, RST>
175where
176    SPI: SpiDevice,
177    BSY: InputPin,
178    DC: OutputPin,
179    RST: OutputPin,
180{
181    /// Initialize for full monochrome operation using the factory OTP waveform.
182    ///
183    /// PANEL_SETTING `0x1f` selects OTP waveform (bit 5 = 0), so no custom LUT
184    /// is loaded. Matches Adafruit's `ti_290t5_monofull_init_code`.
185    fn init(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
186        debug!("powering up MagTag 2.9\" IL0373 mono display");
187
188        self.interface.hard_reset(delay)?;
189
190        self.interface
191            .cmd_with_data(Il0373Cmd::BOOSTER_SOFT_START, &[0x17, 0x17, 0x17])?;
192
193        self.interface.cmd(Il0373Cmd::POWER_ON)?;
194        self.interface.wait_until_idle_active_low(delay);
195        delay.delay_ms(200);
196
197        // 0x1f: OTP waveform (bit 5 = 0), scan up, shift right, booster on
198        // 0x0d: additional panel configuration
199        self.interface
200            .cmd_with_data(Il0373Cmd::PANEL_SETTING, &[0x1f, 0x0d])?;
201
202        self.interface.cmd_with_data(Il0373Cmd::CDI, &[0x97])?;
203
204        Ok(())
205    }
206
207    /// Power down the display controller.
208    fn sleep(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
209        debug!("powering down MagTag 2.9\" IL0373 mono display");
210        self.interface.cmd_with_data(Il0373Cmd::CDI, &[0x17])?;
211        self.interface.cmd(Il0373Cmd::VCM_DC_SETTING)?;
212        self.interface.cmd(Il0373Cmd::POWER_OFF)?;
213        delay.delay_ms(1);
214        Ok(())
215    }
216
217    /// Write `buffer` to the black/white RAM plane (DTM1) and wait until ready.
218    fn update_bw(&mut self, buffer: &[u8], delay: &mut impl DelayNs) -> Result<(), DisplayError> {
219        self.write_dtm1(buffer)?;
220        self.interface.wait_until_idle_active_low(delay);
221        Ok(())
222    }
223
224    /// DTM2 is not used in mono OTP mode; this is a no-op.
225    fn update_red(&mut self, _buffer: &[u8], _delay: &mut impl DelayNs) -> Result<(), DisplayError> {
226        Ok(())
227    }
228
229    /// Write the BW plane and wait until ready. DTM2 is ignored in mono OTP mode.
230    fn update(
231        &mut self,
232        bw_buffer: &[u8],
233        _red_buffer: &[u8],
234        delay: &mut impl DelayNs,
235    ) -> Result<(), DisplayError> {
236        self.write_dtm1(bw_buffer)?;
237        self.interface.wait_until_idle_active_low(delay);
238        Ok(())
239    }
240
241    /// Fill the black/white RAM plane (DTM1) with white, clearing it.
242    fn clear_bw_ram(&mut self) -> Result<(), DisplayError> {
243        let color = Color::White.get_byte_value();
244        self.interface.cmd(Il0373Cmd::DTM1)?;
245        self.interface
246            .data_x_times(color, u32::from(HEIGHT).div_ceil(8) * u32::from(WIDTH))?;
247        Ok(())
248    }
249
250    /// DTM2 is not used in mono OTP mode; this is a no-op.
251    fn clear_red_ram(&mut self) -> Result<(), DisplayError> {
252        Ok(())
253    }
254
255    /// Hardware-reset the panel and put it into a low-power sleep state.
256    fn begin(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
257        self.interface.hard_reset(delay)?;
258        self.sleep(delay)
259    }
260}
261
262/// Driver for the Adafruit MagTag 2.9" 4-level grayscale e-ink display (IL0373 controller).
263///
264/// The IL0373 busy pin is active-low (LOW = busy, HIGH = ready), which is the
265/// opposite of the SSD1680. This struct handles the polarity difference internally.
266///
267/// Use [`Display2in9Gray2`] as the graphics buffer; it is identical to the
268/// SSD1680 variant since both panels share 296×128 dimensions and the same
269/// Gray2 bit encoding across the two framebuffer planes.
270pub struct ThinkInk2in9Gray2<SPI, BSY, DC, RST>
271where
272    SPI: SpiDevice,
273    BSY: InputPin,
274    DC: OutputPin,
275    RST: OutputPin,
276{
277    interface: SpiDisplayInterface<SPI, BSY, DC, RST>,
278}
279
280impl<SPI, BSY, DC, RST> ThinkInk2in9Gray2<SPI, BSY, DC, RST>
281where
282    SPI: SpiDevice,
283    BSY: InputPin,
284    DC: OutputPin,
285    RST: OutputPin,
286{
287    /// Create a new MagTag 2.9" IL0373 grayscale display driver.
288    pub fn new(spi: SPI, busy: BSY, dc: DC, rst: RST) -> Result<Self, DisplayError> {
289        let interface = SpiDisplayInterface::new(spi, busy, dc, rst);
290        Ok(Self { interface })
291    }
292
293    /// Write `buffer` to the black/white RAM plane (DTM1).
294    fn write_dtm1(&mut self, buffer: &[u8]) -> Result<(), DisplayError> {
295        self.interface.cmd_with_data(Il0373Cmd::DTM1, buffer)
296    }
297
298    /// Write `buffer` to the color RAM plane (DTM2).
299    fn write_dtm2(&mut self, buffer: &[u8]) -> Result<(), DisplayError> {
300        self.interface.cmd_with_data(Il0373Cmd::DTM2, buffer)
301    }
302
303    /// Trigger a full display refresh and wait for the panel to become ready.
304    ///
305    /// Issues `DISPLAY_REFRESH`, waits 100 ms for the panel to start updating,
306    /// then polls the busy pin (active-low) until the refresh completes.
307    fn display(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
308        self.interface.cmd(Il0373Cmd::DISPLAY_REFRESH)?;
309        delay.delay_ms(100);
310        self.interface.wait_until_idle_active_low(delay);
311        Ok(())
312    }
313
314    /// Write both Gray2 framebuffer planes to the display and trigger a full refresh.
315    ///
316    /// `high_buffer` is written to DTM1 (black/white plane) and `low_buffer` to
317    /// DTM2 (color plane). Pass [`Display2in9Gray2::high_buffer`] and
318    /// [`Display2in9Gray2::low_buffer`] respectively.
319    ///
320    /// This method calls [`EpdDriver::init`] internally, so there is no need
321    /// to call it separately before invoking this method.
322    pub fn update_gray2_and_display(
323        &mut self,
324        high_buffer: &[u8],
325        low_buffer: &[u8],
326        delay: &mut impl DelayNs,
327    ) -> Result<(), DisplayError> {
328        self.init(delay)?;
329        self.update_bw(high_buffer, delay)?;
330        self.update_red(low_buffer, delay)?;
331        self.display(delay)?;
332        self.sleep(delay)
333    }
334}
335
336impl<SPI, BSY, DC, RST> EpdDriver for ThinkInk2in9Gray2<SPI, BSY, DC, RST>
337where
338    SPI: SpiDevice,
339    BSY: InputPin,
340    DC: OutputPin,
341    RST: OutputPin,
342{
343    /// Reset and fully initialize the display for 4-level grayscale operation.
344    ///
345    /// Performs a hardware reset, sends the Gray4 power and panel configuration,
346    /// loads all five waveform LUT tables, and sets the panel resolution.
347    /// The busy pin (active-low) is polled after power-on before proceeding.
348    fn init(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
349        debug!("powering up MagTag 2.9\" IL0373 grayscale display");
350
351        // Hardware reset
352        self.interface.hard_reset(delay)?;
353
354        // Power setting: external power, VGH=20V, VGL=-20V, VDH=15V, VDL=-15V
355        self.interface
356            .cmd_with_data(Il0373Cmd::POWER_SETTING, &[0x03, 0x00, 0x2b, 0x2b, 0x13])?;
357
358        // Booster soft start: phase A/B/C all 0x17
359        self.interface
360            .cmd_with_data(Il0373Cmd::BOOSTER_SOFT_START, &[0x17, 0x17, 0x17])?;
361
362        // Power on, then wait for the display to be ready
363        self.interface.cmd(Il0373Cmd::POWER_ON)?;
364        self.interface.wait_until_idle_active_low(delay);
365        delay.delay_ms(200);
366
367        // Panel setting: KW/R mode, scan up, shift right, booster on
368        self.interface
369            .cmd_with_data(Il0373Cmd::PANEL_SETTING, &[0x3F])?;
370
371        // PLL: 50Hz frame rate
372        self.interface.cmd_with_data(Il0373Cmd::PLL, &[0x3C])?;
373
374        // VCM DC setting
375        self.interface
376            .cmd_with_data(Il0373Cmd::VCM_DC_SETTING, &[0x12])?;
377
378        // CDI: border/data polarity
379        self.interface.cmd_with_data(Il0373Cmd::CDI, &[0x97])?;
380
381        // Load the 5-part Gray4 LUT
382        self.interface
383            .cmd_with_data(Il0373Cmd::LUT1, &TI_290T5_GRAY4_LUT1)?;
384        self.interface
385            .cmd_with_data(Il0373Cmd::LUTWW, &TI_290T5_GRAY4_LUTWW)?;
386        self.interface
387            .cmd_with_data(Il0373Cmd::LUTBW, &TI_290T5_GRAY4_LUTBW)?;
388        self.interface
389            .cmd_with_data(Il0373Cmd::LUTWB, &TI_290T5_GRAY4_LUTWB)?;
390        self.interface
391            .cmd_with_data(Il0373Cmd::LUTBB, &TI_290T5_GRAY4_LUTBB)?;
392
393        // IL0373 RESOLUTION format: [gate_lines, source_lines_hi, source_lines_lo]
394        // Gate lines = 128 (our WIDTH), source lines = 296 (our HEIGHT).
395        // Note: our WIDTH/HEIGHT naming is inverted relative to the Arduino library,
396        // which uses WIDTH=296 and HEIGHT=128 for this panel.
397        self.interface.cmd_with_data(
398            Il0373Cmd::RESOLUTION,
399            &[
400                (HEIGHT & 0xFF) as u8,       // gate lines:        128 = 0x80
401                ((WIDTH >> 8) & 0xFF) as u8, // source lines high:   1 = 0x01
402                (HEIGHT & 0xFF) as u8,       // source lines low:   40 = 0x28
403            ],
404        )?;
405
406        Ok(())
407    }
408
409    /// Power down the display controller.
410    ///
411    /// Sets CDI to the border-floating state, discharges the VCM DC voltage,
412    /// then issues the power-off command.
413    fn sleep(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
414        debug!("powering down MagTag 2.9\" IL0373 grayscale display");
415
416        // CDI: border floating
417        self.interface.cmd_with_data(Il0373Cmd::CDI, &[0x17])?;
418
419        // VCM DC: discharge
420        self.interface.cmd(Il0373Cmd::VCM_DC_SETTING)?;
421
422        // Power off
423        self.interface.cmd(Il0373Cmd::POWER_OFF)?;
424        delay.delay_ms(1);
425        Ok(())
426    }
427
428    /// Write `buffer` to the black/white RAM plane (DTM1) and wait until ready.
429    fn update_bw(&mut self, buffer: &[u8], delay: &mut impl DelayNs) -> Result<(), DisplayError> {
430        self.write_dtm1(buffer)?;
431        self.interface.wait_until_idle_active_low(delay);
432        Ok(())
433    }
434
435    /// Write `buffer` to the color RAM plane (DTM2) and wait until ready.
436    fn update_red(&mut self, buffer: &[u8], delay: &mut impl DelayNs) -> Result<(), DisplayError> {
437        self.write_dtm2(buffer)?;
438        self.interface.wait_until_idle_active_low(delay);
439        Ok(())
440    }
441
442    /// Write both RAM planes and wait until ready.
443    ///
444    /// `bw_buffer` goes to DTM1 and `red_buffer` goes to DTM2. Does not trigger
445    /// a display refresh; call [`Self::update_gray2_and_display`] for a
446    /// complete write-and-refresh cycle.
447    fn update(
448        &mut self,
449        bw_buffer: &[u8],
450        red_buffer: &[u8],
451        delay: &mut impl DelayNs,
452    ) -> Result<(), DisplayError> {
453        self.write_dtm1(bw_buffer)?;
454        self.write_dtm2(red_buffer)?;
455        self.interface.wait_until_idle_active_low(delay);
456        Ok(())
457    }
458
459    /// Fill the black/white RAM plane (DTM1) with white, clearing it.
460    fn clear_bw_ram(&mut self) -> Result<(), DisplayError> {
461        let color = Color::White.get_byte_value();
462        self.interface.cmd(Il0373Cmd::DTM1)?;
463        self.interface
464            .data_x_times(color, u32::from(HEIGHT).div_ceil(8) * u32::from(WIDTH))?;
465        Ok(())
466    }
467
468    /// Fill the color RAM plane (DTM2) with its cleared state.
469    fn clear_red_ram(&mut self) -> Result<(), DisplayError> {
470        let color = Color::White.inverse().get_byte_value();
471        self.interface.cmd(Il0373Cmd::DTM2)?;
472        self.interface
473            .data_x_times(color, u32::from(HEIGHT).div_ceil(8) * u32::from(WIDTH))?;
474        Ok(())
475    }
476
477    /// Hardware-reset the panel and put it into a low-power sleep state.
478    ///
479    /// Equivalent to calling [`Self::sleep`] after a hard reset. Use this to
480    /// prepare the display before the first [`Self::init`] call.
481    fn begin(&mut self, delay: &mut impl DelayNs) -> Result<(), DisplayError> {
482        self.interface.hard_reset(delay)?;
483        self.sleep(delay)
484    }
485}