ellie@princess: ~
ellie.tty terminal princess

ellie@princess ~ $ bat ~/blog/the-pink-keyboard-firmware-incident.md

cd ../blog
2026-06-21 | 12 min read

The pink keyboard firmware incident

#qmk #firmware #keyboard #embedded #debugging #iso

Buying a keyboard because it is pink is a perfectly reasonable engineering decision. Anyone who disagrees has never spent eight hours a day communicating with a computer through a beige rectangle designed by a committee that thought “texture” meant “slightly different plastic sadness.”

So there I was at Gliched, near Elgiganten, doing the responsible adult thing: impulse-purchasing a NuPhy Halo75 V2 because it was pink, cute, and I needed a new keyboard. That is not a supply-chain event. That is self-care with USB-C.

Then I did what any normal person would do after buying a keyboard: I went to the manufacturer’s website, downloaded the firmware for the model I owned, flashed it, and expected my keys to continue being keys.

This was my first mistake. My second mistake was assuming “firmware for this model” included the ISO version of this model. My third mistake was having hope, which remains the most dangerous undefined behavior in consumer electronics.

The firmware flashed fine. The keyboard booted. The RGB did its little capitalism rainbow. And then the ISO keys were dead.

Not metaphorically dead. Actually dead. The useful Scandinavian/European bits of the board, the keys that make an ISO keyboard an ISO keyboard instead of an ANSI board wearing a taller Enter key as a disguise, simply stopped participating in society. The < > key, the section/sign key, the extra punctuation choreography, all gone sideways.

I had taken a working keyboard and converted it into a very expensive pink argument.

The incident report

NuPhy published firmware for the Halo75 V2, but the downloadable build was effectively for ANSI. Flash that onto an ISO board and the matrix does exactly what firmware always does: precisely the wrong thing, with confidence.

I asked NuPhy for the ISO firmware. They could not provide it.

This is the part where the story should end with a support link, a zip file, and maybe a mild amount of shame about flashing vendor firmware like it came from a reliable civilization. Instead, the trail led to an abandoned-looking GitHub repository containing NuPhy’s QMK source for the ANSI version.

The good news: source code existed.

The bad news: it was old QMK source, vendor-shaped, copied across variants, and written in the dialect of embedded C where every file has three jobs, six globals, and at least one function name that looks like it was translated through a fax machine.

The worse news: I was now emotionally invested.

On June 16, 2026, the first commit landed on the nuphy-keyboards branch:

13064e8c506f Start working on ISO version of the halo75 v2 and add woodpecker

That commit was not a delicate edit. It added a full ISO variant: keyboard metadata, keymaps, LED tables, RF code, side LED code, sleep handling, VIA support, and CI. About 4,800 lines of “I bought this keyboard yesterday and now apparently I maintain it.”

At this point, the problem looked simple in the same way a leaking pipe looks simple before you learn the wall is load-bearing.

The ISO board needed:

  • the right matrix positions,
  • the right ISO keycodes,
  • the right VIA definition,
  • the right LED map,
  • and the firmware to stop treating Nordic punctuation like optional DLC.

So I fixed keymaps. Then fixed keymaps again. Then fixed RGB. Then fixed an invalid matrix position. Then did some more layout fixes. The commit log from June 16 is basically firmware debugging as a cardio exercise:

1Fix invalid matrix position
2Maybe keyfixes for iso?
3Fix rgb
4Fix keymaps
5Fix keymaps
6Fix keymaps
7Minior layout fixes

Yes, “Minior.” The typo is part of the archaeological record. Leave it in the museum.

The rabbit hole

The original NuPhy source was based around QMK 0.25.7-ish. Current upstream QMK had moved on to 0.33.7. That does not sound like a huge jump if you think version numbers are decorative. In firmware land, it means APIs moved, config schemas changed, old aliases disappeared, and whatever worked before now has the social stability of a JavaScript build tool after a minor release.

On June 18, the branch became nuphy-on-current, and this happened:

1977cf375c51 Port nuphy keyboards onto QMK 0.33.7; strip non-nuphy keyboards

That was the forklift upgrade. The old NuPhy code got dragged onto modern QMK, and the repo was stripped down so it was no longer carrying the entire keyboard directory like a cursed shipping container.

The migration broke in all the ways you would expect and several ways you would not unless you have personally offended an STM32.

QMK had renamed metadata expectations. Some keyboards had keyboard.json; newer QMK wanted info.json in the right places. RGB matrix coordinates needed to validate as integers. Old RGB keycodes had been deprecated, so the firmware needed modern RM_* names instead of legacy RGB_* aliases. EEPROM datablock functions had changed shape. GPIO helpers had changed names. ChibiOS configuration needed attention.

