ESPHome display platform: an SSD1306 with no bus and no panel, just its framebuffer in RAM

use with oled_stream component

ssd1306_virtual

An ESPHome display: platform with no bus and no panel: an SSD1306 that exists
only as a framebuffer in RAM. Write the usual lambda:, and another component
reads the buffer.

Build and debug a display before the hardware arrives, or without ever
attaching any.

external_components:
  - source: github://kaboom748/ssd1306_virtual
    components: [ssd1306_virtual]
    refresh: 1d

display:
  - platform: ssd1306_virtual
    id: screen
    model: "SSD1306 128x64"
    update_interval: 33ms
    lambda: |-
      it.printf(0, 0, id(roboto), "%u s", (unsigned) (millis() / 1000));

Pair it with oled_stream to watch it in a browser tab:

oled_stream:
  - display_id: screen
    port: 8080
    contrast_lambda: return id(screen).get_contrast();

oled_stream needs no changes: its display_id accepts any
display::DisplayBuffer, and this is one.

See docs/ssd1306_virtual.md for the full option list, and example/ for two
working configs.

The idea

Nothing about the display is reimplemented. ssd1306_base::SSD1306 has exactly
two pure virtuals:

virtual void command(uint8_t value) = 0;
virtual void write_display_data() = 0;

Everything else - the model table, the geometry, the buffer length, the pixel
layout, fill(), contrast, invert - is concrete. So this component overrides
those two with empty bodies and inherits the rest. The layout is not a copy of
the real driver that can drift from it: it is the real driver.

setup() therefore runs unchanged. It allocates the framebuffer for the
configured model, walks the whole init sequence into the void, and clears the
buffer for real. What is left is a correctly sized, correctly zeroed SSD1306
framebuffer, with no hardware.

The whole component is about 30 lines of code.

Platforms

There is not one #ifdef in it. No bus means nothing to be platform-specific
about: ESP32 (Arduino and ESP-IDF), ESP8266, RP2040, host. RAM is the only
constraint, and the largest model needs 2048 bytes.

Models

All 14 of the ssd1306_base family. Sizes are measured, not computed on paper.

model: native framebuffer
SSD1306 96x16 96x16 192
SSD1306 64x32 64x32 256
SSD1306 72x40 72x40 360
SSD1306 64x48 64x48 384
SSD1306 128x32 128x32 512
SSD1306 128x64 128x64 1024
SH1106 128x32 128x32 512
SH1106 128x64 128x64 1024
SH1106 96x16 96x16 192
SH1106 64x48 64x48 384
SH1107 128x64 64x128 1024
SH1107 128x128 128x128 2048
SSD1305 128x32 128x32 512
SSD1305 128x64 128x64 1024

SH1107 128x64 really is 64 wide and 128 tall. That is upstream’s naming.

Reading the framebuffer

uint8_t *buf = id(screen).get_buffer();
size_t len = id(screen).get_buffer_length();   // == width * height / 8

buffer_ is an inherited protected member, so these are ordinary C++: no cast,
no assumption about anyone’s memory layout. They are shaped like the accessors a
future display::DisplayBuffer would expose upstream, so the day it does, they
can be deleted and callers keep compiling.

Layout, which is the SSD1306’s because it is the SSD1306’s:

pos = x + (y / 8) * width      bit = 1 << (y % 8)      1 = pixel on

Tests

tests/components/ssd1306_virtual/   upstream ESPHome format, for the eventual PR
tests/ci/                           self-contained, what this repo's CI compiles
tests/bench/                        runtime benches, run on the host

tests/bench/run.sh downloads ESPHome, links the component against the real
ssd1306_base, display and display_buffer sources, and runs a live display
on your PC. Nothing is stubbed except the two host platform symbols that live
in the file owning main().

==> test_virtual
[ OK ] a display with NO hardware sets up without failing
[ OK ] get_native_width/height = 128x64
[ OK ] get_buffer() reaches a real allocation
[ OK ] get_buffer_length() = 1024 bytes
[ OK ] width * height / 8 agrees with the allocation
[ OK ] setup() left the whole 1024-byte buffer cleared
[ OK ] pixel (5,9) lands at page 1, bit 1
[ OK ] a mono_page decoder finds it back
[ OK ] line() of 31 px + the lone pixel = 32 pixels lit
[ OK ] get_contrast() = 0.6 with no bus to write it to
[ OK ] rotation 90 swaps get_width/get_height
[ OK ] ...but get_native_* stays PHYSICAL, so the buffer stays 1024

==> sweep      all 14 models, instantiated and measured
==> mutant     asserts a mono_row layout and is rejected

mutant is the reason the rest is worth anything: it asserts the wrong layout
on the same live display and must fail. run.sh treats a passing mutant as an
error, so a bench that has stopped checking anything cannot go unnoticed.

Pass a checkout of oled_stream to also build the integration bench, which
observes rather than assumes that its downcast lands on the same pointer as
get_buffer():

tests/bench/run.sh /path/to/oled_stream/components/oled_stream

Honest limit: the benches run on the host platform with a PC compiler.
They catch type, layout and logic mistakes, which is what this component is made
of, since it has no platform-specific code. They do not replace flashing a board.

No-ops, inherited on purpose

contrast, invert, flip_x, flip_y, offset_x and offset_y are
controller register writes on real hardware: they never touch buffer_. With no
bus they keep their state - get_contrast() answers correctly - but they still
do not reach the framebuffer. A component reading the framebuffer of a real
SSD1306 cannot see them either, so this is fidelity rather than a gap.

reset_pin: is accepted, because rejecting it would break the copy-paste from a
working ssd1306_i2c config. If you set it, a real GPIO gets toggled for
nothing.

If init_internal_() ever failed to allocate, it logs and returns with
buffer_ still null, and the fill() right after it in SSD1306::setup()
dereferences that null. That is an upstream fragility shared with every SSD1306
platform, not something this component adds; at 2048 bytes it will not happen.

License

C++ under GPLv3, Python under MIT - ESPHome’s model. See LICENSE.