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.
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.
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.
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.
uint8_t *buf = id(screen).get_buffer();
size_t len = id(screen).get_buffer_length(); // == width * height / 8buffer_ 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/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.
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.
C++ under GPLv3, Python under MIT - ESPHome's model. See LICENSE.
- **It rehearses your lambda, not your bus.** No panel means no panel quirks:
wiring, address, contrast and controller-init problems of a real SSD1306
will not reproduce here. The contrast slider is simulated in the viewer.