None of this is glamorous. This is the work where a compiler looks at you and says, “I see you brought me 2024 code. Unfortunately, this is 2026, and I have developed standards.”

Then came the proper embedded bugs, the ones that do not politely fail at compile time.

The keyboard that typed from the wrong pins

The best bug was the UART pin conflict.

The Halo75 V2 uses an STM32F072 talking to an nRF wireless module. The keyboard matrix also uses MCU pins. This is normal until the firmware configures the wrong pins for UART and accidentally turns matrix columns into a serial peripheral.

That is not “a bug.” That is a séance with scan codes.

The QMK UART driver defaulted to pins that were fine in some universe, just not this one. On this board, USART1 needed PB6/PB7. Without overriding the UART TX/RX pins, the driver defaulted to A9/A10, which were matrix columns 13 and 14. So when rf_uart_init() ran, it reconfigured keyboard matrix pins as USART pins, and the keyboard started inventing input like it had discovered improv.

The fix landed at 05:15 on June 19:

1dea005ec792 Fix Nuphy Halo75 V2 ISO: LED matrix, typing, and UART pin conflict

That commit brought the board back toward reality: restore the IS31FL3733 compatibility macros, fix I2C addresses, re-enable the RF housekeeping path, bring the RGB matrix power rail up at the right point, restore DMA config, add missing LED positions, and force the UART pins to the board’s actual hardware.

This is the part of firmware work where the abstraction stack politely excuses itself and you are left reading datasheets like a medieval monk with a logic analyzer.

Then Bluetooth still did not work.

The keyboard would show the pairing blink locally, but nothing appeared in the Bluetooth device list. That is a special kind of insult: the UI says “pairing,” the radio says nothing, and your laptop says “new phone who dis.”

The next commit found it:

149352a2a3ac Fix RF/Bluetooth: set UART_TX/RX_PAL_MODE = 0 for STM32F072

On STM32F072, USART1 on PB6/PB7 uses alternate function 0. QMK’s default UART alternate-function mode was 7, appropriate for STM32F4-style expectations, but wrong here. AF7 on those pins was not USART1. The nRF module was never receiving the command to advertise. The keyboard was blinking into the void.

Firmware debugging is mostly asking, “is the hardware lying, is the code lying, or did I accidentally configure a comparator output and call it Bluetooth?”

The answer, as usual, was yes.

The LED side quest, because of course there was one

Once the keyboard typed, the LEDs demanded legal representation.

The Halo75 V2 has 128 LEDs driven by IS31FL3733 chips. The main keys, status indicators, battery/system lights, and side/rim LEDs all share the stage. This is fine until RGB animations decide the entire LED array belongs to them and overwrite status indicators like a startup founder overwriting a database with a Notion export.

The commit log from late June 18 into early June 19 reads like someone negotiating with a disco:

1Fix side LED bugs so it leaves status leds alone
2Fix rim animations overwriting status LEDs
3Fix custom RGB matrix effects overwriting status LEDs
4Fix startup animation power_play_index bound
5Slow down startup animation timing for smoother effect
6Skip status LED indices in startup animation to prevent interference
7Prevent animations from turning off status LEDs
8Add rgb_matrix_indicators_user hook to restore status LEDs after built-in effects

The architectural fix was to separate responsibility. QMK RGB matrix effects can draw the main keyboard lighting, but status and side LEDs need to win at the end of the frame. Otherwise the keyboard loses the ability to tell you useful things like battery, mode, and “please stop turning my indicators into vaporwave.”

Later, that turned into a cleaner shared implementation:

1776478e78ab Major refactor, share the code between ansi and iso as much as possible

Before that refactor, ANSI and ISO carried duplicate copies of RF code, side LED code, sleep code, headers, and config. That is how you grow two firmware variants that look related but fail in different accents. The refactor pulled shared behavior up into keyboards/nuphy/halo75_v2/, leaving the variants to describe what is actually different: physical layout, keymaps, and LED zoning.

The result was smaller, less duplicated, and less likely to require fixing the same UART bug twice. A low bar, yes, but embedded firmware is built on respecting low bars. They are usually connected to ground.

The performance pass

After the board typed and lit up, it still felt sluggish.

There is a very specific humiliation in making a boutique mechanical keyboard feel worse than a three-dollar office keyboard that has seen things in a municipal procurement closet. The Halo75 V2 had side LED work, RF housekeeping, UART command handling, sleep handling, and RGB animation all sharing a tiny real-time budget. Blocking waits in hot-ish paths are how you turn “firmware” into “firm maybe later.”

So June 19 turned into a latency cleanup:

