Skip to content

Add Waveshare ESP32-S3-Touch-LCD-2.8C (480x480 round ST7701) board support (#171) - #179

Draft
Keralots wants to merge 18 commits into
mainfrom
feat/ws-lcd-28c-round480
Draft

Add Waveshare ESP32-S3-Touch-LCD-2.8C (480x480 round ST7701) board support (#171)#179
Keralots wants to merge 18 commits into
mainfrom
feat/ws-lcd-28c-round480

Conversation

@Keralots

Copy link
Copy Markdown
Owner

Draft / do not merge yet — the board is not hardware-verified (I don't own it). Waiting on the #171 reporter to confirm on real hardware. Will mark ready once the tester signs off.

Closes #171 once verified.

What

Adds a community env ws_lcd_28c for the Waveshare ESP32-S3-Touch-LCD-2.8C: a 480×480 round ST7701 RGB panel (over LCD_CAM + a 3-wire SPI init sequence), GT911 capacitive touch, and a TCA9554 IO expander carrying LCD reset / LCD CS / touch reset / buzzer. S3R8 (16 MB flash / 8 MB PSRAM).

Introduces the DISPLAY_ROUND_480 layout profile — the same round dashboard as the 240 round boards at 2× scale.

Notable bits

  • One round renderer, two resolutions. DISPLAY_IS_ROUND makes every round renderer serve both profiles; per-resolution differences are only the LY_RND_* geometry plus LY_SC() / LY_F_SMALL|BODY|LARGE / LY_ICON16, which layout.h defines as 1× defaults and only layout_round480.h overrides. esp32c3_round / esp32s3 / ws_lcd_200 build byte-identical firmware.bin before/after the refactor — proof it's inert elsewhere.
  • No LovyanGFX or framework fork (unlike SenseCAP). Stock espressif32@6.12.0: pin_cs = -1 and initDisplay() holds the expander's CS bit low across tft.init().
  • Standalone env — must NOT extend s3_common, which injects USE_ST7789_INVERT → an unconditional invertDisplay(true); this panel runs inversion OFF.
  • Single owner for the TCA9554 output byte (src/io_expander_tca9554.{h,cpp}, shadow + RMW), because it carries LCD reset/CS, touch reset, SD CS and the buzzer and the chip has no per-bit write.
  • GT911 quirks handled: address latch (0x5D vs 0x14) set in initDisplay() from INT level at reset; status byte 0x814E written back to 0 after each read or touch works exactly once.
  • New 2× VLW font tier (inter_20/27/37), linked only on DISPLAY_ROUND_480.

Also landed (not board-specific): /status now reports board; round clock footer width fix (the footer ran off the bezel on the shipped 240 board too); portal ES8311_AUDIOFIXED_AUDIO rename.

Validation

  • Full env sweep 21/21 green.
  • firmware.bin 2,444,528 B against the 6.25 MB app slot (measured with stat, not PIO's "Flash: used").
  • Pending: the panel + GT911 touch on real hardware. Three unverified knobs are documented in the .ini in try-order (RGB pixel clock, the four RGBCTRL polarity bits, offset_rotation).

Not in tools/release.py / docs/flasher.js / README official set until the tester confirms.

New community env for the Waveshare ESP32-S3-Touch-LCD-2.8C (issue #171):
ST7701 480x480 round IPS over the LCD_CAM RGB bus, GT911 touch, TCA9554 IO
expander, on an S3R8 with 16MB flash / 8MB octal PSRAM.

Standalone env - extending s3_common would inherit USE_ST7789_INVERT, and
applyPanelInversion() turns that into an unconditional invertDisplay(true),
which is wrong for a panel whose vendor init never sends 0x21.

Panel_ST7701_WS28C derives from Panel_ST7701_Base and replaces the stock init
list with the vendor's. CS lives on the expander, but pin_cs stays -1 and the
display bring-up owns the CS window instead, so no LovyanGFX/framework fork is
needed (unlike the SenseCAP board).

PCLK starts at 12 MHz as an .ini knob: the vendor's 30 MHz relies on a bounce
buffer that Bus_RGB does not have.

The layout profile, expander bring-up, touch, buzzer and battery follow in
later commits; this one only makes the board selector resolve.
…profiles

The round dashboard, clock face, edge-glow ring and idle/finished/AP screens
are resolution-independent - only the LY_RND_* geometry and the font tier
differ. Replace the DISPLAY_ROUND_240 tests in the logic files with a derived
DISPLAY_IS_ROUND capability so a second round profile picks all of it up.

Two sites deliberately keep the raw two-flag test, because the capability is
derived inside config.h and is not yet visible to them:

  - include/layout.h is the dispatcher, and config.h includes it before it
    reaches the capability block. Testing DISPLAY_IS_ROUND there would select
    the square layout_default.h with no error, and the include guard would
    block any later correction.
  - src/display_gauges.h never includes config.h, and display_gauges.cpp
    includes it before its own config.h - the round declarations would go
    missing while the .cpp defines them.

include/diy_display_config.h keeps its raw tests too: it validates the DIY
build flags, and there is no DIY 480 RGB variant to validate.

Adds layout_round480.h as a verbatim copy of the 240 profile so the new
dispatcher branch compiles; it is scaled in a later commit.

esp32c3_round and esp32s3_round build byte-identical firmware.bin before and
after (1,694,640 / 1,757,872).
Several BOARD_IS_* enumerations are exhaustive lists a new board falls out of
silently. Each now has an explicit answer:

  - led.cpp pin deny-list: without a branch it ends in `return true`, so the
    RGB control/data pins, the I2C bus and the battery ADC could all be
    assigned as an external status LED. Leaves 42/43/44 free.
  - HAS_FULL_HMS_TABLE: 16 MB board, so it carries the sentences.
  - BUTTON_DEFAULT_PIN: 0, not the generic 4 - which is this board's battery
    ADC.
  - DISPLAY_UPDATE_MS: 100 ms, same as the SenseCAP - same panel class, same
    PSRAM framebuffer.
  - PANEL_HAS_IO_EXPANDER: 1, to bring up Wire.h for the TCA9554.

The camera stays deliberately off (recorded in the comment at the enumeration):
it qualifies on PSRAM and touch, but a second TLS socket and two 200 KB JPEG
buffers are not something to debug on hardware I do not have.

applyPanelInversion() needs no branch - none of its conditions match, so the
panel init stays authoritative. Noted in place, since every other RGB board in
that function is there for a reason.
The 2.8C routes LCD reset, LCD CS, touch reset, SD CS and the buzzer through a
single TCA9554 output register, and that register has no per-bit write - the
whole byte goes out at once. Two independent writers therefore clobber each
other, and the buzzer is a real one: initBuzzer() calls
buzzerBackendShutdown() at startup even when the buzzer is disabled, so a raw
0x00 write there would drop LCD reset and CS on a default config.

io_expander_tca9554.{h,cpp} is that single owner: a shadow byte plus
read-modify-write, board-neutral (the board test stays in initDisplay(), which
is an allowed BOARD_IS_* resolver), gated on PANEL_HAS_IO_EXPANDER.

Bring-up order in initDisplay(), before tft.init():
  GT911 INT low -> touch reset pulse -> 200 ms -> INT released
  -> LCD reset pulse -> 120 ms -> LCD CS low
and CS back high once init() returns.

The INT-low latch has to be here, not in the touch backend: INT level while
reset is released is what picks the GT911's I2C address (low = 0x5D), and
touchInit() does not run until handleSplashPhase(), about two seconds later.

ws_lcd_350's own TCA9554 block is deliberately untouched - it pulses P1 only,
leaves every other expander pin high-Z and has no second writer, so it needs no
shared ownership.
The round dashboard at 480 is the same design at twice the radius, so it needs
faces at twice the pixel size rather than scaled 1x ones. Generates the same
weights at 24 / 32 / 44 px (inter_20, inter_27, inter_37, ~478 KB together) and
links them only on DISPLAY_ROUND_480.

FONT_SMALL_2X / FONT_BODY_2X / FONT_LARGE_2X continue the FontID values past
FONT_7SEG. FontID is never persisted, exposed over the API or used as an array
index - its only state is the currentFont cache - so new values are safe.

They need explicit cases in both setFont() and loadFontInto(), not the
default: path: that path only unloads the smooth font and caches the id, and
the early-out at the top of setFont() then turns every repeat call into a
no-op, so text would draw in whatever built-in font happened to be active. On
boards where the blobs are not linked the cases fall back to the 1x face of the
same rank.

Round code addresses the tier through LY_RND_F_SMALL / _BODY / _LARGE, defined
in each round layout header, so no round renderer names a FontID directly.

esp32c3_round grows 32 bytes (the extra switch arms; no new blobs).
layout_round480.h is now the real profile: every radius, row, gauge centre and
arc-length budget at 2x, with each clearance derivation from the 240 profile
redone at r=240 and the new numbers written into the comments. Angles do not
scale - the arc at twice the radius is twice as long and the 2x glyphs eat
twice the arc, so the half-angles stay and only the *_MAXW budgets double.
Verified against the generated inter_20 / inter_27 advances, which land within
~1% of exactly 2x their 1x faces.

The round renderers are shared between the two profiles, so their in-line
geometry had to stop being literal. Rather than invent a name for each of ~100
band-clear offsets and paddings, layout.h now carries three profile-overridable
knobs that only layout_round480.h overrides:

  LY_SC(v)                   pixel geometry with no name worth inventing
  LY_F_SMALL/_BODY/_LARGE    the text tier
  LY_ICON16                  drawn footprint of the 1-bit icons

Values that are NOT layout keep their literals: raster/AA safety margins stay
1-2 physical pixels, the checkmark and tick strokes stay 3 px, and CLK_BASE_*
stays the built-in 7-seg cell at setTextSize(1). Values that are layout but not
a pixel count got real names instead: LY_RND_7SEG_{SM,BG}_{SCALE,HALFH} for the
digit sizes and LY_GLOW_STORM_SEG_SHIFT for the storm bolt segment length - a
grep for 3-digit literals would never have found that one.

Also fixed while passing through:

  - The splash, AP-mode, no-printer, connecting-WiFi, WiFi-connected, OTA and
    connecting-MQTT screens have no per-profile variant at all and would have
    rendered a 1x cluster in the middle of a 480 panel. They now follow the
    tier. The splash had no layout constants whatsoever; it has three now.
  - drawBatteryIconOnly() was a hardcoded 8x16 glyph. Doubling LY_BAT_* alone
    would have mis-centred it in a 2x box, so its geometry is derived from
    LY_BAT_W/H - reproducing the old drawing exactly at 1x.
  - drawIcon16() block-scales, so the wifi/lightning/lock icons are not
    16 px specks next to 32 px text.
  - The glow ring band scales (GLOW_RING_T), and drawBandRing's rowBuf[248]
    now carries the derivation plus a static_assert, so a future thickness
    change cannot silently overflow it. Widest run is 100 px at 240, 201 at 480.

esp32c3_round, esp32s3 and ws_lcd_200 build byte-identical firmware.bin, so the
refactor is a true no-op everywhere but the new profile.
New whole-file-guarded backend alongside the other capacitive drivers. GT911
differs from CST328 in three ways that matter: 16-bit register addresses, a
status byte at 0x814E whose bit 7 is "frame ready", and a mandatory write-back
of 0 to that byte after every read - skip it and the controller wedges on its
current frame, so touch works exactly once.

The reset pulse and the INT-low address latch deliberately live in
initDisplay(), not here: the touch reset line is a TCA9554 expander bit that
the display bring-up already owns, and touchInit() does not run until
handleSplashPhase(), about two seconds after the reset is over. Both sites say
so, because this is the kind of coupling that gets tidied up and silently
changes the I2C address. touchInit() probes 0x14 as a fallback and logs a
one-shot bus scan, since the address is decided by a pin level at reset.

Also wired up, all of which are silent failures if missed:
  - USE_GT911 added to the "more than one driver" sum in the backend header
    AND to the "== 0" sum in button_touch_none.cpp, which would otherwise
    define the same symbols and fail the link.
  - settings.cpp's touchscreen default, or fresh NVS boots BTN_DISABLED and
    touch looks dead.
  - sanitizeButtonPin() rejects the I2C pins, the touch INT and the rest of
    this board's reserved GPIOs for a manually configured push button.

No new ButtonType and no new portal option: BTN_TOUCHSCREEN is generic and the
web UI already offers it.
…or it

The buzzer is expander bit 7, so it is an on/off line rather than a tone pin.
The scheduler already treats frequency and duration as data and calls the
backend once per step transition, so mapping any nonzero frequency to "on"
keeps every pause and duration and loses only pitch - melodies become rhythm.
Bit-banging a tone over I2C is not an option: a 400 kHz round trip is ~60 us,
so the ceiling is a few hundred Hz of heavy jitter.

Every write goes through the shared expander shadow. A raw write here would
clobber LCD reset, LCD CS, touch reset and SD CS in the same byte - and
initBuzzer() calls buzzerBackendShutdown() at startup even when the buzzer is
disabled, so it would fire on a default config.

Selected by a new BUZZER_BACKEND_TCA9554 capability, which also excludes the
GPIO backend and forces sanitizeBuzzerPin() to hold the pin at 0, so a stale
NVS value carried over from another board is never driven.

The portal's "no pin needed" flag was literally named ES8311_AUDIO and its help
text said "Built-in I2S speaker", neither of which is true on this board.
Renamed to FIXED_AUDIO / DEV.fixedAudio - the condition is "the sound hardware
is wired, not chosen" - and the wording generalized. Touches the DEV bootstrap
contract, so all four sites moved together: the token, the bootstrap line, the
markup id and app.js. node --check passes.
Vendor BAT_Driver.cpp computes mV * 3.0 / 0.980952, so the divider is
effectively 3.058x. Like ws_lcd_154 there is no I2C PMIC, only pack voltage -
no charge state.

The round idle screen does draw this: drawWifiSignalIndicator() swaps in the
battery icon and percentage when a battery is present and enabled. At the 2x
positions (LY_WIFI_X 180, LY_RND_IDLE_WIFI_Y 420) the block spans x 180..264 on
a chord that runs 82..398, so it sits well inside the circle. The icon itself
was parameterized in the layout commit, so it fills its 2x box instead of
sitting mis-centred at 8x16.

The indicator only appears on the connected idle screen - not printing,
finished, HMS or clock.
/status reported heap, uptime, rssi, ip, mac, flash and psram but never which
build was running, which makes remote diagnosis with a tester guesswork.
`board` is additive and safe for both consumers: web/app.js reads named keys
only, and the Companion Tool's detection matches on a key subset rather than an
exact shape.

merge_bins.py's BOARDS dict is an allowlist, so without an entry the tester's
Full.bin could not be built at all - separate from tools/release.py, which is
deliberately left alone until the board is hardware-confirmed. Verified by
producing BambuHelper-ws_lcd_28c-v3.8.1-Full.bin (2,509,904 B).

README gets the community-board row and the PSRAM / multi-printer list entry.
The official firmware filename table stays untouched until promotion.
drawClockInfo() drops a footer line that would collide with the clock
vertically, but it never looked at width - so "<name>  <ip>" simply ran off the
bezel for any ordinary printer name. On a 240 round the line is 158 px on a
118 px chord; the 480 profile inherits it at the same ratio.

Each line is now ellipsized to dateMaxWidth() for its own band, which is
already the round-aware chord helper the date row uses. Flat panels are
unaffected: dateMaxWidth() returns the full width there, so nothing is trimmed
that was not trimmed before.

Found with a layout simulator (C:\tmp\bambuhelper-round480sim), which reads the
real LY_* constants and the real VLW glyph advances and checks every drawn box
against the inscribed circle. All ten round screens are clean on both profiles
now, and no screen is worse at 480 than at 240.
…een dots

Two shared sites still carried 1x geometry after the round-480 layout work, so
they only misbehave on the new profile:

  - The gauge-slot clear bands kept a bare "+4 / +2" and a 1x label row while
    drawGaugeLabel()'s maxW had moved to radius*2 + LY_SC(4). The label could
    therefore out-run its own clear by a couple of pixels and leave edge ghosts
    when a slot changed type. Both the round Rim mini-gauge loop and the shared
    drawIdlePairSlots() now derive the clear from the same expression, and the
    comment says so.
  - drawAnimDots() drew its three dots at a fixed 8 px pitch in the 1x body
    font, which on a 480 panel is a cramped cluster next to 32 px text.

LY_SC() is identity on every profile but round 480, so nothing else changes.
@Keralots Keralots linked an issue Aug 31, 2026 that may be closed by this pull request
Bring in the printer-off-via-plug overlay, the RUNNING substage ETA line,
the AXS15231B I2C finger-release fix and the scaled plug power-confirm ring.

Conflicts:
- src/clock_mode.cpp: keep main's tasmota.h include.
- src/display_ui.cpp: main reworked the power-confirm ring into per-layout
  RR/RT/YSH geometry with the ring top pinned and growing downward. Kept
  that structure and added a DISPLAY_ROUND_480 case plus LY_SC() on the
  title/name/warn offsets so the ring scales on ws_lcd_28c; identity on
  every other layout.
…'t overlap

The temp and layer gauges draw the primary value at cy-4 and the "/target"
(or "/total") line at cy+10 - raw pixel offsets. On the 480 round profile the
fonts are 2x but that 14 px gap stayed 1x, so the value and its target
collided by ~11 px on every temp gauge (nozzle and bed by default). Reported
on the ws_lcd_28c (#171).

Wrap the three offsets in LY_SC() so they double on DISPLAY_ROUND_480 and stay
byte-identical everywhere else. Puts the 480 spacing at 2x the shipped,
hardware-verified 240 round layout.
…ss is 0

Night dimming used to clamp 0 up to a faint but visible floor, because a fully
dark panel could not be woken again. getEffectiveBrightness() is a pure function
of the wall clock, so there was no way to express "the user just pressed the
button": every wake path asked it for a level and got 0 straight back. Worse, on
a live screen (idle, printing) the press never reached a wake branch at all -
that is gated on the sleep-sticky screens - so it fell through into the tap cycle
and shuffled screens nobody could see. The floor hid a dead button.

Drop the floor and add a 30 s wake override instead. A blackout is now night mode
with brightness 0 and no override running; a press lifts it and the panel comes
back at the screensaver brightness, which is the dim level already tuned for
night, then goes dark again on its own. The renderer blanks the framebuffer and
stops drawing for the rest of the window rather than only driving the backlight
to 0 - boards with BACKLIGHT_PIN < 0 (7-pin round GC9A01 modules) have no dimming
at all, so blanking is the only thing that darkens them. The press is swallowed
at the press edge, before the multi-click buffer, so a double-click cannot open
the power-confirm modal over an unrendered screen: its send phase waits on a
frame that would never be drawn. Sleep-sticky screens keep their existing wake,
and the portal's live brightness preview joins clock/off in skipping the write.

Also add a "Turn LED off during night mode" option. It reuses the clamp that
already backs the display-sleep suspend and is driven from the night-window
boundary, independent of panel sleep. The LED stays dark for the whole window,
including a screen peek - the point of the option is an unlit room.
GT911 register 0x814E bit7 is a data-ready edge, not a level, but touchPoll()
fed it into button.cpp's 50 ms level debouncer as "finger down". Between report
frames the bit reads 0, so a held finger arrived as a burst of taps and every
hold gesture - LED hold-to-dim, the power-confirm ring, hold-to-power-on on the
"Printer Off" screen - armed at random or never. Latch the level the last frame
published and repeat it while bit7 is clear, acknowledging only real frames. A
1 s staleness guard drops the latch if the controller stops publishing during a
touch, so a wedged sensor cannot press the confirm ring on its own.

The TCA9554 gave up for good on one failed write: s_present went false and
ioExpanderSet() no-oped for the rest of the session, taking the LCD reset and
CS and the buzzer with it. Move the bring-up into a throttled helper the setter
retries, updating the shadow first so a recovery latches the pending bit.
drawClockInfo() measured with FONT_BODY while drawing with LY_F_BODY, and its
max width was still keyed on DISPLAY_ROUND_240, so the 480 round profile
composed footer lines against the full panel width and only the draw-time chord
clamp kept them on screen. The power-confirm hintY offset was guarded the same
way and is unused there.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request for Waveshare esp32-s3 2.8c

1 participant