LILYGO T-Display-K230 · hardware note · observed

The panel lights, and what was keeping it dark

On 2026‑09‑21 the RM69A10 emitted light under Linux, and the kernel console rendered on the glass. The panel had never been deaf: it had been fully initialised by a different panel's init sequence, and never told to leave zero brightness.

Observed
2026-09-21
webcam, photographs committed
Panel
RM69A10 568×1232 AMOLED
MIPI DSI, 2 lanes, 24 bpp
Console
71×77 characters
fbcon on /dev/fb0, 8×16 font
Init sequence
11 commands, 26 payload bytes
the vendor's live function, not its dead one
Not settled
~3 rows of vertical motion
and no requirement rests on this yet

the photographs

Three states, because one proves nothing

A single bright photograph of a screen could be a reflection, and a single dark one is exactly what a dead panel looks like. So the framebuffer was driven to three known states from a shell on the board and each was photographed. The change is the evidence, not any one frame.

The panel while dd if=/dev/urandom was written to /dev/fb0: bright field, visible speckle.
dd if=/dev/urandom bright field, visible speckle
The panel while dd if=/dev/zero was written to /dev/fb0: dark.
dd if=/dev/zero dark
The panel while 0xFF bytes was written to /dev/fb0: bright band.
0xFF bytes bright band

Writing a pattern at all was necessary, and it is worth saying why: an all‑zero framebuffer on a working panel is indistinguishable from a panel that is off, and for most of this investigation the framebuffer was zeroed. Several days of “the screen is dark” contained an unknown amount of “nothing was ever drawn”.

the cause

We transcribed the function nothing calls

The board is driven by the kernel's generic Canaan panel support, which takes its initialisation sequence from the device tree. That sequence was transcribed from LilyGO's RT‑Smart driver for this exact panel on this exact board — and from the wrong function in it.

in rm69a10.c what it is what that meant
rm69a10_OLD_init()
line 39
no callers anywhere in the tree; byte‑for‑byte identical to hx8399_v2_init()
This is what we had been sending — all 20 commands and 297 payload bytes of it, transcribed faithfully and verified byte‑for‑byte against the vendor source. It is the init sequence for a Himax HX8399, a 1080×1920 panel that is not on this board. It was reachable, plausible, and dead.
rm69a10_568x1232_init()
line 96
what the dispatcher at lines 345 and 347 actually calls
This is the live one. 13 commands, 36 bytes, and it contains three things the Himax sequence does not.

What the board sends now is below, quoted from nix/dts/display-rm69a10-568x1232.dtsi. It follows the live vendor function with one deliberate difference, which is marked further down: the vendor's partial-area block is replaced by ENTER_NORMAL_MODE. Every packet also carries the 3 ms the vendor's dwc_lpdt_send_pkg() waits after each one and ours did not.

panel-init-sequence = [
    15 03 02 fe fd            Raydium page select: FE=FD
    15 03 02 80 fc            page FC
    15 03 02 fe 00            back to the user page 00
    39 03 05 2a 00 00 02 37   column window, 0..567
    39 03 05 2b 00 00 04 cf   row window, 0..1231
    15 03 02 13 00            ENTER_NORMAL_MODE
    15 03 02 35 00            tearing effect on
    15 05 02 51 fe            SET_DISPLAY_BRIGHTNESS 0xFE, then 5 ms
    05 7b 01 11               SLEEP_OUT, then 123 ms
    05 03 01 29               DISPLAY_ON
    15 03 02 3a 77            pixel format
];

The full transcription, command by command, with what each one is for, is in docs/evidence/rm69a10-init-sequence.md; the comparison against how the vendor firmware drives the same part is in docs/evidence/rtsmart-panel-bringup.md.

the console

71×77 characters on the glass

docs/evidence/panel-photos/05-console-on-panel.jpg — the kernel console rendering on the panel, photographed off the board.
[0.174613] fbcon: Taking over console
[3.665931] Console: switching to colour frame buffer device 71x77

71×77 characters is 568/8 by 1232/16: the whole panel at an 8×16 font, which is the arithmetic confirming that fbcon has the real geometry and not a fallback. Two changes were needed, and one of them is a trap worth naming.

the regression

A driver's opinion of itself is not evidence

Hours after the photographs above, the panel went dark again and stayed dark across reboots and a full power cycle. Every status bit the driver exposes said the display was on. DRM reported the connector connected, the CRTC enabled, and dpms On. All of it was true, and none of it was worth anything.

hypothesistest, on the boardresult
the framebuffer is all zeroes wrote 1.4 MB of /dev/urandom, then all‑0xFF, to /dev/fb0 still black
the pushed touch DTB broke it decompiled both DTBs and diffed them only the interrupt type and touchscreen-size-* differ; zero display deltas
…still the DTB pushed the image's original DTB back and rebooted still black
console blanking black 35 s after a cold boot; the default is 600 s not blanking
panel power or reset GPIO /sys/kernel/debug/gpio dsi_reset hi, backlight_gpio hi
display power domain off pm_genpd_summary disp_domain on, subsystem active
display clocks gated clk_summary all disp_* enabled
the panel latched in a bad state power-cycled the board by hand still black

