From 34b53b9aedcb3d4eb31d500de3fa4ab34ceb55fc Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 29 Oct 2026 08:00:42 -0700 Subject: [PATCH 01/29] EPaperDisplay: allow update_refresh_mode to change refresh_sequence Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 26 ++++++++++++++++--- shared-module/epaperdisplay/EPaperDisplay.c | 7 ++++- shared-module/epaperdisplay/EPaperDisplay.h | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index b8858ac552f..081202d493a 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -268,16 +268,27 @@ static mp_obj_t epaperdisplay_epaperdisplay_obj_show(mp_obj_t self_in, mp_obj_t MP_DEFINE_CONST_FUN_OBJ_2(epaperdisplay_epaperdisplay_show_obj, epaperdisplay_epaperdisplay_obj_show); //| def update_refresh_mode( -//| self, start_sequence: ReadableBuffer, seconds_per_frame: float = 180 +//| self, +//| start_sequence: ReadableBuffer, +//| seconds_per_frame: float = 180, +//| *, +//| refresh_sequence: Optional[ReadableBuffer] = None, //| ) -> None: //| """Updates the ``start_sequence`` and ``seconds_per_frame`` parameters to enable -//| varying the refresh mode of the display.""" +//| varying the refresh mode of the display. +//| +//| :param ReadableBuffer start_sequence: Byte sequence sent to initialize the display before each refresh. +//| :param float seconds_per_frame: Minimum number of seconds between refreshes. +//| :param ReadableBuffer refresh_sequence: Byte sequence sent to start a refresh. +//| It uses the same format as ``start_sequence``. +//| When it is None, the current refresh sequence is kept.""" //| static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { - enum { ARG_start_sequence, ARG_seconds_per_frame }; + enum { ARG_start_sequence, ARG_seconds_per_frame, ARG_refresh_sequence }; static const mp_arg_t allowed_args[] = { { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_seconds_per_frame, MP_ARG_OBJ, {.u_obj = MP_OBJ_NEW_SMALL_INT(180)} }, + { MP_QSTR_refresh_sequence, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, }; epaperdisplay_epaperdisplay_obj_t *self = native_display(pos_args[0]); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; @@ -288,8 +299,15 @@ static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, c mp_get_buffer_raise(args[ARG_start_sequence].u_obj, &start_sequence, MP_BUFFER_READ); float seconds_per_frame = mp_obj_get_float(args[ARG_seconds_per_frame].u_obj); + mp_buffer_info_t refresh_sequence; + mp_buffer_info_t *refresh_sequence_ptr = NULL; + if (args[ARG_refresh_sequence].u_obj != mp_const_none) { + mp_get_buffer_raise(args[ARG_refresh_sequence].u_obj, &refresh_sequence, MP_BUFFER_READ); + refresh_sequence_ptr = &refresh_sequence; + } + // Update parameters - epaperdisplay_epaperdisplay_change_refresh_mode_parameters(self, &start_sequence, seconds_per_frame); + epaperdisplay_epaperdisplay_change_refresh_mode_parameters(self, &start_sequence, refresh_sequence_ptr, seconds_per_frame); return mp_const_none; } MP_DEFINE_CONST_FUN_OBJ_KW(epaperdisplay_epaperdisplay_update_refresh_mode_obj, 1, epaperdisplay_epaperdisplay_update_refresh_mode); diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 3b3908d1fb2..8da25337378 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -185,9 +185,14 @@ static void send_command_sequence(epaperdisplay_epaperdisplay_obj_t *self, } void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_epaperdisplay_obj_t *self, - mp_buffer_info_t *start_sequence, float seconds_per_frame) { + mp_buffer_info_t *start_sequence, mp_buffer_info_t *refresh_sequence, float seconds_per_frame) { self->start_sequence = (uint8_t *)start_sequence->buf; self->start_sequence_len = start_sequence->len; + // NULL keeps the current refresh sequence. + if (refresh_sequence != NULL) { + self->refresh_sequence = (uint8_t *)refresh_sequence->buf; + self->refresh_sequence_len = refresh_sequence->len; + } self->milliseconds_per_frame = seconds_per_frame * 1000; } diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 7c72bf1f954..75760785134 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -41,7 +41,7 @@ typedef struct { } epaperdisplay_epaperdisplay_obj_t; void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_epaperdisplay_obj_t *self, - mp_buffer_info_t *start_sequence, float seconds_per_frame); + mp_buffer_info_t *start_sequence, mp_buffer_info_t *refresh_sequence, float seconds_per_frame); void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *self); void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self); void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self); From 3fea298a3cd42846d9fead199aba706ffbb7fe65 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 29 Oct 2026 08:01:37 -0700 Subject: [PATCH 02/29] epaperdisplay: add write_previous_ram_command After a refresh finishes, write the same areas again with this command so the controller's previous image RAM matches the screen. This lets SSD168x style partial updates diff against what is actually shown. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 14 ++- shared-bindings/epaperdisplay/EPaperDisplay.h | 2 + shared-module/epaperdisplay/EPaperDisplay.c | 85 +++++++++++++++++-- shared-module/epaperdisplay/EPaperDisplay.h | 1 + 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 081202d493a..9a269f3dd2e 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -54,6 +54,7 @@ //| black_bits_inverted: bool = False, //| write_color_ram_command: Optional[int] = None, //| color_bits_inverted: bool = False, +//| write_previous_ram_command: Optional[int] = None, //| highlight_color: int = 0x000000, //| highlight_color2: int = 0x000000, //| refresh_display_command: Union[int, circuitpython_typing.ReadableBuffer], @@ -98,6 +99,11 @@ //| :param bool black_bits_inverted: True if 0 bits are used to show black pixels. Otherwise, 1 means to show black. //| :param int write_color_ram_command: Command used to write pixels values into the update region //| :param bool color_bits_inverted: True if 0 bits are used to show the color. Otherwise, 1 means to show color. +//| :param int write_previous_ram_command: Command used to write the same pixel values again after each refresh. +//| Use it for controllers that compare new and previous RAM to do a partial update, such as the SSD1680 old data RAM (0x26). +//| When set, ``refresh()`` waits for the display to finish updating before it returns. +//| Requires a non-empty ``stop_sequence``. +//| Leave ``write_color_ram_command`` as None when it writes the same RAM, or it will overwrite the previous image. //| :param int highlight_color: RGB888 of source color to highlight with third ePaper color. //| :param int highlight_color2: RGB888 of source color to highlight with fourth ePaper color. //| :param int refresh_display_command: Command used to start a display refresh. Single int or byte-packed command sequence @@ -120,7 +126,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, ARG_ram_width, ARG_ram_height, ARG_colstart, ARG_rowstart, ARG_rotation, ARG_set_column_window_command, ARG_set_row_window_command, ARG_set_current_column_command, ARG_set_current_row_command, ARG_write_black_ram_command, ARG_black_bits_inverted, - ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_highlight_color, ARG_highlight_color2, + ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_write_previous_ram_command, ARG_highlight_color, ARG_highlight_color2, ARG_refresh_display_command, ARG_refresh_time, ARG_busy_pin, ARG_busy_state, ARG_seconds_per_frame, ARG_always_toggle_chip_select, ARG_grayscale, ARG_advanced_color_epaper, ARG_spectra6, ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian }; @@ -143,6 +149,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_black_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_write_color_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, { MP_QSTR_color_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + { MP_QSTR_write_previous_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, { MP_QSTR_highlight_color, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_highlight_color2, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_refresh_display_command, MP_ARG_OBJ | MP_ARG_REQUIRED }, @@ -189,6 +196,10 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, if (args[ARG_write_color_ram_command].u_obj != mp_const_none) { write_color_ram_command = mp_obj_get_int(args[ARG_write_color_ram_command].u_obj); } + mp_int_t write_previous_ram_command = NO_COMMAND; + if (args[ARG_write_previous_ram_command].u_obj != mp_const_none) { + write_previous_ram_command = mp_obj_get_int(args[ARG_write_previous_ram_command].u_obj); + } bool two_byte_sequence_length = args[ARG_two_byte_sequence_length].u_bool; @@ -234,6 +245,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.black_bits_inverted = args[ARG_black_bits_inverted].u_bool; construct_args.write_color_ram_command = write_color_ram_command; construct_args.color_bits_inverted = args[ARG_color_bits_inverted].u_bool; + construct_args.write_previous_ram_command = write_previous_ram_command; construct_args.highlight_color = highlight_color; construct_args.highlight_color2 = highlight_color2; construct_args.refresh_sequence = refresh_buf; diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.h b/shared-bindings/epaperdisplay/EPaperDisplay.h index 83f0b0377fe..54b4c3e069a 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.h +++ b/shared-bindings/epaperdisplay/EPaperDisplay.h @@ -37,6 +37,7 @@ typedef struct { bool black_bits_inverted; uint16_t write_color_ram_command; bool color_bits_inverted; + uint16_t write_previous_ram_command; uint32_t highlight_color; uint32_t highlight_color2; const uint8_t *refresh_sequence; @@ -75,6 +76,7 @@ typedef struct { .black_bits_inverted = false, \ .write_color_ram_command = NO_COMMAND, \ .color_bits_inverted = false, \ + .write_previous_ram_command = NO_COMMAND, \ .highlight_color = 0x000000, \ .highlight_color2 = 0x000000, \ .refresh_sequence = NULL, \ diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 8da25337378..001dcaf0abf 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -10,6 +10,7 @@ #include "py/runtime.h" #include "shared/runtime/interrupt_char.h" #include "shared-bindings/displayio/ColorConverter.h" +#include "shared-bindings/microcontroller/__init__.h" #include "shared-bindings/microcontroller/Pin.h" #include "shared-bindings/time/__init__.h" #include "shared-module/displayio/__init__.h" @@ -68,6 +69,7 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla self->black_bits_inverted = args->black_bits_inverted; self->write_color_ram_command = args->write_color_ram_command; self->color_bits_inverted = args->color_bits_inverted; + self->write_previous_ram_command = args->write_previous_ram_command; self->refresh_time = args->refresh_time * 1000; self->busy_state = args->busy_state; self->refreshing = false; @@ -132,6 +134,22 @@ uint16_t common_hal_epaperdisplay_epaperdisplay_get_height(epaperdisplay_epaperd return displayio_display_core_get_height(&self->core); } +// A stuck panel can't hold up what this driver waits for on its own account. +#define EPAPER_WAIT_TIMEOUT_MS 30000 + +// BUSY can take a moment to rise after an update command, and reading it low before then would +// look like a finished update. If it never rises, nothing has started or it was too quick to see, +// so this returns false after 2 ms. Short enough that an interrupt doesn't have to end it. +static bool wait_for_update_start(epaperdisplay_epaperdisplay_obj_t *self) { + for (uint8_t wait = 0; wait < 200; wait++) { + if (common_hal_digitalio_digitalinout_get_value(&self->busy) == self->busy_state) { + return true; + } + common_hal_mcu_delay_us(10); + } + return false; +} + static void wait_for_busy(epaperdisplay_epaperdisplay_obj_t *self) { if (self->busy.base.type == &mp_type_NoneType) { return; @@ -229,14 +247,66 @@ uint32_t common_hal_epaperdisplay_epaperdisplay_get_time_to_refresh(epaperdispla return self->milliseconds_per_frame - elapsed_time; } -static void epaperdisplay_epaperdisplay_finish_refresh(epaperdisplay_epaperdisplay_obj_t *self) { +static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *area, bool write_previous); + +// False if the panel is still updating when the wait is over. +static bool wait_for_update_end(epaperdisplay_epaperdisplay_obj_t *self) { + wait_for_update_start(self); + const uint64_t deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; + bool busy; + while ((busy = common_hal_digitalio_digitalinout_get_value(&self->busy) == self->busy_state) && + !mp_hal_is_interrupted() && supervisor_ticks_ms64() <= deadline) { + RUN_BACKGROUND_TASKS; + } + return !busy; +} + +static void epaperdisplay_epaperdisplay_finish_refresh(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas) { // Actually refresh the display now that all pixel RAM has been updated. send_command_sequence(self, false, self->refresh_sequence, self->refresh_sequence_len); + const uint64_t refresh_start = supervisor_ticks_ms64(); + bool previous_written = true; + mp_obj_t raised = MP_OBJ_NULL; + + if (self->write_previous_ram_command != NO_COMMAND && areas != NULL) { + // Copy the new image into the previous image RAM so the next partial update compares + // against what is on screen. The controller ignores RAM writes while it is updating. + if (self->busy.base.type == &mp_type_NoneType) { + common_hal_time_delay_ms(self->refresh_time); + } else { + previous_written = wait_for_update_end(self); + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + while (areas != NULL && previous_written) { + previous_written = !mp_hal_is_interrupted() && + epaperdisplay_epaperdisplay_refresh_area(self, areas, true); + areas = areas->next; + } + nlr_pop(); + } else { + // Ctrl-C raises from a transfer in progress, and that transaction is still open. + raised = nlr.ret_val; + previous_written = false; + if (!displayio_display_bus_is_free(&self->bus)) { + displayio_display_bus_end_transaction(&self->bus); + } + } + } supervisor_enable_tick(); self->refreshing = true; displayio_display_core_finish_refresh(&self->core); + // The update began before the wait above, and refresh_time and seconds_per_frame count from it. + self->core.last_refresh = refresh_start; + if (!previous_written) { + // The previous RAM is stale, so the next update has to write everything. + self->core.full_refresh = true; + } + if (raised != MP_OBJ_NULL) { + nlr_jump(raised); + } } mp_obj_t common_hal_epaperdisplay_epaperdisplay_get_bus(epaperdisplay_epaperdisplay_obj_t *self) { @@ -272,7 +342,7 @@ mp_obj_t common_hal_epaperdisplay_epaperdisplay_get_root_group(epaperdisplay_epa return self->core.current_group; } -static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *area) { +static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *area, bool write_previous) { uint16_t buffer_size = 128; // In uint32_ts displayio_area_t clipped; @@ -307,7 +377,7 @@ static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay uint32_t mask[mask_length]; uint8_t passes = 1; - if (self->write_color_ram_command != NO_COMMAND) { + if (self->write_color_ram_command != NO_COMMAND && !write_previous) { passes = 2; } for (uint8_t pass = 0; pass < passes; pass++) { @@ -320,6 +390,8 @@ static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay uint8_t write_command = self->write_black_ram_command; if (pass == 1) { write_command = self->write_color_ram_command; + } else if (write_previous) { + write_command = self->write_previous_ram_command; } if (!displayio_display_bus_begin_transaction(&self->bus)) { // Display bus not available now. @@ -455,7 +527,7 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ if (self->acep) { epaperdisplay_epaperdisplay_start_refresh(self); _clean_area(self); - epaperdisplay_epaperdisplay_finish_refresh(self); + epaperdisplay_epaperdisplay_finish_refresh(self, NULL); while (self->refreshing && !mp_hal_is_interrupted()) { RUN_BACKGROUND_TASKS; } @@ -465,11 +537,12 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ } epaperdisplay_epaperdisplay_start_refresh(self); + const displayio_area_t *first_area = current_area; while (current_area != NULL) { - epaperdisplay_epaperdisplay_refresh_area(self, current_area); + epaperdisplay_epaperdisplay_refresh_area(self, current_area, false); current_area = current_area->next; } - epaperdisplay_epaperdisplay_finish_refresh(self); + epaperdisplay_epaperdisplay_finish_refresh(self, first_area); return true; } diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 75760785134..34d9b4d8f80 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -29,6 +29,7 @@ typedef struct { uint16_t refresh_time; uint16_t write_black_ram_command; uint16_t write_color_ram_command; + uint16_t write_previous_ram_command; uint8_t hue; bool busy_state; bool black_bits_inverted; From 819974a3430c303b30eae286d5404bf98ab93b2b Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Thu, 29 Oct 2026 08:02:22 -0700 Subject: [PATCH 03/29] epaperdisplay: add keep_awake to skip reset and sleep between refreshes A keep_awake display ends a transaction still holding its bus when the VM ends, since Ctrl-C can leave one open and the stop sequence would otherwise wait for the bus forever. The stop sequence is also guarded when it follows a refresh. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 18 ++++- shared-bindings/epaperdisplay/EPaperDisplay.h | 4 +- shared-module/displayio/__init__.c | 3 + shared-module/epaperdisplay/EPaperDisplay.c | 68 +++++++++++++++++-- shared-module/epaperdisplay/EPaperDisplay.h | 6 ++ 5 files changed, 91 insertions(+), 8 deletions(-) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 9a269f3dd2e..9ec580c7a86 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -69,6 +69,7 @@ //| two_byte_sequence_length: bool = False, //| start_up_time: float = 0, //| address_little_endian: bool = False, +//| keep_awake: bool = False, //| ) -> None: //| """Create a EPaperDisplay object on the given display bus (`fourwire.FourWire` or `paralleldisplaybus.ParallelBus`). //| @@ -118,6 +119,10 @@ //| :param bool two_byte_sequence_length: When true, use two bytes to define sequence length //| :param float start_up_time: Time to wait after reset before sending commands //| :param bool address_little_endian: Send the least significant byte (not bit) of multi-byte addresses first. Ignored when ram is addressed with one byte +//| :param bool keep_awake: When true, the display is only reset and sent the ``start_sequence`` before the first refresh. +//| The ``stop_sequence`` is not sent after each refresh. It is sent when the display is released. +//| It is also sent before the reset that ``update_refresh_mode()`` asks for, so it can't be empty. +//| This keeps the display's settings and RAM between refreshes so they can be faster. //| """ //| ... //| @@ -129,7 +134,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_write_previous_ram_command, ARG_highlight_color, ARG_highlight_color2, ARG_refresh_display_command, ARG_refresh_time, ARG_busy_pin, ARG_busy_state, ARG_seconds_per_frame, ARG_always_toggle_chip_select, ARG_grayscale, ARG_advanced_color_epaper, ARG_spectra6, - ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian }; + ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, ARG_keep_awake }; static const mp_arg_t allowed_args[] = { { MP_QSTR_display_bus, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, @@ -164,6 +169,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_two_byte_sequence_length, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_start_up_time, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NEW_SMALL_INT(0)} }, { MP_QSTR_address_little_endian, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + { MP_QSTR_keep_awake, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); @@ -174,6 +180,12 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, mp_get_buffer_raise(args[ARG_start_sequence].u_obj, &start_bufinfo, MP_BUFFER_READ); mp_buffer_info_t stop_bufinfo; mp_get_buffer_raise(args[ARG_stop_sequence].u_obj, &stop_bufinfo, MP_BUFFER_READ); + // A reset can follow these displays' last RAM write, and SSD168x controllers only commit the + // last byte when another command arrives. + if (stop_bufinfo.len == 0 && + (args[ARG_keep_awake].u_bool || args[ARG_write_previous_ram_command].u_obj != mp_const_none)) { + mp_arg_error_invalid(MP_QSTR_stop_sequence); + } const mcu_pin_obj_t *busy_pin = validate_obj_is_free_pin_or_none(args[ARG_busy_pin].u_obj, MP_QSTR_busy_pin); @@ -260,6 +272,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.spectra6 = args[ARG_spectra6].u_bool; construct_args.two_byte_sequence_length = two_byte_sequence_length; construct_args.address_little_endian = args[ARG_address_little_endian].u_bool; + construct_args.keep_awake = args[ARG_keep_awake].u_bool; common_hal_epaperdisplay_epaperdisplay_construct(self, &construct_args); return self; @@ -287,7 +300,8 @@ MP_DEFINE_CONST_FUN_OBJ_2(epaperdisplay_epaperdisplay_show_obj, epaperdisplay_ep //| refresh_sequence: Optional[ReadableBuffer] = None, //| ) -> None: //| """Updates the ``start_sequence`` and ``seconds_per_frame`` parameters to enable -//| varying the refresh mode of the display. +//| varying the refresh mode of the display. When ``keep_awake`` is true, the display is +//| reset and sent the new ``start_sequence`` before the next refresh. //| //| :param ReadableBuffer start_sequence: Byte sequence sent to initialize the display before each refresh. //| :param float seconds_per_frame: Minimum number of seconds between refreshes. diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.h b/shared-bindings/epaperdisplay/EPaperDisplay.h index 54b4c3e069a..9ae58b82f10 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.h +++ b/shared-bindings/epaperdisplay/EPaperDisplay.h @@ -52,6 +52,7 @@ typedef struct { bool spectra6; bool two_byte_sequence_length; bool address_little_endian; + bool keep_awake; } epaperdisplay_construct_args_t; #define EPAPERDISPLAY_CONSTRUCT_ARGS_DEFAULTS { \ @@ -90,7 +91,8 @@ typedef struct { .acep = false, \ .spectra6 = false, \ .two_byte_sequence_length = false, \ - .address_little_endian = false \ + .address_little_endian = false, \ + .keep_awake = false \ } void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdisplay_obj_t *self, diff --git a/shared-module/displayio/__init__.c b/shared-module/displayio/__init__.c index 9819b613cab..72c7a9b8af0 100644 --- a/shared-module/displayio/__init__.c +++ b/shared-module/displayio/__init__.c @@ -170,6 +170,9 @@ static void common_hal_displayio_release_displays_impl(bool keep_primary) { #endif #if CIRCUITPY_EPAPERDISPLAY } else if (display_type == &epaperdisplay_epaperdisplay_type) { + if (keep_primary) { + epaperdisplay_epaperdisplay_end_held_bus(&displays[i].epaper_display); + } release_epaperdisplay(&displays[i].epaper_display); #endif #if CIRCUITPY_FRAMEBUFFERIO diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 001dcaf0abf..508fe96fb06 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -84,6 +84,9 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla self->stop_sequence_len = args->stop_sequence_len; self->refresh_sequence = args->refresh_sequence; self->refresh_sequence_len = args->refresh_sequence_len; + self->keep_awake = args->keep_awake; + self->awake = false; + self->needs_start = false; self->busy.base.type = &mp_type_NoneType; self->two_byte_sequence_length = args->two_byte_sequence_length; @@ -212,6 +215,22 @@ void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_ep self->refresh_sequence_len = refresh_sequence->len; } self->milliseconds_per_frame = seconds_per_frame * 1000; + self->needs_start = true; +} + +// For a panel kept awake. Ctrl-C raises from a send in progress, and that transaction is still +// open. The caller still has things to do first, so the interrupt raises later. +static void send_stop_sequence_guarded(epaperdisplay_epaperdisplay_obj_t *self) { + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + nlr_pop(); + } else { + if (!displayio_display_bus_is_free(&self->bus)) { + displayio_display_bus_end_transaction(&self->bus); + } + mp_sched_exception(nlr.ret_val); + } } static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdisplay_obj_t *self) { @@ -224,6 +243,17 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla return; } + if (self->keep_awake && self->awake) { + if (!self->needs_start) { + // The panel is still configured from the previous refresh. + return; + } + // SSD168x controllers commit the last RAM byte when the next command arrives, so a reset + // can't come straight after a RAM write. + send_stop_sequence_guarded(self); + self->awake = false; + } + // run start sequence self->bus.bus_reset(self->bus.bus); @@ -233,6 +263,8 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla if (mp_hal_is_interrupted()) { return; } + self->awake = true; + self->needs_start = false; } uint32_t common_hal_epaperdisplay_epaperdisplay_get_time_to_refresh(epaperdisplay_epaperdisplay_obj_t *self) { @@ -503,8 +535,10 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ if (common_hal_digitalio_digitalinout_get_value(&self->busy) != self->busy_state) { supervisor_disable_tick(); self->refreshing = false; - // Run stop sequence but don't wait for busy because busy is set when sleeping. - send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + if (!self->keep_awake) { + // Run stop sequence but don't wait for busy because busy is set when sleeping. + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + } } else { return false; } @@ -558,8 +592,10 @@ void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *s if (refresh_done) { supervisor_disable_tick(); self->refreshing = false; - // Run stop sequence but don't wait for busy because busy is set when sleeping. - send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + if (!self->keep_awake) { + // Run stop sequence but don't wait for busy because busy is set when sleeping. + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + } } } } @@ -575,8 +611,15 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { supervisor_disable_tick(); self->refreshing = false; // Run stop sequence but don't wait for busy because busy is set when sleeping. - send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + if (self->keep_awake) { + send_stop_sequence_guarded(self); + } else { + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + } + } else if (self->keep_awake && self->awake) { + send_stop_sequence_guarded(self); } + self->awake = false; release_display_core(&self->core); if (self->busy.base.type == &digitalio_digitalinout_type) { @@ -584,9 +627,24 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { } } +void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self) { + // The VM is over, so whatever holds the bus can never let it go: end it here. + if (self->keep_awake && !displayio_display_bus_is_free(&self->bus)) { + displayio_display_bus_end_transaction(&self->bus); + } +} + void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) { displayio_display_core_set_root_group(&self->core, &circuitpython_splash); self->core.full_refresh = true; + // Let the panel sleep after the next refresh when code isn't running. + if (self->keep_awake && self->awake && !self->refreshing) { + // Nothing can catch a raise from a send. A reload may be pending. + epaperdisplay_epaperdisplay_end_held_bus(self); + send_stop_sequence_guarded(self); + self->awake = false; + } + self->keep_awake = false; } void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t *self) { diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 34d9b4d8f80..1e48dcb52b0 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -38,6 +38,9 @@ typedef struct { bool grayscale; bool acep; bool two_byte_sequence_length; + bool keep_awake; + bool awake; + bool needs_start; // update_refresh_mode() changed the start sequence. display_chip_select_behavior_t chip_select; } epaperdisplay_epaperdisplay_obj_t; @@ -46,6 +49,9 @@ void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_ep void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *self); void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self); void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self); +// The VM is over: a keep_awake display ends whatever transaction still holds its bus, since its +// stop sequence would wait for the bus forever. Other displays are left alone. Does not raise. +void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self); size_t maybe_refresh_epaperdisplay(void); void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t *self); From f2ffcb426000dfec32c60ed630066c3dd2fe56f9 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sun, 1 Nov 2026 06:59:49 -0800 Subject: [PATCH 04/29] epaperdisplay: add lag_frames drive The previous RAM holds frame N-lag instead of N-1, so each changed pixel is driven for lag_frames updates, the same number toward black and toward white. Only the box around changed bytes is sent. clean_sequence runs before the first refresh and when the display is released, leaving the panel white and balanced. A waveform or panel reset cut short leaves the panel dark, so while one is under way an interrupt waits and is raised when it ends. A send it stops starts over. Waits are bounded per update so a stuck panel can't hang shutdown, and the watchdog is fed while interrupts are held. CIRCUITPY_EPAPERDISPLAY_LAG gates it, off by default and on for Feather RP2040 ThinkInk. clean_sequence is accepted on every build. lag_frames raises ValueError where the drive is off. Co-Authored-By: Claude Opus 5.5 --- .../mpconfigboard.mk | 2 + py/circuitpy_mpconfig.mk | 4 + shared-bindings/epaperdisplay/EPaperDisplay.c | 75 +- shared-bindings/epaperdisplay/EPaperDisplay.h | 8 +- shared-module/epaperdisplay/EPaperDisplay.c | 879 +++++++++++++++++- shared-module/epaperdisplay/EPaperDisplay.h | 29 + 6 files changed, 976 insertions(+), 21 deletions(-) diff --git a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk index 1f71dcb76bf..1caf2f862ee 100644 --- a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk @@ -7,3 +7,5 @@ CHIP_VARIANT = RP2040 CHIP_FAMILY = rp2 EXTERNAL_FLASH_DEVICES = "GD25Q64C,W25Q64JVxQ" + +CIRCUITPY_EPAPERDISPLAY_LAG = 1 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index e6903fcf6a0..516a0f89b90 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -310,6 +310,10 @@ CFLAGS += -DCIRCUITPY_QSPIBUS=$(CIRCUITPY_QSPIBUS) CIRCUITPY_EPAPERDISPLAY ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_EPAPERDISPLAY=$(CIRCUITPY_EPAPERDISPLAY) +# Fast e-paper updates with balanced drive: EPaperDisplay's lag_frames and clean(). +CIRCUITPY_EPAPERDISPLAY_LAG ?= 0 +CFLAGS += -DCIRCUITPY_EPAPERDISPLAY_LAG=$(CIRCUITPY_EPAPERDISPLAY_LAG) + CIRCUITPY_I2CDISPLAYBUS ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_I2CDISPLAYBUS=$(CIRCUITPY_I2CDISPLAYBUS) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 9ec580c7a86..777c22791e7 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -70,6 +70,8 @@ //| start_up_time: float = 0, //| address_little_endian: bool = False, //| keep_awake: bool = False, +//| lag_frames: int = 0, +//| clean_sequence: Optional[ReadableBuffer] = None, //| ) -> None: //| """Create a EPaperDisplay object on the given display bus (`fourwire.FourWire` or `paralleldisplaybus.ParallelBus`). //| @@ -123,6 +125,19 @@ //| The ``stop_sequence`` is not sent after each refresh. It is sent when the display is released. //| It is also sent before the reset that ``update_refresh_mode()`` asks for, so it can't be empty. //| This keeps the display's settings and RAM between refreshes so they can be faster. +//| :param int lag_frames: When more than 0, the previous RAM holds the frame from ``lag_frames`` refreshes ago +//| instead of the last one, so each changed pixel is driven for ``lag_frames`` refreshes, the same number +//| toward black and toward white. Only the box around the changed bytes is sent. ``refresh()`` returns as soon +//| as the update starts. Poll ``busy`` before the next one. If the image stops changing, call ``refresh()`` +//| ``lag_frames`` more times to finish the drive. Implies ``keep_awake``. Needs ``write_previous_ram_command``, +//| ``busy_pin``, ``clean_sequence``, the window commands and a monochrome display, and allocates +//| ``lag_frames * width * height / 8`` bytes outside the VM heap until the display is released. +//| Only on builds with ``CIRCUITPY_EPAPERDISPLAY_LAG``. Elsewhere, more than 0 raises `ValueError`. +//| :param ~circuitpython_typing.ReadableBuffer clean_sequence: Byte-packed commands for a full refresh, such as +//| one using the waveform stored in the display, for use when both RAM planes are white. It runs before the +//| first refresh with ``lag_frames``. When the display is released or the code stops, the display finishes +//| its drive, goes white and runs this and the ``stop_sequence``. Display drivers should provide it. +//| It is accepted on every build and only used with ``lag_frames``. //| """ //| ... //| @@ -134,7 +149,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_write_previous_ram_command, ARG_highlight_color, ARG_highlight_color2, ARG_refresh_display_command, ARG_refresh_time, ARG_busy_pin, ARG_busy_state, ARG_seconds_per_frame, ARG_always_toggle_chip_select, ARG_grayscale, ARG_advanced_color_epaper, ARG_spectra6, - ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, ARG_keep_awake }; + ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, ARG_keep_awake, ARG_lag_frames, ARG_clean_sequence }; static const mp_arg_t allowed_args[] = { { MP_QSTR_display_bus, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, @@ -170,6 +185,8 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_start_up_time, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NEW_SMALL_INT(0)} }, { MP_QSTR_address_little_endian, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_keep_awake, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + { MP_QSTR_lag_frames, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, + { MP_QSTR_clean_sequence, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); @@ -195,6 +212,37 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, mp_raise_ValueError(MP_ERROR_TEXT("Display rotation must be in 90 degree increments")); } + mp_int_t lag_frames = mp_arg_validate_int_range(args[ARG_lag_frames].u_int, 0, 32, MP_QSTR_lag_frames); + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (lag_frames > 0) { + // Each one a driver can leave out by mistake names itself. The rest are the display type. + qstr missing = MP_QSTRnull; + if (args[ARG_busy_pin].u_obj == mp_const_none) { + missing = MP_QSTR_busy_pin; + } else if (args[ARG_clean_sequence].u_obj == mp_const_none) { + missing = MP_QSTR_clean_sequence; + } else if (args[ARG_write_previous_ram_command].u_obj == mp_const_none) { + missing = MP_QSTR_write_previous_ram_command; + } else if (args[ARG_set_column_window_command].u_int == NO_COMMAND) { + missing = MP_QSTR_set_column_window_command; + } else if (args[ARG_set_row_window_command].u_int == NO_COMMAND) { + missing = MP_QSTR_set_row_window_command; + } else if (args[ARG_write_color_ram_command].u_obj != mp_const_none || + args[ARG_highlight_color].u_int != 0 || args[ARG_highlight_color2].u_int != 0 || + args[ARG_grayscale].u_bool || args[ARG_advanced_color_epaper].u_bool || + args[ARG_spectra6].u_bool) { + missing = MP_QSTR_lag_frames; + } + if (missing != MP_QSTRnull) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("Invalid %q"), missing); + } + } + #else + if (lag_frames > 0) { + mp_raise_ValueError_varg(MP_ERROR_TEXT("Invalid %q"), MP_QSTR_lag_frames); + } + #endif + primary_display_t *disp = allocate_display_or_raise(); epaperdisplay_epaperdisplay_obj_t *self = &disp->epaper_display; @@ -272,7 +320,14 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.spectra6 = args[ARG_spectra6].u_bool; construct_args.two_byte_sequence_length = two_byte_sequence_length; construct_args.address_little_endian = args[ARG_address_little_endian].u_bool; - construct_args.keep_awake = args[ARG_keep_awake].u_bool; + construct_args.keep_awake = args[ARG_keep_awake].u_bool || lag_frames > 0; + construct_args.lag_frames = lag_frames; + if (args[ARG_clean_sequence].u_obj != mp_const_none) { + mp_buffer_info_t clean_bufinfo; + mp_get_buffer_raise(args[ARG_clean_sequence].u_obj, &clean_bufinfo, MP_BUFFER_READ); + construct_args.clean_sequence = clean_bufinfo.buf; + construct_args.clean_sequence_len = clean_bufinfo.len; + } common_hal_epaperdisplay_epaperdisplay_construct(self, &construct_args); return self; @@ -340,13 +395,24 @@ MP_DEFINE_CONST_FUN_OBJ_KW(epaperdisplay_epaperdisplay_update_refresh_mode_obj, //| def refresh(self) -> None: //| """Refreshes the display immediately or raises an exception if too soon. Use -//| ``time.sleep(display.time_to_refresh)`` to sleep until a refresh can occur.""" +//| ``time.sleep(display.time_to_refresh)`` to sleep until a refresh can occur. +//| +//| The minimum time between refreshes is ``seconds_per_frame``, 180 by default. With +//| ``lag_frames``, set it to 0 and poll ``busy`` instead, since this returns once the update +//| starts. The first refresh also runs the ``clean_sequence``, which takes seconds, and an +//| interrupt during it raises when it ends.""" //| ... //| static mp_obj_t epaperdisplay_epaperdisplay_obj_refresh(mp_obj_t self_in) { epaperdisplay_epaperdisplay_obj_t *self = native_display(self_in); bool ok = common_hal_epaperdisplay_epaperdisplay_refresh(self); if (!ok) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (self->lag_frames > 0) { + // Interrupted is not too soon: raise what interrupted it. + mp_handle_pending(true); + } + #endif mp_raise_RuntimeError(MP_ERROR_TEXT("Refresh too soon")); } return mp_const_none; @@ -371,7 +437,8 @@ MP_PROPERTY_GETTER(epaperdisplay_epaperdisplay_time_to_refresh_obj, //| busy: bool //| """True when the display is refreshing. This uses the ``busy_pin`` when available or the -//| ``refresh_time`` otherwise.""" +//| ``refresh_time`` otherwise. With ``lag_frames``, a panel still busy after 30 seconds is +//| given up on, so this goes False and the next ``refresh()`` resets the panel and cleans it.""" static mp_obj_t epaperdisplay_epaperdisplay_obj_get_busy(mp_obj_t self_in) { epaperdisplay_epaperdisplay_obj_t *self = native_display(self_in); return mp_obj_new_bool(common_hal_epaperdisplay_epaperdisplay_get_busy(self)); diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.h b/shared-bindings/epaperdisplay/EPaperDisplay.h index 9ae58b82f10..829215369f0 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.h +++ b/shared-bindings/epaperdisplay/EPaperDisplay.h @@ -53,6 +53,9 @@ typedef struct { bool two_byte_sequence_length; bool address_little_endian; bool keep_awake; + uint8_t lag_frames; + const uint8_t *clean_sequence; + uint32_t clean_sequence_len; } epaperdisplay_construct_args_t; #define EPAPERDISPLAY_CONSTRUCT_ARGS_DEFAULTS { \ @@ -92,7 +95,10 @@ typedef struct { .spectra6 = false, \ .two_byte_sequence_length = false, \ .address_little_endian = false, \ - .keep_awake = false \ + .keep_awake = false, \ + .lag_frames = 0, \ + .clean_sequence = NULL, \ + .clean_sequence_len = 0 \ } void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdisplay_obj_t *self, diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 508fe96fb06..6c54931c2c3 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -9,12 +9,18 @@ #include "py/gc.h" #include "py/runtime.h" #include "shared/runtime/interrupt_char.h" +#if CIRCUITPY_EPAPERDISPLAY_LAG && CIRCUITPY_WATCHDOG +#include "shared-bindings/watchdog/WatchDogTimer.h" +#endif #include "shared-bindings/displayio/ColorConverter.h" #include "shared-bindings/microcontroller/__init__.h" #include "shared-bindings/microcontroller/Pin.h" #include "shared-bindings/time/__init__.h" #include "shared-module/displayio/__init__.h" #include "supervisor/shared/display.h" +#if CIRCUITPY_EPAPERDISPLAY_LAG +#include "supervisor/port_heap.h" +#endif #include "supervisor/shared/tick.h" #if CIRCUITPY_TINYUSB @@ -26,6 +32,32 @@ #define DELAY 0x80 +// One update or stored-waveform refresh takes less. This bounds each wait for an update on a lag +// display, and the wait before writing the previous RAM. A stock display's waits have no bound. +#define EPAPER_WAIT_TIMEOUT_MS 30000 + +#if CIRCUITPY_EPAPERDISPLAY_LAG +// SSD168x only: NOP. These controllers keep the last RAM byte until the next command and lose it to +// a reset, so a RAM write ends with this. Lag drive is only for displays where it is harmless. +#define LAG_RAM_COMMIT_COMMAND 0x7f +#endif + +#if CIRCUITPY_EPAPERDISPLAY_LAG +enum { LAG_COMMIT_NONE, LAG_COMMIT_PLANE, LAG_COMMIT_ACTIVATE }; +#endif + +#if CIRCUITPY_EPAPERDISPLAY_LAG +static void lag_construct(epaperdisplay_epaperdisplay_obj_t *self, uint8_t lag_frames); +static void lag_flush(epaperdisplay_epaperdisplay_obj_t *self); +static void lag_give_up_if_stuck(epaperdisplay_epaperdisplay_obj_t *self); +static void lag_free(epaperdisplay_epaperdisplay_obj_t *self); +static void lag_shutdown(epaperdisplay_epaperdisplay_obj_t *self); +static void lag_after_raise(epaperdisplay_epaperdisplay_obj_t *self); +static MP_NORETURN void lag_raise(epaperdisplay_epaperdisplay_obj_t *self, mp_obj_t raised); +static bool lag_refresh(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas); +static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self); +#endif // CIRCUITPY_EPAPERDISPLAY_LAG + void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdisplay_obj_t *self, const epaperdisplay_construct_args_t *args) { uint16_t color_depth = 1; @@ -65,6 +97,20 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla false /* data_as_commands */, args->always_toggle_chip_select, false /* SH1107_addressing */, args->address_little_endian); + #if CIRCUITPY_EPAPERDISPLAY_LAG + self->lag_slots = NULL; + self->lag_frames = 0; + self->lag_busy_deadline = 0; + self->lag_bus_held = false; + self->lag_state_lost = false; + self->lag_slot_overwriting = false; + self->lag_commit = LAG_COMMIT_NONE; + self->lag_holding_interrupts = false; + self->lag_held_exception = MP_OBJ_NULL; + self->clean_sequence = args->clean_sequence; + self->clean_sequence_len = args->clean_sequence_len; + #endif // CIRCUITPY_EPAPERDISPLAY_LAG + self->write_black_ram_command = args->write_black_ram_command; self->black_bits_inverted = args->black_bits_inverted; self->write_color_ram_command = args->write_color_ram_command; @@ -77,6 +123,12 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla self->chip_select = args->always_toggle_chip_select ? CHIP_SELECT_TOGGLE_EVERY_BYTE : CHIP_SELECT_UNTOUCHED; self->grayscale = grayscale; + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (args->lag_frames > 0) { + lag_construct(self, args->lag_frames); + } + #endif // CIRCUITPY_EPAPERDISPLAY_LAG + self->start_sequence = args->start_sequence; self->start_sequence_len = args->start_sequence_len; self->start_up_time_ms = args->start_up_time * 1000; @@ -137,9 +189,6 @@ uint16_t common_hal_epaperdisplay_epaperdisplay_get_height(epaperdisplay_epaperd return displayio_display_core_get_height(&self->core); } -// A stuck panel can't hold up what this driver waits for on its own account. -#define EPAPER_WAIT_TIMEOUT_MS 30000 - // BUSY can take a moment to rise after an update command, and reading it low before then would // look like a finished update. If it never rises, nothing has started or it was too quick to see, // so this returns false after 2 ms. Short enough that an interrupt doesn't have to end it. @@ -153,17 +202,171 @@ static bool wait_for_update_start(epaperdisplay_epaperdisplay_obj_t *self) { return false; } +#if CIRCUITPY_EPAPERDISPLAY_LAG +static bool lag_deadline_passed(epaperdisplay_epaperdisplay_obj_t *self) { + return self->lag_busy_deadline != 0 && supervisor_ticks_ms64() > self->lag_busy_deadline; +} + +// When a wait for BUSY or the bus gives up, or 0 for never. Lag drive always has an end, so a +// stuck panel can't hold up a caller that holds interrupts or has no way to be interrupted. +static uint64_t display_wait_deadline(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_busy_deadline != 0 || self->lag_frames == 0) { + return self->lag_busy_deadline; + } + return supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; +} + +// Between hold and release a waveform or a panel reset is under way, and cutting it short leaves +// the panel dark. Whatever interrupts (Ctrl-C, a reload, a watchdog) waits for the release. +static bool display_interrupted(epaperdisplay_epaperdisplay_obj_t *self) { + if (!self->lag_holding_interrupts) { + return mp_hal_is_interrupted(); + } + if (mp_hal_is_interrupted()) { + if (self->lag_held_exception == MP_OBJ_NULL) { + self->lag_held_exception = MP_STATE_THREAD(mp_pending_exception); + } + MP_STATE_THREAD(mp_pending_exception) = MP_OBJ_NULL; + } + #if CIRCUITPY_WATCHDOG + // A stored waveform takes seconds, and a reset mid-waveform leaves the panel dark. These waits + // are bounded, so a hang still ends. + if (common_hal_watchdog_get_mode(&common_hal_mcu_watchdogtimer_obj) != WATCHDOGMODE_NONE) { + common_hal_watchdog_feed(&common_hal_mcu_watchdogtimer_obj); + } + #endif + return false; +} + +static void lag_hold_interrupts(epaperdisplay_epaperdisplay_obj_t *self) { + self->lag_holding_interrupts = true; + if (self->lag_busy_deadline == 0) { + self->lag_busy_deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; + } + display_interrupted(self); +} + +static void lag_release_interrupts(epaperdisplay_epaperdisplay_obj_t *self) { + if (!self->lag_holding_interrupts) { + return; + } + self->lag_holding_interrupts = false; + self->lag_busy_deadline = 0; + if (self->lag_held_exception != MP_OBJ_NULL) { + mp_sched_exception(self->lag_held_exception); + self->lag_held_exception = MP_OBJ_NULL; + } +} + +// A transfer in progress raises when something interrupts. On a port with async SPI that can be +// any transfer, however short. While holding, that returns false: the transfer stopped partway, +// so the caller goes back to where the RAM or command can start again. +static bool lag_send(epaperdisplay_epaperdisplay_obj_t *self, display_byte_type_t type, + const uint8_t *data, uint32_t length) { + if (!self->lag_holding_interrupts) { + self->bus.send(self->bus.bus, type, self->chip_select, data, length); + return true; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + self->bus.send(self->bus.bus, type, self->chip_select, data, length); + nlr_pop(); + return true; + } + MP_STATE_THREAD(mp_pending_exception) = nlr.ret_val; + display_interrupted(self); + return false; +} + +static bool display_send_command(epaperdisplay_epaperdisplay_obj_t *self, const uint8_t *command, + const uint8_t *data, uint32_t length) { + for (uint8_t attempt = 0; attempt < 3; attempt++) { + if (lag_send(self, DISPLAY_COMMAND, command, 1) && lag_send(self, DISPLAY_DATA, data, length)) { + return true; + } + // The send stopped, and it may have arrived. If it was an update, sending it again would + // drive every pixel a second time. BUSY high says it landed. A command with no data could + // be an update that BUSY hasn't shown yet, so don't risk it: the drive is now unknown. + if (wait_for_update_start(self)) { + return true; + } + if (length == 0) { + self->lag_state_lost = true; + return false; + } + } + return false; +} + +// set_region_to_update() has its own transaction, so a raise from it leaves the bus locked. +static bool lag_set_region(epaperdisplay_epaperdisplay_obj_t *self, displayio_area_t *box) { + for (volatile uint8_t attempt = 0; attempt < 3; attempt++) { + // It skips its commands if it can't get the bus, which would leave the old window. + if (!displayio_display_bus_is_free(&self->bus)) { + return false; + } + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + self->lag_bus_held = true; + displayio_display_bus_set_region_to_update(&self->bus, &self->core, box); + self->lag_bus_held = false; + nlr_pop(); + return true; + } + if (self->lag_bus_held) { + if (!displayio_display_bus_is_free(&self->bus)) { + displayio_display_bus_end_transaction(&self->bus); + } + self->lag_bus_held = false; + } + if (!self->lag_holding_interrupts) { + nlr_jump(nlr.ret_val); + } + MP_STATE_THREAD(mp_pending_exception) = nlr.ret_val; + display_interrupted(self); + } + return false; +} + +// A delay ends early when interrupted, which a held interrupt must not do. +static void display_delay_ms(epaperdisplay_epaperdisplay_obj_t *self, uint32_t delay_ms) { + uint64_t start = supervisor_ticks_ms64(); + uint32_t elapsed = 0; + while (true) { + common_hal_time_delay_ms(delay_ms - elapsed); + elapsed = supervisor_ticks_ms64() - start; + if (!self->lag_holding_interrupts || elapsed >= delay_ms) { + return; + } + display_interrupted(self); + } +} +#else // CIRCUITPY_EPAPERDISPLAY_LAG +#define display_wait_deadline(self) ((uint64_t)0) +#define display_interrupted(self) mp_hal_is_interrupted() +#define display_send_command(self, command, data, length) ( \ + (self)->bus.send((self)->bus.bus, DISPLAY_COMMAND, (self)->chip_select, command, 1), \ + (self)->bus.send((self)->bus.bus, DISPLAY_DATA, (self)->chip_select, data, length), \ + true) +#define display_delay_ms(self, delay_ms) common_hal_time_delay_ms(delay_ms) +#endif // CIRCUITPY_EPAPERDISPLAY_LAG + static void wait_for_busy(epaperdisplay_epaperdisplay_obj_t *self) { if (self->busy.base.type == &mp_type_NoneType) { return; } + const uint64_t deadline = display_wait_deadline(self); while (common_hal_digitalio_digitalinout_get_value(&self->busy) == self->busy_state && - !mp_hal_is_interrupted()) { + !display_interrupted(self)) { + if (deadline != 0 && supervisor_ticks_ms64() > deadline) { + return; + } RUN_BACKGROUND_TASKS; } } -static void send_command_sequence(epaperdisplay_epaperdisplay_obj_t *self, +// True if every command went out. Lag drive counts an update only if its last command did. +static bool send_command_sequence(epaperdisplay_epaperdisplay_obj_t *self, bool should_wait_for_busy, const uint8_t *sequence, uint32_t sequence_len) { uint32_t i = 0; while (i < sequence_len) { @@ -176,13 +379,26 @@ static void send_command_sequence(epaperdisplay_epaperdisplay_obj_t *self, data_size = ((data_size & ~DELAY) << 8) + *(cmd + 2); data = cmd + 3; } - while (!displayio_display_bus_begin_transaction(&self->bus) && - !mp_hal_is_interrupted()) { + bool have_bus; + const uint64_t deadline = display_wait_deadline(self); + while (!(have_bus = displayio_display_bus_begin_transaction(&self->bus)) && + !display_interrupted(self) && (deadline == 0 || supervisor_ticks_ms64() <= deadline)) { RUN_BACKGROUND_TASKS; } - self->bus.send(self->bus.bus, DISPLAY_COMMAND, self->chip_select, cmd, 1); - self->bus.send(self->bus.bus, DISPLAY_DATA, self->chip_select, data, data_size); + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (!have_bus && self->lag_frames > 0) { + return false; + } + self->lag_bus_held = have_bus; + #endif // CIRCUITPY_EPAPERDISPLAY_LAG + const bool sent = display_send_command(self, cmd, data, data_size); displayio_display_bus_end_transaction(&self->bus); + #if CIRCUITPY_EPAPERDISPLAY_LAG + self->lag_bus_held = false; + #endif // CIRCUITPY_EPAPERDISPLAY_LAG + if (!sent) { + return false; + } uint16_t delay_length_ms = 0; if (delay) { data_size++; @@ -191,22 +407,49 @@ static void send_command_sequence(epaperdisplay_epaperdisplay_obj_t *self, delay_length_ms = 500; } } - common_hal_time_delay_ms(delay_length_ms); + display_delay_ms(self, delay_length_ms); if (should_wait_for_busy) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (self->lag_frames > 0) { + wait_for_update_start(self); + } + #endif wait_for_busy(self); } - if (mp_hal_is_interrupted()) { - return; - } i += 2 + data_size; if (self->two_byte_sequence_length) { i++; } + if (display_interrupted(self)) { + return i >= sequence_len; + } } + return true; } void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_epaperdisplay_obj_t *self, mp_buffer_info_t *start_sequence, mp_buffer_info_t *refresh_sequence, float seconds_per_frame) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (self->lag_frames > 0) { + // Finish the drive with the waveform that started it, and rewrite both planes after. + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + lag_flush(self); + wait_for_busy(self); + nlr_pop(); + } else { + lag_raise(self, nlr.ret_val); + } + if (self->lag_settle > 0 || self->lag_commit != LAG_COMMIT_NONE) { + // Still owed: keep the mode that started it. + if (!mp_hal_is_interrupted()) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_display_bus); + } + return; + } + self->lag_force_full = true; + } + #endif // CIRCUITPY_EPAPERDISPLAY_LAG self->start_sequence = (uint8_t *)start_sequence->buf; self->start_sequence_len = start_sequence->len; // NULL keeps the current refresh sequence. @@ -248,6 +491,7 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla // The panel is still configured from the previous refresh. return; } + wait_for_busy(self); // SSD168x controllers commit the last RAM byte when the next command arrives, so a reset // can't come straight after a RAM write. send_stop_sequence_guarded(self); @@ -257,10 +501,10 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla // run start sequence self->bus.bus_reset(self->bus.bus); - common_hal_time_delay_ms(self->start_up_time_ms); + display_delay_ms(self, self->start_up_time_ms); - send_command_sequence(self, true, self->start_sequence, self->start_sequence_len); - if (mp_hal_is_interrupted()) { + if (!send_command_sequence(self, true, self->start_sequence, self->start_sequence_len) || + display_interrupted(self)) { return; } self->awake = true; @@ -540,10 +784,19 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); } } else { + #if CIRCUITPY_EPAPERDISPLAY_LAG + lag_give_up_if_stuck(self); + #endif return false; } } if (self->core.current_group == NULL) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + // Nothing to draw, but the drive already started is still owed. + if (self->lag_frames > 0 && self->awake) { + return lag_refresh(self, NULL); + } + #endif return true; } // Refresh at seconds per frame rate. @@ -555,6 +808,11 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ return false; } const displayio_area_t *current_area = epaperdisplay_epaperdisplay_get_refresh_areas(self); + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (self->lag_frames > 0) { + return lag_refresh(self, current_area); + } + #endif // CIRCUITPY_EPAPERDISPLAY_LAG if (current_area == NULL) { return true; } @@ -597,6 +855,11 @@ void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *s send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); } } + #if CIRCUITPY_EPAPERDISPLAY_LAG + else { + lag_give_up_if_stuck(self); + } + #endif } } @@ -606,6 +869,10 @@ bool common_hal_epaperdisplay_epaperdisplay_get_busy(epaperdisplay_epaperdisplay } void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + lag_shutdown(self); + lag_free(self); + #endif // CIRCUITPY_EPAPERDISPLAY_LAG if (self->refreshing) { wait_for_busy(self); supervisor_disable_tick(); @@ -620,6 +887,9 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { send_stop_sequence_guarded(self); } self->awake = false; + #if CIRCUITPY_EPAPERDISPLAY_LAG + lag_release_interrupts(self); + #endif // CIRCUITPY_EPAPERDISPLAY_LAG release_display_core(&self->core); if (self->busy.base.type == &digitalio_digitalinout_type) { @@ -631,10 +901,24 @@ void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t // The VM is over, so whatever holds the bus can never let it go: end it here. if (self->keep_awake && !displayio_display_bus_is_free(&self->bus)) { displayio_display_bus_end_transaction(&self->bus); + #if CIRCUITPY_EPAPERDISPLAY_LAG + self->lag_bus_held = false; + #endif } } void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + if (self->lag_frames > 0) { + // The heap is still here but the sequences won't be, and the display would otherwise + // persist to draw the splash with them. Finish now and stay asleep. Its bus still holds + // the pins until release_displays(), as for any display that persists. + epaperdisplay_epaperdisplay_end_held_bus(self); + release_epaperdisplay(self); + self->base.type = &mp_type_NoneType; + return; + } + #endif // CIRCUITPY_EPAPERDISPLAY_LAG displayio_display_core_set_root_group(&self->core, &circuitpython_splash); self->core.full_refresh = true; // Let the panel sleep after the next refresh when code isn't running. @@ -653,7 +937,570 @@ void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t gc_collect_ptr((void *)self->start_sequence); gc_collect_ptr((void *)self->stop_sequence); gc_collect_ptr((void *)self->refresh_sequence); + #if CIRCUITPY_EPAPERDISPLAY_LAG + gc_collect_ptr((void *)self->clean_sequence); + gc_collect_ptr(self->lag_held_exception); + #endif // CIRCUITPY_EPAPERDISPLAY_LAG +} + +#if CIRCUITPY_EPAPERDISPLAY_LAG +// Lag drive. Plane 0x26 holds frame N-lag instead of N-1, so a pixel that changes is driven for +// lag steps, then driven the other way by the same number of steps if it changes back. Each +// frame is rendered once into the ring and the planes are sent from there. +// +// The ring has lag slots, N-1 to N-lag, and N replaces N-lag in its slot. The box to send must +// be known before that, so it comes from the areas that were rendered, not from comparing frames. +// Frames N and N-lag can only differ inside the areas of the last lag steps. Sending the areas of +// the last lag + 1 steps also covers what the previous step left unequal, so the planes are +// equal outside the box: a byte there is either outside the last box too, so equal by induction, +// or was last written with frames that have not changed there since. + +static void lag_construct(epaperdisplay_epaperdisplay_obj_t *self, uint8_t lag_frames) { + self->lag_stride = (self->core.area.x2 + 7) / 8; + self->lag_rows = self->core.area.y2; + self->lag_slot_size = self->lag_stride * self->lag_rows; + self->lag_frames = lag_frames; + self->lag_head = 0; + self->lag_box_index = 0; + self->lag_settle = 0; + // The panel's RAM is unknown, so the first step writes everything. + self->lag_force_full = true; + size_t meta_size = lag_frames * sizeof(uint8_t *) + (lag_frames + 1) * sizeof(displayio_area_t); + self->lag_slots = port_malloc(meta_size, false); + bool ok = self->lag_slots != NULL; + if (ok) { + memset(self->lag_slots, 0, meta_size); + self->lag_boxes = (displayio_area_t *)(self->lag_slots + lag_frames); + } + for (uint8_t i = 0; ok && i < lag_frames; i++) { + self->lag_slots[i] = port_malloc(self->lag_slot_size, true); + ok = self->lag_slots[i] != NULL; + } + if (!ok) { + lag_free(self); + self->base.type = &mp_type_NoneType; + m_malloc_fail(self->lag_slot_size); + } + // History starts white, the state a cleared panel is in. + for (uint8_t i = 0; i < lag_frames; i++) { + memset(self->lag_slots[i], self->black_bits_inverted ? 0x00 : 0xff, self->lag_slot_size); + } +} + +static void lag_free(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_slots != NULL) { + for (uint8_t i = 0; i < self->lag_frames; i++) { + if (self->lag_slots[i] != NULL) { + port_free(self->lag_slots[i]); + } + } + port_free(self->lag_slots); + self->lag_slots = NULL; + } + self->lag_frames = 0; +} + +static void lag_render_area(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *area, uint8_t *frame, bool white) { + displayio_area_t clipped; + if (!displayio_display_core_clip_area(&self->core, area, &clipped)) { + return; + } + if (white) { + for (int16_t row = clipped.y1; row < clipped.y2; row++) { + memset(frame + row * self->lag_stride + clipped.x1 / 8, self->black_bits_inverted ? 0x00 : 0xff, + displayio_area_width(&clipped) / 8); + } + return; + } + uint16_t width = displayio_area_width(&clipped); + uint16_t bytes_per_row = width / 8; + uint16_t rows_per_buffer = MAX(1, 128 * 32 / width); + size_t words = (rows_per_buffer * width + 31) / 32; + uint32_t buffer[words]; + uint32_t mask[words]; + + self->core.colorspace.grayscale = true; + self->core.colorspace.grayscale_bit = 7; + for (int16_t y = clipped.y1; y < clipped.y2; y += rows_per_buffer) { + displayio_area_t subrectangle = { + .x1 = clipped.x1, + .y1 = y, + .x2 = clipped.x2, + .y2 = MIN(y + rows_per_buffer, clipped.y2), + }; + memset(mask, 0, sizeof(mask)); + memset(buffer, 0, sizeof(buffer)); + displayio_display_core_fill_area(&self->core, &subrectangle, mask, buffer); + if (self->black_bits_inverted) { + for (size_t k = 0; k < words; k++) { + buffer[k] = ~buffer[k]; + } + } + const uint8_t *in = (const uint8_t *)buffer; + for (int16_t row = subrectangle.y1; row < subrectangle.y2; row++) { + memcpy(frame + row * self->lag_stride + clipped.x1 / 8, in, bytes_per_row); + in += bytes_per_row; + } + // Run background tasks like a regular refresh does. + RUN_BACKGROUND_TASKS; + } +} + +static bool lag_send_ram_commit(epaperdisplay_epaperdisplay_obj_t *self) { + const uint8_t command = LAG_RAM_COMMIT_COMMAND; + return lag_send(self, DISPLAY_COMMAND, &command, 1); +} + +// Rows are gathered so a narrow box doesn't cost one SPI transfer per row. +static bool lag_send_plane(epaperdisplay_epaperdisplay_obj_t *self, uint8_t command, const uint8_t *frame, const displayio_area_t *box) { + if (!lag_send(self, DISPLAY_COMMAND, &command, 1)) { + return false; + } + uint16_t bytes_per_row = displayio_area_width(box) / 8; + const uint8_t *in = frame + box->y1 * self->lag_stride + box->x1 / 8; + if (bytes_per_row == self->lag_stride) { + return lag_send(self, DISPLAY_DATA, in, displayio_area_height(box) * bytes_per_row) && lag_send_ram_commit(self); + } + uint8_t chunk[512]; + size_t used = 0; + for (int16_t y = box->y1; y < box->y2; y++) { + if (used + bytes_per_row > sizeof(chunk)) { + if (!lag_send(self, DISPLAY_DATA, chunk, used)) { + return false; + } + used = 0; + } + memcpy(chunk + used, in, bytes_per_row); + used += bytes_per_row; + in += self->lag_stride; + } + return lag_send(self, DISPLAY_DATA, chunk, used) && lag_send_ram_commit(self); +} + +// A send stopped by a held interrupt leaves the RAM pointer partway, so start over from the region. +static bool lag_write_plane(epaperdisplay_epaperdisplay_obj_t *self, uint8_t command, const uint8_t *frame, displayio_area_t *box) { + for (uint8_t attempt = 0; attempt < 3; attempt++) { + if (!lag_set_region(self, box) || !displayio_display_bus_begin_transaction(&self->bus)) { + return false; + } + self->lag_bus_held = true; + const bool sent = lag_send_plane(self, command, frame, box); + displayio_display_bus_end_transaction(&self->bus); + self->lag_bus_held = false; + if (sent) { + return true; + } + } + return false; +} + +// Finish a step whose frame is already in the ring: frame N to its plane, then the update. Each +// part stays owed until it has gone out, so a pixel is only counted as driven if it was. Interrupts +// wait, since a send they stop can't be told from one that arrived. False if the bus can't be had. +static bool lag_commit_step(epaperdisplay_epaperdisplay_obj_t *self) { + const bool was_holding = self->lag_holding_interrupts; + lag_hold_interrupts(self); + bool ok = true; + if (self->lag_commit == LAG_COMMIT_PLANE) { + ok = lag_write_plane(self, self->write_black_ram_command, self->lag_slots[self->lag_head], &self->lag_commit_box); + if (ok) { + self->lag_commit = LAG_COMMIT_ACTIVATE; + } + } + if (ok && self->lag_commit == LAG_COMMIT_ACTIVATE) { + // Nothing can differ when the box is empty, so an update would only take time. + if (self->lag_commit_drive) { + ok = send_command_sequence(self, false, self->refresh_sequence, self->refresh_sequence_len); + if (ok) { + wait_for_update_start(self); + supervisor_enable_tick(); + self->refreshing = true; + self->lag_update_start = supervisor_ticks_ms64(); + } + } + if (ok) { + self->lag_commit = LAG_COMMIT_NONE; + } + } + if (!was_holding) { + lag_release_interrupts(self); + } + return ok; +} + +// Send one step: frame N-lag to the previous plane, then frame N, rendered from areas (may be +// NULL) over its slot, or white in those areas without rendering, to the other. The history +// doesn't advance if the bus can't be had before the slot is touched, so a retry does the same +// step. Once it has, the step is owed and true is returned, whatever happens to the rest. +static bool lag_step(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas, bool white) { + uint8_t lag = self->lag_frames; + uint8_t next = (self->lag_head + 1) % lag; + uint8_t *frame = self->lag_slots[next]; + displayio_area_t *boxes = self->lag_boxes; + uint8_t index = self->lag_box_index; + bool changed = areas != NULL; + + for (const displayio_area_t *area = areas; area != NULL; area = area->next) { + displayio_area_t clipped; + if (displayio_display_core_clip_area(&self->core, area, &clipped)) { + displayio_area_union(&boxes[index], &clipped, &boxes[index]); + } + } + // The oldest entry is lag steps back: only needed to make the planes equal again. + uint8_t oldest = (index + 1) % (lag + 1); + displayio_area_t drive_box = {0}; + for (uint8_t i = 0; i <= lag; i++) { + if (i != oldest) { + displayio_area_union(&drive_box, &boxes[i], &drive_box); + } + } + displayio_area_t write_box; + if (self->lag_force_full) { + displayio_display_core_clip_area(&self->core, &self->core.area, &write_box); + } else { + displayio_area_union(&drive_box, &boxes[oldest], &write_box); + } + bool write = !displayio_area_empty(&write_box); + + if (write && !lag_write_plane(self, self->write_previous_ram_command, frame, &write_box)) { + return false; + } + // N-1 and N-lag only differ inside the box. + self->lag_slot_overwriting = true; + if (write && next != self->lag_head) { + const uint8_t *in = self->lag_slots[self->lag_head] + write_box.y1 * self->lag_stride + write_box.x1 / 8; + uint8_t *out = frame + write_box.y1 * self->lag_stride + write_box.x1 / 8; + uint16_t bytes_per_row = displayio_area_width(&write_box) / 8; + for (int16_t y = write_box.y1; y < write_box.y2; y++) { + memcpy(out, in, bytes_per_row); + in += self->lag_stride; + out += self->lag_stride; + } + } + for (const displayio_area_t *area = areas; area != NULL; area = area->next) { + lag_render_area(self, area, frame, white); + } + // The slot no longer holds N-lag, so from here the step has to finish or that drive is lost. + self->lag_slot_overwriting = false; + self->lag_head = next; + self->lag_box_index = oldest; + boxes[oldest] = (displayio_area_t) {0}; + if (changed) { + self->lag_settle = lag; + } else if (self->lag_settle > 0) { + self->lag_settle--; + } + self->lag_force_full = false; + self->lag_commit_box = write_box; + self->lag_commit_drive = !displayio_area_empty(&drive_box); + self->lag_commit = write ? LAG_COMMIT_PLANE : LAG_COMMIT_ACTIVATE; + lag_commit_step(self); + return true; +} + +static bool lag_refresh_unguarded(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas) { + if (!self->awake || self->lag_state_lost) { + // The panel may still hold anything, including ghosts from a run that lost power. + if (!lag_clean(self) || mp_hal_is_interrupted()) { + return false; + } + areas = &self->core.area; + } + if (self->lag_commit != LAG_COMMIT_NONE) { + // The areas stay marked, so the next refresh draws them. + return lag_commit_step(self); + } + if (areas == NULL && self->lag_settle == 0) { + return true; + } + if (self->needs_start) { + lag_hold_interrupts(self); + epaperdisplay_epaperdisplay_start_refresh(self); + lag_release_interrupts(self); + } else { + epaperdisplay_epaperdisplay_start_refresh(self); + } + bool ok = !self->needs_start && lag_step(self, areas, false); + if (ok) { + displayio_display_core_finish_refresh(&self->core); + } else { + self->core.refresh_in_progress = false; + } + return ok; +} + +// An update takes far less than the limit, so a panel still BUSY is stuck, and only a reset can +// help. That has to wait for the next refresh or the shutdown, since the bus may be in use here. +// Until then a script polling busy can go on. +static void lag_give_up_if_stuck(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_frames > 0 && !self->lag_holding_interrupts && + supervisor_ticks_ms64() - self->lag_update_start > EPAPER_WAIT_TIMEOUT_MS) { + supervisor_disable_tick(); + self->refreshing = false; + self->awake = false; + self->lag_state_lost = true; + } +} + +// False if the update is still running: interrupted, or the panel is stuck. RAM writes and a reset +// would land on that update, so nothing more can be sent. +static bool lag_wait_idle(epaperdisplay_epaperdisplay_obj_t *self) { + wait_for_busy(self); + return common_hal_digitalio_digitalinout_get_value(&self->busy) != self->busy_state; +} + +// The deadline bounds each piece of work, not the whole drive, which can be 32 updates and a clean. +// Only called after progress, so a bus that is never free still runs out. +static void lag_renew_deadline(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_busy_deadline != 0) { + self->lag_busy_deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; + } +} + +// Give every changed pixel the rest of its drive so the panel is left DC balanced. +static void lag_flush(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_frames == 0 || !self->awake) { + return; + } + while (self->lag_settle > 0 || self->lag_commit != LAG_COMMIT_NONE) { + if (!lag_wait_idle(self) || display_interrupted(self)) { + return; + } + if (self->refreshing) { + supervisor_disable_tick(); + self->refreshing = false; + } + if (self->lag_commit != LAG_COMMIT_NONE ? !lag_commit_step(self) : !lag_step(self, NULL, false)) { + return; + } + lag_renew_deadline(self); + } +} + +static bool lag_refresh(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas) { + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + bool ok = lag_refresh_unguarded(self, areas); + nlr_pop(); + return ok; + } + lag_raise(self, nlr.ret_val); +} + +// Fill a whole RAM plane with one color from a small buffer. +static bool lag_send_solid(epaperdisplay_epaperdisplay_obj_t *self, uint8_t command, bool white) { + if (!lag_send(self, DISPLAY_COMMAND, &command, 1)) { + return false; + } + uint8_t chunk[512]; + memset(chunk, white != self->black_bits_inverted ? 0xff : 0x00, sizeof(chunk)); + size_t remaining = self->lag_slot_size; + while (remaining > 0) { + size_t count = MIN(remaining, sizeof(chunk)); + if (!lag_send(self, DISPLAY_DATA, chunk, count)) { + return false; + } + remaining -= count; + } + return lag_send_ram_commit(self); +} + +static bool lag_write_solid(epaperdisplay_epaperdisplay_obj_t *self, uint8_t command, bool white) { + displayio_area_t box; + displayio_display_core_clip_area(&self->core, &self->core.area, &box); + for (uint8_t attempt = 0; attempt < 3; attempt++) { + if (!lag_set_region(self, &box) || !displayio_display_bus_begin_transaction(&self->bus)) { + return false; + } + self->lag_bus_held = true; + const bool sent = lag_send_solid(self, command, white); + displayio_display_bus_end_transaction(&self->bus); + self->lag_bus_held = false; + if (sent) { + return true; + } + } + return false; +} + +// A normal step to white, then the owed steps. Everything changed so far gets all of its drive, +// and the ring and both planes end up white with nothing owed. False if interrupted first, with +// the drive still owed. +static bool lag_to_white(epaperdisplay_epaperdisplay_obj_t *self) { + while (true) { + if (!lag_wait_idle(self) || display_interrupted(self)) { + return false; + } + if (self->refreshing) { + supervisor_disable_tick(); + self->refreshing = false; + } + if (self->lag_commit == LAG_COMMIT_NONE) { + break; + } + if (!lag_commit_step(self)) { + return false; + } + } + displayio_area_t whole = self->core.area; + whole.next = NULL; + if (!lag_step(self, &whole, true)) { + return false; + } + lag_renew_deadline(self); + lag_flush(self); + if (self->lag_settle > 0 || self->lag_commit != LAG_COMMIT_NONE || !lag_wait_idle(self)) { + return false; + } + if (self->refreshing) { + supervisor_disable_tick(); + self->refreshing = false; + } + return true; +} + +// An exception came out of a bus send (Ctrl-C during a long transfer raises) or a render. Without +// this the bus stays locked and chip select low, and everything after waits for it. The next +// step writes all of both planes, since this one may have stopped partway through either. +static void lag_abandon_step(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_bus_held) { + displayio_display_bus_end_transaction(&self->bus); + self->lag_bus_held = false; + } + self->core.refresh_in_progress = false; + self->lag_force_full = true; + if (self->lag_slot_overwriting) { + // The drive it held can't be sent any more. + self->lag_slot_overwriting = false; + self->lag_state_lost = true; + } +} + +static void lag_after_raise(epaperdisplay_epaperdisplay_obj_t *self) { + lag_abandon_step(self); + lag_release_interrupts(self); +} + +// Raise once: what was held, if anything, instead of both that and this. +static MP_NORETURN void lag_raise(epaperdisplay_epaperdisplay_obj_t *self, mp_obj_t raised) { + if (self->lag_held_exception != MP_OBJ_NULL) { + raised = self->lag_held_exception; + self->lag_held_exception = MP_OBJ_NULL; + } + lag_after_raise(self); + nlr_jump(raised); +} + +static bool lag_wake(epaperdisplay_epaperdisplay_obj_t *self) { + self->awake = false; + self->bus.bus_reset(self->bus.bus); + display_delay_ms(self, self->start_up_time_ms); + if (!send_command_sequence(self, true, self->start_sequence, self->start_sequence_len)) { + return false; + } + self->awake = true; + self->needs_start = false; + return true; +} + +// What the panel received is unknown, so paying back could drive pixels the wrong way. Forget the +// drive and let the stored waveform, which is balanced by itself, bring the panel to white. +static bool lag_recover(epaperdisplay_epaperdisplay_obj_t *self) { + // An update that may have started shouldn't be cut. A stuck panel has already been waited for. + if (self->awake) { + wait_for_busy(self); + } + if (!lag_wake(self) || !lag_wait_idle(self)) { + return false; + } + lag_renew_deadline(self); + if (!lag_write_solid(self, self->write_previous_ram_command, true) || + !lag_write_solid(self, self->write_black_ram_command, true) || + !send_command_sequence(self, true, self->clean_sequence, self->clean_sequence_len) || + !lag_wait_idle(self)) { + return false; + } + lag_renew_deadline(self); + if (!lag_wake(self)) { + return false; + } + for (uint8_t i = 0; i < self->lag_frames; i++) { + memset(self->lag_slots[i], self->black_bits_inverted ? 0x00 : 0xff, self->lag_slot_size); + } + memset(self->lag_boxes, 0, (self->lag_frames + 1) * sizeof(displayio_area_t)); + self->lag_settle = 0; + self->lag_commit = LAG_COMMIT_NONE; + self->lag_force_full = false; + self->lag_state_lost = false; + return true; +} + +// A clean can't stop partway, so an interrupt raises when it ends. False if the bus can't be had. +static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self) { + if (!displayio_display_bus_is_free(&self->bus)) { + return false; + } + lag_hold_interrupts(self); + self->core.full_refresh = true; + bool ok; + if (self->lag_state_lost) { + ok = lag_recover(self); + } else { + ok = self->awake || lag_wake(self); + ok = ok && lag_to_white(self); + if (ok) { + lag_renew_deadline(self); + self->lag_force_full = true; + ok = send_command_sequence(self, true, self->clean_sequence, self->clean_sequence_len) && lag_wait_idle(self); + if (ok) { + lag_renew_deadline(self); + ok = lag_wake(self); + lag_renew_deadline(self); + } + } + } + lag_release_interrupts(self); + return ok; +} + +// Leave the panel white and balanced when the display goes away: release, or the end of the VM, +// where nothing can catch an exception. Interrupts stay held until release_epaperdisplay() has sent +// the stop sequence. All of it is bounded so a stuck panel can't hang shutdown. Doesn't allocate. +static void lag_shutdown(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_frames == 0 || (!self->awake && !self->lag_state_lost)) { + return; // Nothing was drawn. + } + lag_after_raise(self); // Anything still held from before is ours to end. + lag_hold_interrupts(self); + volatile bool done = false; + // Each raise abandons the step and the loop starts it again, up to 4 times. + volatile uint8_t raises = 0; + while (!done && !lag_deadline_passed(self) && raises < 4) { + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + if (self->lag_state_lost) { + done = lag_recover(self); + } else if (lag_to_white(self)) { + lag_renew_deadline(self); + done = send_command_sequence(self, true, self->clean_sequence, self->clean_sequence_len); + } + nlr_pop(); + RUN_BACKGROUND_TASKS; // Whatever holds the bus may let it go. + } else { + lag_abandon_step(self); + raises++; + if (self->lag_held_exception == MP_OBJ_NULL) { + self->lag_held_exception = nlr.ret_val; + } + } + } + wait_for_busy(self); + if (self->refreshing) { + supervisor_disable_tick(); + self->refreshing = false; + } } +#endif // CIRCUITPY_EPAPERDISPLAY_LAG size_t maybe_refresh_epaperdisplay(void) { for (uint8_t i = 0; i < CIRCUITPY_DISPLAY_LIMIT; i++) { diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 1e48dcb52b0..ee2867a56b6 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -42,6 +42,35 @@ typedef struct { bool awake; bool needs_start; // update_refresh_mode() changed the start sequence. display_chip_select_behavior_t chip_select; + #if CIRCUITPY_EPAPERDISPLAY_LAG + // Lag drive. Only used when lag_frames > 0. The ring has lag_frames slots: frames N-1 to + // N-lag_frames. + uint8_t **lag_slots; + const uint8_t *clean_sequence; + uint32_t clean_sequence_len; + uint64_t lag_busy_deadline; // When not 0, stop waiting for BUSY or the bus after this tick. + // What interrupted while interrupts are held. A send can raise one from the heap, so it is + // marked in epaperdisplay_epaperdisplay_collect_ptrs(). + mp_obj_t lag_held_exception; + bool lag_holding_interrupts; + bool lag_bus_held; // Inside a transaction, so an exception must end it. + bool lag_state_lost; // What the panel received is unknown, so it needs a stored-waveform refresh. + bool lag_slot_overwriting; // The oldest frame in the ring is being replaced. + bool lag_commit_drive; // The owed step has pixels to drive. + uint8_t lag_commit; // What the newest step in the ring still has to send. + displayio_area_t lag_commit_box; + uint64_t lag_update_start; // When the running update was sent. + // What each of the last lag_frames + 1 steps rendered, in pixels. + displayio_area_t *lag_boxes; + uint32_t lag_slot_size; + uint16_t lag_stride; + uint16_t lag_rows; + uint8_t lag_frames; + uint8_t lag_head; // Slot of the newest frame. + uint8_t lag_box_index; // Entry in lag_boxes for this step. + uint8_t lag_settle; // Steps owed until every plane pair matches the newest frame. + bool lag_force_full; // Write everything on the next step. + #endif } epaperdisplay_epaperdisplay_obj_t; void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_epaperdisplay_obj_t *self, From 539e4067679dad3e682529558621082c96d2e6bc Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sun, 1 Nov 2026 06:59:49 -0800 Subject: [PATCH 05/29] epaperdisplay: add clean() for balanced wipes with lag drive Brings every pixel to white, optionally runs clean_sequence, then drives every pixel frames updates each way. For scripts to remove ghosting while running. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 40 +++++++++++++ shared-bindings/epaperdisplay/EPaperDisplay.h | 9 +++ shared-module/epaperdisplay/EPaperDisplay.c | 56 +++++++++++++++++-- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 777c22791e7..79520783d68 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -419,6 +419,43 @@ static mp_obj_t epaperdisplay_epaperdisplay_obj_refresh(mp_obj_t self_in) { } MP_DEFINE_CONST_FUN_OBJ_1(epaperdisplay_epaperdisplay_refresh_obj, epaperdisplay_epaperdisplay_obj_refresh); +//| def clean(self, frames: int = 8, *, full: bool = False) -> None: +//| """Remove ghosting from a display using ``lag_frames``, with balanced drive. Only on builds +//| with ``CIRCUITPY_EPAPERDISPLAY_LAG``. +//| +//| All pixels go to white, including the drive they are still owed. If ``full`` is True, +//| the ``clean_sequence`` is sent, followed by the ``start_sequence``. Then every pixel is +//| driven ``frames`` updates toward black and ``frames`` updates toward white. This blocks, +//| but a playing video keeps decoding. The next ``refresh()`` draws everything again. +//| +//| :param int frames: Updates in each direction. 0 skips the wipe. +//| :param bool full: Also do a full refresh with ``clean_sequence``.""" +//| ... +//| +#if CIRCUITPY_EPAPERDISPLAY_LAG +static mp_obj_t epaperdisplay_epaperdisplay_clean(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_frames, ARG_full }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_frames, MP_ARG_INT, {.u_int = 8} }, + { MP_QSTR_full, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + epaperdisplay_epaperdisplay_obj_t *self = native_display(pos_args[0]); + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + if (self->lag_frames == 0) { + mp_raise_RuntimeError(MP_ERROR_TEXT("Invalid state")); + } + mp_int_t frames = mp_arg_validate_int_range(args[ARG_frames].u_int, 0, 255, MP_QSTR_frames); + if (!common_hal_epaperdisplay_epaperdisplay_clean(self, frames, args[ARG_full].u_bool)) { + // Interrupted is not too soon: raise what interrupted it. + mp_handle_pending(true); + mp_raise_RuntimeError(MP_ERROR_TEXT("Refresh too soon")); + } + return mp_const_none; +} +MP_DEFINE_CONST_FUN_OBJ_KW(epaperdisplay_epaperdisplay_clean_obj, 1, epaperdisplay_epaperdisplay_clean); +#endif + //| time_to_refresh: float //| """Time, in fractional seconds, until the ePaper display can be refreshed.""" static mp_obj_t epaperdisplay_epaperdisplay_obj_get_time_to_refresh(mp_obj_t self_in) { @@ -534,6 +571,9 @@ static const mp_rom_map_elem_t epaperdisplay_epaperdisplay_locals_dict_table[] = { MP_ROM_QSTR(MP_QSTR_show), MP_ROM_PTR(&epaperdisplay_epaperdisplay_show_obj) }, { MP_ROM_QSTR(MP_QSTR_update_refresh_mode), MP_ROM_PTR(&epaperdisplay_epaperdisplay_update_refresh_mode_obj) }, { MP_ROM_QSTR(MP_QSTR_refresh), MP_ROM_PTR(&epaperdisplay_epaperdisplay_refresh_obj) }, + #if CIRCUITPY_EPAPERDISPLAY_LAG + { MP_ROM_QSTR(MP_QSTR_clean), MP_ROM_PTR(&epaperdisplay_epaperdisplay_clean_obj) }, + #endif { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&epaperdisplay_epaperdisplay_width_obj) }, { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&epaperdisplay_epaperdisplay_height_obj) }, diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.h b/shared-bindings/epaperdisplay/EPaperDisplay.h index 829215369f0..63c65960e10 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.h +++ b/shared-bindings/epaperdisplay/EPaperDisplay.h @@ -106,6 +106,15 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_obj_t *self); +#if CIRCUITPY_EPAPERDISPLAY_LAG +// Lag drive only. Brings every pixel to white with balanced drive. When full, sends the clean +// sequence and the start sequence. Then wipes each pixel frames toward black and frames toward +// white. Blocks with background tasks running. The next refresh redraws the whole display. +// Doesn't allocate. Holds any interrupt until it ends. Returns false, with any owed drive still +// owed, if the bus can't be had. +bool common_hal_epaperdisplay_epaperdisplay_clean(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames, bool full); +#endif + mp_obj_t common_hal_epaperdisplay_epaperdisplay_get_root_group(epaperdisplay_epaperdisplay_obj_t *self); bool common_hal_epaperdisplay_epaperdisplay_set_root_group(epaperdisplay_epaperdisplay_obj_t *self, displayio_group_t *root_group); diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 6c54931c2c3..7c369b16c07 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -55,7 +55,7 @@ static void lag_shutdown(epaperdisplay_epaperdisplay_obj_t *self); static void lag_after_raise(epaperdisplay_epaperdisplay_obj_t *self); static MP_NORETURN void lag_raise(epaperdisplay_epaperdisplay_obj_t *self, mp_obj_t raised); static bool lag_refresh(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas); -static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self); +static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames, bool full); #endif // CIRCUITPY_EPAPERDISPLAY_LAG void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdisplay_obj_t *self, @@ -1201,7 +1201,7 @@ static bool lag_step(epaperdisplay_epaperdisplay_obj_t *self, const displayio_ar static bool lag_refresh_unguarded(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas) { if (!self->awake || self->lag_state_lost) { // The panel may still hold anything, including ghosts from a run that lost power. - if (!lag_clean(self) || mp_hal_is_interrupted()) { + if (!lag_clean(self, 0, true) || mp_hal_is_interrupted()) { return false; } areas = &self->core.area; @@ -1323,6 +1323,41 @@ static bool lag_write_solid(epaperdisplay_epaperdisplay_obj_t *self, uint8_t com return false; } +// Updates with the planes as they are. Background tasks run, so a video keeps decoding. Returns how +// many went out. +static uint8_t lag_wipe_phase(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames) { + for (uint8_t i = 0; i < frames; i++) { + lag_renew_deadline(self); + if (!send_command_sequence(self, false, self->refresh_sequence, self->refresh_sequence_len)) { + return i; + } + wait_for_update_start(self); + if (!lag_wait_idle(self)) { + return i + 1; // A stuck panel: writes would be dropped, so nothing more can help. + } + } + return frames; +} + +// Both planes must be white. Every pixel gets frames toward black, then the same number toward white, +// and both planes are white again at the end. The caller holds interrupts, so the phases stay +// together. If that can't be done the drive is unknown, and only a stored-waveform refresh is safe. +static void lag_wipe(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames) { + bool ok = lag_write_solid(self, self->write_black_ram_command, false); + if (ok) { + const uint8_t toward_black = lag_wipe_phase(self, frames); + lag_renew_deadline(self); + ok = toward_black == frames && lag_wait_idle(self) && + lag_write_solid(self, self->write_previous_ram_command, false) && + lag_write_solid(self, self->write_black_ram_command, true) && + lag_wipe_phase(self, frames) == frames && + lag_write_solid(self, self->write_previous_ram_command, true); + } + if (!ok) { + self->lag_state_lost = true; + } +} + // A normal step to white, then the owed steps. Everything changed so far gets all of its drive, // and the ring and both planes end up white with nothing owed. False if interrupted first, with // the drive still owed. @@ -1436,7 +1471,7 @@ static bool lag_recover(epaperdisplay_epaperdisplay_obj_t *self) { } // A clean can't stop partway, so an interrupt raises when it ends. False if the bus can't be had. -static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self) { +static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames, bool full) { if (!displayio_display_bus_is_free(&self->bus)) { return false; } @@ -1448,7 +1483,7 @@ static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self) { } else { ok = self->awake || lag_wake(self); ok = ok && lag_to_white(self); - if (ok) { + if (ok && full) { lag_renew_deadline(self); self->lag_force_full = true; ok = send_command_sequence(self, true, self->clean_sequence, self->clean_sequence_len) && lag_wait_idle(self); @@ -1459,10 +1494,23 @@ static bool lag_clean(epaperdisplay_epaperdisplay_obj_t *self) { } } } + if (ok && frames > 0) { + lag_wipe(self, frames); + } lag_release_interrupts(self); return ok; } +bool common_hal_epaperdisplay_epaperdisplay_clean(epaperdisplay_epaperdisplay_obj_t *self, uint8_t frames, bool full) { + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + bool ok = lag_clean(self, frames, full); + nlr_pop(); + return ok; + } + lag_raise(self, nlr.ret_val); +} + // Leave the panel white and balanced when the display goes away: release, or the end of the VM, // where nothing can catch an exception. Interrupts stay held until release_epaperdisplay() has sent // the stop sequence. All of it is bounded so a stuck panel can't hang shutdown. Doesn't allocate. From 8898cc9025815b4d0ffde3e07451820862985064 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sun, 1 Nov 2026 06:59:49 -0800 Subject: [PATCH 06/29] displayio: raise before releasing a lag display whose bus stays held release_displays() lost the owed drive and the stop sequence silently when something held the bus for the whole 30 s bound. Wait for the bus first, and raise with every display untouched, so the script can free it and try again. The end of the VM can't catch an exception, and doesn't need to: nothing can still hold the bus by then. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/displayio/__init__.c | 6 ++++++ shared-module/displayio/__init__.c | 10 +++++++++ shared-module/displayio/__init__.h | 4 ++++ shared-module/epaperdisplay/EPaperDisplay.c | 23 ++++++++++++++++++--- shared-module/epaperdisplay/EPaperDisplay.h | 6 ++++++ 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/shared-bindings/displayio/__init__.c b/shared-bindings/displayio/__init__.c index 12981cbbae0..5d79c104b36 100644 --- a/shared-bindings/displayio/__init__.c +++ b/shared-bindings/displayio/__init__.c @@ -84,11 +84,17 @@ //| Once you release a builtin display (one that is initialized automatically, not by user code), //| the display will remain released even if you restart the CircuitPython VM, for instance by typing ctrl-D. //| The display is not re-initialized until you do a hard reset or manually initialize it again. +//| +//| If an EPaperDisplay using ``lag_frames`` can't get its bus, this waits up to 30 seconds and then +//| raises `RuntimeError` with every display left as it was, so you can free the bus and call it again. //| """ //| ... //| //| static mp_obj_t displayio_release_displays(void) { + #if CIRCUITPY_EPAPERDISPLAY_LAG + displayio_prepare_release_displays(); + #endif common_hal_displayio_release_displays(); return mp_const_none; } diff --git a/shared-module/displayio/__init__.c b/shared-module/displayio/__init__.c index 72c7a9b8af0..047fda3bf1c 100644 --- a/shared-module/displayio/__init__.c +++ b/shared-module/displayio/__init__.c @@ -243,6 +243,16 @@ static void common_hal_displayio_release_displays_impl(bool keep_primary) { } } +#if CIRCUITPY_EPAPERDISPLAY_LAG +void displayio_prepare_release_displays(void) { + for (uint8_t i = 0; i < CIRCUITPY_DISPLAY_LIMIT; i++) { + if (displays[i].display_base.type == &epaperdisplay_epaperdisplay_type) { + epaperdisplay_epaperdisplay_prepare_release(&displays[i].epaper_display); + } + } +} +#endif + void common_hal_displayio_release_displays(void) { common_hal_displayio_release_displays_impl(false); } diff --git a/shared-module/displayio/__init__.h b/shared-module/displayio/__init__.h index 4674bda582d..e61060ff26f 100644 --- a/shared-module/displayio/__init__.h +++ b/shared-module/displayio/__init__.h @@ -118,6 +118,10 @@ extern displayio_group_t circuitpython_splash; void displayio_background(void); void reset_displays(void); +#if CIRCUITPY_EPAPERDISPLAY_LAG +// Raises if a display using lag_frames can't finish its drive, before any display is released. +void displayio_prepare_release_displays(void); +#endif void displayio_gc_collect(void); primary_display_t *allocate_display(void); diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 7c369b16c07..f5472b76044 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -897,6 +897,22 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { } } +#if CIRCUITPY_EPAPERDISPLAY_LAG +void epaperdisplay_epaperdisplay_prepare_release(epaperdisplay_epaperdisplay_obj_t *self) { + if (self->lag_frames == 0 || (!self->awake && !self->lag_state_lost)) { + return; + } + const uint64_t deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; + while (!displayio_display_bus_is_free(&self->bus)) { + mp_handle_pending(true); + if (supervisor_ticks_ms64() > deadline) { + mp_raise_RuntimeError_varg(MP_ERROR_TEXT("%q in use"), MP_QSTR_display_bus); + } + RUN_BACKGROUND_TASKS; + } +} +#endif // CIRCUITPY_EPAPERDISPLAY_LAG + void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self) { // The VM is over, so whatever holds the bus can never let it go: end it here. if (self->keep_awake && !displayio_display_bus_is_free(&self->bus)) { @@ -910,9 +926,10 @@ void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) { #if CIRCUITPY_EPAPERDISPLAY_LAG if (self->lag_frames > 0) { - // The heap is still here but the sequences won't be, and the display would otherwise - // persist to draw the splash with them. Finish now and stay asleep. Its bus still holds - // the pins until release_displays(), as for any display that persists. + // Only a primary display gets here; reset_displays() has released the others. The heap is + // still here but the sequences won't be, and the display would otherwise persist to draw + // the splash with them. Finish now and stay asleep. Its bus still holds the pins until + // the next VM ends or release_displays(). epaperdisplay_epaperdisplay_end_held_bus(self); release_epaperdisplay(self); self->base.type = &mp_type_NoneType; diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index ee2867a56b6..0442e81f6d8 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -81,6 +81,12 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self); // The VM is over: a keep_awake display ends whatever transaction still holds its bus, since its // stop sequence would wait for the bus forever. Other displays are left alone. Does not raise. void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self); +#if CIRCUITPY_EPAPERDISPLAY_LAG +// Waits up to 30 s for the bus so a release can finish the drive. Raises what interrupted it, or +// RuntimeError if the bus stays held, with the display and its drive unchanged. Only call it where +// an exception can be caught. Doesn't allocate. +void epaperdisplay_epaperdisplay_prepare_release(epaperdisplay_epaperdisplay_obj_t *self); +#endif size_t maybe_refresh_epaperdisplay(void); void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t *self); From 7316a558e9845cd3f9375ff8993284711857f21c Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sun, 1 Nov 2026 15:03:27 -0800 Subject: [PATCH 07/29] epaperdisplay: try one reset when shutdown finds BUSY stuck The wait used the whole deadline, so a panel a reset would free got no clean. Reset once, wait once more with no renewal, and run the stored waveform if BUSY falls. If it stays high, nothing more is sent. Co-Authored-By: Claude Opus 5.5 --- shared-module/epaperdisplay/EPaperDisplay.c | 24 ++++++++++++++++++++- shared-module/epaperdisplay/EPaperDisplay.h | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index f5472b76044..7db03980d04 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -1269,7 +1269,7 @@ static bool lag_wait_idle(epaperdisplay_epaperdisplay_obj_t *self) { // The deadline bounds each piece of work, not the whole drive, which can be 32 updates and a clean. // Only called after progress, so a bus that is never free still runs out. static void lag_renew_deadline(epaperdisplay_epaperdisplay_obj_t *self) { - if (self->lag_busy_deadline != 0) { + if (self->lag_busy_deadline != 0 && !self->lag_last_try) { self->lag_busy_deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; } } @@ -1564,6 +1564,28 @@ static void lag_shutdown(epaperdisplay_epaperdisplay_obj_t *self) { supervisor_disable_tick(); self->refreshing = false; } + // BUSY outlasted the wait. One reset may free the panel for the stored waveform. If BUSY stays + // high after it, nothing more is sent, and the deadline isn't renewed, so this is the last wait. + if (!done && raises < 4 && common_hal_digitalio_digitalinout_get_value(&self->busy) == self->busy_state) { + self->lag_busy_deadline = supervisor_ticks_ms64() + EPAPER_WAIT_TIMEOUT_MS; + self->lag_last_try = true; + self->awake = false; + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + self->bus.bus_reset(self->bus.bus); + display_delay_ms(self, self->start_up_time_ms); + if (lag_wait_idle(self)) { + lag_recover(self); + } + nlr_pop(); + } else { + lag_abandon_step(self); + if (self->lag_held_exception == MP_OBJ_NULL) { + self->lag_held_exception = nlr.ret_val; + } + } + self->lag_last_try = false; + } } #endif // CIRCUITPY_EPAPERDISPLAY_LAG diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 0442e81f6d8..ba650ead480 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -54,6 +54,7 @@ typedef struct { mp_obj_t lag_held_exception; bool lag_holding_interrupts; bool lag_bus_held; // Inside a transaction, so an exception must end it. + bool lag_last_try; // Shutdown's reset after a stuck wait: the deadline is not renewed. bool lag_state_lost; // What the panel received is unknown, so it needs a stored-waveform refresh. bool lag_slot_overwriting; // The oldest frame in the ring is being replaced. bool lag_commit_drive; // The owed step has pixels to drive. From 868602f7659c33aca1570cbae48baa9afc3112e5 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:23 -0700 Subject: [PATCH 08/29] videocore: allow 1 bit per pixel sources Packed pixels with each row padded to a whole byte, so a 1-bit source can stream into a plain buffer. Framebuffer targets stay whole bytes per pixel. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 26 +++++++++++++++---------- shared-module/videocore/VideoBuffer.c | 15 ++++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 7c9ab908b3e..da37a0c16b7 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -29,9 +29,13 @@ //| unsupported position directly raises an error, but moving a containing //| Group may give garbled output instead. //| -//| When the target is a plain buffer, frames are written compactly (row -//| stride equals ``width * bits_per_pixel / 8``) and can be read from -//| Python at any time, for example to process or save camera pixels. +//| When the target is a plain buffer, frames are written with no gap +//| between rows, so each row takes ``(width * bits_per_pixel + 7) // 8`` +//| bytes. Python code can read the buffer at any time, for example to +//| process or save camera pixels. 1-bit pixels are packed together, and +//| each row is padded out to a whole byte. +//| A Group shows a plain buffer target only when its pixels are 16 bits +//| and the display uses 16-bit color. //| //| See the `videocore` module overview for complete examples. //| """ @@ -67,15 +71,17 @@ static mp_obj_t videocore_videobuffer_make_new(const mp_obj_type_t *type, size_t //| """Begin streaming the given video source into the target. //| //| The source streams continuously, writing each new frame where the -//| VideoBuffer tells it to, replacing the previous one. The source's -//| frame format must be byte aligned. The source's size and format are -//| then fixed for the duration of the ``play()``; sources that change -//| them require ``stop()`` and a new ``play()``. +//| VideoBuffer tells it to, replacing the previous one. The source can +//| use 1 bit per pixel, or a whole number of bytes per pixel. +//| The source's size and format are then fixed for the duration of the +//| ``play()``; sources that change them require ``stop()`` and a new +//| ``play()``. //| //| When the target is a framebuffer, its bits per pixel must match the -//| framebuffer color depth and the whole frame must fit within the -//| framebuffer at the current ``x``/``y`` position. An exception may be -//| raised if the video can't be played directly into the framebuffer. +//| source and be a whole number of bytes, and the whole frame must fit +//| within the framebuffer at the current ``x``/``y`` position. An +//| exception may be raised if the video can't be played directly into +//| the framebuffer. //| //| When the target is a plain buffer, it must be large enough for a //| whole frame. diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 72b669269db..76d4379f729 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -141,9 +141,15 @@ static void videocore_videobuffer_update_area(videocore_videobuffer_obj_t *self) self->current_area.next = NULL; } -// Bytes in one row of a source frame. +// Bytes in one row of a source frame. 1-bit pixels are packed and each row is +// padded to a whole byte. Framebuffer targets always use whole bytes (see +// read_source_format). +static size_t videocore_videobuffer_row_bytes(mp_int_t width, mp_int_t bits_per_pixel) { + return ((size_t)width * bits_per_pixel + 7) / 8; +} + static size_t videocore_videobuffer_source_row_bytes(videocore_videobuffer_obj_t *self) { - return (size_t)self->source_width * self->source_bits_per_pixel / 8; + return videocore_videobuffer_row_bytes(self->source_width, self->source_bits_per_pixel); } // Bytes a frame covers in a framebuffer with this row stride, from its first @@ -321,12 +327,13 @@ static void videocore_videobuffer_read_source_format(videocore_videobuffer_obj_t const videosource_p_t *protocol = videosource_check(source); mp_int_t bits_per_pixel = protocol->get_bits_per_pixel(source); - if (bits_per_pixel % 8 != 0) { + // Whole bytes per pixel, or 1 bit packed into each byte. + if (bits_per_pixel % 8 != 0 && bits_per_pixel != 1) { mp_raise_ValueError(MP_ERROR_TEXT("video format must be byte aligned")); } mp_int_t width = protocol->get_width(source); mp_int_t height = protocol->get_height(source); - size_t frame_bytes = (size_t)((width * height * bits_per_pixel) / 8); + size_t frame_bytes = (size_t)height * videocore_videobuffer_row_bytes(width, bits_per_pixel); if (self->framebuffer_protocol != NULL) { mp_int_t color_depth = FB_GETTER(get_color_depth, 16); From 2faca5c49440420282eb01a70464b457e5bd70c6 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:24 -0700 Subject: [PATCH 09/29] videogif: decode GIFs to 1 bit per pixel A bits_per_pixel argument picks RGB565 or packed 1-bit frames, for e-paper. A pixel is white when its luma is at least half, so a black and white GIF converts exactly. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videogif/GifDecoder.c | 25 +++++++++--- shared-bindings/videogif/GifDecoder.h | 7 ++-- shared-module/videogif/GifDecoder.c | 58 +++++++++++++++++++++++---- shared-module/videogif/GifDecoder.h | 5 +++ 4 files changed, 79 insertions(+), 16 deletions(-) diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index 6a97e5724b4..a5bd589542b 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -72,35 +72,48 @@ //| self, //| file: Union[str, typing.BinaryIO], //| *, +//| bits_per_pixel: int = 16, //| reverse_bytes_in_word: bool = False, //| ) -> None: //| """Open a GIF file so it can be played. //| //| :param file: The name of a GIF file, such as ``"/animation.gif"``, //| or a GIF file opened in binary mode. +//| :param int bits_per_pixel: The pixel format of each frame. Use 16 +//| for color pixels (RGB565), or 1 for black and white pixels. +//| With 1, eight pixels are packed into each byte, with the +//| leftmost pixel in the most significant bit. A pixel is white +//| (1) when its brightness is at least 128 out of 255, so a GIF +//| that uses only black and white converts exactly. //| :param bool reverse_bytes_in_word: Swap the two bytes of each 16-bit //| pixel. Use the same value as your display, such as -//| `busdisplay.BusDisplay`'s ``reverse_bytes_in_word``. +//| `busdisplay.BusDisplay`'s ``reverse_bytes_in_word``. It has no +//| effect when ``bits_per_pixel`` is 1. //| -//| Frames are RGB565. Raises `ValueError` if the file is not a GIF or if -//| the GIF is wider than 320 pixels. +//| Raises `ValueError` if ``bits_per_pixel`` is not 1 or 16, if the +//| file is not a GIF, or if the GIF is wider than 320 pixels. //| """ //| ... //| static mp_obj_t videogif_gifdecoder_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { - enum { ARG_file, ARG_reverse_bytes_in_word, NUM_ARGS }; + enum { ARG_file, ARG_bits_per_pixel, ARG_reverse_bytes_in_word, NUM_ARGS }; static const mp_arg_t allowed_args[] = { { MP_QSTR_file, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_bits_per_pixel, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 16} }, { MP_QSTR_reverse_bytes_in_word, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, }; MP_STATIC_ASSERT(MP_ARRAY_SIZE(allowed_args) == NUM_ARGS); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const mp_int_t bits_per_pixel = args[ARG_bits_per_pixel].u_int; + if (bits_per_pixel != 1 && bits_per_pixel != 16) { + mp_arg_error_invalid(MP_QSTR_bits_per_pixel); + } mp_obj_t file = gifio_open_file(args[ARG_file].u_obj); videogif_gifdecoder_obj_t *self = mp_obj_malloc_with_finaliser(videogif_gifdecoder_obj_t, &videogif_gifdecoder_type); - common_hal_videogif_gifdecoder_construct(self, file, args[ARG_reverse_bytes_in_word].u_bool); + common_hal_videogif_gifdecoder_construct(self, file, bits_per_pixel, args[ARG_reverse_bytes_in_word].u_bool); return MP_OBJ_FROM_PTR(self); } @@ -206,7 +219,7 @@ static mp_int_t videogif_gifdecoder_videosource_get_height(mp_obj_t self_in) { } static mp_int_t videogif_gifdecoder_get_bits_per_pixel(mp_obj_t self_in) { - return 16; + return common_hal_videogif_gifdecoder_get_bits_per_pixel(native_gifdecoder(self_in)); } static const videosource_p_t videogif_gifdecoder_videosource_protocol = { diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h index a5421c35726..90d5d605465 100644 --- a/shared-bindings/videogif/GifDecoder.h +++ b/shared-bindings/videogif/GifDecoder.h @@ -10,16 +10,17 @@ extern const mp_obj_type_t videogif_gifdecoder_type; -// Open the GIF in file (a file opened in binary mode). Frames are RGB565. -// Raises when the file isn't a GIF or is wider than AnimatedGIF supports. +// Open the GIF in file (a file opened in binary mode). bits_per_pixel is 1 or +// 16. Raises when the file isn't a GIF or is wider than AnimatedGIF supports. void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - bool reverse_bytes_in_word); + mp_int_t bits_per_pixel, bool reverse_bytes_in_word); // Stop playing, free the decoder and drop the file. Safe to call more than // once. void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self); bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_width(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *self); +mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_obj_t *self); // Videosource protocol steps, driven by videocore.VideoBuffer. Frames are // decoded by the supervisor background tick, never in an interrupt. play diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 57c52bddb4f..5ec39abeec5 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -10,6 +10,7 @@ #include "py/mpstate.h" #include "py/runtime.h" +#include "shared-module/displayio/ColorConverter.h" #include "shared-module/gifio/OnDiskGif.h" #include "supervisor/port_heap.h" #include "supervisor/shared/tick.h" @@ -20,8 +21,23 @@ // decoding falls behind the GIF's delays. #define VIDEOGIF_MIN_GAP_MS 5 -static inline void videogif_put_pixel(uint8_t *row, int x, uint16_t value) { - memcpy(row + x * 2, &value, 2); +// 1 bit per pixel: an RGB888 palette entry is white when its luma is at least +// half, so any black and white palette maps exactly. +static bool videogif_is_white(const uint8_t *rgb) { + return displayio_colorconverter_compute_luma((rgb[0] << 16) | (rgb[1] << 8) | rgb[2]) >= 128; +} + +static inline void videogif_put_pixel(videogif_gifdecoder_obj_t *self, uint8_t *row, int x, uint16_t value) { + if (self->bits_per_pixel == 1) { + const uint8_t mask = 0x80 >> (x % 8); + if (value) { + row[x / 8] |= mask; + } else { + row[x / 8] &= ~mask; + } + } else { + memcpy(row + x * 2, &value, 2); + } } static displayio_area_t videogif_canvas(videogif_gifdecoder_obj_t *self) { @@ -36,11 +52,16 @@ static displayio_area_t videogif_canvas(videogif_gifdecoder_obj_t *self) { // screen descriptor names. static void videogif_fill(videogif_gifdecoder_obj_t *self, const displayio_area_t *area) { const uint8_t index = self->gif->ucBackground; - const uint16_t value = self->gif->pPalette[index]; + uint16_t value; + if (self->bits_per_pixel == 1) { + value = videogif_is_white((const uint8_t *)self->gif->pPalette + index * 3); + } else { + value = self->gif->pPalette[index]; + } for (int y = area->y1; y < area->y2; y++) { uint8_t *row = self->destination.base + y * self->destination.stride; for (int x = area->x1; x < area->x2; x++) { - videogif_put_pixel(row, x, value); + videogif_put_pixel(self, row, x, value); } } } @@ -66,6 +87,14 @@ static void videogif_draw_line(GIFDRAW *draw) { if (self->new_frame) { self->new_frame = false; videogif_start_frame(self); + if (self->bits_per_pixel == 1) { + memset(self->white, 0, sizeof(self->white)); + for (int i = 0; i < 256; i++) { + if (videogif_is_white(draw->pPalette24 + i * 3)) { + self->white[i / 8] |= 1 << (i % 8); + } + } + } } const int y = draw->iY + draw->y; @@ -83,13 +112,23 @@ static void videogif_draw_line(GIFDRAW *draw) { if (draw->ucHasTransparency && index == draw->ucTransparent) { continue; } - videogif_put_pixel(row, draw->iX + i, draw->pPalette[index]); + uint16_t value; + if (self->bits_per_pixel == 1) { + value = (self->white[index / 8] >> (index % 8)) & 1; + } else { + value = draw->pPalette[index]; + } + videogif_put_pixel(self, row, draw->iX + i, value); } } void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - bool reverse_bytes_in_word) { - const uint8_t palette_type = reverse_bytes_in_word ? GIF_PALETTE_RGB565_BE : GIF_PALETTE_RGB565_LE; + mp_int_t bits_per_pixel, bool reverse_bytes_in_word) { + // 1 bit per pixel needs the RGB888 palette to compute luma. + uint8_t palette_type = GIF_PALETTE_RGB888; + if (bits_per_pixel == 16) { + palette_type = reverse_bytes_in_word ? GIF_PALETTE_RGB565_BE : GIF_PALETTE_RGB565_LE; + } // Outside the VM heap so it doesn't need a large gap there or grow it. Its // only heap pointers, the file and self, are held by self too. The // finaliser frees it. @@ -100,6 +139,7 @@ void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, m GIF_begin(self->gif, palette_type); self->gif->pfnDraw = videogif_draw_line; self->file = file; + self->bits_per_pixel = bits_per_pixel; gifio_open_gif(self->gif, file); } @@ -134,6 +174,10 @@ mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *se return self->gif != NULL ? self->gif->iCanvasHeight : 0; } +mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_obj_t *self) { + return self->bits_per_pixel; +} + // Keep the supervisor tick on only while a frame can come due. static void videogif_update_tick(videogif_gifdecoder_obj_t *self) { const bool ticking = self->playing && !self->paused && !self->finished; diff --git a/shared-module/videogif/GifDecoder.h b/shared-module/videogif/GifDecoder.h index 3efa7919fd4..c63d0e6b20a 100644 --- a/shared-module/videogif/GifDecoder.h +++ b/shared-module/videogif/GifDecoder.h @@ -37,6 +37,11 @@ typedef struct videogif_gifdecoder_obj { int32_t repeat_count; int32_t loops_done; uint8_t disposal; + // 1 or 16. + uint8_t bits_per_pixel; + // For 1 bit per pixel: which palette entries are white, one bit each, + // for the palette of the frame being decoded. + uint8_t white[256 / 8]; bool playing; bool paused; // Played its last loop, or stopped on an error: no more frames are due. From d922e31354b83ece65e0ad1b793f4c34f7a490b4 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 17:04:24 -0700 Subject: [PATCH 10/29] gifio: let a board raise the GIF width limit AnimatedGIF refuses anything wider than 320 pixels, so 400x300 and 800x480 e-paper animations would not open. CIRCUITPY_GIFIO_MAX_WIDTH stays 320 by default, so other boards don't pay for a longer line buffer. Co-Authored-By: Claude Opus 5.5 --- lib/AnimatedGIF/AnimatedGIF_circuitpy.h | 7 ++++++- py/circuitpy_mpconfig.mk | 4 ++++ shared-bindings/gifio/OnDiskGif.c | 2 +- shared-bindings/videogif/GifDecoder.c | 3 ++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h index c1f359194f5..a09aabbc1ab 100644 --- a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h +++ b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h @@ -33,7 +33,12 @@ #define MAX_CHUNK_SIZE 255 #define LZW_BUF_SIZE (6 * MAX_CHUNK_SIZE) #define LZW_HIGHWATER (4 * MAX_CHUNK_SIZE) -#define MAX_WIDTH 320 +// Make ports set the width from mpconfigboard.mk. gif.c includes only this header, and the +// zephyr-cp build passes no width, so it needs this default. +#ifndef CIRCUITPY_GIFIO_MAX_WIDTH +#define CIRCUITPY_GIFIO_MAX_WIDTH 320 +#endif +#define MAX_WIDTH CIRCUITPY_GIFIO_MAX_WIDTH #define FILE_BUF_SIZE 4096 #define PIXEL_FIRST 0 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 516a0f89b90..de5fab18d0a 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -393,6 +393,10 @@ CFLAGS += -DCIRCUITPY_GETPASS=$(CIRCUITPY_GETPASS) CIRCUITPY_GIFIO ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_GIFIO=$(CIRCUITPY_GIFIO) +# Widest GIF that can be decoded. Each pixel costs a byte per open GIF. +CIRCUITPY_GIFIO_MAX_WIDTH ?= 320 +CFLAGS += -DCIRCUITPY_GIFIO_MAX_WIDTH=$(CIRCUITPY_GIFIO_MAX_WIDTH) + CIRCUITPY_GNSS ?= 0 CFLAGS += -DCIRCUITPY_GNSS=$(CIRCUITPY_GNSS) diff --git a/shared-bindings/gifio/OnDiskGif.c b/shared-bindings/gifio/OnDiskGif.c index 3c25c7ffab4..d94d7c184d6 100644 --- a/shared-bindings/gifio/OnDiskGif.c +++ b/shared-bindings/gifio/OnDiskGif.c @@ -108,7 +108,7 @@ mp_obj_t gifio_open_file(mp_obj_t file) { //| //| If the image is too large it will be cropped at the bottom and right when displayed. //| -//| **Limitations**: The image width is limited to 320 pixels at present. `ValueError` +//| **Limitations**: The image width is limited to 320 pixels on most boards. `ValueError` //| will be raised if the image is too wide. The height //| is not limited but images that are too large will cause a memory exception. //| """ diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index a5bd589542b..3b9c45c5a6f 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -91,7 +91,8 @@ //| effect when ``bits_per_pixel`` is 1. //| //| Raises `ValueError` if ``bits_per_pixel`` is not 1 or 16, if the -//| file is not a GIF, or if the GIF is wider than 320 pixels. +//| file is not a GIF, or if the GIF is wider than the board supports +//| (320 pixels on most boards). //| """ //| ... //| From 34870bdd77630a8562f0c696d572518b11794366 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 13:05:48 -0700 Subject: [PATCH 11/29] videocore: show 1 bit plain buffers on a monochrome display Copy whole bytes when the rects line up and no layer above claimed the pixels. Polarity and bit order already match a monochrome EPaperDisplay: MSB first, 1 = white. Falls back to a per-pixel copy that honors the mask. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 4 +- shared-module/videocore/VideoBuffer.c | 89 ++++++++++++++++++++----- 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index da37a0c16b7..819ae8eb291 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -35,7 +35,9 @@ //| process or save camera pixels. 1-bit pixels are packed together, and //| each row is padded out to a whole byte. //| A Group shows a plain buffer target only when its pixels are 16 bits -//| and the display uses 16-bit color. +//| and the display uses 16-bit color, or its pixels are 1 bit and the +//| display is monochrome with the leftmost pixel in the top bit of each +//| byte, such as `epaperdisplay.EPaperDisplay`. A 1 bit means white. //| //| See the `videocore` module overview for complete examples. //| """ diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 76d4379f729..c2c4b078ea7 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -592,6 +592,75 @@ static mp_obj_t videocore_videobuffer_draw_protocol_self(mp_obj_t protocol_conta return protocol_container; } +// Whether a layer above already claimed a pixel near the overlap. Checks whole words, which may +// include pixels between overlap rows or just outside the overlap. That can only make the caller +// take its slower path; it never misses a claimed pixel. +static bool videocore_videobuffer_overlap_claimed(const uint32_t *mask, const displayio_area_t *area, + const displayio_area_t *overlap) { + const size_t span_px = displayio_area_width(area); + const size_t first_px = (size_t)(overlap->y1 - area->y1) * span_px + (overlap->x1 - area->x1); + const size_t end_px = (size_t)(overlap->y2 - 1 - area->y1) * span_px + (overlap->x2 - area->x1); + for (size_t word = first_px / 32; word <= (end_px - 1) / 32; word++) { + if (mask[word] != 0) { + return true; + } + } + return false; +} + +// Copy a 1 bit per pixel plain buffer into a 1 bit display's buffer. Both are MSB first with +// 1 = white, the same as a monochrome EPaperDisplay's first RAM pass before any inversion. +// Whole bytes are copied when the rects line up and nothing above claimed these pixels. +static bool videocore_videobuffer_fill_area_1bit(videocore_videobuffer_obj_t *self, + const _displayio_colorspace_t *colorspace, const displayio_area_t *area, const displayio_area_t *overlap, + bool full_coverage, uint32_t *mask, uint32_t *buffer) { + // Every grayscale bit of a 1 bit pixel is that pixel, so this also serves each grayscale pass. + if (!colorspace->pixels_in_byte_share_row || !colorspace->reverse_pixels_in_byte || + !colorspace->grayscale) { + return false; + } + const size_t span_px = displayio_area_width(area); + const size_t width = displayio_area_width(overlap); + const size_t row_bytes = videocore_videobuffer_source_row_bytes(self); + const size_t x_in_frame = overlap->x1 - self->current_area.x1; + const size_t x_in_area = overlap->x1 - area->x1; + const uint8_t *frame = self->stream_base; + uint8_t *out = (uint8_t *)buffer; + + const bool whole_bytes = span_px % 8 == 0 && x_in_area % 8 == 0 && x_in_frame % 8 == 0 && width % 8 == 0 && + !videocore_videobuffer_overlap_claimed(mask, area, overlap); + for (int16_t y = overlap->y1; y < overlap->y2; y++) { + const size_t px = (size_t)(y - area->y1) * span_px + x_in_area; + const uint8_t *row = frame + (size_t)(y - self->current_area.y1) * row_bytes; + if (whole_bytes) { + memcpy(out + px / 8, row + x_in_frame / 8, width / 8); + // Full coverage ends the layer walk, so filling the mask once below is enough. + if (!full_coverage) { + for (size_t p = px; p < px + width; p++) { + mask[p / 32] |= 1u << (p % 32); + } + } + continue; + } + for (size_t i = 0; i < width; i++) { + const size_t p = px + i; + if (mask[p / 32] & (1u << (p % 32))) { + continue; + } + const size_t f = x_in_frame + i; + if (row[f / 8] & (0x80 >> (f % 8))) { + out[p / 8] |= 0x80 >> (p % 8); + } + mask[p / 32] |= 1u << (p % 32); + } + } + if (whole_bytes && full_coverage) { + // A FramebufferDisplay writes nothing back when the mask is empty. + memset(mask, 0xff, (displayio_area_size(area) + 31) / 32 * sizeof(uint32_t)); + } + return full_coverage; +} + static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displayio_colorspace_t *colorspace, const displayio_area_t *area, uint32_t *mask, uint32_t *buffer) { videocore_videobuffer_obj_t *self = MP_OBJ_TO_PTR(self_in); // buffer isn't necessarily the framebuffer. It may be a scratch buffer. @@ -619,21 +688,7 @@ static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displa const uint8_t *frame = (const uint8_t *)self->stream_base; uint8_t *out = (uint8_t *)buffer; - // Check whole words. This may include pixels between overlap rows or - // just outside the overlap, which can only make us take the slower - // per-pixel path below; it never misses a claimed pixel. - const size_t first_px = (size_t)(overlap.y1 - area->y1) * span_px + - (overlap.x1 - area->x1); - const size_t end_px = (size_t)(overlap.y2 - 1 - area->y1) * span_px + - (overlap.x2 - area->x1); - bool masked = false; - for (size_t word = first_px / 32; word <= (end_px - 1) / 32; word++) { - if (mask[word] != 0) { - masked = true; - break; - } - } - if (full_coverage && !masked) { + if (full_coverage && !videocore_videobuffer_overlap_claimed(mask, area, &overlap)) { // Sole coverer: nothing was composed, so the driver skips the copy. return true; } @@ -665,6 +720,10 @@ static bool videocore_videobuffer_draw_fill_area(mp_obj_t self_in, const _displa return full_coverage; } + if (colorspace->depth == 1 && self->source_bits_per_pixel == 1) { + return videocore_videobuffer_fill_area_1bit(self, colorspace, area, &overlap, full_coverage, mask, buffer); + } + // Raw buffer target: bulk copy, no color conversion: the display depth // must match the video's pixel format byte for byte. if (colorspace->depth != self->source_bits_per_pixel || colorspace->depth != 16) { From 7a50f9e7f9702979b35059eaaf2ffeee816e77eb Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 14:41:24 -0700 Subject: [PATCH 12/29] videocore: document that a rotated display raises ValueError Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 819ae8eb291..6c8439c7b0d 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -29,6 +29,9 @@ //| unsupported position directly raises an error, but moving a containing //| Group may give garbled output instead. //| +//| A VideoBuffer can only be translated. Showing it on a rotated display, or +//| in a scaled Group, raises `ValueError`. +//| //| When the target is a plain buffer, frames are written with no gap //| between rows, so each row takes ``(width * bits_per_pixel + 7) // 8`` //| bytes. Python code can read the buffer at any time, for example to @@ -38,6 +41,9 @@ //| and the display uses 16-bit color, or its pixels are 1 bit and the //| display is monochrome with the leftmost pixel in the top bit of each //| byte, such as `epaperdisplay.EPaperDisplay`. A 1 bit means white. +//| On a tricolor display with separate black and color RAM, a 1 bit video +//| only draws black and white, and a highlight colored layer below it shows +//| through. Other tricolor displays don't draw it. //| //| See the `videocore` module overview for complete examples. //| """ From d88f2afc3d3bfe09ec36df0dca287ddbf8539ef2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Sat, 31 Oct 2026 14:41:24 -0700 Subject: [PATCH 13/29] adafruit_feather_rp2040_thinkink: enable videocore This is the board the 1 bit GIF numbers come from. Co-Authored-By: Claude Opus 5.5 --- .../boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk | 2 ++ py/circuitpy_mpconfig.mk | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk index 1caf2f862ee..64d4852ebf6 100644 --- a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk @@ -9,3 +9,5 @@ CHIP_FAMILY = rp2 EXTERNAL_FLASH_DEVICES = "GD25Q64C,W25Q64JVxQ" CIRCUITPY_EPAPERDISPLAY_LAG = 1 +CIRCUITPY_GIFIO_MAX_WIDTH = 800 +CIRCUITPY_VIDEOCORE = 1 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index de5fab18d0a..64d0b6f3b2c 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -859,9 +859,8 @@ CFLAGS += -DCIRCUITPY_ULAB=$(CIRCUITPY_ULAB) # element-wise array arithmetic roughly 1.5x slower. CIRCUITPY_ULAB_OPTIMIZE_SIZE ?= 0 -# Video pipeline: playing video sources into framebuffers. Requires a port -# with a video source such as mipicsi and a framebuffer output such as -# mipidsi. +# Video pipeline: playing video sources into framebuffers or plain buffers. +# A source can be a port's camera, such as mipicsi, or a GIF file with videogif. CIRCUITPY_VIDEOCORE ?= 0 CFLAGS += -DCIRCUITPY_VIDEOCORE=$(CIRCUITPY_VIDEOCORE) From 2d8485c35a64e4d6cc47e453bcca555413f34c94 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 2 Nov 2026 13:53:06 -0800 Subject: [PATCH 14/29] epaperdisplay: add CIRCUITPY_EPAPERDISPLAY_FAST, off on atmel-samd The faster refresh options (write_previous_ram_command, keep_awake and update_refresh_mode's refresh_sequence) cost about 900 bytes, which no longer fits on several SAMD21 and SAMD51 boards. With the flag off, EPaperDisplay builds as it did before. Lag drive needs it. Co-Authored-By: Claude Opus 5.5 --- ports/atmel-samd/mpconfigport.mk | 1 + py/circuitpy_mpconfig.mk | 7 +++ shared-bindings/epaperdisplay/EPaperDisplay.c | 43 ++++++++++++-- shared-module/displayio/__init__.c | 2 + shared-module/epaperdisplay/EPaperDisplay.c | 56 ++++++++++++++++++- shared-module/epaperdisplay/EPaperDisplay.h | 6 ++ 6 files changed, 107 insertions(+), 8 deletions(-) diff --git a/ports/atmel-samd/mpconfigport.mk b/ports/atmel-samd/mpconfigport.mk index 29a881d2d98..7d14227571a 100644 --- a/ports/atmel-samd/mpconfigport.mk +++ b/ports/atmel-samd/mpconfigport.mk @@ -13,6 +13,7 @@ CIRCUITPY_LTO = 1 CIRCUITPY_KEYPAD_DEMUX ?= 0 CIRCUITPY_LVFONTIO ?= 0 +CIRCUITPY_EPAPERDISPLAY_FAST ?= 0 CIRCUITPY_DIGITALINOUT_PROTOCOL = 0 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 516a0f89b90..1d5d1484d7c 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -310,9 +310,16 @@ CFLAGS += -DCIRCUITPY_QSPIBUS=$(CIRCUITPY_QSPIBUS) CIRCUITPY_EPAPERDISPLAY ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_EPAPERDISPLAY=$(CIRCUITPY_EPAPERDISPLAY) +# Faster e-paper refreshes: EPaperDisplay's write_previous_ram_command and keep_awake. +CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) +CFLAGS += -DCIRCUITPY_EPAPERDISPLAY_FAST=$(CIRCUITPY_EPAPERDISPLAY_FAST) + # Fast e-paper updates with balanced drive: EPaperDisplay's lag_frames and clean(). CIRCUITPY_EPAPERDISPLAY_LAG ?= 0 CFLAGS += -DCIRCUITPY_EPAPERDISPLAY_LAG=$(CIRCUITPY_EPAPERDISPLAY_LAG) +ifeq ($(CIRCUITPY_EPAPERDISPLAY_LAG)$(CIRCUITPY_EPAPERDISPLAY_FAST),10) +$(error CIRCUITPY_EPAPERDISPLAY_LAG needs CIRCUITPY_EPAPERDISPLAY_FAST) +endif CIRCUITPY_I2CDISPLAYBUS ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_I2CDISPLAYBUS=$(CIRCUITPY_I2CDISPLAYBUS) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 79520783d68..ce7ef4a3708 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -107,6 +107,7 @@ //| When set, ``refresh()`` waits for the display to finish updating before it returns. //| Requires a non-empty ``stop_sequence``. //| Leave ``write_color_ram_command`` as None when it writes the same RAM, or it will overwrite the previous image. +//| Not available on all boards. //| :param int highlight_color: RGB888 of source color to highlight with third ePaper color. //| :param int highlight_color2: RGB888 of source color to highlight with fourth ePaper color. //| :param int refresh_display_command: Command used to start a display refresh. Single int or byte-packed command sequence @@ -125,6 +126,7 @@ //| The ``stop_sequence`` is not sent after each refresh. It is sent when the display is released. //| It is also sent before the reset that ``update_refresh_mode()`` asks for, so it can't be empty. //| This keeps the display's settings and RAM between refreshes so they can be faster. +//| Not available on all boards. //| :param int lag_frames: When more than 0, the previous RAM holds the frame from ``lag_frames`` refreshes ago //| instead of the last one, so each changed pixel is driven for ``lag_frames`` refreshes, the same number //| toward black and toward white. Only the box around the changed bytes is sent. ``refresh()`` returns as soon @@ -132,12 +134,13 @@ //| ``lag_frames`` more times to finish the drive. Implies ``keep_awake``. Needs ``write_previous_ram_command``, //| ``busy_pin``, ``clean_sequence``, the window commands and a monochrome display, and allocates //| ``lag_frames * width * height / 8`` bytes outside the VM heap until the display is released. -//| Only on builds with ``CIRCUITPY_EPAPERDISPLAY_LAG``. Elsewhere, more than 0 raises `ValueError`. +//| Only on builds with ``CIRCUITPY_EPAPERDISPLAY_LAG``. Elsewhere, more than 0 raises `ValueError`, +//| and boards without ``keep_awake`` don't accept it. //| :param ~circuitpython_typing.ReadableBuffer clean_sequence: Byte-packed commands for a full refresh, such as //| one using the waveform stored in the display, for use when both RAM planes are white. It runs before the //| first refresh with ``lag_frames``. When the display is released or the code stops, the display finishes //| its drive, goes white and runs this and the ``stop_sequence``. Display drivers should provide it. -//| It is accepted on every build and only used with ``lag_frames``. +//| It is accepted wherever ``keep_awake`` is and only used with ``lag_frames``. //| """ //| ... //| @@ -146,10 +149,18 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, ARG_ram_width, ARG_ram_height, ARG_colstart, ARG_rowstart, ARG_rotation, ARG_set_column_window_command, ARG_set_row_window_command, ARG_set_current_column_command, ARG_set_current_row_command, ARG_write_black_ram_command, ARG_black_bits_inverted, - ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_write_previous_ram_command, ARG_highlight_color, ARG_highlight_color2, + ARG_write_color_ram_command, ARG_color_bits_inverted, + #if CIRCUITPY_EPAPERDISPLAY_FAST + ARG_write_previous_ram_command, + #endif + ARG_highlight_color, ARG_highlight_color2, ARG_refresh_display_command, ARG_refresh_time, ARG_busy_pin, ARG_busy_state, ARG_seconds_per_frame, ARG_always_toggle_chip_select, ARG_grayscale, ARG_advanced_color_epaper, ARG_spectra6, - ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, ARG_keep_awake, ARG_lag_frames, ARG_clean_sequence }; + ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, + #if CIRCUITPY_EPAPERDISPLAY_FAST + ARG_keep_awake, ARG_lag_frames, ARG_clean_sequence, + #endif + }; static const mp_arg_t allowed_args[] = { { MP_QSTR_display_bus, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, @@ -169,7 +180,9 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_black_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_write_color_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, { MP_QSTR_color_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_write_previous_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, + #endif { MP_QSTR_highlight_color, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_highlight_color2, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_refresh_display_command, MP_ARG_OBJ | MP_ARG_REQUIRED }, @@ -184,9 +197,11 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_two_byte_sequence_length, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_start_up_time, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NEW_SMALL_INT(0)} }, { MP_QSTR_address_little_endian, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, + #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_keep_awake, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_lag_frames, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, { MP_QSTR_clean_sequence, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, + #endif }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); @@ -197,12 +212,14 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, mp_get_buffer_raise(args[ARG_start_sequence].u_obj, &start_bufinfo, MP_BUFFER_READ); mp_buffer_info_t stop_bufinfo; mp_get_buffer_raise(args[ARG_stop_sequence].u_obj, &stop_bufinfo, MP_BUFFER_READ); + #if CIRCUITPY_EPAPERDISPLAY_FAST // A reset can follow these displays' last RAM write, and SSD168x controllers only commit the // last byte when another command arrives. if (stop_bufinfo.len == 0 && (args[ARG_keep_awake].u_bool || args[ARG_write_previous_ram_command].u_obj != mp_const_none)) { mp_arg_error_invalid(MP_QSTR_stop_sequence); } + #endif const mcu_pin_obj_t *busy_pin = validate_obj_is_free_pin_or_none(args[ARG_busy_pin].u_obj, MP_QSTR_busy_pin); @@ -212,6 +229,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, mp_raise_ValueError(MP_ERROR_TEXT("Display rotation must be in 90 degree increments")); } + #if CIRCUITPY_EPAPERDISPLAY_FAST mp_int_t lag_frames = mp_arg_validate_int_range(args[ARG_lag_frames].u_int, 0, 32, MP_QSTR_lag_frames); #if CIRCUITPY_EPAPERDISPLAY_LAG if (lag_frames > 0) { @@ -241,7 +259,8 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, if (lag_frames > 0) { mp_raise_ValueError_varg(MP_ERROR_TEXT("Invalid %q"), MP_QSTR_lag_frames); } - #endif + #endif // CIRCUITPY_EPAPERDISPLAY_LAG + #endif // CIRCUITPY_EPAPERDISPLAY_FAST primary_display_t *disp = allocate_display_or_raise(); epaperdisplay_epaperdisplay_obj_t *self = &disp->epaper_display; @@ -256,10 +275,12 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, if (args[ARG_write_color_ram_command].u_obj != mp_const_none) { write_color_ram_command = mp_obj_get_int(args[ARG_write_color_ram_command].u_obj); } + #if CIRCUITPY_EPAPERDISPLAY_FAST mp_int_t write_previous_ram_command = NO_COMMAND; if (args[ARG_write_previous_ram_command].u_obj != mp_const_none) { write_previous_ram_command = mp_obj_get_int(args[ARG_write_previous_ram_command].u_obj); } + #endif bool two_byte_sequence_length = args[ARG_two_byte_sequence_length].u_bool; @@ -305,7 +326,9 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.black_bits_inverted = args[ARG_black_bits_inverted].u_bool; construct_args.write_color_ram_command = write_color_ram_command; construct_args.color_bits_inverted = args[ARG_color_bits_inverted].u_bool; + #if CIRCUITPY_EPAPERDISPLAY_FAST construct_args.write_previous_ram_command = write_previous_ram_command; + #endif construct_args.highlight_color = highlight_color; construct_args.highlight_color2 = highlight_color2; construct_args.refresh_sequence = refresh_buf; @@ -320,6 +343,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.spectra6 = args[ARG_spectra6].u_bool; construct_args.two_byte_sequence_length = two_byte_sequence_length; construct_args.address_little_endian = args[ARG_address_little_endian].u_bool; + #if CIRCUITPY_EPAPERDISPLAY_FAST construct_args.keep_awake = args[ARG_keep_awake].u_bool || lag_frames > 0; construct_args.lag_frames = lag_frames; if (args[ARG_clean_sequence].u_obj != mp_const_none) { @@ -328,6 +352,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, construct_args.clean_sequence = clean_bufinfo.buf; construct_args.clean_sequence_len = clean_bufinfo.len; } + #endif common_hal_epaperdisplay_epaperdisplay_construct(self, &construct_args); return self; @@ -362,14 +387,16 @@ MP_DEFINE_CONST_FUN_OBJ_2(epaperdisplay_epaperdisplay_show_obj, epaperdisplay_ep //| :param float seconds_per_frame: Minimum number of seconds between refreshes. //| :param ReadableBuffer refresh_sequence: Byte sequence sent to start a refresh. //| It uses the same format as ``start_sequence``. -//| When it is None, the current refresh sequence is kept.""" +//| When it is None, the current refresh sequence is kept. Not available on all boards.""" //| static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { enum { ARG_start_sequence, ARG_seconds_per_frame, ARG_refresh_sequence }; static const mp_arg_t allowed_args[] = { { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_seconds_per_frame, MP_ARG_OBJ, {.u_obj = MP_OBJ_NEW_SMALL_INT(180)} }, + #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_refresh_sequence, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + #endif }; epaperdisplay_epaperdisplay_obj_t *self = native_display(pos_args[0]); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; @@ -380,12 +407,16 @@ static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, c mp_get_buffer_raise(args[ARG_start_sequence].u_obj, &start_sequence, MP_BUFFER_READ); float seconds_per_frame = mp_obj_get_float(args[ARG_seconds_per_frame].u_obj); + #if CIRCUITPY_EPAPERDISPLAY_FAST mp_buffer_info_t refresh_sequence; + #endif mp_buffer_info_t *refresh_sequence_ptr = NULL; + #if CIRCUITPY_EPAPERDISPLAY_FAST if (args[ARG_refresh_sequence].u_obj != mp_const_none) { mp_get_buffer_raise(args[ARG_refresh_sequence].u_obj, &refresh_sequence, MP_BUFFER_READ); refresh_sequence_ptr = &refresh_sequence; } + #endif // Update parameters epaperdisplay_epaperdisplay_change_refresh_mode_parameters(self, &start_sequence, refresh_sequence_ptr, seconds_per_frame); diff --git a/shared-module/displayio/__init__.c b/shared-module/displayio/__init__.c index 047fda3bf1c..976663b7ec5 100644 --- a/shared-module/displayio/__init__.c +++ b/shared-module/displayio/__init__.c @@ -170,9 +170,11 @@ static void common_hal_displayio_release_displays_impl(bool keep_primary) { #endif #if CIRCUITPY_EPAPERDISPLAY } else if (display_type == &epaperdisplay_epaperdisplay_type) { + #if CIRCUITPY_EPAPERDISPLAY_FAST if (keep_primary) { epaperdisplay_epaperdisplay_end_held_bus(&displays[i].epaper_display); } + #endif release_epaperdisplay(&displays[i].epaper_display); #endif #if CIRCUITPY_FRAMEBUFFERIO diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index 7db03980d04..ffcc29dbc9f 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -115,7 +115,9 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla self->black_bits_inverted = args->black_bits_inverted; self->write_color_ram_command = args->write_color_ram_command; self->color_bits_inverted = args->color_bits_inverted; + #if CIRCUITPY_EPAPERDISPLAY_FAST self->write_previous_ram_command = args->write_previous_ram_command; + #endif self->refresh_time = args->refresh_time * 1000; self->busy_state = args->busy_state; self->refreshing = false; @@ -136,9 +138,11 @@ void common_hal_epaperdisplay_epaperdisplay_construct(epaperdisplay_epaperdispla self->stop_sequence_len = args->stop_sequence_len; self->refresh_sequence = args->refresh_sequence; self->refresh_sequence_len = args->refresh_sequence_len; + #if CIRCUITPY_EPAPERDISPLAY_FAST self->keep_awake = args->keep_awake; self->awake = false; self->needs_start = false; + #endif self->busy.base.type = &mp_type_NoneType; self->two_byte_sequence_length = args->two_byte_sequence_length; @@ -189,6 +193,7 @@ uint16_t common_hal_epaperdisplay_epaperdisplay_get_height(epaperdisplay_epaperd return displayio_display_core_get_height(&self->core); } +#if CIRCUITPY_EPAPERDISPLAY_FAST // BUSY can take a moment to rise after an update command, and reading it low before then would // look like a finished update. If it never rises, nothing has started or it was too quick to see, // so this returns false after 2 ms. Short enough that an interrupt doesn't have to end it. @@ -201,6 +206,7 @@ static bool wait_for_update_start(epaperdisplay_epaperdisplay_obj_t *self) { } return false; } +#endif // CIRCUITPY_EPAPERDISPLAY_FAST #if CIRCUITPY_EPAPERDISPLAY_LAG static bool lag_deadline_passed(epaperdisplay_epaperdisplay_obj_t *self) { @@ -452,15 +458,20 @@ void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_ep #endif // CIRCUITPY_EPAPERDISPLAY_LAG self->start_sequence = (uint8_t *)start_sequence->buf; self->start_sequence_len = start_sequence->len; + #if CIRCUITPY_EPAPERDISPLAY_FAST // NULL keeps the current refresh sequence. if (refresh_sequence != NULL) { self->refresh_sequence = (uint8_t *)refresh_sequence->buf; self->refresh_sequence_len = refresh_sequence->len; } + #endif self->milliseconds_per_frame = seconds_per_frame * 1000; + #if CIRCUITPY_EPAPERDISPLAY_FAST self->needs_start = true; + #endif } +#if CIRCUITPY_EPAPERDISPLAY_FAST // For a panel kept awake. Ctrl-C raises from a send in progress, and that transaction is still // open. The caller still has things to do first, so the interrupt raises later. static void send_stop_sequence_guarded(epaperdisplay_epaperdisplay_obj_t *self) { @@ -475,6 +486,7 @@ static void send_stop_sequence_guarded(epaperdisplay_epaperdisplay_obj_t *self) mp_sched_exception(nlr.ret_val); } } +#endif // CIRCUITPY_EPAPERDISPLAY_FAST static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdisplay_obj_t *self) { if (!displayio_display_bus_is_free(&self->bus)) { @@ -486,6 +498,7 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla return; } + #if CIRCUITPY_EPAPERDISPLAY_FAST if (self->keep_awake && self->awake) { if (!self->needs_start) { // The panel is still configured from the previous refresh. @@ -497,6 +510,7 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla send_stop_sequence_guarded(self); self->awake = false; } + #endif // run start sequence self->bus.bus_reset(self->bus.bus); @@ -507,8 +521,10 @@ static void epaperdisplay_epaperdisplay_start_refresh(epaperdisplay_epaperdispla display_interrupted(self)) { return; } + #if CIRCUITPY_EPAPERDISPLAY_FAST self->awake = true; self->needs_start = false; + #endif } uint32_t common_hal_epaperdisplay_epaperdisplay_get_time_to_refresh(epaperdisplay_epaperdisplay_obj_t *self) { @@ -523,6 +539,7 @@ uint32_t common_hal_epaperdisplay_epaperdisplay_get_time_to_refresh(epaperdispla return self->milliseconds_per_frame - elapsed_time; } +#if CIRCUITPY_EPAPERDISPLAY_FAST static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *area, bool write_previous); // False if the panel is still updating when the wait is over. @@ -584,6 +601,17 @@ static void epaperdisplay_epaperdisplay_finish_refresh(epaperdisplay_epaperdispl nlr_jump(raised); } } +#else +static void epaperdisplay_epaperdisplay_finish_refresh(epaperdisplay_epaperdisplay_obj_t *self, const displayio_area_t *areas) { + // Actually refresh the display now that all pixel RAM has been updated. + send_command_sequence(self, false, self->refresh_sequence, self->refresh_sequence_len); + + supervisor_enable_tick(); + self->refreshing = true; + + displayio_display_core_finish_refresh(&self->core); +} +#endif // CIRCUITPY_EPAPERDISPLAY_FAST mp_obj_t common_hal_epaperdisplay_epaperdisplay_get_bus(epaperdisplay_epaperdisplay_obj_t *self) { return self->bus.bus; @@ -666,9 +694,12 @@ static bool epaperdisplay_epaperdisplay_refresh_area(epaperdisplay_epaperdisplay uint8_t write_command = self->write_black_ram_command; if (pass == 1) { write_command = self->write_color_ram_command; - } else if (write_previous) { + } + #if CIRCUITPY_EPAPERDISPLAY_FAST + else if (write_previous) { write_command = self->write_previous_ram_command; } + #endif if (!displayio_display_bus_begin_transaction(&self->bus)) { // Display bus not available now. return false; @@ -779,10 +810,15 @@ bool common_hal_epaperdisplay_epaperdisplay_refresh(epaperdisplay_epaperdisplay_ if (common_hal_digitalio_digitalinout_get_value(&self->busy) != self->busy_state) { supervisor_disable_tick(); self->refreshing = false; + #if CIRCUITPY_EPAPERDISPLAY_FAST if (!self->keep_awake) { // Run stop sequence but don't wait for busy because busy is set when sleeping. send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); } + #else + // Run stop sequence but don't wait for busy because busy is set when sleeping. + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + #endif } else { #if CIRCUITPY_EPAPERDISPLAY_LAG lag_give_up_if_stuck(self); @@ -850,10 +886,15 @@ void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *s if (refresh_done) { supervisor_disable_tick(); self->refreshing = false; + #if CIRCUITPY_EPAPERDISPLAY_FAST if (!self->keep_awake) { // Run stop sequence but don't wait for busy because busy is set when sleeping. send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); } + #else + // Run stop sequence but don't wait for busy because busy is set when sleeping. + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + #endif } #if CIRCUITPY_EPAPERDISPLAY_LAG else { @@ -878,15 +919,22 @@ void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self) { supervisor_disable_tick(); self->refreshing = false; // Run stop sequence but don't wait for busy because busy is set when sleeping. + #if CIRCUITPY_EPAPERDISPLAY_FAST if (self->keep_awake) { send_stop_sequence_guarded(self); } else { send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); } - } else if (self->keep_awake && self->awake) { + #else + send_command_sequence(self, false, self->stop_sequence, self->stop_sequence_len); + #endif + } + #if CIRCUITPY_EPAPERDISPLAY_FAST + else if (self->keep_awake && self->awake) { send_stop_sequence_guarded(self); } self->awake = false; + #endif #if CIRCUITPY_EPAPERDISPLAY_LAG lag_release_interrupts(self); #endif // CIRCUITPY_EPAPERDISPLAY_LAG @@ -913,6 +961,7 @@ void epaperdisplay_epaperdisplay_prepare_release(epaperdisplay_epaperdisplay_obj } #endif // CIRCUITPY_EPAPERDISPLAY_LAG +#if CIRCUITPY_EPAPERDISPLAY_FAST void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self) { // The VM is over, so whatever holds the bus can never let it go: end it here. if (self->keep_awake && !displayio_display_bus_is_free(&self->bus)) { @@ -922,6 +971,7 @@ void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t #endif } } +#endif // CIRCUITPY_EPAPERDISPLAY_FAST void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) { #if CIRCUITPY_EPAPERDISPLAY_LAG @@ -938,6 +988,7 @@ void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) #endif // CIRCUITPY_EPAPERDISPLAY_LAG displayio_display_core_set_root_group(&self->core, &circuitpython_splash); self->core.full_refresh = true; + #if CIRCUITPY_EPAPERDISPLAY_FAST // Let the panel sleep after the next refresh when code isn't running. if (self->keep_awake && self->awake && !self->refreshing) { // Nothing can catch a raise from a send. A reload may be pending. @@ -946,6 +997,7 @@ void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self) self->awake = false; } self->keep_awake = false; + #endif } void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t *self) { diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index ba650ead480..75ac486e0aa 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -29,7 +29,9 @@ typedef struct { uint16_t refresh_time; uint16_t write_black_ram_command; uint16_t write_color_ram_command; + #if CIRCUITPY_EPAPERDISPLAY_FAST uint16_t write_previous_ram_command; + #endif uint8_t hue; bool busy_state; bool black_bits_inverted; @@ -38,9 +40,11 @@ typedef struct { bool grayscale; bool acep; bool two_byte_sequence_length; + #if CIRCUITPY_EPAPERDISPLAY_FAST bool keep_awake; bool awake; bool needs_start; // update_refresh_mode() changed the start sequence. + #endif display_chip_select_behavior_t chip_select; #if CIRCUITPY_EPAPERDISPLAY_LAG // Lag drive. Only used when lag_frames > 0. The ring has lag_frames slots: frames N-1 to @@ -79,9 +83,11 @@ void epaperdisplay_epaperdisplay_change_refresh_mode_parameters(epaperdisplay_ep void epaperdisplay_epaperdisplay_background(epaperdisplay_epaperdisplay_obj_t *self); void epaperdisplay_epaperdisplay_reset(epaperdisplay_epaperdisplay_obj_t *self); void release_epaperdisplay(epaperdisplay_epaperdisplay_obj_t *self); +#if CIRCUITPY_EPAPERDISPLAY_FAST // The VM is over: a keep_awake display ends whatever transaction still holds its bus, since its // stop sequence would wait for the bus forever. Other displays are left alone. Does not raise. void epaperdisplay_epaperdisplay_end_held_bus(epaperdisplay_epaperdisplay_obj_t *self); +#endif #if CIRCUITPY_EPAPERDISPLAY_LAG // Waits up to 30 s for the bus so a release can finish the drive. Raises what interrupted it, or // RuntimeError if the bus stays held, with the display and its drive unchanged. Only call it where From 8d19a332f9f8463b592ef44dc4f4b629ca53fd87 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 2 Nov 2026 16:42:09 -0800 Subject: [PATCH 15/29] Update circuitpython.pot after merging video-gif Co-Authored-By: Claude Opus 5.5 --- locale/circuitpython.pot | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 5c541171090..82dbf2295df 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -861,6 +861,7 @@ msgstr "" #: ports/analog/common-hal/busio/SPI.c #: ports/espressif/common-hal/espidf/__init__.c #: ports/nordic/common-hal/_bleio/__init__.c +#: shared-bindings/epaperdisplay/EPaperDisplay.c msgid "Invalid state" msgstr "" @@ -1735,8 +1736,10 @@ msgstr "" #: ports/raspberrypi/common-hal/usb_host/Port.c #: shared-bindings/digitalio/DigitalInOut.c shared-bindings/emmcio/EMMC.c #: shared-bindings/i2cioexpander/IOPin.c shared-bindings/microcontroller/Pin.c -#: shared-module/_airlift/__init__.c shared-module/max3421e/Max3421E.c -#: shared-module/usb/core/Device.c shared-module/usb_host_audio/USBIn.c +#: shared-module/_airlift/__init__.c +#: shared-module/epaperdisplay/EPaperDisplay.c +#: shared-module/max3421e/Max3421E.c shared-module/usb/core/Device.c +#: shared-module/usb_host_audio/USBIn.c msgid "%q in use" msgstr "" From 8cbd70ed263445cd53b17ab5fa6f908a9d871382 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 08:56:10 -0800 Subject: [PATCH 16/29] Warn that fast e-paper updates exceed panel limits Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index ce7ef4a3708..287f770f4cd 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -141,6 +141,10 @@ //| first refresh with ``lag_frames``. When the display is released or the code stops, the display finishes //| its drive, goes white and runs this and the ``stop_sequence``. Display drivers should provide it. //| It is accepted wherever ``keep_awake`` is and only used with ``lag_frames``. +//| +//| .. warning:: ``keep_awake``, ``write_previous_ram_command`` and ``lag_frames`` allow updates +//| much faster than panel makers' limits on refresh rate. Fast updates need a balanced waveform, +//| and long-term wear on the panel is unknown. //| """ //| ... //| From dac704bad68d58fe1f3045ca84c339bd2dfa30b2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 18:12:28 -0800 Subject: [PATCH 17/29] videocore: VideoBuffer sets the pixel format, not GifDecoder VideoBuffer takes bits_per_pixel for a plain buffer, or uses the framebuffer's color depth, and asks the source for it at play(). The GIF source decodes to whichever it is asked for; the camera only accepts its own format. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/videocore/VideoBuffer.c | 16 +++++++++-- shared-bindings/videogif/GifDecoder.c | 37 ++++++++++++------------- shared-bindings/videogif/GifDecoder.h | 13 +++++---- shared-module/videocore/VideoBuffer.c | 10 +++++-- shared-module/videocore/VideoBuffer.h | 6 +++- shared-module/videocore/__init__.c | 8 ++++++ shared-module/videocore/__init__.h | 6 ++++ shared-module/videogif/GifDecoder.c | 24 ++++++++++++---- 8 files changed, 84 insertions(+), 36 deletions(-) diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index 6c8439c7b0d..de54e69df70 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -53,25 +53,37 @@ //| framebuffer: Union[ //| circuitpython_typing.FrameBuffer, circuitpython_typing.WriteableBuffer //| ], +//| *, +//| bits_per_pixel: int = 16, //| ) -> None: //| """Create a VideoBuffer that plays video into the given framebuffer //| //| :param framebuffer: The framebuffer to copy video frames into, or a //| writable buffer such as a ``bytearray`` or ``memoryview``. +//| :param int bits_per_pixel: The pixel format of a writable buffer: 16 +//| for color (RGB565), or 1 for black and white. ``play()`` asks the +//| source for this format and raises `ValueError` if the source +//| can't provide it. A framebuffer uses its own color depth instead. //| """ //| static mp_obj_t videocore_videobuffer_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { - enum { ARG_framebuffer, NUM_ARGS }; + enum { ARG_framebuffer, ARG_bits_per_pixel, NUM_ARGS }; static const mp_arg_t allowed_args[] = { { MP_QSTR_framebuffer, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_bits_per_pixel, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 16} }, }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; MP_STATIC_ASSERT(MP_ARRAY_SIZE(allowed_args) == NUM_ARGS); mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + const mp_int_t bits_per_pixel = args[ARG_bits_per_pixel].u_int; + if (bits_per_pixel != 1 && bits_per_pixel != 16) { + mp_arg_error_invalid(MP_QSTR_bits_per_pixel); + } + videocore_videobuffer_obj_t *self = mp_obj_malloc_with_finaliser(videocore_videobuffer_obj_t, &videocore_videobuffer_type); - common_hal_videocore_videobuffer_construct(self, args[ARG_framebuffer].u_obj); + common_hal_videocore_videobuffer_construct(self, args[ARG_framebuffer].u_obj, bits_per_pixel); return MP_OBJ_FROM_PTR(self); } diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index e16f84046ad..60406b0c4b4 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -70,46 +70,38 @@ //| def __init__( //| self, //| filename: str, -//| *, -//| bits_per_pixel: int = 16, //| ) -> None: //| """Open a GIF file so it can be played. //| //| :param str filename: The name of a GIF file, such as ``"/animation.gif"``. //| The GifDecoder closes the file when it is deinitialized. -//| :param int bits_per_pixel: The pixel format of each frame. Use 16 -//| for color pixels (RGB565), or 1 for black and white pixels. -//| With 1, eight pixels are packed into each byte, with the -//| leftmost pixel in the most significant bit. A pixel is white -//| (1) when its brightness is at least 128 out of 255, so a GIF -//| that uses only black and white converts exactly. -//| -//| 16-bit frames are in the byte order of the display or framebuffer -//| that shows them. Raises `ValueError` if ``bits_per_pixel`` is not 1 -//| or 16, if the file is not a GIF, or if the GIF is wider than the -//| board supports (320 pixels on most boards). +//| +//| The `videocore.VideoBuffer` playing the GIF sets the pixel format. +//| 16-bit frames are RGB565, in the byte order of the display or +//| framebuffer that shows them. 1-bit frames pack eight pixels into +//| each byte, with the leftmost pixel in the most significant bit. A +//| pixel is white (1) when its brightness is at least 128 out of 255, +//| so a GIF that uses only black and white converts exactly. +//| +//| Raises `ValueError` if the file is not a GIF, or if the GIF is wider +//| than the board supports (320 pixels on most boards). //| """ //| ... //| static mp_obj_t videogif_gifdecoder_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { - enum { ARG_filename, ARG_bits_per_pixel, NUM_ARGS }; + enum { ARG_filename, NUM_ARGS }; static const mp_arg_t allowed_args[] = { { MP_QSTR_filename, MP_ARG_REQUIRED | MP_ARG_OBJ }, - { MP_QSTR_bits_per_pixel, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 16} }, }; MP_STATIC_ASSERT(MP_ARRAY_SIZE(allowed_args) == NUM_ARGS); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); - const mp_int_t bits_per_pixel = args[ARG_bits_per_pixel].u_int; - if (bits_per_pixel != 1 && bits_per_pixel != 16) { - mp_arg_error_invalid(MP_QSTR_bits_per_pixel); - } mp_obj_t filename = mp_arg_validate_type_string(args[ARG_filename].u_obj, MP_QSTR_filename); mp_obj_t file = mp_call_function_2(MP_OBJ_FROM_PTR(&mp_builtin_open_obj), filename, MP_ROM_QSTR(MP_QSTR_rb)); videogif_gifdecoder_obj_t *self = mp_obj_malloc_with_finaliser(videogif_gifdecoder_obj_t, &videogif_gifdecoder_type); - common_hal_videogif_gifdecoder_construct(self, file, bits_per_pixel); + common_hal_videogif_gifdecoder_construct(self, file); return MP_OBJ_FROM_PTR(self); } @@ -218,6 +210,10 @@ static mp_int_t videogif_gifdecoder_get_bits_per_pixel(mp_obj_t self_in) { return common_hal_videogif_gifdecoder_get_bits_per_pixel(native_gifdecoder(self_in)); } +static bool videogif_gifdecoder_set_bits_per_pixel(mp_obj_t self_in, mp_int_t bits_per_pixel) { + return common_hal_videogif_gifdecoder_set_bits_per_pixel(native_gifdecoder(self_in), bits_per_pixel); +} + static const videosource_p_t videogif_gifdecoder_videosource_protocol = { MP_PROTO_IMPLEMENT(MP_QSTR_protocol_videosource) .play = videogif_gifdecoder_play, @@ -228,6 +224,7 @@ static const videosource_p_t videogif_gifdecoder_videosource_protocol = { .get_width = videogif_gifdecoder_videosource_get_width, .get_height = videogif_gifdecoder_videosource_get_height, .get_bits_per_pixel = videogif_gifdecoder_get_bits_per_pixel, + .set_bits_per_pixel = videogif_gifdecoder_set_bits_per_pixel, }; MP_DEFINE_CONST_OBJ_TYPE( diff --git a/shared-bindings/videogif/GifDecoder.h b/shared-bindings/videogif/GifDecoder.h index f405a243887..99146c39fd2 100644 --- a/shared-bindings/videogif/GifDecoder.h +++ b/shared-bindings/videogif/GifDecoder.h @@ -10,11 +10,10 @@ extern const mp_obj_type_t videogif_gifdecoder_type; -// Open the GIF in file, which the constructor opened from its filename. -// bits_per_pixel is 1 or 16. Raises when the file isn't a GIF or is wider than -// AnimatedGIF supports. -void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - mp_int_t bits_per_pixel); +// Open the GIF in file, which the constructor opened from its filename. Frames +// are 16-bit until set_bits_per_pixel asks for 1. Raises when the file isn't a +// GIF or is wider than AnimatedGIF supports. +void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file); // Stop playing, free the decoder and close the file. Safe to call more than // once. void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self); @@ -22,6 +21,10 @@ bool common_hal_videogif_gifdecoder_deinited(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_width(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_height(videogif_gifdecoder_obj_t *self); mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_obj_t *self); +// Decode frames to 1 or 16 bits per pixel from the next play. Returns false +// while playing or for another value. Never raises. +bool common_hal_videogif_gifdecoder_set_bits_per_pixel(videogif_gifdecoder_obj_t *self, + mp_int_t bits_per_pixel); // Videosource protocol steps, driven by videocore.VideoBuffer. Frames are // decoded by the supervisor background tick, never in an interrupt. play diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 64be0951dc4..809b84b91ae 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -272,7 +272,8 @@ static void videocore_videobuffer_move_dest( } } -void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *self, mp_obj_t framebuffer) { +void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *self, mp_obj_t framebuffer, + mp_int_t bits_per_pixel) { // Initialise what the finaliser uses before anything can raise. self->source = mp_const_none; self->stream_base = NULL; @@ -285,12 +286,15 @@ void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *sel self->framebuffer_protocol->get_height == NULL) { mp_raise_ValueError(MP_ERROR_TEXT("framebuffer missing required functions")); } + self->bits_per_pixel = FB_GETTER(get_color_depth, 16); } else if (!mp_get_buffer(framebuffer, &self->bufinfo, MP_BUFFER_WRITE)) { // Not a framebuffer and not a writable buffer such as a bytearray or // memoryview. mp_raise_TypeError_varg(MP_ERROR_TEXT("%q must be of type %q or %q, not %q"), MP_QSTR_framebuffer, MP_QSTR_buffer, MP_QSTR_framebuffer, mp_obj_get_type(framebuffer)->name); + } else { + self->bits_per_pixel = bits_per_pixel; } self->x = 0; @@ -458,7 +462,9 @@ void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp if (self->playing) { mp_raise_RuntimeError(MP_ERROR_TEXT("already playing")); } - videosource_check(source); + if (!videosource_set_bits_per_pixel(source, self->bits_per_pixel)) { + mp_arg_error_invalid(MP_QSTR_bits_per_pixel); + } self->stale_size = false; self->source_ended = false; videocore_videobuffer_forget_frame(self); diff --git a/shared-module/videocore/VideoBuffer.h b/shared-module/videocore/VideoBuffer.h index 9f48cc001e2..d944e7c278a 100644 --- a/shared-module/videocore/VideoBuffer.h +++ b/shared-module/videocore/VideoBuffer.h @@ -32,6 +32,9 @@ typedef struct { mp_int_t source_height; mp_int_t source_bits_per_pixel; size_t source_frame_bytes; + // The pixel format play() asks the source for: the plain buffer's, or + // the framebuffer's color depth. + mp_int_t bits_per_pixel; // Base of the destination the source last accepted, or NULL when there // is none. const void *stream_base; @@ -80,7 +83,8 @@ typedef struct { bool paused; } videocore_videobuffer_obj_t; -void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *self, mp_obj_t framebuffer); +void common_hal_videocore_videobuffer_construct(videocore_videobuffer_obj_t *self, mp_obj_t framebuffer, + mp_int_t bits_per_pixel); void common_hal_videocore_videobuffer_play(videocore_videobuffer_obj_t *self, mp_obj_t source); void common_hal_videocore_videobuffer_set_position(videocore_videobuffer_obj_t *self, mp_int_t x, mp_int_t y); void common_hal_videocore_videobuffer_stop(videocore_videobuffer_obj_t *self); diff --git a/shared-module/videocore/__init__.c b/shared-module/videocore/__init__.c index 2b261368a9c..412da7ab3ea 100644 --- a/shared-module/videocore/__init__.c +++ b/shared-module/videocore/__init__.c @@ -49,3 +49,11 @@ mp_int_t videosource_get_height(mp_obj_t source) { mp_int_t videosource_get_bits_per_pixel(mp_obj_t source) { return videosource_check(source)->get_bits_per_pixel(source); } + +bool videosource_set_bits_per_pixel(mp_obj_t source, mp_int_t bits_per_pixel) { + const videosource_p_t *protocol = videosource_check(source); + if (protocol->set_bits_per_pixel == NULL) { + return protocol->get_bits_per_pixel(source) == bits_per_pixel; + } + return protocol->set_bits_per_pixel(source, bits_per_pixel); +} diff --git a/shared-module/videocore/__init__.h b/shared-module/videocore/__init__.h index f07c81edae9..60513ac87c6 100644 --- a/shared-module/videocore/__init__.h +++ b/shared-module/videocore/__init__.h @@ -94,6 +94,10 @@ typedef struct _videosource_p_t { mp_int_t (*get_height)(mp_obj_t self); // Bits per pixel of the frames the source streams. mp_int_t (*get_bits_per_pixel)(mp_obj_t self); + // Stream frames with this many bits per pixel from the next play. Called + // by VideoBuffer.play before play(). Returns whether the source can. May be + // NULL when the source only streams get_bits_per_pixel(). + bool (*set_bits_per_pixel)(mp_obj_t self, mp_int_t bits_per_pixel); } videosource_p_t; // Protocol dispatch helpers. play and stop are no-ops when the source doesn't @@ -111,6 +115,8 @@ bool videosource_set_destination(mp_obj_t source, const videocore_destination_t mp_int_t videosource_get_width(mp_obj_t source); mp_int_t videosource_get_height(mp_obj_t source); mp_int_t videosource_get_bits_per_pixel(mp_obj_t source); +// Ask source to stream bits_per_pixel. Returns whether it will. +bool videosource_set_bits_per_pixel(mp_obj_t source, mp_int_t bits_per_pixel); // Raise when the group tree contains a VideoBuffer that streams into a // framebuffer other than the one this display renders. Called when a group diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index 4984fc40253..195505d7c49 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -180,10 +180,7 @@ static void videogif_open_gif(GIFIMAGE *gif, mp_obj_t file) { } } -void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file, - mp_int_t bits_per_pixel) { - // 1 bit per pixel needs the RGB888 palette to compute luma. - const uint8_t palette_type = bits_per_pixel == 16 ? GIF_PALETTE_RGB565_LE : GIF_PALETTE_RGB888; +void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file) { // Outside the VM heap so it doesn't need a large gap there or grow it. Its // only heap pointers, the file and self, are held by self too. The // finaliser frees it. @@ -191,10 +188,10 @@ void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, m if (self->gif == NULL) { m_malloc_fail(sizeof(GIFIMAGE)); } - GIF_begin(self->gif, palette_type); + GIF_begin(self->gif, GIF_PALETTE_RGB565_LE); self->gif->pfnDraw = videogif_draw_line; self->file = file; - self->bits_per_pixel = bits_per_pixel; + self->bits_per_pixel = 16; videogif_open_gif(self->gif, file); } @@ -236,6 +233,21 @@ mp_int_t common_hal_videogif_gifdecoder_get_bits_per_pixel(videogif_gifdecoder_o return self->bits_per_pixel; } +bool common_hal_videogif_gifdecoder_set_bits_per_pixel(videogif_gifdecoder_obj_t *self, + mp_int_t bits_per_pixel) { + if (bits_per_pixel == self->bits_per_pixel) { + return true; + } + if (self->playing || self->gif == NULL || (bits_per_pixel != 1 && bits_per_pixel != 16)) { + return false; + } + self->bits_per_pixel = bits_per_pixel; + // 1 bit per pixel needs the RGB888 palette to compute luma. play() + // restarts, which reads the GIF header again with this palette type. + self->gif->ucPaletteType = bits_per_pixel == 1 ? GIF_PALETTE_RGB888 : GIF_PALETTE_RGB565_LE; + return true; +} + // Keep the supervisor tick on only while a frame can come due. static void videogif_update_tick(videogif_gifdecoder_obj_t *self) { const bool ticking = self->playing && !self->paused && !self->finished; From 5697d2847d23f5c5408cdb4f085492e6ed6702fa Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 18:47:54 -0800 Subject: [PATCH 18/29] epaperdisplay: explain how lag drive relates to FAST Co-Authored-By: Claude Opus 5.5 --- shared-module/epaperdisplay/EPaperDisplay.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/shared-module/epaperdisplay/EPaperDisplay.h b/shared-module/epaperdisplay/EPaperDisplay.h index 75ac486e0aa..03ad9028ff1 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.h +++ b/shared-module/epaperdisplay/EPaperDisplay.h @@ -47,8 +47,10 @@ typedef struct { #endif display_chip_select_behavior_t chip_select; #if CIRCUITPY_EPAPERDISPLAY_LAG - // Lag drive. Only used when lag_frames > 0. The ring has lag_frames slots: frames N-1 to - // N-lag_frames. + // Lag drive, built on FAST's write_previous_ram_command. FAST writes the last frame to the + // previous RAM, so each changed pixel is driven once. Lag writes the frame from lag_frames + // refreshes ago, so a changed pixel is driven lag_frames times, the same number each way. + // The ring holds those frames. Only used when lag_frames > 0. uint8_t **lag_slots; const uint8_t *clean_sequence; uint32_t clean_sequence_len; From a3bc4c6e9b32c6731171e88698f78fccadefef7a Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 3 Nov 2026 18:53:48 -0800 Subject: [PATCH 19/29] epaperdisplay: same API on every build The fast and lag arguments and clean() now exist on every build. Builds without them raise NotImplementedError when they are used. Co-Authored-By: Claude Opus 5.5 --- shared-bindings/epaperdisplay/EPaperDisplay.c | 65 +++++++++---------- 1 file changed, 31 insertions(+), 34 deletions(-) diff --git a/shared-bindings/epaperdisplay/EPaperDisplay.c b/shared-bindings/epaperdisplay/EPaperDisplay.c index 287f770f4cd..d8f34d93d94 100644 --- a/shared-bindings/epaperdisplay/EPaperDisplay.c +++ b/shared-bindings/epaperdisplay/EPaperDisplay.c @@ -107,7 +107,7 @@ //| When set, ``refresh()`` waits for the display to finish updating before it returns. //| Requires a non-empty ``stop_sequence``. //| Leave ``write_color_ram_command`` as None when it writes the same RAM, or it will overwrite the previous image. -//| Not available on all boards. +//| Raises `NotImplementedError` on boards without it. //| :param int highlight_color: RGB888 of source color to highlight with third ePaper color. //| :param int highlight_color2: RGB888 of source color to highlight with fourth ePaper color. //| :param int refresh_display_command: Command used to start a display refresh. Single int or byte-packed command sequence @@ -126,7 +126,7 @@ //| The ``stop_sequence`` is not sent after each refresh. It is sent when the display is released. //| It is also sent before the reset that ``update_refresh_mode()`` asks for, so it can't be empty. //| This keeps the display's settings and RAM between refreshes so they can be faster. -//| Not available on all boards. +//| Raises `NotImplementedError` on boards without it. //| :param int lag_frames: When more than 0, the previous RAM holds the frame from ``lag_frames`` refreshes ago //| instead of the last one, so each changed pixel is driven for ``lag_frames`` refreshes, the same number //| toward black and toward white. Only the box around the changed bytes is sent. ``refresh()`` returns as soon @@ -134,13 +134,12 @@ //| ``lag_frames`` more times to finish the drive. Implies ``keep_awake``. Needs ``write_previous_ram_command``, //| ``busy_pin``, ``clean_sequence``, the window commands and a monochrome display, and allocates //| ``lag_frames * width * height / 8`` bytes outside the VM heap until the display is released. -//| Only on builds with ``CIRCUITPY_EPAPERDISPLAY_LAG``. Elsewhere, more than 0 raises `ValueError`, -//| and boards without ``keep_awake`` don't accept it. +//| Raises `NotImplementedError` on boards without it. //| :param ~circuitpython_typing.ReadableBuffer clean_sequence: Byte-packed commands for a full refresh, such as //| one using the waveform stored in the display, for use when both RAM planes are white. It runs before the //| first refresh with ``lag_frames``. When the display is released or the code stops, the display finishes //| its drive, goes white and runs this and the ``stop_sequence``. Display drivers should provide it. -//| It is accepted wherever ``keep_awake`` is and only used with ``lag_frames``. +//| It is only used with ``lag_frames``. //| //| .. warning:: ``keep_awake``, ``write_previous_ram_command`` and ``lag_frames`` allow updates //| much faster than panel makers' limits on refresh rate. Fast updates need a balanced waveform, @@ -153,18 +152,12 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, ARG_ram_width, ARG_ram_height, ARG_colstart, ARG_rowstart, ARG_rotation, ARG_set_column_window_command, ARG_set_row_window_command, ARG_set_current_column_command, ARG_set_current_row_command, ARG_write_black_ram_command, ARG_black_bits_inverted, - ARG_write_color_ram_command, ARG_color_bits_inverted, - #if CIRCUITPY_EPAPERDISPLAY_FAST - ARG_write_previous_ram_command, - #endif + ARG_write_color_ram_command, ARG_color_bits_inverted, ARG_write_previous_ram_command, ARG_highlight_color, ARG_highlight_color2, ARG_refresh_display_command, ARG_refresh_time, ARG_busy_pin, ARG_busy_state, ARG_seconds_per_frame, ARG_always_toggle_chip_select, ARG_grayscale, ARG_advanced_color_epaper, ARG_spectra6, ARG_two_byte_sequence_length, ARG_start_up_time, ARG_address_little_endian, - #if CIRCUITPY_EPAPERDISPLAY_FAST - ARG_keep_awake, ARG_lag_frames, ARG_clean_sequence, - #endif - }; + ARG_keep_awake, ARG_lag_frames, ARG_clean_sequence }; static const mp_arg_t allowed_args[] = { { MP_QSTR_display_bus, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, @@ -184,9 +177,7 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_black_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_write_color_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, { MP_QSTR_color_bits_inverted, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, - #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_write_previous_ram_command, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, - #endif { MP_QSTR_highlight_color, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_highlight_color2, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0x000000} }, { MP_QSTR_refresh_display_command, MP_ARG_OBJ | MP_ARG_REQUIRED }, @@ -201,15 +192,27 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, { MP_QSTR_two_byte_sequence_length, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_start_up_time, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NEW_SMALL_INT(0)} }, { MP_QSTR_address_little_endian, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, - #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_keep_awake, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = false} }, { MP_QSTR_lag_frames, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, { MP_QSTR_clean_sequence, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = mp_const_none} }, - #endif }; mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + #if !CIRCUITPY_EPAPERDISPLAY_FAST + if (args[ARG_write_previous_ram_command].u_obj != mp_const_none) { + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_write_previous_ram_command); + } + if (args[ARG_keep_awake].u_bool) { + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_keep_awake); + } + #endif + #if !CIRCUITPY_EPAPERDISPLAY_LAG + if (args[ARG_lag_frames].u_int != 0) { + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_lag_frames); + } + #endif + mp_obj_t display_bus = args[ARG_display_bus].u_obj; mp_buffer_info_t start_bufinfo; @@ -259,10 +262,6 @@ static mp_obj_t epaperdisplay_epaperdisplay_make_new(const mp_obj_type_t *type, mp_raise_ValueError_varg(MP_ERROR_TEXT("Invalid %q"), missing); } } - #else - if (lag_frames > 0) { - mp_raise_ValueError_varg(MP_ERROR_TEXT("Invalid %q"), MP_QSTR_lag_frames); - } #endif // CIRCUITPY_EPAPERDISPLAY_LAG #endif // CIRCUITPY_EPAPERDISPLAY_FAST @@ -391,16 +390,15 @@ MP_DEFINE_CONST_FUN_OBJ_2(epaperdisplay_epaperdisplay_show_obj, epaperdisplay_ep //| :param float seconds_per_frame: Minimum number of seconds between refreshes. //| :param ReadableBuffer refresh_sequence: Byte sequence sent to start a refresh. //| It uses the same format as ``start_sequence``. -//| When it is None, the current refresh sequence is kept. Not available on all boards.""" +//| When it is None, the current refresh sequence is kept. Raises `NotImplementedError` +//| on boards without it.""" //| static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { enum { ARG_start_sequence, ARG_seconds_per_frame, ARG_refresh_sequence }; static const mp_arg_t allowed_args[] = { { MP_QSTR_start_sequence, MP_ARG_REQUIRED | MP_ARG_OBJ }, { MP_QSTR_seconds_per_frame, MP_ARG_OBJ, {.u_obj = MP_OBJ_NEW_SMALL_INT(180)} }, - #if CIRCUITPY_EPAPERDISPLAY_FAST { MP_QSTR_refresh_sequence, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, - #endif }; epaperdisplay_epaperdisplay_obj_t *self = native_display(pos_args[0]); mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; @@ -411,16 +409,15 @@ static mp_obj_t epaperdisplay_epaperdisplay_update_refresh_mode(size_t n_args, c mp_get_buffer_raise(args[ARG_start_sequence].u_obj, &start_sequence, MP_BUFFER_READ); float seconds_per_frame = mp_obj_get_float(args[ARG_seconds_per_frame].u_obj); - #if CIRCUITPY_EPAPERDISPLAY_FAST mp_buffer_info_t refresh_sequence; - #endif mp_buffer_info_t *refresh_sequence_ptr = NULL; - #if CIRCUITPY_EPAPERDISPLAY_FAST if (args[ARG_refresh_sequence].u_obj != mp_const_none) { + #if !CIRCUITPY_EPAPERDISPLAY_FAST + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_refresh_sequence); + #endif mp_get_buffer_raise(args[ARG_refresh_sequence].u_obj, &refresh_sequence, MP_BUFFER_READ); refresh_sequence_ptr = &refresh_sequence; } - #endif // Update parameters epaperdisplay_epaperdisplay_change_refresh_mode_parameters(self, &start_sequence, refresh_sequence_ptr, seconds_per_frame); @@ -455,8 +452,8 @@ static mp_obj_t epaperdisplay_epaperdisplay_obj_refresh(mp_obj_t self_in) { MP_DEFINE_CONST_FUN_OBJ_1(epaperdisplay_epaperdisplay_refresh_obj, epaperdisplay_epaperdisplay_obj_refresh); //| def clean(self, frames: int = 8, *, full: bool = False) -> None: -//| """Remove ghosting from a display using ``lag_frames``, with balanced drive. Only on builds -//| with ``CIRCUITPY_EPAPERDISPLAY_LAG``. +//| """Remove ghosting from a display using ``lag_frames``, with balanced drive. Raises +//| `NotImplementedError` on boards without ``lag_frames``. //| //| All pixels go to white, including the drive they are still owed. If ``full`` is True, //| the ``clean_sequence`` is sent, followed by the ``start_sequence``. Then every pixel is @@ -467,8 +464,10 @@ MP_DEFINE_CONST_FUN_OBJ_1(epaperdisplay_epaperdisplay_refresh_obj, epaperdisplay //| :param bool full: Also do a full refresh with ``clean_sequence``.""" //| ... //| -#if CIRCUITPY_EPAPERDISPLAY_LAG static mp_obj_t epaperdisplay_epaperdisplay_clean(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + #if !CIRCUITPY_EPAPERDISPLAY_LAG + mp_raise_NotImplementedError_varg(MP_ERROR_TEXT("%q"), MP_QSTR_clean); + #else enum { ARG_frames, ARG_full }; static const mp_arg_t allowed_args[] = { { MP_QSTR_frames, MP_ARG_INT, {.u_int = 8} }, @@ -487,9 +486,9 @@ static mp_obj_t epaperdisplay_epaperdisplay_clean(size_t n_args, const mp_obj_t mp_raise_RuntimeError(MP_ERROR_TEXT("Refresh too soon")); } return mp_const_none; + #endif } MP_DEFINE_CONST_FUN_OBJ_KW(epaperdisplay_epaperdisplay_clean_obj, 1, epaperdisplay_epaperdisplay_clean); -#endif //| time_to_refresh: float //| """Time, in fractional seconds, until the ePaper display can be refreshed.""" @@ -606,9 +605,7 @@ static const mp_rom_map_elem_t epaperdisplay_epaperdisplay_locals_dict_table[] = { MP_ROM_QSTR(MP_QSTR_show), MP_ROM_PTR(&epaperdisplay_epaperdisplay_show_obj) }, { MP_ROM_QSTR(MP_QSTR_update_refresh_mode), MP_ROM_PTR(&epaperdisplay_epaperdisplay_update_refresh_mode_obj) }, { MP_ROM_QSTR(MP_QSTR_refresh), MP_ROM_PTR(&epaperdisplay_epaperdisplay_refresh_obj) }, - #if CIRCUITPY_EPAPERDISPLAY_LAG { MP_ROM_QSTR(MP_QSTR_clean), MP_ROM_PTR(&epaperdisplay_epaperdisplay_clean_obj) }, - #endif { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&epaperdisplay_epaperdisplay_width_obj) }, { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&epaperdisplay_epaperdisplay_height_obj) }, From c9f3c2add80061d1467a413c0a4e97398577489b Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 4 Nov 2026 07:32:03 -0800 Subject: [PATCH 20/29] epaperdisplay: fast updates on only for espressif and raspberrypi Those are the ports with e-paper boards. Other ports keep the same API and raise NotImplementedError, which saves flash on small boards such as the nRF52840 dongles that overflowed. Co-Authored-By: Claude Opus 5.5 --- ports/atmel-samd/mpconfigport.mk | 1 - ports/espressif/mpconfigport.mk | 1 + ports/raspberrypi/mpconfigport.mk | 1 + py/circuitpy_mpconfig.mk | 3 ++- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ports/atmel-samd/mpconfigport.mk b/ports/atmel-samd/mpconfigport.mk index 7d14227571a..29a881d2d98 100644 --- a/ports/atmel-samd/mpconfigport.mk +++ b/ports/atmel-samd/mpconfigport.mk @@ -13,7 +13,6 @@ CIRCUITPY_LTO = 1 CIRCUITPY_KEYPAD_DEMUX ?= 0 CIRCUITPY_LVFONTIO ?= 0 -CIRCUITPY_EPAPERDISPLAY_FAST ?= 0 CIRCUITPY_DIGITALINOUT_PROTOCOL = 0 diff --git a/ports/espressif/mpconfigport.mk b/ports/espressif/mpconfigport.mk index 7955ec9e82a..74004d5e5a5 100644 --- a/ports/espressif/mpconfigport.mk +++ b/ports/espressif/mpconfigport.mk @@ -57,6 +57,7 @@ CIRCUITPY_FULL_BUILD ?= 1 # The 24 KB stack has room for a larger display refresh buffer. CIRCUITPY_DISPLAY_AREA_BUFFER_SIZE ?= 2048 +CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) CIRCUITPY_BUSIO_SPI_ASYNC ?= 1 # If SSL is enabled, it's mbedtls diff --git a/ports/raspberrypi/mpconfigport.mk b/ports/raspberrypi/mpconfigport.mk index efb5f77e9ee..76942632a5b 100644 --- a/ports/raspberrypi/mpconfigport.mk +++ b/ports/raspberrypi/mpconfigport.mk @@ -10,6 +10,7 @@ CIRCUITPY_RP2PIO ?= 1 CIRCUITPY_ASYNC_SPI ?= 1 # The 24 KB stack has room for a larger display refresh buffer. CIRCUITPY_DISPLAY_AREA_BUFFER_SIZE ?= 2048 +CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) CIRCUITPY_NEOPIXEL_WRITE ?= $(CIRCUITPY_RP2PIO) CIRCUITPY_FLOPPYIO ?= 1 CIRCUITPY_FRAMEBUFFERIO ?= $(CIRCUITPY_DISPLAYIO) diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 84f4341bcab..2eed4f05e61 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -314,7 +314,8 @@ CIRCUITPY_EPAPERDISPLAY ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_EPAPERDISPLAY=$(CIRCUITPY_EPAPERDISPLAY) # Faster e-paper refreshes: EPaperDisplay's write_previous_ram_command and keep_awake. -CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) +# On only in ports with e-paper boards and room for it. +CIRCUITPY_EPAPERDISPLAY_FAST ?= 0 CFLAGS += -DCIRCUITPY_EPAPERDISPLAY_FAST=$(CIRCUITPY_EPAPERDISPLAY_FAST) # Fast e-paper updates with balanced drive: EPaperDisplay's lag_frames and clean(). From fa90d4468d88087cb0b581d6df1a1d1c9367c60c Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 4 Nov 2026 10:53:20 -0800 Subject: [PATCH 21/29] epaperdisplay: lag drive on for espressif and raspberrypi It was only on for the Feather RP2040 ThinkInk, but external panels need it too. It costs about 7 KB of flash and 80 bytes of RAM. The frame memory is only allocated when lag_frames is used. Co-Authored-By: Claude Opus 5.5 --- ports/espressif/mpconfigport.mk | 1 + .../boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk | 2 -- ports/raspberrypi/mpconfigport.mk | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ports/espressif/mpconfigport.mk b/ports/espressif/mpconfigport.mk index 74004d5e5a5..81e64e4adfa 100644 --- a/ports/espressif/mpconfigport.mk +++ b/ports/espressif/mpconfigport.mk @@ -58,6 +58,7 @@ CIRCUITPY_FULL_BUILD ?= 1 # The 24 KB stack has room for a larger display refresh buffer. CIRCUITPY_DISPLAY_AREA_BUFFER_SIZE ?= 2048 CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) +CIRCUITPY_EPAPERDISPLAY_LAG ?= $(CIRCUITPY_EPAPERDISPLAY_FAST) CIRCUITPY_BUSIO_SPI_ASYNC ?= 1 # If SSL is enabled, it's mbedtls diff --git a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk index 1caf2f862ee..1f71dcb76bf 100644 --- a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk @@ -7,5 +7,3 @@ CHIP_VARIANT = RP2040 CHIP_FAMILY = rp2 EXTERNAL_FLASH_DEVICES = "GD25Q64C,W25Q64JVxQ" - -CIRCUITPY_EPAPERDISPLAY_LAG = 1 diff --git a/ports/raspberrypi/mpconfigport.mk b/ports/raspberrypi/mpconfigport.mk index 76942632a5b..c145f9d2509 100644 --- a/ports/raspberrypi/mpconfigport.mk +++ b/ports/raspberrypi/mpconfigport.mk @@ -11,6 +11,7 @@ CIRCUITPY_ASYNC_SPI ?= 1 # The 24 KB stack has room for a larger display refresh buffer. CIRCUITPY_DISPLAY_AREA_BUFFER_SIZE ?= 2048 CIRCUITPY_EPAPERDISPLAY_FAST ?= $(CIRCUITPY_EPAPERDISPLAY) +CIRCUITPY_EPAPERDISPLAY_LAG ?= $(CIRCUITPY_EPAPERDISPLAY_FAST) CIRCUITPY_NEOPIXEL_WRITE ?= $(CIRCUITPY_RP2PIO) CIRCUITPY_FLOPPYIO ?= 1 CIRCUITPY_FRAMEBUFFERIO ?= $(CIRCUITPY_DISPLAYIO) From 869d6638b943a74f559c5b40987431536b9893a7 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 4 Nov 2026 10:53:35 -0800 Subject: [PATCH 22/29] epaperdisplay: explain why lag drive stays balanced Each frame is driven toward once and away from once, lag steps later. Adds a small example of a pixel that changes back early. Co-Authored-By: Claude Opus 5.5 --- shared-module/epaperdisplay/EPaperDisplay.c | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/shared-module/epaperdisplay/EPaperDisplay.c b/shared-module/epaperdisplay/EPaperDisplay.c index ffcc29dbc9f..8d8709d1d8d 100644 --- a/shared-module/epaperdisplay/EPaperDisplay.c +++ b/shared-module/epaperdisplay/EPaperDisplay.c @@ -1013,9 +1013,23 @@ void epaperdisplay_epaperdisplay_collect_ptrs(epaperdisplay_epaperdisplay_obj_t } #if CIRCUITPY_EPAPERDISPLAY_LAG -// Lag drive. Plane 0x26 holds frame N-lag instead of N-1, so a pixel that changes is driven for -// lag steps, then driven the other way by the same number of steps if it changes back. Each -// frame is rendered once into the ring and the planes are sent from there. +// Lag drive. Plane 0x26 holds frame N-lag instead of N-1. Each step drives a pixel toward black +// where N is black and N-lag is white, toward white where N is white and N-lag is black, and not +// at all where they match. +// +// That keeps the drive balanced without counting. Each frame is used twice: pixels are driven +// toward it in the step where it is N, and away from it lag steps later, where it is N-lag. So a +// pixel that ends the way it started has been driven the same amount toward black and toward +// white. For example, lag 3 and a pixel that is black for 2 steps (both planes start white): +// +// step 1 2 3 4 5 6 +// frame N W B B W W W +// frame N-3 W W W W B B +// drive - B B - W W 2 toward black, 2 toward white +// +// A change that holds for lag steps or more is driven lag steps, so a bigger lag drives harder. +// When frames stop changing, lag more steps finish the drive. Each frame is rendered once into +// the ring and the planes are sent from there. // // The ring has lag slots, N-1 to N-lag, and N replaces N-lag in its slot. The box to send must // be known before that, so it comes from the areas that were rendered, not from comparing frames. From 95af59d1d12803841ecd9ae6ca8c26f57b707b14 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 9 Nov 2026 16:56:40 -0800 Subject: [PATCH 23/29] gifio, videogif: size the GIF line buffer to the canvas The AnimatedGIF line buffer was a fixed MAX_WIDTH array in GIFIMAGE, so the widest GIF was a build setting. Make it a pointer and allocate iCanvasWidth bytes after GIF_init(): on the VM heap for gifio.OnDiskGif, with port_malloc for videogif.GifDecoder. This drops CIRCUITPY_GIFIO_MAX_WIDTH and the 320 pixel limit. Reject frames that extend past the right edge of the canvas, since the buffer is now exactly canvas wide. Also trim the VideoBuffer bits_per_pixel docstring per review. Co-Authored-By: Claude Opus 5.5 --- lib/AnimatedGIF/AnimatedGIF_circuitpy.h | 8 +------- lib/AnimatedGIF/gif.c | 9 +++++---- .../mpconfigboard.mk | 1 - py/circuitpy_mpconfig.mk | 4 ---- shared-bindings/gifio/OnDiskGif.c | 5 ++--- shared-bindings/videocore/VideoBuffer.c | 5 ++--- shared-bindings/videogif/GifDecoder.c | 3 +-- shared-module/gifio/OnDiskGif.c | 12 ++++-------- shared-module/videogif/GifDecoder.c | 12 +++++++++--- 9 files changed, 24 insertions(+), 35 deletions(-) diff --git a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h index a09aabbc1ab..ec98364d295 100644 --- a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h +++ b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h @@ -33,12 +33,6 @@ #define MAX_CHUNK_SIZE 255 #define LZW_BUF_SIZE (6 * MAX_CHUNK_SIZE) #define LZW_HIGHWATER (4 * MAX_CHUNK_SIZE) -// Make ports set the width from mpconfigboard.mk. gif.c includes only this header, and the -// zephyr-cp build passes no width, so it needs this default. -#ifndef CIRCUITPY_GIFIO_MAX_WIDTH -#define CIRCUITPY_GIFIO_MAX_WIDTH 320 -#endif -#define MAX_WIDTH CIRCUITPY_GIFIO_MAX_WIDTH #define FILE_BUF_SIZE 4096 #define PIXEL_FIRST 0 @@ -145,7 +139,7 @@ typedef struct gif_image_tag //unsigned char *pFrameBuffer; unsigned int *pFrameBuffer; unsigned char *pPixels, *pOldPixels; - unsigned char ucLineBuf[MAX_WIDTH]; // current line + unsigned char *ucLineBuf; // current line, iCanvasWidth bytes. The caller allocates it after GIF_init(). unsigned char ucFileBuf[FILE_BUF_SIZE]; // holds temp data and pixel stack unsigned short pPalette[384]; // can hold RGB565 or RGB888 - set in begin() unsigned short pLocalPalette[384]; // color palettes for GIF images diff --git a/lib/AnimatedGIF/gif.c b/lib/AnimatedGIF/gif.c index 316fc1ab749..9000fe2fecc 100644 --- a/lib/AnimatedGIF/gif.c +++ b/lib/AnimatedGIF/gif.c @@ -253,10 +253,6 @@ static int GIFInit(GIFIMAGE *pGIF) if (!GIFParseInfo(pGIF, 1)) // gather info for the first frame return 0; // something went wrong; not a GIF file? (*pGIF->pfnSeek)(&pGIF->GIFFile, 0); // seek back to start of the file - if (pGIF->iCanvasWidth > MAX_WIDTH) { // need to allocate more space - pGIF->iError = GIF_TOO_WIDE; - return 0; - } return 1; } /* GIFInit() */ @@ -445,6 +441,11 @@ static int GIFParseInfo(GIFIMAGE *pPage, int bInfoOnly) pPage->iWidth = INTELSHORT(&p[iOffset+4]); pPage->iHeight = INTELSHORT(&p[iOffset+6]); iOffset += 8; + // The line buffer is sized to the canvas, so a frame must fit inside it. + if (pPage->iX + pPage->iWidth > pPage->iCanvasWidth) { + pPage->iError = GIF_DECODE_ERROR; + return 0; + } /* Image descriptor 7 6 5 4 3 2 1 0 M=0 - use global color map, ignore pixel diff --git a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk index 5fa4b3f14d0..99df118f311 100644 --- a/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_feather_rp2040_thinkink/mpconfigboard.mk @@ -8,5 +8,4 @@ CHIP_FAMILY = rp2 EXTERNAL_FLASH_DEVICES = "GD25Q64C,W25Q64JVxQ" -CIRCUITPY_GIFIO_MAX_WIDTH = 800 CIRCUITPY_VIDEOCORE = 1 diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index 5969c2d8cf8..bf0155bdf9f 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -397,10 +397,6 @@ CFLAGS += -DCIRCUITPY_GETPASS=$(CIRCUITPY_GETPASS) CIRCUITPY_GIFIO ?= $(CIRCUITPY_DISPLAYIO) CFLAGS += -DCIRCUITPY_GIFIO=$(CIRCUITPY_GIFIO) -# Widest GIF that can be decoded. Each pixel costs a byte per open GIF. -CIRCUITPY_GIFIO_MAX_WIDTH ?= 320 -CFLAGS += -DCIRCUITPY_GIFIO_MAX_WIDTH=$(CIRCUITPY_GIFIO_MAX_WIDTH) - CIRCUITPY_GNSS ?= 0 CFLAGS += -DCIRCUITPY_GNSS=$(CIRCUITPY_GNSS) diff --git a/shared-bindings/gifio/OnDiskGif.c b/shared-bindings/gifio/OnDiskGif.c index 0ee0365f5d8..a16934d41c6 100644 --- a/shared-bindings/gifio/OnDiskGif.c +++ b/shared-bindings/gifio/OnDiskGif.c @@ -97,9 +97,8 @@ //| //| If the image is too large it will be cropped at the bottom and right when displayed. //| -//| **Limitations**: The image width is limited to 320 pixels at present. `ValueError` -//| will be raised if the image is too wide. The height -//| is not limited but images that are too large will cause a memory exception. +//| **Limitations**: The width and height are not limited, but images that are too +//| large will cause a memory exception. //| """ //| ... //| diff --git a/shared-bindings/videocore/VideoBuffer.c b/shared-bindings/videocore/VideoBuffer.c index de54e69df70..c64c972ba1a 100644 --- a/shared-bindings/videocore/VideoBuffer.c +++ b/shared-bindings/videocore/VideoBuffer.c @@ -61,9 +61,8 @@ //| :param framebuffer: The framebuffer to copy video frames into, or a //| writable buffer such as a ``bytearray`` or ``memoryview``. //| :param int bits_per_pixel: The pixel format of a writable buffer: 16 -//| for color (RGB565), or 1 for black and white. ``play()`` asks the -//| source for this format and raises `ValueError` if the source -//| can't provide it. A framebuffer uses its own color depth instead. +//| for color (RGB565), or 1 for black and white. A framebuffer uses +//| its own color depth instead. //| """ //| static mp_obj_t videocore_videobuffer_make_new(const mp_obj_type_t *type, size_t n_args, size_t n_kw, const mp_obj_t *all_args) { diff --git a/shared-bindings/videogif/GifDecoder.c b/shared-bindings/videogif/GifDecoder.c index a1caf1e1735..11a6b882aca 100644 --- a/shared-bindings/videogif/GifDecoder.c +++ b/shared-bindings/videogif/GifDecoder.c @@ -88,8 +88,7 @@ //| pixel is white (1) when its brightness is at least 128 out of 255, //| so a GIF that uses only black and white converts exactly. //| -//| Raises `ValueError` if the file is not a GIF, or if the GIF is wider -//| than the board supports (320 pixels on most boards). +//| Raises `ValueError` if the file is not a GIF. //| """ //| ... //| diff --git a/shared-module/gifio/OnDiskGif.c b/shared-module/gifio/OnDiskGif.c index 132220e98ad..0dc23a5df62 100644 --- a/shared-module/gifio/OnDiskGif.c +++ b/shared-module/gifio/OnDiskGif.c @@ -164,15 +164,10 @@ void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file int result = GIF_init(&self->gif); if (result != 1) { - switch (self->gif.iError) { - case GIF_TOO_WIDE: - mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_width, MAX_WIDTH); - break; - default: - mp_arg_error_invalid(MP_QSTR_file); - break; - } + mp_arg_error_invalid(MP_QSTR_file); } + // One line of 8-bit pixels. It holds no pointers, so the GC doesn't scan it. + self->gif.ucLineBuf = m_malloc_without_collect(self->gif.iCanvasWidth); int bpp = 16; if (use_palette == true) { @@ -202,6 +197,7 @@ void common_hal_gifio_ondiskgif_deinit(gifio_ondiskgif_t *self) { return; } self->file = NULL; + self->gif.ucLineBuf = NULL; common_hal_displayio_bitmap_deinit(self->bitmap); self->bitmap = NULL; self->palette = NULL; diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index f4eb4868996..e0a00602845 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -177,11 +177,14 @@ static void videogif_open_gif(GIFIMAGE *gif, mp_obj_t file) { gif->GIFFile.iSize = (int32_t)size; if (GIF_init(gif) != 1) { - if (gif->iError == GIF_TOO_WIDE) { - mp_raise_ValueError_varg(MP_ERROR_TEXT("%q must be <= %d"), MP_QSTR_width, MAX_WIDTH); - } mp_arg_error_invalid(MP_QSTR_filename); } + // One line of 8-bit pixels, sized to the canvas. Outside the VM heap like gif itself, and + // freed with it in deinit. + gif->ucLineBuf = port_malloc(gif->iCanvasWidth, false); + if (gif->ucLineBuf == NULL) { + m_malloc_fail(gif->iCanvasWidth); + } } void common_hal_videogif_gifdecoder_construct(videogif_gifdecoder_obj_t *self, mp_obj_t file) { @@ -215,6 +218,9 @@ void common_hal_videogif_gifdecoder_deinit(videogif_gifdecoder_obj_t *self) { videogif_end_stream(self); common_hal_videogif_gifdecoder_stop(self); if (self->gif != NULL) { + if (self->gif->ucLineBuf != NULL) { + port_free(self->gif->ucLineBuf); + } port_free(self->gif); self->gif = NULL; } From 372ad51710cd62768f06089d5ae70d80cc084737 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 10 Nov 2026 19:08:15 -0800 Subject: [PATCH 24/29] gifio: pass the line buffer to GIF_init GIF_init now takes the line buffer and its size, and fails with GIF_TOO_WIDE if the canvas is wider. Passing NULL and 0 only measures the canvas, so OnDiskGif and videogif measure, allocate, then init. Co-Authored-By: Claude Opus 5.5 --- lib/AnimatedGIF/AnimatedGIF_circuitpy.h | 7 +++++-- lib/AnimatedGIF/gif.c | 12 ++++++++++-- shared-module/gifio/OnDiskGif.c | 10 +++++++--- shared-module/videogif/GifDecoder.c | 13 +++++++++---- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h index ec98364d295..63c71e665e5 100644 --- a/lib/AnimatedGIF/AnimatedGIF_circuitpy.h +++ b/lib/AnimatedGIF/AnimatedGIF_circuitpy.h @@ -139,7 +139,7 @@ typedef struct gif_image_tag //unsigned char *pFrameBuffer; unsigned int *pFrameBuffer; unsigned char *pPixels, *pOldPixels; - unsigned char *ucLineBuf; // current line, iCanvasWidth bytes. The caller allocates it after GIF_init(). + unsigned char *ucLineBuf; // current line, at least iCanvasWidth bytes. Set by GIF_init(). unsigned char ucFileBuf[FILE_BUF_SIZE]; // holds temp data and pixel stack unsigned short pPalette[384]; // can hold RGB565 or RGB888 - set in begin() unsigned short pLocalPalette[384]; // color palettes for GIF images @@ -165,7 +165,10 @@ int GIF_getComment(GIFIMAGE *pGIF, char *destBuffer); int GIF_getInfo(GIFIMAGE *pGIF, GIFINFO *pInfo); int GIF_getLastError(GIFIMAGE *pGIF); int GIF_getLoopCount(GIFIMAGE *pGIF); -int GIF_init(GIFIMAGE *pGIF); +// Reads the header and fills in the canvas size. pLineBuf holds one line of pixels and must be +// at least iCanvasWidth bytes, or this fails with GIF_TOO_WIDE. Pass NULL and 0 to only measure +// the canvas, then call again with a buffer before decoding. Returns 1 on success, 0 on failure. +int GIF_init(GIFIMAGE *pGIF, unsigned char *pLineBuf, int iLineBufSize); void GIF_setDrawCallback(GIFIMAGE *pGIF, GIF_DRAW_CALLBACK *pfnDraw); void GIF_scaleHalf(uint16_t *pCurrent, uint16_t *pPrev, int iWidth, int bBigEndian); diff --git a/lib/AnimatedGIF/gif.c b/lib/AnimatedGIF/gif.c index 9000fe2fecc..fe800c88962 100644 --- a/lib/AnimatedGIF/gif.c +++ b/lib/AnimatedGIF/gif.c @@ -163,8 +163,16 @@ int GIF_getLastError(GIFIMAGE *pGIF) return pGIF->iError; } /* GIF_getLastError() */ -int GIF_init(GIFIMAGE *pGIF) { - return GIFInit(pGIF); +int GIF_init(GIFIMAGE *pGIF, unsigned char *pLineBuf, int iLineBufSize) { + pGIF->ucLineBuf = pLineBuf; + if (!GIFInit(pGIF)) { + return 0; + } + if (pLineBuf != NULL && pGIF->iCanvasWidth > iLineBufSize) { + pGIF->iError = GIF_TOO_WIDE; + return 0; + } + return 1; } // diff --git a/shared-module/gifio/OnDiskGif.c b/shared-module/gifio/OnDiskGif.c index 0dc23a5df62..00884c387c3 100644 --- a/shared-module/gifio/OnDiskGif.c +++ b/shared-module/gifio/OnDiskGif.c @@ -162,12 +162,16 @@ void common_hal_gifio_ondiskgif_construct(gifio_ondiskgif_t *self, mp_obj_t file } self->gif.GIFFile.iSize = (int32_t)size; - int result = GIF_init(&self->gif); - if (result != 1) { + // Measure the canvas first, then hand the decoder a line buffer of that width. + if (GIF_init(&self->gif, NULL, 0) != 1) { mp_arg_error_invalid(MP_QSTR_file); } // One line of 8-bit pixels. It holds no pointers, so the GC doesn't scan it. - self->gif.ucLineBuf = m_malloc_without_collect(self->gif.iCanvasWidth); + int width = self->gif.iCanvasWidth; + unsigned char *line_buf = m_malloc_without_collect(width); + if (GIF_init(&self->gif, line_buf, width) != 1) { + mp_arg_error_invalid(MP_QSTR_file); + } int bpp = 16; if (use_palette == true) { diff --git a/shared-module/videogif/GifDecoder.c b/shared-module/videogif/GifDecoder.c index e0a00602845..a018d58688e 100644 --- a/shared-module/videogif/GifDecoder.c +++ b/shared-module/videogif/GifDecoder.c @@ -176,14 +176,19 @@ static void videogif_open_gif(GIFIMAGE *gif, mp_obj_t file) { } gif->GIFFile.iSize = (int32_t)size; - if (GIF_init(gif) != 1) { + // Measure the canvas first, then hand the decoder a line buffer of that width. + if (GIF_init(gif, NULL, 0) != 1) { mp_arg_error_invalid(MP_QSTR_filename); } // One line of 8-bit pixels, sized to the canvas. Outside the VM heap like gif itself, and // freed with it in deinit. - gif->ucLineBuf = port_malloc(gif->iCanvasWidth, false); - if (gif->ucLineBuf == NULL) { - m_malloc_fail(gif->iCanvasWidth); + int width = gif->iCanvasWidth; + unsigned char *line_buf = port_malloc(width, false); + if (line_buf == NULL) { + m_malloc_fail(width); + } + if (GIF_init(gif, line_buf, width) != 1) { + mp_arg_error_invalid(MP_QSTR_filename); } } From 100c967d02540ca6c8962ca5fd9f39414cacfe8f Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 10 Nov 2026 19:08:15 -0800 Subject: [PATCH 25/29] gifio: free the line buffer in deinit Co-Authored-By: Claude Opus 5.5 --- shared-module/gifio/OnDiskGif.c | 1 + 1 file changed, 1 insertion(+) diff --git a/shared-module/gifio/OnDiskGif.c b/shared-module/gifio/OnDiskGif.c index 00884c387c3..97fee92cdcf 100644 --- a/shared-module/gifio/OnDiskGif.c +++ b/shared-module/gifio/OnDiskGif.c @@ -201,6 +201,7 @@ void common_hal_gifio_ondiskgif_deinit(gifio_ondiskgif_t *self) { return; } self->file = NULL; + m_del(uint8_t, self->gif.ucLineBuf, self->gif.iCanvasWidth); self->gif.ucLineBuf = NULL; common_hal_displayio_bitmap_deinit(self->bitmap); self->bitmap = NULL; From d1ac8bc6767bf8d7b2656a7ad68cf2e176b8dabc Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 11 Nov 2026 06:25:41 -0800 Subject: [PATCH 26/29] videocore: rotate 1-bit video on rotated displays Map each screen pixel back to the source the same way the 16-bit copy does. Keep the whole-byte copy when source x runs forward along screen x, and read bit by bit otherwise. Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 54 ++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 3fabeceaf4f..0dd3c7a3400 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -22,7 +22,7 @@ #endif #define FB_GETTER(method, default_value) \ - (self->framebuffer_protocol->method \ + (self->framebuffer_protocol->method \ ? self->framebuffer_protocol->method(self->framebuffer) \ : (default_value)) @@ -695,7 +695,9 @@ static bool videocore_videobuffer_overlap_claimed(const uint32_t *mask, const di // Copy a 1 bit per pixel plain buffer into a 1 bit display's buffer. Both are MSB first with // 1 = white, the same as a monochrome EPaperDisplay's first RAM pass before any inversion. -// Whole bytes are copied when the rects line up and nothing above claimed these pixels. +// On a rotated display each screen pixel is walked back to its source pixel, as the 16 bit copy +// does. Whole bytes are copied when the source runs forwards along screen x, the rects line up +// and nothing above claimed these pixels. static bool videocore_videobuffer_fill_area_1bit(videocore_videobuffer_obj_t *self, const _displayio_colorspace_t *colorspace, const displayio_area_t *area, const displayio_area_t *overlap, bool full_coverage, uint32_t *mask, uint32_t *buffer) { @@ -707,16 +709,56 @@ static bool videocore_videobuffer_fill_area_1bit(videocore_videobuffer_obj_t *se const size_t span_px = displayio_area_width(area); const size_t width = displayio_area_width(overlap); const size_t row_bytes = videocore_videobuffer_source_row_bytes(self); - const size_t x_in_frame = overlap->x1 - self->current_area.x1; const size_t x_in_area = overlap->x1 - area->x1; const uint8_t *frame = self->stream_base; uint8_t *out = (uint8_t *)buffer; - const bool whole_bytes = span_px % 8 == 0 && x_in_area % 8 == 0 && x_in_frame % 8 == 0 && width % 8 == 0 && - !videocore_videobuffer_overlap_claimed(mask, area, overlap); + // While the video isn't shown there is no transform and the orientations match. + const displayio_buffer_transform_t *t = &self->absolute_transform; + const bool shown = t->scale != 0; + const int8_t dx = shown ? t->dx : 1; + const int8_t dy = shown ? t->dy : 1; + const bool transpose = shown && t->transpose_xy; + const mp_int_t origin_x = shown ? t->x : 0; + const mp_int_t origin_y = shown ? t->y : 0; + const bool contiguous = !transpose && dx > 0; + // The first source column of every row when the source runs along screen x. + const mp_int_t first_x = videocore_videobuffer_source_axis(dx, overlap->x1, origin_x, self->x); + + const bool whole_bytes = contiguous && span_px % 8 == 0 && x_in_area % 8 == 0 && first_x % 8 == 0 && + width % 8 == 0 && !videocore_videobuffer_overlap_claimed(mask, area, overlap); for (int16_t y = overlap->y1; y < overlap->y2; y++) { const size_t px = (size_t)(y - area->y1) * span_px + x_in_area; - const uint8_t *row = frame + (size_t)(y - self->current_area.y1) * row_bytes; + if (!contiguous) { + // Mirrored or transposed: walk each screen pixel back to its source pixel. Screen x + // steps along a source row, or down a source column on a transposed display. + mp_int_t source_x; + mp_int_t source_y; + if (transpose) { + source_x = videocore_videobuffer_source_axis(dy, y, origin_y, self->x); + source_y = videocore_videobuffer_source_axis(dx, overlap->x1, origin_x, self->y); + } else { + source_x = videocore_videobuffer_source_axis(dx, overlap->x1, origin_x, self->x); + source_y = videocore_videobuffer_source_axis(dy, y, origin_y, self->y); + } + for (size_t i = 0; i < width; i++) { + const size_t p = px + i; + if (mask[p / 32] & (1u << (p % 32))) { + continue; + } + const mp_int_t step = (mp_int_t)i * dx; + const mp_int_t fx = transpose ? source_x : source_x + step; + const mp_int_t fy = transpose ? source_y + step : source_y; + if (frame[(size_t)fy * row_bytes + (size_t)fx / 8] & (0x80 >> (fx % 8))) { + out[p / 8] |= 0x80 >> (p % 8); + } + mask[p / 32] |= 1u << (p % 32); + } + continue; + } + const size_t x_in_frame = first_x; + const mp_int_t source_y = videocore_videobuffer_source_axis(dy, y, origin_y, self->y); + const uint8_t *row = frame + (size_t)source_y * row_bytes; if (whole_bytes) { memcpy(out + px / 8, row + x_in_frame / 8, width / 8); // Full coverage ends the layer walk, so filling the mask once below is enough. From e84b8d65a77b6df9e476da9003b9d3c840fed215 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 11 Nov 2026 06:47:48 -0800 Subject: [PATCH 27/29] Turn off gifio on the makerdiary nRF52840 dongle The gifio line buffer change pushed the ru build 4 bytes over flash. The dongle has no display, so gifio isn't useful there. Co-Authored-By: Claude Opus 5.5 --- .../boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk b/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk index 1a650d5d4db..25e9ebcaf94 100644 --- a/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk +++ b/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk @@ -8,3 +8,6 @@ MCU_CHIP = nrf52840 CIRCUITPY_BUILD_EXTENSIONS = hex,uf2 INTERNAL_FLASH_FILESYSTEM = 1 + +# No display on this USB stick, and the ru build is short on flash. +CIRCUITPY_GIFIO = 0 From f0b0e92e5b3a5a56561275c6cdb125b89ac75f35 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 11 Nov 2026 10:45:23 -0800 Subject: [PATCH 28/29] videocore: restore the FB_GETTER indent My local uncrustify reindented one line of the macro; put it back the way main has it so CI's formatter passes. Co-Authored-By: Claude Opus 5.5 --- shared-module/videocore/VideoBuffer.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared-module/videocore/VideoBuffer.c b/shared-module/videocore/VideoBuffer.c index 0dd3c7a3400..a0d4c81f89e 100644 --- a/shared-module/videocore/VideoBuffer.c +++ b/shared-module/videocore/VideoBuffer.c @@ -22,7 +22,7 @@ #endif #define FB_GETTER(method, default_value) \ - (self->framebuffer_protocol->method \ + (self->framebuffer_protocol->method \ ? self->framebuffer_protocol->method(self->framebuffer) \ : (default_value)) From ddca844370c8c1bb02383a6e339d282652a4e7f0 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Wed, 11 Nov 2026 17:47:55 -0800 Subject: [PATCH 29/29] Turn gifio back on for the makerdiary nRF52840 dongle Main now turns off msgpack on nRF52840 internal-flash boards, which frees far more than the 4 bytes the ru build was over. Co-Authored-By: Claude Opus 5.5 --- .../boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk | 3 --- 1 file changed, 3 deletions(-) diff --git a/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk b/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk index 25e9ebcaf94..1a650d5d4db 100644 --- a/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk +++ b/ports/nordic/boards/makerdiary_nrf52840_mdk_usb_dongle/mpconfigboard.mk @@ -8,6 +8,3 @@ MCU_CHIP = nrf52840 CIRCUITPY_BUILD_EXTENSIONS = hex,uf2 INTERNAL_FLASH_FILESYSTEM = 1 - -# No display on this USB stick, and the ru build is short on flash. -CIRCUITPY_GIFIO = 0