Add Waveshare ESP32-S3-Touch-LCD-2.8C (480x480 round ST7701) board support (#171) - #179
Draft
Keralots wants to merge 18 commits into
Draft
Add Waveshare ESP32-S3-Touch-LCD-2.8C (480x480 round ST7701) board support (#171)#179Keralots wants to merge 18 commits into
Keralots wants to merge 18 commits into
Conversation
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #171 once verified.
What
Adds a community env
ws_lcd_28cfor 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_480layout profile — the same round dashboard as the 240 round boards at 2× scale.Notable bits
DISPLAY_IS_ROUNDmakes every round renderer serve both profiles; per-resolution differences are only theLY_RND_*geometry plusLY_SC()/LY_F_SMALL|BODY|LARGE/LY_ICON16, whichlayout.hdefines as 1× defaults and onlylayout_round480.hoverrides.esp32c3_round/esp32s3/ws_lcd_200build byte-identical firmware.bin before/after the refactor — proof it's inert elsewhere.espressif32@6.12.0:pin_cs = -1andinitDisplay()holds the expander's CS bit low acrosstft.init().s3_common, which injectsUSE_ST7789_INVERT→ an unconditionalinvertDisplay(true); this panel runs inversion OFF.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.initDisplay()from INT level at reset; status byte 0x814E written back to 0 after each read or touch works exactly once.inter_20/27/37), linked only onDISPLAY_ROUND_480.Also landed (not board-specific):
/statusnow reportsboard; round clock footer width fix (the footer ran off the bezel on the shipped 240 board too); portalES8311_AUDIO→FIXED_AUDIOrename.Validation
stat, not PIO's "Flash: used")..iniin 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.