Reviving a dead touch panel on Smatfarm LED fixtures

Two Smatfarm reef LED fixtures, same model, touch panels both dead on arrival of the fault – the capacitive panel itself stopped responding while the rest of each fixture (remote control, 2.4GHz master/slave group sync) kept working fine. Rather than replace the panels, which are a known weak point near saltwater (salt creep/humidity getting into the sensor or its connector), the plan became: identify the microcontroller, pull the firmware, and patch in a UART command interface so an external controller (ESP32) can drive the fixture directly, bypassing the dead touch input entirely.

This page documents the process as it actually happened, including the parts that didn't work on the first try.

Identifying the hardware

Opening the fixture and inspecting the mainboard under a loupe identified the main chip: a GigaDevice GD32F130C8T6 – an ARM Cortex-M3, pin/peripheral compatible with STM32F0/F1, 64KB flash, 8KB SRAM, LQFP-48 package. The board also had two labeled debug headers sitting right next to each other, which made everything that followed possible without blind probing:

UART1:  V  T  R  G
SWD1:   V  G  D  C

V = supply sense, G = ground, T/R = UART TX/RX, D/C = SWDIO/SWCLK.

UART1 and SWD1 debug headers, close up Full Smatfarm mainboard, showing the six per-channel driver inductors, both LED panel boards, and the UART1/SWD1 headers

Left: the two headers as labeled on the silkscreen. Right: the full mainboard – six driver inductors (two rows of three, one per LED channel) confirm the 6-channel design found later in firmware; the small QFN chip near the headers is the GD32F130C8T6 itself.

UART: confirming the chip is alive

A 3.3V USB-to-serial adapter on the UART1 header (115200 baud) immediately showed a boot banner:

Software Ver3.0
Mar 29 2022  09:16:37
-------------
Task CheckSelf...
Task Work...

followed by a continuous idle-loop counter (IDLE:1, IDLE:2, ...). This confirmed the MCU boots normally and the firmware runs an RTOS-style task scheduler – useful context, but also a dead end for interactivity: sending any input back over this UART (newline, help, ?, AT) produced zero reaction. This console is transmit-only logging, not a command shell.

A controlled test – watch the idle counter for 10 seconds untouched, then press the touch panel once – produced no visible change in the log at all. Combined with the one-way UART, this ruled out a quick software fix and confirmed the problem needed a hardware-level bypass, not a firmware setting.

SWD access and a full firmware dump

The SWD1 header gives direct debug access to the chip. A CH347-based USB debug probe (set to JTAG/SWD mode via its onboard DIP switch) can drive this, but needs OpenOCD built with CH347 support – that driver only exists in OpenOCD's development branch, not yet in any tagged release, so the distro-packaged version doesn't have it:

git clone --depth 1 https://github.com/openocd-org/openocd.git
cd openocd
git submodule update --init --recursive
./bootstrap
./configure --enable-ch347 --enable-internal-jimtcl
make -j$(nproc)

With that built, a plain SWD connection using the generic stm32f1x.cfg target (GD32F1x0 is pin/peripheral compatible) worked immediately:

openocd -c "adapter driver ch347" -c "transport select swd" \
  -f target/stm32f1x.cfg -c init -c "reset halt" \
  -c "dump_image smatfarm_dump.bin 0x08000000 0x10000"

65536 bytes out, verified as a legitimate ARM Cortex-M image (correct vector table: reset handler, NMI, HardFault, SVCall, PendSV all pointing at sensible addresses). This dump became the safety net for everything that followed – every later step could be undone by reflashing it.

Verify your dump before you trust it

My 64KB dump turned out to be corrupt in its upper half, and it took a long time to notice because the corruption is perfectly regular rather than obviously broken. Within each 4KB block of 0x08008000–0x0800ffff, in each 512-byte unit n, the first 252 - 20n bytes read back as 0x00. Not one byte in 24,576 deviates from that formula, and it does not apply to the lower 32KB at all, where 81–90% of the bytes the formula would zero are real code.

It destroyed real data. The menu label strings are a uniform 16-byte-stride array; the slots up to 0x0800989c read as zeros while the slots from 0x080098ac on contain clean intact text, and the boundary sits exactly where the formula predicts. Seven of the zeroed slots are actively referenced as labels by menu descriptor entries, and a shipping product does not ship menus pointing at empty strings.