What broke the tie was the persistent journal. journalctl --list-boots held all four boots of that card, and the display lines are byte‑identical in every one of them — including the first. So that card had never lit the panel, which dates the regression to the image rather than to anything done at runtime, and leaves exactly one kernel‑affecting commit between the last lit image and this one: a patch giving the DSI link 25% burst headroom, built at 17:12 and never visually confirmed.

docs/evidence/panel-photos/06-sheared-console-31.68mhz.jpg — the same console at the cancelling pixel clock. Lit, legible, and sheared: this photograph is what diagnosed the clock, and is not a picture of the panel working.

There is a postscript in docs/evidence/dsi-burst-headroom.md worth reading if you ever multiply a clock: the patch also moved the PHY's VCO range. phy_clk_freq is in kHz and immediately feeds a ladder of comparisons that pick the VCO bucket, so 297 000 landed under < 330000 and 371 250 under < 440000. The original commit called that out as intended, and it was intended, and the patch still broke the picture — because nothing moved the VO's pixel timing to match.

what is not known

Lit is not working

1

The brightness flicker is largely gone. Something still moves.

Remeasured on the corrected image — headroom patch removed, 49.5 MHz pixel clock, LILYGO 52.19 Hz timings, lit and unsheared — over 30 s of webcam video at a locked manual exposure, because otherwise what gets measured is the camera's gain control. Brightness dips below 90% of the mean: none, against a baseline of 19 dips in 30 s with the worst at 53%. Two dips fall below 95%, which is what “it flickers sometimes” looks like as a number.

Vertical motion remains, about 3 panel rows out of 1232, seen directly as bands moving in unison with no tearing. The measurement note is worth reading for what it refuses to conclude: the apparent displacement grows 9.3× down the frame, which looks exactly like timing error accumulating from a vsync anchor — and the panel is shot at an oblique angle whose perspective magnification is 5.6× across the same strip, in the same direction. Uniform shift and accumulation are not distinguished by that footage, so neither is claimed. Warping the frames into true panel space would remove the perspective term and is the current attempt; the first try produced a neatly rectified photograph of the worktop, and the second had the near and far ends of the panel the wrong way round. Both are written up, which is the useful part.

2

We do not drive the VO the way the vendor does.

One divergence is deliberate and settled: the vendor ends its sequence 31 / 30 / 12 00 — partial columns, partial rows, ENTER_PARTIAL_MODE over a restricted area — and copying that exactly lit the panel but banded the image, which is what a restricted partial area looks like when the VO streams a full frame into it. ENTER_NORMAL_MODE replaced it, and photograph 04 is the full-screen result. The divergences that remain are listed in priority order in the bring‑up note: no SoC display reset on enable, a stale reset pulse, vco_cntrl 0x19 against the 0x17 Linux computes, and CRTC‑before‑encoder ordering that streams video into a link that is not configured yet.

3

A failed DCS read is not proof the panel is deaf.

RDDPM (0x0A) still times out — -110, CMD_PKT_STATUS 0x50015, GEN_PLD_R_EMPTY set — on a panel that is visibly working. Earlier in this investigation that timeout was read as strong evidence the panel was receiving nothing. That inference is now known to be unsafe, and the correction belongs on the record next to the conclusion it spoiled.

4

What the corrected image does, and what it is still not.

Reverting restores pclk × 6 at the LILYGO 52.19 Hz timings — 297 MHz on the link, the rate the console photograph above was taken at — and that image has since been built, flashed and measured: lit, no shear, and the brightness numbers in the item above. So the panel currently in the board is working in the sense that light comes out of it and the console is legible.

It is not working in the sense a requirement would need. Nothing has been drawn at a known position and photographed against it, the residual motion is unexplained, and the panel's colour, orientation and refresh have never been checked against anything. Lit is a long way from correct, and this note claims only the first.

Evidence: docs/evidence/panel-lit.md, docs/evidence/dsi-burst-headroom.md, docs/evidence/panel-dark.md, docs/evidence/dsi-phy-hang.md, docs/evidence/rm69a10-init-sequence.md, docs/evidence/rtsmart-panel-bringup.md, and the photographs under docs/evidence/panel-photos/.

Vendor source read for this note: rm69a10.c and gt9895.c in LilyGO's RT‑Smart tree, and canaan_dsi.c and drivers/gpu/drm/panel/panel-canaan-universal.c in the pinned Xuantie kernel. Neither tree is committed here — they are large vendor clones — so those paths are named but not linked.