Skip to content

Lemon Wired ZMK firmware (USB split via Pico-PIO-USB)#90

Open
Olson3R wants to merge 17 commits into
rianadon:mainfrom
Olson3R:lemon-wired-zmk-phase1
Open

Lemon Wired ZMK firmware (USB split via Pico-PIO-USB)#90
Olson3R wants to merge 17 commits into
rianadon:mainfrom
Olson3R:lemon-wired-zmk-phase1

Conversation

@Olson3R

@Olson3R Olson3R commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Generator support for ZMK firmware on the Cosmos Lemon Wired RP2040 board, with Pico-PIO-USB as the split transport (a USB-C cable between the two halves' Link ports). Companion to the rianadon/zmk PR #1.

Depends on:

  • #87 — keyboard layout selector (foundational)
  • #88 — Miryoku keymap preset

This PR's diff currently includes those two stacks. Once #87 and #88 land, the diff will narrow to just the lemon-wired commits below.

What's in this stack (lemon-wired only)

  • New lemon-wired microcontroller path through generateConf / generateOverlay / generateDefconfig / generateWestYaml / boardOverlay in src/routes/beta/lib/firmware/zmk.ts.
  • Per-side conf emit: central gets CONFIG_USB_DEVICE_STACK + CONFIG_ZMK_USB + an optional CDC ACM console; peripheral gets the device stack + a zsu_cdc_acm node under zephyr_udc0 (the central's PIO-USB UHC enumerates and drives that bulk pair). Both halves disable CONFIG_FLASH/NVS/SETTINGS until the rainadon-zmk fork ships a working RP2040 flash driver.
  • Per-side overlay emit: central disables &uart0, brings up raspberrypi,pio-usb-host on GP0, exposes zmk,usb-split over it, and adds cdc_acm_uart0 for the console when enabled. Peripheral declares zsu_cdc_acm under &zephyr_udc0.
  • west.yml points at Olson3R/rainadon-zmk@main (the fork carrying the cosmos_lemon_wired board + USB split transport); the Pico-PIO-USB module + Olson3R/zephyr-module-pio-usb@v0.1.0 wrapper come in transitively from the fork's app/west.yml.
  • Splits string-literal types 'lemon-wired' | 'lemon-wireless' and 'uart' | 'pio-usb' into as const enum-likes (Microcontroller, SplitTransport).
  • UI: Pico-PIO-USB is the default split transport; UART is the legacy fallback.

Test plan

  • npm run check passes
  • make dev — open /beta, design a 36-key Lemon Wired layout, choose ZMK firmware
  • Pick "Pico-PIO-USB" transport (default), download the zip
  • Generated config/west.yml references Olson3R/rainadon-zmk@main
  • Generated central .conf has CONFIG_USB_DEVICE_STACK=y + CONFIG_ZMK_USB=y + CONFIG_ZMK_SPLIT_USB=y + CONFIG_FLASH=n + CONFIG_USB_DEVICE_PRODUCT="<your name>" + a CONSOLE/LOG block when console is enabled
  • Generated peripheral .conf has the device stack but no CONFIG_ZMK_USB; product name has " (peripheral)" suffix
  • Generated central overlay disables &uart0 and adds pio_usb_host + usb_split + cdc_acm_uart0 nodes
  • Generated peripheral overlay adds zsu_cdc_acm under &zephyr_udc0
  • west init -l config/ && west update && west build succeeds for both halves
  • Pick "UART (legacy)" transport; generator emits zmk,wired-split per-side (regression check)
  • Wired unibody (no split): generator emits no USB-split or UART-split configs

🤖 Generated with Claude Code

@vercel

vercel Bot commented Apr 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cosmos-keyboards Ready Ready Preview May 3, 2026 5:52am

@Olson3R Olson3R force-pushed the lemon-wired-zmk-phase1 branch from b6bfc2a to c546775 Compare May 1, 2026 13:50
@Olson3R Olson3R changed the title Lemon Wired ZMK firmware (USB split) + Miryoku/layout presets Lemon Wired ZMK firmware (USB split via Pico-PIO-USB) May 1, 2026
@Olson3R Olson3R force-pushed the lemon-wired-zmk-phase1 branch from c546775 to b77a885 Compare May 1, 2026 16:50
Olson3R and others added 8 commits May 1, 2026 15:03
Cosmos previously baked QWERTY into letterForKeycap() and FLIPPED_KEY,
so every keycap legend and ZMK/QMK keycode came out QWERTY. This adds a
layout dimension to the cosmos config (proto field 33, persisted in the
URL) and wires it through the alpha-letter generation path so swapping
the layout updates both the on-keycap legends and the firmware export.

- src/lib/layouts/ exposes 5 layouts as a const-keyed registry with
  per-layout right-row letters and split-half flip maps. DEFAULT_LAYOUT
  preserves QWERTY back-compat for older shared configs.
- letterForKeycap, cosmosFingers, keycapInfo, and flippedKey accept an
  optional layout (default: QWERTY); mirrorCluster threads the keyboard's
  layout in via its callers (config.cosmos, toCode, HandFitView,
  VisualEditor2).
- Editor adds a Layout field after Keycaps. Switching layout calls
  applyLayoutToKeys, which uses alphaColumns() to update only the alpha
  block; number/F/punctuation rows stay layout-independent.
- Firmware exporters need no changes: ZMK/QMK already key off
  keycap.letter, which is now layout-aware end-to-end.

Tests cover all five layouts plus flip behavior. CI svelte-check
(bun src/scripts/check.ts) clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lock in the contract that the layout dimension flows from cosmos config
through key letters to firmware keycodes:

- Round-trip every LAYOUT_IDS value through encode → serialize → decode
  and verify the layout survives.
- Verify a legacy URL (encoded before this feature) still decodes as
  QWERTY for back-compat.
- Build a default Cosmos config and confirm applyLayoutToKeys updates
  alpha-block letters per layout while leaving the number row untouched.
- Confirm ZMK and QMK keycode() emit per-letter codes, and that the
  rightmost-of-center home-row position emits a different keycode under
  each layout (h/h/m/d/y across QWERTY/Colemak/Colemak-DH/Dvorak/Workman).

The keycode() helpers in zmk.ts and qmk.ts are now exported so the
end-to-end tests can verify the firmware contract directly without
needing a full FullGeometry stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tsconfig-paths' matchPath returns the absolute path verbatim, so a
specifier like \`\$lib/layouts\` (a folder with index.ts) resolved to
\`src/lib/layouts.js\` and 404'd in Vercel's Node-only build path. Bun
handles this natively, so it only surfaces under the Node loader.

Stat the resolved path; if it's a directory, append \`/index\` before the
extension so ts-node finds index.ts (or index.js).

Verified by importing \`\$lib/layouts\` through register_loader.js
(previously: Cannot find module 'src/lib/layouts.js'; now: resolves and
exports DEFAULT_LAYOUT, LAYOUT, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per PR review, the keycap icon was a placeholder; the Letter icon (mdiAlphaA)
is more semantically aligned with a layout selector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR-review bug: switching from QWERTY to Colemak (or any layout swap)
duplicated the bottom-row outer-punctuation keys (e.g. [, ]) into the
row above them.

Cause: applyLayoutToKeys gated the swap on k.profile.row, but
keycapInfo() collapses row 5 → 4 for MT3 keycap-profile reasons. So a
row-5 key with letter `[` arrives in the swap loop as profile.row === 4
and its letter is replaced with the target layout's row-4 letter at the
same alpha-column index.

Fix: gate by whether the original letter is one a layout actually
manages — add isAlphaLetter() that recognizes any legend appearing in
any registered layout's row 2/3/4 (right side or, after flipping, left
side). Row-5 outer punctuation (`{`, `}`, `[`, `]`, `\`) isn't in any
alpha row, so it stays untouched across all layouts.

Tests in layouts.test.ts cover the helper directly. The end-to-end test
file was already broken pre-PR due to bun-test not resolving SvelteKit
$assets aliases (separate infra issue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three of the PR-review items, sharing a layout-matcher and a CUSTOM enum
entry:

#5 (detect layout on expert→basic): toFullCosmosConfig used to leave
state.options.layout at QWERTY regardless of what the user actually
typed in expert mode. Run the new detectLayout(kbd) right after
toFullCosmosConfig in App.setMode so the dropdown reflects the keymap
the user has — including switching to "Custom" when the keymap doesn't
match any registered layout.

#4 (Custom layout + auto-detect): add LAYOUT.CUSTOM to the registry as
a sentinel with no letter mapping. The dropdown lists it explicitly
(LAYOUT_NAMES is the new label source). Picking Custom from a named
layout opens a small helper dialog explaining how to edit individual
key legends. A reactive `$:` block in VisualEditor2 watches protoConfig
and auto-flips to Custom whenever detectLayout no longer matches the
stored named layout — covers per-key letter edits, alpha-column
deletes, etc.

rianadon#6 (block named-layout switch from Custom when keys are missing): when
the user is on Custom and picks a named layout, missingKeysFor() checks
that the keyboard has the canonical 5-col alpha block. If not, the
dropdown reverts and a dialog lists the letters they'd need to add
back (e.g. "p ; / '").

detectLayout / missingKeysFor live in visualEditorHelpers.ts (alongside
applyLayoutToKeys — they need CosmosKeyboard awareness). The matcher
allows extra alpha columns past the canonical block (per the
maintainer: "adding a key outside the layout's range" shouldn't
auto-flip), and treats anything below 5 alpha cols as Custom.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eview

Per PR review, replace the basic <Select> with the SelectThingy used
elsewhere (keycaps, switches, microcontroller). On hover each option
shows:

  - The layout's name and a one-sentence description (added to the
    KeyboardLayout schema).
  - A small split-ortholinear render of its alpha block — left and
    right halves side-by-side, home row highlighted. Custom shows '?'
    placeholders.

The dropdown change also flips updateLayout from a DOM Event to a
SelectThingy CustomEvent. The Custom-out-of-Custom revert no longer
needs to mutate target.value — leaving $protoConfig.layout alone is
enough because SelectThingy is bound to the store value and re-syncs
on the next reactive tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Most cosmos configs store only the right finger cluster and synthesize
the left at render time via mirrorCluster + flipLetter (now layout-
aware after PR rianadon#87 added the layouts feature). The Letter editor in
Viewer3D was bound to the raw stored letter, so clicking a visually-
left key in 3D — say Colemak's "p" — showed the right-side
source-of-truth letter "l" in the input. Same key on screen, two
different letters in two places.

Track which visual half the user clicked in a new clickedVisualSide
store (set by KeyboardKey + KeyboardKeyInstance, both of which already
know their cluster's side). When a left-side click resolves to a
right-cluster key (i.e. mirror form is active), flip the displayed
value through flipLetter on read AND on write. Edits still go to the
right cluster's storage so the mirror behavior — edit one half,
both update — is preserved.

Type fix in the same area: ALPHA_ROWS in SelectLayoutInner is now
typed as `(2 | 3 | 4)[]` so the rightRows record can be indexed
without TS noise about a generic number index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Olson3R Olson3R force-pushed the lemon-wired-zmk-phase1 branch from b77a885 to 0b4a4dc Compare May 1, 2026 20:32
Without the protoConfig.set(next) the layout dropdown and 3D view
keep showing whatever was loaded before the expert-mode edits, even
though state.options is updated to the post-detect cosmos config.
The dropdown binds to \$protoConfig.layout, so the store has to be
the channel.

Also recompute config = fromCosmosConfig(next) so the 3D view
re-materializes from the new cosmos state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Olson3R and others added 3 commits May 2, 2026 23:52
Per maintainer review: layout no longer lives as a stored field on
CosmosKeyboard. It's a pure function of the alpha-key labels — like
clusterAngle/clusterSeparation already are. The dropdown calls
detectLayout(\$protoConfig) at render time and applyLayoutToKeys when
the user picks a new option. Custom is the absence of a named match.

This subsumes the maintainer's other code-review concern (the
suspicious reactive `$:` block in VisualEditor2 that broke the
browser back button) — there's nothing to auto-flip because nothing
holds stale state. The dropdown re-derives on every reactivity
trigger.

Schema changes:
- src/proto/cosmos.proto: \`reserved 33;\` for the old layout field
  number. Old URLs with \`layout=N\` are silently dropped on decode;
  the layout is re-derived from the keys' letters.
- CosmosKeyboard interface drops the \`layout\` field; KEYBOARD_DEFAULTS
  drops \`layout\`; encode/decode drops \`encodeLayout\`/\`decodeLayout\`.

Helper relocation: detectLayout, missingKeysFor, alphaColumns,
STANDARD_ALPHA_COLS move from visualEditorHelpers.ts (editor module)
into config.cosmos.ts (the data-model module) so mirrorCluster can
call detectLayout internally without circular imports.

mirrorCluster signature: takes \`kbd: CosmosKeyboard | undefined\`
instead of \`layout: LayoutId\`. Internal callers pass the kbd in
scope; the module-level cluster cache passes \`undefined\` and gets
QWERTY semantics (labels are erased anyway). All 6 internal sites +
3 external sites (HandFitView, toCode, visualEditorHelpers) updated.

Read sites converted to detectLayout(kbd):
- setClusterSize: detect BEFORE the resize so the seed letters reflect
  the user's pre-resize layout. CUSTOM falls back to QWERTY for the
  seed (CUSTOM has no canonical letters).
- Viewer3D Letter input flip-on-read/write: detectLayout(\$protoConfig)
  is the layout for the flipMap.

Write sites removed: the dropdown handler, App.setMode, and the
endToEnd test fixture all stop writing \`kbd.layout\`. The first two
just call applyLayoutToKeys; the test asserts via detectLayout instead.

Tests rewritten to round-trip via the keys (the layout survives
because applyLayoutToKeys writes Colemak/etc. letters into
\`profile.letter\`; serialize/deserialize preserves those; detectLayout
re-derives the named layout from them).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A small popover-alert system that the layout dropdown can use for
"missing keys" warnings and "switched to Custom" hints without
blocking the page with a modal Dialog. Maintainer asked for this in
PR rianadon#87 review ("now's a good time").

Public API in src/lib/store.ts:

  pushAlert({ message, anchor, variant?, durationMs? }): symbol
  dismissAlert(id)
  alerts: Writable<AlertItem[]>

\`Alert.svelte\` mounts once in App.svelte and iterates the alerts
store. Each alert renders via \`AlertPopover.svelte\`, which positions
itself just below + left-aligned with its anchor element using
\`getBoundingClientRect\` (no extra dep needed; the existing
melt-ui/svelte-easy-popover libs are heavier than necessary for this).
A 10s setTimeout drives auto-dismiss; a CSS keyframe animates a
matching progress bar at the bottom. Both pause on pointer-enter and
resume on pointer-leave so users can read longer messages. A
MutationObserver watches the anchor — if it leaves the DOM the alert
self-dismisses.

Variants: info (pink, default), warn (amber), error (red).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Custom-info dialog → an info alert anchored to the Layout field.
- "Missing keys for X" dialog → a warn alert with the missing letters.
  Both rely on the new pushAlert/Alert framework and dismiss the user
  out of a modal context (the dropdown stays usable).
- Drop the now-unused base path import and the dialog templates.
- Lighten the home-row pink in the dropdown's alpha-block preview
  per maintainer feedback (less contrast between rows; still visibly
  distinguished as the home row).
- Drop the '?' grid preview for Custom — text-only is enough.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Olson3R and others added 5 commits May 3, 2026 00:13
Adds microcontroller, wiredVersion, splitTransport, linkPort fields so the
ZMK generator can emit per-MCU configs. Excludes microcontroller from the
persistent storable in PeaConfig.svelte since it's derived from the
geometry config, not a user preference.

No behavior change yet — generator code paths still hard-coded to Lemon
Wireless. Subsequent commits add boardName dispatch, BOARD_OVERLAY split,
and per-MCU DTSI generation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Make the ZMK generator MCU-aware: boardName() picks cosmos_lemon_wired
(or _v5) for the wired path, BOARD_OVERLAY is split into NRF52 and RP2040
variants, and generateDTSI is driven off a per-MCU profile (rows/cols/
encoder pins, optional SPI-595 shifter, ext-power-hog polarity, VIK SPI
register prefix). The wired BOARD_OVERLAY sets up &uart0 on GP0/GP1 for
the wired-split transport, &spi1 + &i2c1 for VIK, and the VIK gpio-map
for the wired pinout.

generateConf adds CONFIG_PINCTRL/GPIO/ZMK_SPLIT/ZMK_SPLIT_WIRED for the
wired path and forces RGB underglow off (Phase 1 has no PIO ws2812 yet).
generateWestYaml pins the wired path to Olson3R/rainadon-zmk on the
lemon-wired-zmk-phase1 branch.

UI: PeaConfig.svelte gains a Firmware select (QMK / ZMK) on the wired
branch; ZMK mode shows centralSide / splitTransport (UART only for now)
/ Studio / USB logging plus the Miryoku picker, and exposes a Download
ZMK code button. fullOptions now passes microcontroller through and
forces underGlowAtStart=false for wired ZMK.

Cross-link from the Lemon Wired doc to the new ZMK option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The new cosmos_lemon_wired board (added in the rainadon-zmk fork on the
lemon-wired-zmk-phase1 branch) already enables uart0/spi1/i2c1 with their
pinctrl groups, so the per-shield overlay no longer needs to redefine
them. Keep only the wired_split node + VIK connector pin map + bus
aliases here.

Drop the cosmos_lemon_wired_v5 board name — the only Phase-1 hardware
diff between v0.4 and v0.5 (LED-relay polarity) already lives in the
per-shield ext_power_hog. One Zephyr board is enough; if v0.5 ever needs
its own DTS we can split then.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wires the generator end-to-end with the Phase-2 USB split work that landed
in Olson3R/rainadon-zmk + Olson3R/zephyr-module-pio-usb (v0.1.0):

  - generateConf: per-side conf — USB device stack + ZMK_USB on the
    central, USB device stack only on the peripheral (its CDC ACM bulk
    pair is what the central's PIO-USB UHC drives).
  - generateDefconfig: ZMK_SPLIT_USB=y on both halves under usb-split.
  - generateOverlay: per-side DT — central disables uart0, brings up
    raspberrypi,pio-usb-host on GP0, exposes zmk,usb-split with that as
    its UHC, and optionally wires a CDC ACM console on native USB-C.
    Peripheral declares zsu_cdc_acm under zephyr_udc0.
  - BOARD_OVERLAY_RP2040: drops the unconditional zmk,wired-split node;
    the legacy UART transport now lives in the per-side overlay only
    when splitTransport === 'uart'.
  - generateWestYaml: bumps revision from lemon-wired-zmk-phase1 to
    main on Olson3R/rainadon-zmk (the fork's app/west.yml pulls in
    zephyr-module-pio-usb @ v0.1.0 transitively).

Also lifts splitTransport / microcontroller string-literal types into
exported `as const` enum-like objects (SplitTransport, Microcontroller)
and updates PeaConfig.svelte to match — Pico-PIO-USB is now the default,
UART is the legacy alternative.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Post-review pass on commit 73bac5a. Three real bugs + two QoL gaps:

- generateConf treated unibody as central and emitted USB-split configs
  for non-existent splits. Mirror generateOverlay's split/isCentral
  semantics so unibody+wired stays out of the split branches entirely
  (and ZMK_SPLIT/ZMK_SPLIT_WIRED no longer leak into unibody output).
- Emit CONFIG_FLASH=n / NVS=n / SETTINGS=n for wired — Zephyr 3.5's
  RP2040 flash driver is broken in the rainadon-zmk fork; without the
  override, settings init hangs. The board defconfig sets these to y
  for when the driver lands; per-shield .conf overrides until then.
- Emit CONFIG_USB_DEVICE_PRODUCT/VID/PID per side for the pio-usb path
  (peripheral product gets a "(peripheral)" suffix).
- Emit a CONSOLE/LOG block on the central when enableConsole=true so
  the CDC ACM port actually surfaces logs (was silent before).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

1 participant