I assume this is the same CH347T that produced the 50% write failures below, though I have not proven the mechanism. The practical points:

  • Don't reflash a full 64KB image. flash write_image erase ... 0x08000000 writes your possibly-bad dump back over good flash. Write only the pages you actually changed – which is what apply_patch_v2.py produces.
  • Check your dump. Re-read a region on its own, at a different start offset and size, and compare it against the same slice of the big dump. If the two disagree, or a zero/0xFF pattern shifts, the readout is unreliable.
openocd ... -c "dump_image chunk.bin 0x08008000 0x2000"
cmp <(dd if=full_dump.bin bs=1 skip=$((0x8000)) count=$((0x2000)) 2>/dev/null) chunk.bin

Any difference means at least one of the two reads is wrong. Chase that before you flash anything.

Finding somewhere to put the code

Static disassembly (arm-none-eabi-objdump) combined with scanning the binary for known peripheral base addresses (GPIO, ADC, TIMER, USART – all confirmed against GigaDevice's own register definitions, not guessed) located the relevant code. The single most useful technique turned out to be empirical rather than static: setting a hardware watchpoint over the live SWD connection on the ADC result register and literally watching which function touched it:

openocd ... -c "halt" -c "wp 0x4001242c 4 r" -c "resume" \
  -c "sleep 5000" -c "halt" -c "reg pc" -c "reg lr"

That led to a clean, self-contained 124-byte function at 0x08000678: a loop over 6 channels, each compared against a fixed threshold, setting a flag if any channel crossed it. At the time I read this as touch detection and overwrote it. It was not. It was the fan controller – see the section below, because that mistake is the most useful thing on this page.

Two RAM buffers matter here, and mixing them up costs an evening: 0x20000068 holds the live per-channel brightness that the PWM output actually follows, and 0x2000006e holds the setpoints. A fade routine walks the first toward the second. Writing the live buffer gets silently undone on the next fade tick; writing the setpoint buffer is what sticks.

The patch

Rather than build a parallel control path, that 124-byte function gets replaced with a small UART command parser. Three real bugs surfaced during testing, each worth recording since none were obvious from the datasheet alone:

  1. Wrong data register. The USART has separate receive and transmit data registers (USART_RDATA at offset 0x24, USART_TDATA at offset 0x28) – not one shared register as assumed from modeling the read path on the firmware's own (write-only) logging code. Reading from TDATA instead of RDATA silently returns stale transmit-buffer content, never the actual received byte.
  2. RX was configured for DMA, not polling. USART_CTL2 had both DENR (DMA for reception) and DENT (DMA for transmission) set. With DENR active, a DMA controller silently consumes every received byte – and clears the RXNE flag – before a polling loop ever gets a chance to see it. The fix clears DENR every call (idempotent, no init-order dependency) while leaving DENT alone so the existing TX logging keeps working.
  3. A hidden schedule-override flag. Writing the right value to the right buffer still didn't stick – a separate time-of-day scheduler (sunrise/sunset/moon ramps) was recomputing and overwriting it every cycle. Tracing the scheduler's caller found a mode byte that gates whether it runs at all: values 0/2/3 let it recompute, anything else skips it. The patch sets this to a non-scheduled value after every manual write, so the schedule leaves manual values alone until... TBD whether/how to hand control back; see Status.
void uart_cmd_poll_impl(void) {
    /* fan control -- restores what the original function at 0x08000678 did */
    uint8_t i, on = 0;
    for (i = 0; i < 6; i++)
        if (CHANNEL_BUF[i] >= FAN_ON_LEVEL) { on = 1; break; }
    if (on) GPIOB_BOP = FAN_PIN; else GPIOB_BC = FAN_PIN;   // PB9
    FAN_STATE = on;                                          // for the UI screen

    /* UART command parser */
    USART0_CTL2 &= ~DENR_BIT;                 // idempotent DMA-RX disable
    if (!(USART0_STAT & RXNE_BIT)) return;
    uint8_t b = (uint8_t)USART0_RDATA;          // RDATA, not TDATA
    if (b >= 0xA0 && b <= 0xA5) {
        pending_channel = b - 0xA0;
        awaiting_value = 1;                      // self-healing: always resets state
    } else if (awaiting_value) {
        CHANNEL_BUF[pending_channel] = b;         // setpoint buffer, not the
        awaiting_value = 0;                        // ramping "current value" buffer
        SCHEDULE_MODE = MANUAL_MODE_VALUE;          // stop the scheduler overwriting it
    }
}

That is the core, and it is what the three bugs above were found in; the current impl.c adds status readback, temperature, the failsafe and self-initialization on top, and runs to about 500 bytes. Fan logic and UART parser together already come to 144 bytes, which does not fit the 124-byte hole – and no amount of rearranging the C gets it under, because GCC keeps re-materializing the peripheral base addresses into the literal pool whatever you do. So instead of squeezing, the code goes somewhere there is room. 0x08000678 gets a 4-byte tail-call trampoline and the real implementation lives at 0x0800a000:

08000678:  f009 bcc2    b.w  0x0800a000     ; 4 bytes, rest NOP-padded

A b.w rather than a call is deliberate: it leaves the link register holding the original caller's return address, and because the implementation is a leaf function touching only caller-saved registers, its own bx lr returns straight through to the caller. No stack juggling, no prologue to match.

There is plenty of room at 0x0800a000: roughly 24KB of this 64KB part (0x08009c00–0x0800fbff) holds nothing, and no code anywhere in the firmware references it. The settings page is the last 1KB at 0x0800fc00, well clear.

The patch's own state is placed manually, outside the firmware's .bss layout, which has two consequences. It is never zero-initialized on reset, so the code must not assume a clean 0 start – it checks a magic cookie on entry and initializes itself the first time it runs. And the address has to be chosen, not guessed: the vector table's initial stack pointer is 0x20001310 and nothing in the firmware references SRAM above that, so 0x20001310–0x20001fff is free. The state sits at 0x20001fd0, inside the 8KB SRAM and well clear of the stack. An earlier revision had it at 0x20001ff8, which was fine for two bytes and silently ran off the end of RAM the moment more state was added – so the linker script now carries ASSERT(. <= 0x20002000) to make that a build failure rather than a runtime mystery.

The function I overwrote was the fan controller

This is the part worth reading even if you never touch a Smatfarm fixture.

I identified 0x08000678 as dead touch-handling code and replaced it. Weeks later, going back over the firmware properly, I disassembled the original bytes I had overwritten:

for ch = 1..6:  if setpoint[ch] >= 20  ->  flag = 1, break
flag ? GPIOB_BOP(0x48000418) = 0x200      ; PB9 high, fan ON
     : GPIOB_BC (0x48000428) = 0x200      ; PB9 low,  fan OFF
0x20000115 = flag                          ; mirrored for the UI

That is fan control. Not touch detection – the function contains no ADC access at all. Three things confirm it independently: the helper it calls returns *(0x2000006e + ch-1), the channel setpoint buffer; the fixture's own Temperature menu screen draws Temperature:xx C / Fan: / On and reads its fan state from exactly 0x20000115; and GPIOB_BOP appears in precisely one literal pool in the entire 64KB image – this function's.

So the v1 patch left the fan permanently off. The GPIO init explicitly drives PB9 low at boot and nothing else ever raises it, so there is no recovery path: the fixture simply never cools itself again. On an LED fixture over a reef tank that is not a cosmetic bug.

How the misread happened is worth naming, because it is not an exotic failure. There is an ADC_RDATA literal at 0x08000674, four bytes before the function starts. It belongs to the previous function's literal pool. I read it as this function's, and it fit the story I was already looking for – the touch panel genuinely was dead and I was hunting for the touch code. Confirmation bias, with a disassembler.

The lesson, stated plainly so it is reusable: before overwriting any function in a binary patch, disassemble that exact function and say out loud what it does. Don't infer purpose from nearby literals – work out whose literal pool they actually belong to by finding the function boundaries. And when a patch deletes a function, ask what else in the system depended on it. Here, nothing else did, which is exactly why it failed silently.

The linker put the wrong function first

A second bug in the same family as the one above, found later and worth publishing for the same reason: it shipped, and it was invisible.

The trampoline at 0x08000678 is a b.w to a hardcoded address. So whatever occupies the first byte of 0x0800a000 is what gets called – and the original linker script simply said:

.text : { *(.text*) *(.rodata*) }

That does not pin anything. It emits the input sections in whatever order the compiler produced them, and GCC is under no obligation to put your entry function first. In the shipped build it had placed the small static helper tx_byte at 0x0800a000 instead of uart_cmd_poll_impl. Every call from the main loop therefore landed in tx_byte, which spun on a transmit flag and returned. The fan logic never ran and not one UART byte was ever parsed.

It stayed hidden because the fixture under test was permanently on SWD, never on UART – so nothing ever exercised the path that was broken. The build was correct in every respect a build checks: it compiled, it linked, it fit the page, it flashed, it verified byte-for-byte. It was simply wired to the wrong function.

The fix is to stop relying on emission order and assert the result:

ENTRY(uart_cmd_poll_impl)
SECTIONS {
  . = 0x0800a000;
  .text : {
    KEEP(*(.text.uart_cmd_poll_impl))   /* pinned first */
    *(.text*)
    *(.rodata*)
  }
  . = 0x20001fd0;
  .bss  : { *(.bss*) *(COMMON) }
}
ASSERT(. <= 0x20002000, "state overflows the top of the 8KB SRAM")
ASSERT(uart_cmd_poll_impl == 0x0800a000,
       "entry point is not at the start of the page")

The KEEP(*(.text.uart_cmd_poll_impl)) line only has something to match if you also compile with -ffunction-sections, which the build commands below now do. If you forget it, the ASSERT fires and the link fails – which is the point.

Stated reusably: when patched code is reached by a branch to a fixed address, the entry symbol must be pinned first and that fact must be asserted at build time. A verified flash write proves the bytes arrived, not that the right bytes are at the front. And the general lesson is the same one the fan controller taught: a thing that has never been exercised is not working, it is untested – regardless of how much of the process around it succeeded.

The fixture's own logging steals the last byte

The readback frame failed its checksum on every single attempt – not intermittently, which was the useful clue. A complete, well-formed 23-byte frame arrived each time, the length byte was right, the payload decoded to sensible values, and the receiver's computed XOR was arithmetically correct for the bytes it held. Yet it never matched the checksum byte at the end.

The checksum byte was 0x49 every time, across frames with completely different payloads and tick counters. A checksum that does not vary with the data is not a checksum. 0x49 is ASCII 'I' – the first character of the fixture's own IR RAW[%x] KEY[%x] log output.

The fixture logs over the same USART it replies on. Whatever it emits immediately after our last byte can land in the final slot of the frame, so position 22 was never our checksum at all – it was a byte of the firmware's own log stream. Everything before it was ours and intact, which is exactly why the payload decoded perfectly and only the comparison failed.

The fix is two bytes of padding after the checksum:

tx_byte(ck);
tx_byte(0x0D);
tx_byte(0x0A);   /* sacrificial: absorbs whatever the log emits next */

The intruding byte now displaces a trailer instead of the checksum, and as a bonus 0x0D 0x0A terminates the fixture's own log line harmlessly. A receiver reads 2 + len bytes and ignores anything after, so this costs nothing on the other end. Readback started working on the first try with this in place.

Two things are worth taking away. First, if a checksum fails deterministically, suspect your framing before your arithmetic – genuine corruption is sporadic, and a constant "checksum" is a sign you are reading the wrong byte entirely. Second, this cost far more time than it should have because the diagnostic only retained the last 16 bytes of the frame. The header and length byte were never visible, so several confident conclusions about them were reached by assuming the checksum matched and solving for the unknowns – which is circular, and produced a plausible-looking frame that agreed with a theory rather than with the fixture. Capture the whole frame and record which validation step rejected it; both answers then arrive in one shot instead of five.

Flashing: the CH347T v4.41 problem

This is the part that didn't go smoothly. The patched image verified correctly against the original byte-for-byte outside the intended 114-byte change window, but flashing it hit a genuine, documented hardware bug: CH347T firmware 4.41 erroneously drives SWDIO high for 392ns after the last ACK bit when reading AP registers – which happens constantly during flash programming (status polling). That causes brief bus contention with the target and corrupts data with parity errors.

In practice this showed up as roughly a 50% failure rate on flash write operations – sometimes a write/verify completed cleanly, sometimes it timed out and left flash erased (blank, including the vector table, until reflashed). Every failure was recoverable from the firmware backup, but it made the process unreliable enough to be worth fixing properly rather than repeatedly retrying:

Adapter D (SWDIO) ---/\/\/\--- Target SWDIO
                     330 ohm (220-470 ohm range)

A single series resistor (220–470Ω) inserted inline in the SWDIO wire limits the current during that contention window enough for the line to settle to a valid level instead of a corrupted one, without materially slowing down normal SWD transactions at these clock speeds.

Status: working

Patched, flashed, and confirmed working. Sending 0xA0–0xA5 (select channel) followed by a value byte over UART at 115200 baud sets that channel's brightness directly, and it holds – survives the scheduler's recompute cycle, doesn't revert, doesn't need repeating. Turning individual channels and all six off was confirmed, as was setting a specific mid-range brightness and having it stick.

One debugging note worth recording since it cost real time: several "the fix doesn't work" results late in testing turned out to be a 9600-vs-115200 baud mismatch on the test side (the serial port had silently reset to its default after a USB replug), not a firmware problem at all – worth checking before suspecting the chip when a previously-working command stops responding.

Flashing the GD32F130 over SWD via the CH347 adapter remains unreliable (the v4.41 firmware bug above, the SWDIO resistor mitigates but doesn't fully eliminate it) – budget for a few retries per flash, with the firmware backup as the safety net, same as throughout this project.

Why bytes drop, and why the threshold is where it is. Early testing found that commands sent less than about half a second apart intermittently lost bytes, while two-second gaps were reliable every time. That was recorded as an empirical rule without an explanation. The explanation turns out to be exact, and it is the single most important thing to understand before building on this protocol: the stock main loop calls the patched function from behind a 1000 ms gate (the elapsed helper at 0x08003d94), and the USART's hardware receive buffer holds exactly one byte. So the fixture looks at the UART once per second, and anything that arrives in between has one byte of buffer to survive in. The "half a second" threshold was never about the wire – it is the 1 Hz poll period showing through.

Two consequences follow, and both bite. A two-byte channel command written as a single write() puts its bytes 87 µs apart at 115200 baud, so the second overwrites the first and the fixture sees only the value byte – which fails the 0xA0–0xA5 range test and is silently discarded. The symptom is a controller that reports success while not one setpoint ever changes, with ORERR set in USART_STAT. And a readback reply can take a full poll period to even begin, so a receiver that waits 400 ms gives up before the fixture has looked.

There are two ways to live with this. The simple one is to pace every single byte more than a second apart – correct, but a six-channel write then takes about fourteen seconds. The better one is to fix it in the patch: once a byte is waiting, keep consuming for a short window so an entire back-to-back burst lands in one poll.

if (!(USART0_STAT & RBNE_BIT)) return;   /* nothing waiting: no spin at all */
for (;;) {
    if (USART0_STAT & RBNE_BIT) {
        handle_byte((uint8_t)USART0_RDATA);
        if (++count >= BURST_MAX_BYTES) break;
        idle = 0;
    } else if (++idle >= BURST_IDLE_SPIN) break;
}

BURST_IDLE_SPIN is sized to roughly 0.6 ms, comfortably more than one 87 µs byte time, so it keeps collecting while bytes keep arriving and gives up shortly after the burst ends. It costs nothing when idle, because the loop is only entered if a byte is already waiting, and BURST_MAX_BYTES stops line noise from holding the fixture's main loop. With this in place the whole six-channel write is one burst in one poll, and the sender only needs to space bursts more than a second apart rather than every byte.

The protocol. Writes are two bytes: 0xA0–0xA5 selects a channel, then one value byte 0–100. Three further commands:

0xB0            status readback (reply below)
0xB2            hand control back to the fixture's own schedule
0xB3 lo hi      set the failsafe timeout, little-endian, in ticks

All four command bytes are above 0x9F, outside the 0–100 brightness range and non-ASCII, so they cannot collide with a value byte or with the fixture's own IR RAW[%x] KEY[%x] logging on the shared TX line. 0xB0 replies with:

[0]      0xB0
[1]      length of everything that follows, checksum included
[2]      schedule_mode   1 = manual override in effect
                         0/2/3 = the fixture's own scheduler is running
                                 (i.e. it power-cycled, or the failsafe fired)
[3]      fan state       0 = off, 1 = running
[4:6]    heatsink temperature, degC x10, little-endian
[6:10]   poll counter, little-endian
[10:16]  six setpoints   what was commanded
[16:22]  six live values after the fixture's internal fade ramp
[22]     XOR checksum of bytes 2..21
[23,24]  0x0D 0x0A trailer -- see below, this is not optional

The length byte is what makes this worth doing properly: fields are only ever appended, so a parser written against an older firmware keeps working, and the frame can grow later without a flag day. Everything before it was a fixed 15 bytes with no way to tell versions apart.

Readback matters more than it first appears. Writes have no acknowledgement, so without it a controller is flying blind – and because a power cycle resets the override flag and hands control back to the fixture's internal sunrise/sunset schedule, a controller needs a way to notice that and re-assert. schedule_mode is that signal.

Temperature comes from the same source the fixture's own Temperature menu screen uses. get_temperature() at 0x08000d6c reads ADC channel 0 and walks a 100-entry NTC lookup table at 0x08009654 whose temperature column runs 0, 10, 20 … 990 – i.e. degrees C scaled by ten, 0.0 to 99.0. The main loop stores that as a halfword at 0x2000010c, and the very next instruction is the call to the patched function, so a readback always reports a value at most one cycle old. Worth checking that chain yourself rather than taking the address on trust: a wrong address here does not fail, it reports convincing nonsense.

The failsafe is the part that matters over a tank. Manual override is sticky by design, which means a controller that dies leaves the lights frozen at whatever was last commanded – potentially full output, overnight, indefinitely. So the patch tracks how long it has been since it last heard anything and, past the timeout, writes the schedule-mode byte back to a scheduled value and lets the fixture resume its own sunrise/sunset ramp. A dumb schedule is a much better failure mode than a stuck one. 0xB2 triggers the same handback deliberately; 0xB3 retunes the timeout at runtime so it needs no reflash.

Two details in that are easy to get wrong. The tick unit is set by how often the main loop calls the patch, and that call sits behind the firmware's elapsed(1000ms) helper at 0x08003d94 – so one tick is one second, and a 16-bit timeout covers just over 18 hours. The default is 3600. And the timeout has to be petted by readback, not only by channel writes: a sensible controller only writes a channel when its value actually changes, so a midday plateau can legitimately go hours without one. The right question is "have I heard from the controller", not "has it told me to do something".

Two timing constraints are not optional. Bursts must be spaced further apart than the fixture's 1000 ms poll period (see above for why, and what the one-byte buffer does to anything closer), and on the controller side MicroPython's UART timeout argument does not reliably bound read() – poll uart.any() against your own deadline instead. The driver linked above does both. Allow at least 1.5 s for a readback reply, since the fixture may not look at your request for a full second.

Fan control was broken by the v1 patch and is fixed in v2 (see above). If you flashed anything from an earlier version of this page, reflash with the current downloads before putting the fixture into service – and in the meantime don't run it hard, because the fan will not come on.

The controller side is written: an ESP32-S3 running MicroPython, which holds a 97-node daily curve, interpolates it, drives the six channels over this protocol, and serves a small web UI showing live channel values, heatsink temperature and fan state, with OTA updates and an optional OLED driven over the released display pins. The smatfarm.py driver below is the part of it that speaks to the fixture, and is usable on its own.

What is not yet proven: at the time of writing the UART path has been exercised against the protocol, not against a fixture in service – the test fixture has been on SWD throughout. Given that the linker bug above hid precisely because an untested path looked finished, that distinction is worth stating rather than glossing. Treat the firmware side as complete and the end-to-end integration as pending.

Downloads

The patch sources and a script that produces flashable page files from your own firmware dump. Deliberately not a pre-patched firmware binary – the dump is Smatfarm/GigaDevice's own compiled firmware, not something this site has the right to redistribute. Dump your own fixture's flash (steps below) and run the script against it instead.

  • impl.c – the patch implementation: UART command parser, the reimplemented fan control, temperature and fan readback, and the dead-controller failsafe. Also carries an optional RELEASE_DISPLAY_PINS build flag (off by default) – see below
  • impl.ld – linker script placing it at 0x0800a000 in unused flash, pinning the entry point and asserting both that and the SRAM bound
  • tramp.s – the 4-byte trampoline that goes at 0x08000678
  • apply_patch_v2.py – validates your dump and writes the two page files to flash
  • smatfarm.py – MicroPython driver for the ESP32 side: paced writes, length-aware readback parsing with resync, handback and failsafe control, and state re-assertion

Build and apply:

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -ffreestanding -nostdlib \
    -fno-common -ffunction-sections -fdata-sections -c impl.c -o impl.o
arm-none-eabi-ld -T impl.ld impl.o -o impl.elf
arm-none-eabi-objcopy -O binary -j .text impl.elf impl.bin

arm-none-eabi-as -mcpu=cortex-m3 -mthumb tramp.s -o tramp.o
arm-none-eabi-ld -Ttext=0x08000678 --defsym=uart_cmd_poll_impl=0x0800a000 \
    -e 0x08000678 tramp.o -o tramp.elf
arm-none-eabi-objcopy -O binary -j .text tramp.elf tramp.bin

python3 apply_patch_v2.py my_smatfarm_backup.bin tramp.bin impl.bin

-ffunction-sections is not optional – the linker script pins the entry point by section name and silently has nothing to match without it. The ASSERT in the script catches that, so a wrong build fails to link rather than producing a plausible binary that calls the wrong function.

That prints the exact OpenOCD commands for the two page writes. tramp.bin must be exactly 4 bytes (09 f0 c2 bc). impl.bin comes out around 620 bytes and must stay under the 1024-byte page; its first instruction is the start of uart_cmd_poll_impl, which is worth confirming directly rather than assuming:

arm-none-eabi-nm impl.elf | grep uart_cmd_poll_impl    # must be 0800a000

If either differs, stop and check your toolchain before flashing anything.

Optional: handing the OLED to your own controller. Building with -DRELEASE_DISPLAY_PINS=1 sets PA3 (CS), PA5 (DC), PA6 (SCK) and PA7 (MOSI) to input mode on every poll, so the GD32 stops driving the display's SPI bus and an external microcontroller can take it over. The fixture's own screen goes blank; LEDs, fan, scheduler and the UART interface all keep working. The display is an SSD1306 128x32 – confirmed from the init sequence (0xA8 0x1F multiplex ratio 31, and 0xDA 0x02 COM pin config), so a stock SSD1306 driver on the other end will drive it. There is no reset line on this board, so pass your driver a spare unused pin for it.

Leave the flag at 0 unless you are actually wiring something to those four pins, and flash the patch before connecting anything: until the GD32 releases them they are push-pull outputs, and tying two driven outputs together can damage either chip.

The trampoline link step prints warning: ... Forcing branch to absolute symbol in Thumb mode. That one is expected and harmless – it is the linker noting that the branch target is a fixed address rather than a symbol it resolved itself, which is exactly what we want here. Check the 4 bytes are 09 f0 c2 bc and carry on.

apply_patch_v2.py refuses to run against an already-patched image, so you cannot accidentally stack a second patch on top of a first. If it tells you the prologue is wrong, reflash your original backup before doing anything else.

Flashing this yourself

Written for someone with basic Linux command-line familiarity, repeating a dead touch panel on their own Smatfarm fixture (or genuinely identical chip/firmware – check your own boot banner's version string first). Needs: a CH347-based USB debug adapter (cheap, widely available, often sold as a combined UART/SPI/I2C/JTAG tool), a 220–470Ω resistor, and basic soldering/jumper-wire skills to reach the SWD1 header.

1. Install build dependencies

sudo apt install -y git autoconf automake libtool libhidapi-dev texinfo \
  gcc-arm-none-eabi binutils-arm-none-eabi build-essential pkg-config libusb-1.0-0-dev

Most of this is for building OpenOCD from source (next step) – the CH347 driver isn't in any tagged OpenOCD release yet, only its development branch, so the distro-packaged openocd package won't have it. gcc-arm-none-eabi and binutils-arm-none-eabi are the ARM cross-compiler and disassembler for building/inspecting the patch itself.

2. Build OpenOCD with CH347 support

git clone --depth 1 https://github.com/openocd-org/openocd.git
cd openocd
git submodule update --init --recursive
./bootstrap
./configure --enable-ch347 --enable-internal-jimtcl
make -j$(nproc)

--enable-internal-jimtcl is needed because the bundled Tcl interpreter OpenOCD uses internally isn't present as a system package either – without it, configure fails looking for a system jimtcl that doesn't exist on most distros.

3. udev rule, so you don't need sudo for every flash

sudo cp openocd/contrib/60-openocd.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger

Without this, the adapter's USB device node is root-owned and every openocd invocation needs sudo. Unplug and replug the adapter afterward for it to take effect.

4. Wire up SWD1 (with the resistor!)

SWD1:   V  G  D  C
adapter D pin ---/\/\/\--- target D pad   (220-470 ohm, inline)
adapter G pin ------------ target G pad   (direct)
adapter C pin ------------ target C pad   (direct)

The resistor matters: CH347T firmware 4.41 (the common version on cheap adapters) has a documented SWD timing bug that corrupts flash writes intermittently without it – see the Flashing section above. Leave V unconnected; most of these adapters run fixed 3.3V logic and don't need the sense line, and the fixture should already be on its own power.

5. Back up your own firmware first

openocd -c "adapter driver ch347" -c "transport select swd" \
  -f target/stm32f1x.cfg -c init -c "reset halt" \
  -c "dump_image my_smatfarm_backup.bin 0x08000000 0x10000"

Keep this file. It's your only way back if a flash write fails badly (flash programming on this adapter fails roughly half the time due to the CH347T bug above – recoverable by reflashing this backup, but only if you have it).

6. Build and apply the patch

Grab impl.c, impl.ld, tramp.s and apply_patch_v2.py from the Downloads section, then run the build commands shown there. You should end up with impl.bin (144 bytes) and tramp.bin (4 bytes, 09 f0 c2 bc).

python3 apply_patch_v2.py my_smatfarm_backup.bin tramp.bin impl.bin

This validates your dump first – it refuses to run if the bytes at 0x08000678 aren't the expected 10 b5 prologue, which catches both a different firmware version and an already-patched fixture. It then writes two small files: the 1KB page holding the trampoline, and the implementation. It deliberately does not produce a full 64KB image; see "Verify your dump" above for why that matters.

7. Flash it

Two page writes, not a whole-chip rewrite. The script prints these with your actual filenames:

openocd -f interface/ch347.cfg -f target/gd32f1x0.cfg \
  -c "init" -c "reset halt" \
  -c "mww 0xE0042004 0x300" \
  -c "flash erase_address 0x08000400 0x400" \
  -c "flash write_image page_08000400.bin 0x08000400" \
  -c "verify_image page_08000400.bin 0x08000400" \
  -c "flash erase_address 0x0800a000 0x400" \
  -c "flash write_image page_0800a000.bin 0x0800a000" \
  -c "verify_image page_0800a000.bin 0x0800a000" \
  -c "reset run" -c "shutdown"

The mww 0xE0042004 0x300 freezes the watchdog during the flash operation (DBGMCU_CR, setting the IWDG_STOP/WWDG_STOP bits) – without it, a long-running flash write can let the watchdog expire and reset the chip mid-write.

Expect this to need a few attempts (the CH347T bug above). If a write fails, check whether the page came back blank and reflash before retrying. Because v2 only touches two pages, a failed write damages far less than the old whole-image approach did – but keep your backup from step 5 regardless.

8. Verify

Wire the UART1 header instead (V/T/R/G, 115200 baud, standard USB-serial adapter – no resistor needed here, this is a different, lower-risk connection), send a channel-select byte followed by a value byte, and confirm the corresponding channel changes brightness and stays there:

stty -F /dev/ttyACM0 115200 raw -echo
printf '\xa0' > /dev/ttyACM0   # select channel 0
printf '\x80' > /dev/ttyACM0   # set it to 0x80 (~50%)

Channel bytes are 0xA0 (channel 0) through 0xA5 (channel 5); the byte sent immediately after is the 0-255 brightness value for whichever channel was last selected.