1cf1f86e9329 Remove blocking waits from hot-ish paths
28f1c1a0b4d7 Cap rgb animations at approx. 60 fps
31b5728b8298 Coalesce/drop low-priority deferred UART commands
40314ae48b22 Harden RF parser and make device reset non-blocking
51b9638856b3 Remove dead ack-wait from uart_send_cmd, convert sleep callers to deferred

The idea was straightforward: keep the main loop moving. Do not block the scan path waiting for decorative work. Do not make a factory reset freeze the firmware for over a second. Do not let low-priority UART chatter pile up while key reports are trying to leave the building.

This is the point where “keyboard firmware” stops being a keymap and becomes a tiny cooperative scheduler with lighting and emotional baggage.

The RF parser also got stricter. Instead of trusting arbitrary packet lengths and setting ACK/sync state too early, it started validating command-specific lengths and resetting RX state on parse exits. In C, optimism is not a strategy. It is how you spend Saturday night discovering that one stale byte can become a lifestyle.

The HID console plot twist

Then came the weird one: Cmd+V behaved strangely.

At this stage, it was reasonable to suspect ghosting. The keyboard had already had a real UART/matrix pin conflict. The firmware had just been ported across years of QMK changes. There were RF paths, NKRO reports, modifier state, layers, debounce, and a physical matrix all involved. Plenty of suspects. Nobody had an alibi.

So I added a debug console:

10de414bd565 debug: add matrix/report debug console for input diagnostics

It can be enabled like this:

1qmk flash -kb nuphy/halo75_v2/iso -km via -e CONSOLE_ENABLE=yes

The console logs raw matrix rows, debounced rows, per-column masks, key events, layers, mods, weak mods, oneshot mods, and the row state at the moment each event fires. It is intentionally cheap: compare MATRIX_ROWS, skip output if nothing changed, allocate nothing, and print only when useful.

This is exactly the kind of tool you write when you are convinced the machine is gaslighting you and you would like timestamps for the deposition.

And then the console proved the keyboard was innocent.

The “ghosting” was me.

Because of nerve damage in my left hand, I was accidentally bumping Ctrl without feeling it. So Cmd+V was sometimes not Cmd+V in the clean little mental model where fingers are reliable peripherals. The firmware was fine. The matrix was fine. The report was fine. The bug was an accessibility mismatch between switch resistance and sensory feedback.

That is the kind of debugging revelation that makes you stare at the ceiling for a moment.

Hours of firmware investigation. A custom HID console. Matrix masks. Modifier reports. RF paranoia. And the fix was not a bitmask.

The fix was replacing the Ctrl switch with one that has higher resistance, so I can feel when I bump it.

Honestly? That is beautiful. Annoying, but beautiful.

Software people love pretending all bugs live in software. Embedded work cures you of that disease. The system includes the PCB, the MCU, the wireless module, the firmware, the operating system, the desk, the key switch, and the human hand attached to the debugging session. Ignore any layer and it will eventually file a bug report in production.

The post-mortem

By June 20, the branch had reached:

1d1fe40f3bcc Minior bugfix

The repository is public, if you want to inspect the suffering in its natural habitat: myceliatrix/qmk_firmware.

From June 16 to June 20, the firmware went from “I would like my ISO keys back” to a modern QMK 0.33.7 port with:

  • ISO and ANSI variants,
  • shared Halo75 V2 firmware code,
  • VIA-compatible keymaps,
  • fixed ISO matrix/key positions,
  • working LED matrix and side LED zoning,
  • IS31FL3733 compatibility fixes,
  • UART pin and alternate-function fixes for STM32F072,
  • RF/Bluetooth housekeeping restored,
  • USB suspend behavior fixed for wireless mode,
  • fewer blocking waits,
  • a stricter RF parser,
  • deferred UART command handling,
  • and a HID console that found a human factors bug wearing a firmware costume.

This is why “just flash the firmware” is a cursed phrase. Firmware is not an app update. Firmware is a tiny treaty between silicon, timing, voltage, physical switches, protocol state, and somebody’s hand at 01:07 in the morning.

The lesson is not “never buy a pink keyboard impulsively.” That would be cowardice, and also aesthetically wrong.

The lesson is: if a device ships with QMK, source matters. If the vendor only publishes one layout’s firmware, layout matters. If an old firmware tree works, the exact QMK version matters. If wireless goes quiet, alternate functions matter. If keys appear haunted, matrix logs matter. And if your body is part of the input system, switch feel matters too.

Also, before assuming your firmware is broken, maybe check whether your Ctrl key is being lovingly shoulder-checked by a hand with partial sensation.

But still write the debug console.

Future you deserves receipts.


back to all field notes
NORMAL ~/blog
--:--:--