From b2b9585e7255a309880e28b9dde2c346e6fb5a7b Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:03:49 -0400 Subject: [PATCH 01/17] docs: add moonlight module design spec --- .../2026-07-15-moonlight-module-design.md | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-15-moonlight-module-design.md diff --git a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md new file mode 100644 index 0000000..e320744 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md @@ -0,0 +1,241 @@ +# Moonlight — QMK Community Module for Room Lighting + +**Date:** 2026-07-15 +**Status:** Approved design, pending implementation plan +**Repo:** `zsa/qmk_modules` (this repo), new module `moonlight/` +**Firmware base:** `zsa/qmk_firmware`, branch `firmware25` (latest stable ZSA fork; supports community modules) + +## Purpose + +Upcycle broken ZSA keyboards as USB-powered room lights. A keyboard whose +matrix is too damaged for typing usually still has fully working LEDs and +power circuitry. Moonlight turns it into a lamp: + +1. The LEDs turn on as soon as the board receives USB power — including from + a dumb wall charger or power bank, with no computer and no USB enumeration. +2. A set of assignable keycodes controls the light: on/off, brightness, hue, + preset colors, and animations (start/stop/next/faster/slower). +3. The board is **lamp-only by default**: it never sends keystrokes to a + host, so a shorting matrix can't type garbage into a computer. + +Moonlight is purely for light control. It is not a keyboard-lighting / +typing-feedback feature, and the README must say so up front. + +## Why a community module + +- Community modules (QMK ≥ 25 / ZSA `firmware25`) can declare keycodes, + provide hooks (`keyboard_post_init`, `pre_process_record`, + `process_record`), and ship a `config.h` that participates in the build's + config chain. That covers every requirement with **zero changes to the + firmware repo**. +- The previous plan (a userspace in the `qmk_for_lighting` fork of + `firmware23`) is retired. `firmware23` was a mistake; it predates module + support. The `qmk_for_lighting` repo is no longer needed. + +## Git hygiene + +All work must go in the community modules repo in a branch called `feat/moonlight`. Keep commits tidy and easy to review, batch related changes together. It is important that the whole change ships as a cohesive pull request that makes sense. Avoid overengineering. + +## Target boards + +Moonlander (`keyboards/zsa/moonlander`) and Voyager (`keyboards/zsa/voyager`) +on `firmware25`. The module itself is board-agnostic: it requires only +`RGB_MATRIX_ENABLE` and errors out at compile time without it. Other +rgb-matrix boards (e.g. Planck EZ) should work but are untested/out of scope. + +## Module layout + +``` +moonlight/ +├── qmk_module.json # manifest: module_name "moonlight", +│ # features { rgb_matrix: true }, keycodes list +├── config.h # NO_USB_STARTUP_CHECK + tunable defaults +├── moonlight.c # all behavior: hooks + keycode handlers +├── examples/ +│ ├── voyager_lamp.json # complete keymap.json, "modules": ["moonlight"] +│ └── moonlander_lamp.json +└── README.md +``` + +Users hand-edit a keymap.json (typically starting from an example), keep +`"modules": ["moonlight"]`, arrange the keycodes on whichever physical keys +still work, and build with `qmk compile`. + +## Standalone power (works on a wall charger) + +**Mechanism (verified against `firmware25` sources):** + +- Boot does not wait for enumeration: `WAIT_FOR_USB` is not defined for + these boards, so `protocol_pre_init()` proceeds immediately. +- The only thing that darkens the board without a host is the suspend trap + in `tmk_core/protocol/chibios/chibios.c` (~line 184): the STM32F303 USB + peripheral raises SUSP after ~3 ms of bus idle (always true on a charger), + ChibiOS marks the driver `USB_SUSPENDED`, and the main loop spins in + `suspend_power_down()` forever (remote wakeup is never negotiated without + enumeration). This kills the LEDs and key scanning. +- That entire block is guarded by `#if !defined(NO_USB_STARTUP_CHECK)`. + Moonlight's `config.h` defines **`NO_USB_STARTUP_CHECK`**. Module config.h + files are added to the build's config chain (`build_keyboard.mk`, + `config_h_community_module_appender`), and the define is purely additive. +- With the suspend block compiled out, no other path touches the LEDs: + `usb_event_suspend_handler()` only records device state (no `SLEEP_LED` on + these boards), and Voyager's `"sleep": true` (`RGB_MATRIX_SLEEP`) acts only + through `suspend_power_down_quantum()`, which is now never called. + +**Consequences (accepted, documented in README):** + +- The board ignores USB suspend entirely: plugged into a computer that goes + to sleep, the lamp stays on and keeps drawing full LED current. +- Drawing LED-level current from a port without enumerating exceeds the + USB unconfigured-device budget (100 mA). Chargers don't care; this is a + lamp, not a certified USB device. +- USB still enumerates normally when a real host is present, so reflashing + keeps working. + +## Keycodes and behavior model + +The light is always in one of two shapes: + +- **Steady** — `RGB_MATRIX_SOLID_COLOR` at the current HSV. +- **Animating** — any enabled, non-reactive rgb_matrix animation. + +All persistent state lives in QMK's existing `rgb_matrix_config` +(enable/mode/HSV/speed), saved to EEPROM through the standard +eeprom-persisting API variants. The only module state is one RAM variable: +the last animation mode used (so "stop, then start" resumes the same +animation within a power session). + +| Keycode | Alias | Behavior | +|---|---|---| +| `MOONLIGHT_ON` | `MNL_ON` | `rgb_matrix_enable()` | +| `MOONLIGHT_OFF` | `MNL_OFF` | `rgb_matrix_disable()` | +| `MOONLIGHT_BRIGHTER` | `MNL_BRI` | brightness up one step | +| `MOONLIGHT_DIMMER` | `MNL_DIM` | brightness down one step, floored (see guardrails) | +| `MOONLIGHT_HUE_UP` | `MNL_HUU` | hue wheel forward | +| `MOONLIGHT_HUE_DOWN` | `MNL_HUD` | hue wheel backward | +| `MOONLIGHT_ANIM_START` | `MNL_AST` | switch to last-used animation (default: breathing) | +| `MOONLIGHT_ANIM_STOP` | `MNL_ASP` | freeze into steady solid color at current HSV | +| `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | next animation, skipping reactive effects (runtime skip) | +| `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | animation speed up | +| `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | animation speed down | +| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | steady mode + jump to preset HSV | + +Notes: + +- Hue/brightness changes apply in both shapes (they recolor animations too, + where the effect uses the base HSV). +- `MOONLIGHT_ANIM_NEXT` was added beyond the original request because + without it only one animation is ever reachable. No toggle keycode: + on/off are discrete by design. +- Preset colors are defined per keymap in its `config.h`: + `#define MOONLIGHT_PRESET_1 {HSV_CORAL}` (any `{h, s, v}` triple, so warm + white via low saturation is possible). Unset slots get built-in defaults. + Pressing a preset always lands in steady mode. + +## Reactive-animation exclusion + +Reactive effects make no sense on a lamp and must never be reachable via +`MOONLIGHT_ANIM_NEXT` / `MOONLIGHT_ANIM_START`. Because a module's config.h +is included *before* the keyboard's generated config, the module cannot +compile them out; instead the module skips them at runtime against a +compile-time table of mode IDs, each entry guarded by its `#ifdef` (only +enabled effects have enum values): + +- `SOLID_REACTIVE_SIMPLE`, `SOLID_REACTIVE`, `SOLID_REACTIVE_WIDE`, + `SOLID_REACTIVE_MULTIWIDE`, `SOLID_REACTIVE_CROSS`, + `SOLID_REACTIVE_MULTICROSS`, `SOLID_REACTIVE_NEXUS`, + `SOLID_REACTIVE_MULTINEXUS`, `SPLASH`, `MULTISPLASH`, `SOLID_SPLASH`, + `SOLID_MULTISPLASH` +- `TYPING_HEATMAP` (framebuffer effect, but keypress-driven — reactive for + our purposes) +- `SOLID_COLOR` is also skipped by the cycler: it is the "steady" state, + reached via `MOONLIGHT_ANIM_STOP`, not part of the animation carousel. + +Entries for effects a given board doesn't enable simply compile away via +their `#ifdef` guards, so the table is safe on any configuration. + +## Power-up behavior + +`keyboard_post_init_moonlight()` runs after EEPROM state is restored: + +1. Force `rgb_matrix_enable()` — a lamp on a wall switch always comes on, + even if it was off when unplugged. +2. If restored brightness is below `MOONLIGHT_MIN_BOOT_BRIGHTNESS` + (default 40 of 255), raise it to that floor — never boot dark. +3. If the restored mode is reactive or out of range (e.g. the board was + previously flashed with different firmware), snap to steady solid color. + +Everything else (hue, speed, chosen animation) restores exactly as last set. + +## Lamp-only mode + +`pre_process_record_moonlight()` runs before any other keycode processing: + +- Moonlight keycodes: pass through (handled in `process_record_moonlight()`). +- `QK_BOOT`: pass through, so a keymap can keep a flash key. +- Everything else: consumed (`return false`) — nothing ever reaches the + host. Bootmagic (hold key while plugging in) is unaffected, as it runs + before keymap processing. + +Controlled by `MOONLIGHT_LAMP_ONLY`, **default on**. A keymap that wants +light control on a *working* keyboard can `#define MOONLIGHT_LAMP_ONLY 0` +in its config.h (keymap config is included after module config, so the +override works). + +## Configuration surface (module `config.h` defaults) + +| Define | Default | Meaning | +|---|---|---| +| `MOONLIGHT_LAMP_ONLY` | `1` | swallow all non-moonlight keycodes | +| `MOONLIGHT_MIN_BOOT_BRIGHTNESS` | `40` | brightness floor applied at power-up | +| `MOONLIGHT_MIN_BRIGHTNESS` | `16` | floor for `MOONLIGHT_DIMMER` (dark ≠ off; use `MNL_OFF`) | +| `MOONLIGHT_DEFAULT_ANIMATION` | breathing | animation used by first `MNL_AST` of a session; falls back to the first enabled non-reactive animation if breathing is disabled | +| `MOONLIGHT_PRESET_1`…`_8` | built-in palette | per-keymap HSV preset colors | + +Plus the non-tunable `NO_USB_STARTUP_CHECK`. + +## Error handling / guardrails + +- `#error` at compile time if `RGB_MATRIX_ENABLE` is not set, with a message + naming the module and the requirement. +- Brightness stepping clamps to `[MOONLIGHT_MIN_BRIGHTNESS, max_brightness]` + (the boards already cap max via `rgb_matrix.max_brightness`). +- Animation cycling wraps modulo the enabled-effect count and re-skips until + it lands on a valid non-reactive mode — this also protects against the + out-of-bounds-mode class of bug previously patched in `firmware23` + (stale EEPROM mode after flashing a build with fewer animations). +- All EEPROM writes go through QMK's debounced eeconfig API (as the existing + RGB keycodes do), so holding a repeat key doesn't thrash flash-backed + EEPROM emulation. + +## Testing + +Honest scope for firmware: no unit-test rig exists for community modules, +so coverage is compile verification plus structured hardware verification. +(This is a deliberate, documented deviation from the user's global 80 % +TDD rule — on-hardware firmware behavior is the wrong place to force it.) + +1. **Build checks (automatable):** compile both `examples/*.json` against + `zsa/qmk_firmware@firmware25`. The qmk_modules repo currently has no CI, + so add a GitHub Action that checks out `firmware25`, clones this repo + into `modules/`, and runs `qmk compile` on both example keymaps. +2. **Hardware checklist (in README):** + - Boots lit from a dumb USB charger / power bank. + - Every keycode behaves per the table above. + - Power-cycle restores color/brightness/animation; light is ON after + power-up even if turned off before unplugging. + - Plugged into a computer: no keystrokes ever registered (lamp-only); + light stays on when the computer sleeps. + - Reflashing works (QK_BOOT key and/or physical reset). + +## Out of scope + +- ErgoDox EZ / Planck EZ support (module should largely work on any + rgb_matrix board, but only Moonlander and Voyager are verified). +- Oryx integration or new Oryx-assignable keycodes. +- Pausing an animation mid-frame (stop = freeze into steady color). +- Any changes to `zsa/qmk_firmware` core. + +## Subagents + +Use subagents to implement this. Use smaller/weaker models as subagent subject to your judgment and review. Exercise autonomy, this spec is a goal. \ No newline at end of file From ffc882087fba34d54022f35a0b81579639781f5a Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:14:31 -0400 Subject: [PATCH 02/17] docs: presets preserve brightness; list module in root readme --- .../specs/2026-07-15-moonlight-module-design.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md index e320744..ef3b5f5 100644 --- a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md +++ b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md @@ -61,6 +61,9 @@ Users hand-edit a keymap.json (typically starting from an example), keep `"modules": ["moonlight"]`, arrange the keycodes on whichever physical keys still work, and build with `qmk compile`. +The repo root `README.md` also gets a one-line entry for `moonlight` in its +available-modules list. + ## Standalone power (works on a wall charger) **Mechanism (verified against `firmware25` sources):** @@ -118,7 +121,7 @@ animation within a power session). | `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | next animation, skipping reactive effects (runtime skip) | | `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | animation speed up | | `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | animation speed down | -| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | steady mode + jump to preset HSV | +| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | steady mode + jump to preset hue/saturation (current brightness preserved) | Notes: @@ -131,6 +134,10 @@ Notes: `#define MOONLIGHT_PRESET_1 {HSV_CORAL}` (any `{h, s, v}` triple, so warm white via low saturation is possible). Unset slots get built-in defaults. Pressing a preset always lands in steady mode. +- Presets change color only: the module applies the preset's hue and + saturation and preserves the current brightness (the `v` in the triple is + accepted for convenience with `HSV_*` macros but ignored). Changing color + never causes a brightness jump. ## Reactive-animation exclusion @@ -190,7 +197,7 @@ override works). | `MOONLIGHT_MIN_BOOT_BRIGHTNESS` | `40` | brightness floor applied at power-up | | `MOONLIGHT_MIN_BRIGHTNESS` | `16` | floor for `MOONLIGHT_DIMMER` (dark ≠ off; use `MNL_OFF`) | | `MOONLIGHT_DEFAULT_ANIMATION` | breathing | animation used by first `MNL_AST` of a session; falls back to the first enabled non-reactive animation if breathing is disabled | -| `MOONLIGHT_PRESET_1`…`_8` | built-in palette | per-keymap HSV preset colors | +| `MOONLIGHT_PRESET_1`…`_8` | built-in palette | per-keymap preset colors (hue/sat applied, v ignored) | Plus the non-tunable `NO_USB_STARTUP_CHECK`. From 409daf79c3762db46100ec467023c411f631a7eb Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:22:57 -0400 Subject: [PATCH 03/17] docs: moonlight implementation plan; preset config macros get _HSV suffix --- .../plans/2026-07-15-moonlight-module.md | 860 ++++++++++++++++++ .../2026-07-15-moonlight-module-design.md | 7 +- 2 files changed, 864 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-15-moonlight-module.md diff --git a/docs/superpowers/plans/2026-07-15-moonlight-module.md b/docs/superpowers/plans/2026-07-15-moonlight-module.md new file mode 100644 index 0000000..7b35695 --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-moonlight-module.md @@ -0,0 +1,860 @@ +# Moonlight Module Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A `moonlight` QMK community module that turns broken ZSA keyboards into USB-powered room lights: LEDs on from any USB power source (no host needed), a full set of light-control keycodes, lamp-only by default. + +**Architecture:** Pure community module in `zsa/qmk_modules` (no firmware-repo changes), built against `zsa/qmk_firmware@firmware25`. All behavior lives in `moonlight/moonlight.c` via module hooks (`keyboard_post_init_moonlight`, `pre_process_record_moonlight`, `process_record_moonlight`); keycodes are declared in `qmk_module.json`; the standalone-power fix is a single `NO_USB_STARTUP_CHECK` define in the module's `config.h`. + +**Tech Stack:** QMK community modules API (≥ 1.0.0), rgb_matrix API, C, `qmk` CLI, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-07-15-moonlight-module-design.md` (same repo — read it first). + +## Global Constraints + +- All work in the `zsa/qmk_modules` repo on branch `feat/moonlight`; tidy, batched commits; one cohesive PR at the end. Avoid overengineering. +- Firmware base: `zsa/qmk_firmware`, branch `firmware25`. Zero changes to that repo. +- Module name `moonlight`; keycodes `MOONLIGHT_*` with `MNL_*` aliases exactly as tabled in the spec. +- Keycode manifest order is load-bearing: `MOONLIGHT_ON` must be first and `MOONLIGHT_PRESET_8` last, with no gaps, so `case MOONLIGHT_ON ... MOONLIGHT_PRESET_8:` range matches work. +- Target boards: `zsa/voyager` (LAYOUT, 52 keys) and `zsa/moonlander` (LAYOUT, 72 keys). +- "Tests" for this project are compile checks (no unit-test rig exists for community modules) — every task ends with a `qmk compile` verification. Hardware verification happens once at the end via the README checklist. + +## Development environment (used by every task) + +The firmware checkout at `~/Documents/github/qmk_firmware` vendors this repo as the submodule `modules/zsa`. **The submodule working tree is the primary dev checkout** — edits there are immediately buildable. Finished commits get pushed back to the canonical local repo (`~/Documents/github/qmk_modules`, remote name `outer`). + +```bash +FW=~/Documents/github/qmk_firmware +MOD=$FW/modules/zsa # dev checkout of qmk_modules (this repo) +OUTER=~/Documents/github/qmk_modules +``` + +Build command used throughout (run from anywhere; `qmk` knows the firmware path after Task 1): + +```bash +qmk compile $MOD/moonlight/examples/voyager_lamp.json +``` + +--- + +### Task 1: Build environment on firmware25 + +**Files:** +- No repo files. Sets up `~/Documents/github/qmk_firmware` on `firmware25` and the module dev checkout. + +**Interfaces:** +- Produces: a working `qmk compile` baseline for `zsa/voyager`; `$MOD` checked out on `feat/moonlight` with remote `outer` → `~/Documents/github/qmk_modules`. + +- [ ] **Step 1: Switch firmware checkout to firmware25 and init submodules** + +```bash +cd ~/Documents/github/qmk_firmware +git status --short # must be clean; stop and report if not +git fetch origin +git checkout firmware25 +qmk git-submodule # inits/updates lib/* and modules/* submodules; takes a while +``` + +- [ ] **Step 2: Point qmk CLI at this checkout** + +```bash +qmk config user.qmk_home=$HOME/Documents/github/qmk_firmware +qmk doctor | tail -5 +``` +Expected: `qmk doctor` ends with "QMK is ready to go" (warnings are fine, errors are not). + +- [ ] **Step 3: Baseline compile (proves toolchain + firmware25 build)** + +```bash +qmk compile -kb zsa/voyager -km default +``` +Expected: ends with `[OK]` lines and produces `zsa_voyager_default.bin`. If this fails, fix the environment before proceeding — nothing else in the plan can work. + +- [ ] **Step 4: Set up the module dev checkout on feat/moonlight** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git remote add outer ~/Documents/github/qmk_modules 2>/dev/null || true +git fetch outer +git checkout -b feat/moonlight outer/feat/moonlight +git log --oneline -2 # expect the two docs: commits (spec) +``` + +--- + +### Task 2: Module scaffold — manifest, config.h, compilable skeleton + +**Files:** +- Create: `moonlight/qmk_module.json` +- Create: `moonlight/config.h` +- Create: `moonlight/moonlight.c` +- Create: `moonlight/examples/voyager_lamp.json` (minimal; finalized in Task 8) + +**Interfaces:** +- Produces: keycodes `MOONLIGHT_ON` … `MOONLIGHT_PRESET_8` (aliases `MNL_ON` … `MNL_P8`) usable in keymaps and in C; module compiles into a build. + +- [ ] **Step 1: Write the manifest** + +`moonlight/qmk_module.json` — keycode order matters (see Global Constraints): + +```json +{ + "module_name": "Moonlight", + "maintainer": "ZSA", + "license": "GPL-2.0-or-later", + "features": { + "rgb_matrix": true + }, + "keycodes": [ + { "key": "MOONLIGHT_ON", "aliases": ["MNL_ON"] }, + { "key": "MOONLIGHT_OFF", "aliases": ["MNL_OFF"] }, + { "key": "MOONLIGHT_BRIGHTER", "aliases": ["MNL_BRI"] }, + { "key": "MOONLIGHT_DIMMER", "aliases": ["MNL_DIM"] }, + { "key": "MOONLIGHT_HUE_UP", "aliases": ["MNL_HUU"] }, + { "key": "MOONLIGHT_HUE_DOWN", "aliases": ["MNL_HUD"] }, + { "key": "MOONLIGHT_ANIM_START", "aliases": ["MNL_AST"] }, + { "key": "MOONLIGHT_ANIM_STOP", "aliases": ["MNL_ASP"] }, + { "key": "MOONLIGHT_ANIM_NEXT", "aliases": ["MNL_ANX"] }, + { "key": "MOONLIGHT_ANIM_FASTER", "aliases": ["MNL_FST"] }, + { "key": "MOONLIGHT_ANIM_SLOWER", "aliases": ["MNL_SLW"] }, + { "key": "MOONLIGHT_PRESET_1", "aliases": ["MNL_P1"] }, + { "key": "MOONLIGHT_PRESET_2", "aliases": ["MNL_P2"] }, + { "key": "MOONLIGHT_PRESET_3", "aliases": ["MNL_P3"] }, + { "key": "MOONLIGHT_PRESET_4", "aliases": ["MNL_P4"] }, + { "key": "MOONLIGHT_PRESET_5", "aliases": ["MNL_P5"] }, + { "key": "MOONLIGHT_PRESET_6", "aliases": ["MNL_P6"] }, + { "key": "MOONLIGHT_PRESET_7", "aliases": ["MNL_P7"] }, + { "key": "MOONLIGHT_PRESET_8", "aliases": ["MNL_P8"] } + ] +} +``` + +- [ ] **Step 2: Write config.h** + +`moonlight/config.h`: + +```c +// Copyright 2026 ZSA Technology Labs, Inc <@zsa> +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +// Keep running when USB never enumerates (dumb charger / power bank). +// Compiles out the suspend trap in tmk_core/protocol/chibios/chibios.c that +// would otherwise park the board in suspend_power_down() forever. +#define NO_USB_STARTUP_CHECK + +// Swallow every non-moonlight keycode so a broken matrix can never type +// into a host. Keymaps for working keyboards may set this to 0. +#ifndef MOONLIGHT_LAMP_ONLY +# define MOONLIGHT_LAMP_ONLY 1 +#endif + +// A lamp never boots dark: brightness floor applied at power-up. +#ifndef MOONLIGHT_MIN_BOOT_BRIGHTNESS +# define MOONLIGHT_MIN_BOOT_BRIGHTNESS 40 +#endif + +// MOONLIGHT_DIMMER floor (dim ≠ off; MOONLIGHT_OFF turns the light off). +#ifndef MOONLIGHT_MIN_BRIGHTNESS +# define MOONLIGHT_MIN_BRIGHTNESS 16 +#endif +``` + +- [ ] **Step 3: Write the skeleton moonlight.c** + +`moonlight/moonlight.c`: + +```c +// Copyright 2026 ZSA Technology Labs, Inc <@zsa> +// SPDX-License-Identifier: GPL-2.0-or-later +// +// Moonlight: light control for upcycled keyboards. Purely a lamp module — +// see README.md. All behavior is driven by rgb_matrix. + +#include QMK_KEYBOARD_H + +ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); + +#ifndef RGB_MATRIX_ENABLE +# error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." +#endif +``` + +- [ ] **Step 4: Write the minimal example keymap (finalized in Task 8)** + +`moonlight/examples/voyager_lamp.json` — one layer, mostly `KC_NO`, a few moonlight keys to prove keycode generation. Voyager `LAYOUT` takes 52 keys: 4 rows × 6 columns per half (rows interleave left/right), then 2 thumb keys per half. + +```json +{ + "keyboard": "zsa/voyager", + "keymap": "moonlight_lamp", + "layout": "LAYOUT", + "modules": ["zsa/moonlight"], + "layers": [ + [ + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", + "KC_NO", "KC_NO" + ] + ] +} +``` + +- [ ] **Step 5: Compile to verify the scaffold** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS (`[OK]`, `.bin` produced). Failure modes to fix here: manifest schema errors, module not found (path/name mismatch), keycode generation errors. + +- [ ] **Step 6: Commit and push to outer** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/ +git commit -m "feat: scaffold moonlight module (manifest, config, skeleton)" +git push outer feat/moonlight +``` + +--- + +### Task 3: Core light control — on/off, brightness, hue + +**Files:** +- Modify: `moonlight/moonlight.c` + +**Interfaces:** +- Consumes: keycodes from Task 2; QMK rgb_matrix API (`rgb_matrix_enable/disable`, `rgb_matrix_increase_val`, `rgb_matrix_get_hsv`, `rgb_matrix_sethsv`, `rgb_matrix_increase_hue`, `rgb_matrix_decrease_hue` — all EEPROM-persisting variants). +- Produces: `process_record_moonlight(uint16_t keycode, keyrecord_t *record)` handling the six core keycodes; later tasks extend its `switch`. + +- [ ] **Step 1: Add the handler skeleton and core cases** + +Append to `moonlight/moonlight.c`: + +```c +bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { + if (!process_record_moonlight_kb(keycode, record)) { + return false; + } + + // Moonlight keycodes act on press only; consume the release too. + if (!record->event.pressed) { + switch (keycode) { + case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: + return false; + default: + return true; + } + } + + switch (keycode) { + case MOONLIGHT_ON: + rgb_matrix_enable(); + return false; + case MOONLIGHT_OFF: + rgb_matrix_disable(); + return false; + case MOONLIGHT_BRIGHTER: + rgb_matrix_increase_val(); // clamps at RGB_MATRIX_MAXIMUM_BRIGHTNESS + return false; + case MOONLIGHT_DIMMER: { + HSV hsv = rgb_matrix_get_hsv(); + uint8_t v = hsv.v > MOONLIGHT_MIN_BRIGHTNESS + RGB_MATRIX_VAL_STEP + ? hsv.v - RGB_MATRIX_VAL_STEP + : MOONLIGHT_MIN_BRIGHTNESS; + rgb_matrix_sethsv(hsv.h, hsv.s, v); + return false; + } + case MOONLIGHT_HUE_UP: + rgb_matrix_increase_hue(); + return false; + case MOONLIGHT_HUE_DOWN: + rgb_matrix_decrease_hue(); + return false; + default: + return true; + } +} +``` + +- [ ] **Step 2: Compile** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS. + +- [ ] **Step 3: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/moonlight.c +git commit -m "feat: core light control (on/off, brightness, hue)" +git push outer feat/moonlight +``` + +--- + +### Task 4: Animation model — reactive skip table, start/stop/next/faster/slower + +**Files:** +- Modify: `moonlight/moonlight.c` + +**Interfaces:** +- Consumes: `process_record_moonlight` switch from Task 3. +- Produces: `moonlight_mode_is_lamp_safe(uint8_t mode)` and `moonlight_next_anim(uint8_t from)` (static helpers used by Task 6); `moonlight_last_anim` (static `uint8_t`, 0 = none yet). + +- [ ] **Step 1: Add helpers ABOVE `process_record_moonlight`** + +```c +// Modes that must never run on a lamp: reactive/keypress-driven effects, +// plus NONE and out-of-range. SOLID_COLOR is excluded from the carousel +// too — it is the "steady" state reached via MOONLIGHT_ANIM_STOP. +static bool moonlight_mode_is_lamp_safe(uint8_t mode) { + switch (mode) { + case RGB_MATRIX_NONE: + case RGB_MATRIX_SOLID_COLOR: +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_SIMPLE + case RGB_MATRIX_SOLID_REACTIVE_SIMPLE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE + case RGB_MATRIX_SOLID_REACTIVE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_WIDE + case RGB_MATRIX_SOLID_REACTIVE_WIDE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE + case RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_CROSS + case RGB_MATRIX_SOLID_REACTIVE_CROSS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTICROSS + case RGB_MATRIX_SOLID_REACTIVE_MULTICROSS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_NEXUS + case RGB_MATRIX_SOLID_REACTIVE_NEXUS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS + case RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS: +#endif +#ifdef ENABLE_RGB_MATRIX_SPLASH + case RGB_MATRIX_SPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_MULTISPLASH + case RGB_MATRIX_MULTISPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_SPLASH + case RGB_MATRIX_SOLID_SPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_MULTISPLASH + case RGB_MATRIX_SOLID_MULTISPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_TYPING_HEATMAP + case RGB_MATRIX_TYPING_HEATMAP: // framebuffer effect, keypress-driven +#endif + return false; + default: + return mode < RGB_MATRIX_EFFECT_MAX; + } +} + +// Next lamp-safe animation after `from`, wrapping. Falls back to +// SOLID_COLOR if the board somehow has no lamp-safe animations. +static uint8_t moonlight_next_anim(uint8_t from) { + uint8_t mode = from; + for (uint8_t i = 0; i < RGB_MATRIX_EFFECT_MAX; i++) { + mode = (mode + 1 < RGB_MATRIX_EFFECT_MAX) ? mode + 1 : 1; + if (moonlight_mode_is_lamp_safe(mode)) { + return mode; + } + } + return RGB_MATRIX_SOLID_COLOR; +} + +#if !defined(MOONLIGHT_DEFAULT_ANIMATION) && defined(ENABLE_RGB_MATRIX_BREATHING) +# define MOONLIGHT_DEFAULT_ANIMATION RGB_MATRIX_BREATHING +#endif + +static uint8_t moonlight_default_anim(void) { +#ifdef MOONLIGHT_DEFAULT_ANIMATION + if (moonlight_mode_is_lamp_safe(MOONLIGHT_DEFAULT_ANIMATION)) { + return MOONLIGHT_DEFAULT_ANIMATION; + } +#endif + return moonlight_next_anim(RGB_MATRIX_SOLID_COLOR); +} + +// Last animation used this power session (0 = none yet), so +// stop-then-start resumes the same animation. +static uint8_t moonlight_last_anim = 0; +``` + +- [ ] **Step 2: Add the animation cases to the `switch` in `process_record_moonlight`** (before `default:`) + +```c + case MOONLIGHT_ANIM_START: { + uint8_t target = moonlight_last_anim ? moonlight_last_anim : moonlight_default_anim(); + rgb_matrix_enable(); + rgb_matrix_mode(target); + moonlight_last_anim = target; + return false; + } + case MOONLIGHT_ANIM_STOP: { + uint8_t cur = rgb_matrix_get_mode(); + if (moonlight_mode_is_lamp_safe(cur)) { + moonlight_last_anim = cur; // resume point for the next START + } + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); + return false; + } + case MOONLIGHT_ANIM_NEXT: { + uint8_t cur = rgb_matrix_get_mode(); + uint8_t base = moonlight_mode_is_lamp_safe(cur) + ? cur + : (moonlight_last_anim ? moonlight_last_anim : RGB_MATRIX_SOLID_COLOR); + uint8_t next = moonlight_next_anim(base); + rgb_matrix_enable(); + rgb_matrix_mode(next); + moonlight_last_anim = next; + return false; + } + case MOONLIGHT_ANIM_FASTER: + rgb_matrix_increase_speed(); + return false; + case MOONLIGHT_ANIM_SLOWER: + rgb_matrix_decrease_speed(); + return false; +``` + +- [ ] **Step 3: Compile** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS. If a `RGB_MATRIX_*` case constant is undeclared, its `#ifdef ENABLE_...` guard name is wrong for firmware25 — check `quantum/rgb_matrix/rgb_matrix.h` enum and the `ENABLE_RGB_MATRIX_*` names in the generated `info_config.h` under `.build/`. + +- [ ] **Step 4: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/moonlight.c +git commit -m "feat: animation start/stop/next/speed with reactive-effect exclusion" +git push outer feat/moonlight +``` + +--- + +### Task 5: Preset colors + +**Files:** +- Modify: `moonlight/moonlight.c` + +**Interfaces:** +- Consumes: `process_record_moonlight` switch; `HSV` type and `HSV_*` macros from `color.h` (available via `QMK_KEYBOARD_H`). +- Produces: `moonlight_presets[8]` (static `const HSV[]`); presets apply hue+sat only, brightness preserved. + +- [ ] **Step 1: Add preset defaults and table ABOVE `process_record_moonlight`** + +```c +// Preset palette. Keymaps override any slot in their config.h, e.g. +// #define MOONLIGHT_PRESET_1_HSV {HSV_TEAL} +// Only hue and saturation are applied; current brightness is preserved +// (the v component is accepted for HSV_* macro convenience but ignored). +#ifndef MOONLIGHT_PRESET_1_HSV +# define MOONLIGHT_PRESET_1_HSV {HSV_RED} +#endif +#ifndef MOONLIGHT_PRESET_2_HSV +# define MOONLIGHT_PRESET_2_HSV {HSV_CORAL} +#endif +#ifndef MOONLIGHT_PRESET_3_HSV +# define MOONLIGHT_PRESET_3_HSV {HSV_GOLD} +#endif +#ifndef MOONLIGHT_PRESET_4_HSV +# define MOONLIGHT_PRESET_4_HSV {HSV_GREEN} +#endif +#ifndef MOONLIGHT_PRESET_5_HSV +# define MOONLIGHT_PRESET_5_HSV {HSV_AZURE} +#endif +#ifndef MOONLIGHT_PRESET_6_HSV +# define MOONLIGHT_PRESET_6_HSV {HSV_BLUE} +#endif +#ifndef MOONLIGHT_PRESET_7_HSV +# define MOONLIGHT_PRESET_7_HSV {HSV_PURPLE} +#endif +#ifndef MOONLIGHT_PRESET_8_HSV +# define MOONLIGHT_PRESET_8_HSV {HSV_WHITE} +#endif + +static const HSV moonlight_presets[] = { + MOONLIGHT_PRESET_1_HSV, MOONLIGHT_PRESET_2_HSV, MOONLIGHT_PRESET_3_HSV, MOONLIGHT_PRESET_4_HSV, + MOONLIGHT_PRESET_5_HSV, MOONLIGHT_PRESET_6_HSV, MOONLIGHT_PRESET_7_HSV, MOONLIGHT_PRESET_8_HSV, +}; +``` + +Note: the config macros are named `MOONLIGHT_PRESET_n_HSV` (not `MOONLIGHT_PRESET_n`) because the bare names are the keycode identifiers — they cannot also be object-like macros. + +- [ ] **Step 2: Add the preset case to the `switch`** (before `default:`) + +```c + case MOONLIGHT_PRESET_1 ... MOONLIGHT_PRESET_8: { + HSV preset = moonlight_presets[keycode - MOONLIGHT_PRESET_1]; + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); // presets always land steady + rgb_matrix_sethsv(preset.h, preset.s, rgb_matrix_get_hsv().v); + return false; + } +``` + +- [ ] **Step 3: Compile** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS. + +- [ ] **Step 4: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/moonlight.c +git commit -m "feat: preset color slots (hue/sat only, brightness preserved)" +git push outer feat/moonlight +``` + +--- + +### Task 6: Power-up behavior — always on, never dark, never reactive + +**Files:** +- Modify: `moonlight/moonlight.c` + +**Interfaces:** +- Consumes: `moonlight_mode_is_lamp_safe`, `moonlight_last_anim` (Task 4); `MOONLIGHT_MIN_BOOT_BRIGHTNESS` (Task 2). +- Produces: `keyboard_post_init_moonlight(void)`. + +- [ ] **Step 1: Add the hook at the END of moonlight.c** + +```c +void keyboard_post_init_moonlight(void) { + keyboard_post_init_moonlight_kb(); + + // A lamp on a wall switch always comes on. + rgb_matrix_enable(); + + // Never boot dark. + HSV hsv = rgb_matrix_get_hsv(); + if (hsv.v < MOONLIGHT_MIN_BOOT_BRIGHTNESS) { + rgb_matrix_sethsv(hsv.h, hsv.s, MOONLIGHT_MIN_BOOT_BRIGHTNESS); + } + + // EEPROM may hold a reactive or out-of-range mode (previous firmware, + // fewer animations, etc.). Snap those to steady; remember valid + // animations as the START resume point. + uint8_t mode = rgb_matrix_get_mode(); + if (mode == RGB_MATRIX_SOLID_COLOR) { + // steady — nothing to do + } else if (moonlight_mode_is_lamp_safe(mode)) { + moonlight_last_anim = mode; // restored mid-animation; keep animating + } else { + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); + } +} +``` + +- [ ] **Step 2: Compile** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS. + +- [ ] **Step 3: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/moonlight.c +git commit -m "feat: power-up always on, brightness floor, reactive-mode snap" +git push outer feat/moonlight +``` + +--- + +### Task 7: Lamp-only mode + +**Files:** +- Modify: `moonlight/moonlight.c` + +**Interfaces:** +- Consumes: `MOONLIGHT_LAMP_ONLY` (Task 2); keycode range from Task 2. +- Produces: `pre_process_record_moonlight(uint16_t keycode, keyrecord_t *record)`. + +- [ ] **Step 1: Add the hook ABOVE `process_record_moonlight`** + +```c +// Lamp-only: nothing but moonlight controls (and QK_BOOT, for flashing) +// gets processed — a broken matrix can never type into a host. +bool pre_process_record_moonlight(uint16_t keycode, keyrecord_t *record) { + if (!pre_process_record_moonlight_kb(keycode, record)) { + return false; + } +#if MOONLIGHT_LAMP_ONLY + switch (keycode) { + case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: + case QK_BOOT: + return true; + default: + return false; + } +#else + return true; +#endif +} +``` + +- [ ] **Step 2: Compile** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +``` +Expected: PASS. + +- [ ] **Step 3: Verify the opt-out path also compiles** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa/moonlight +sed -i '' 's/# define MOONLIGHT_LAMP_ONLY 1/# define MOONLIGHT_LAMP_ONLY 0/' config.h +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +sed -i '' 's/# define MOONLIGHT_LAMP_ONLY 0/# define MOONLIGHT_LAMP_ONLY 1/' config.h +git diff --stat # must be empty +``` +Expected: both compiles PASS; working tree clean afterwards. + +- [ ] **Step 4: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/moonlight.c +git commit -m "feat: lamp-only mode swallows all non-moonlight keycodes" +git push outer feat/moonlight +``` + +--- + +### Task 8: Example keymaps for both boards + +**Files:** +- Modify: `moonlight/examples/voyager_lamp.json` (full layout) +- Create: `moonlight/examples/moonlander_lamp.json` + +**Interfaces:** +- Consumes: `MNL_*` aliases (Task 2). +- Produces: two ready-to-flash reference keymaps; the canonical key arrangement documented in the README (Task 9). + +Key-order note: a keymap.json layer is a flat array in the same order as the board's `LAYOUT` macro arguments. Before finalizing, open `keyboards/zsa/voyager/keymaps/default/keymap.c` and `keyboards/zsa/moonlander/keymaps/default/keymap.c` in the firmware checkout and confirm the row structure assumed below (Voyager: rows interleave left/right, 6 per half-row, 4 rows, then 2+2 thumbs = 52; Moonlander: half-rows of 7,7,7,6,5 interleaved, then 4+4 thumbs = 72). `qmk compile` hard-fails on a count mismatch, which catches structural errors. + +- [ ] **Step 1: Finalize voyager_lamp.json** + +Replace the layers array of `moonlight/examples/voyager_lamp.json` with: + +```json + "layers": [ + [ + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", + "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", + "KC_NO", "KC_NO" + ] + ] +``` + +Layout logic (also goes in the README): left hand = controls (row 1: power/brightness, row 2: hue, row 3: animation), bottom row = presets spilling onto the right hand, `QK_BOOT` on the far top-right corner where it is hard to hit by accident. + +- [ ] **Step 2: Create moonlander_lamp.json** + +`moonlight/examples/moonlander_lamp.json`: + +```json +{ + "keyboard": "zsa/moonlander", + "keymap": "moonlight_lamp", + "layout": "LAYOUT", + "modules": ["zsa/moonlight"], + "layers": [ + [ + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", + "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO" + ] + ] +} +``` + +- [ ] **Step 3: Compile BOTH** + +```bash +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json +qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/moonlander_lamp.json +``` +Expected: both PASS. A key-count error means the row structure assumption was wrong — fix against the default keymap.c ordering. + +- [ ] **Step 4: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/examples/ +git commit -m "feat: reference lamp keymaps for voyager and moonlander" +git push outer feat/moonlight +``` + +--- + +### Task 9: Documentation — module README + root README entry + +**Files:** +- Create: `moonlight/README.md` +- Modify: `README.md` (repo root, "Available modules" list) + +**Interfaces:** +- Consumes: everything above (documents it). + +- [ ] **Step 1: Write moonlight/README.md** + +Content requirements (write full prose, not stubs; keycode table can be copied from the spec): + +1. **Opening paragraph, verbatim intent:** Moonlight is purely for light control — it turns a keyboard (typically one too broken to type on) into a USB-powered room light. It is not a keyboard-lighting or typing-feedback feature; by default it never sends a single keystroke to a computer. +2. **How it works:** lights on from any USB power source (wall charger, power bank, computer — no enumeration needed); state persists across power cycles; the light always comes on at power-up. +3. **Keycode table:** all 19 keycodes with aliases and behavior (copy from spec §"Keycodes and behavior model", including the preset hue/sat-only note). +4. **Quick start:** clone firmware25, `qmk compile modules/zsa/moonlight/examples/voyager_lamp.json`, flash with Keymapp or `qmk flash`; diagram/description of the example layout from Task 8. +5. **Customization:** the `MOONLIGHT_*` config defines table (from spec §"Configuration surface", using the `_HSV` names from Task 5), how to remap keys in the json, `MOONLIGHT_LAMP_ONLY 0` for working keyboards. Note that in lamp-only mode layer-switching keys are swallowed too — arrange everything on one layer. +6. **Caveats:** ignores USB suspend (stays lit and drawing current when a host sleeps); exceeds the USB unconfigured-device power budget by design — fine on chargers; reflashing still works normally (`QK_BOOT` key or physical reset button). +7. **Hardware test checklist** (copy the five items from spec §"Testing"). + +- [ ] **Step 2: Add the root README entry** + +In the repo root `README.md`, `Available modules` list, add: + +```markdown +- `moonlight`: Turns a (broken) keyboard into a USB-powered room light — + standalone power support, light-control keycodes, lamp-only by default +``` + +- [ ] **Step 3: Commit and push** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add moonlight/README.md README.md +git commit -m "docs: moonlight README and root module listing" +git push outer feat/moonlight +``` + +--- + +### Task 10: CI — build both examples on GitHub Actions + +**Files:** +- Create: `.github/workflows/build_moonlight.yml` + +**Interfaces:** +- Consumes: example keymaps (Task 8). +- Produces: CI that fails if either example stops compiling against firmware25. + +- [ ] **Step 1: Write the workflow** + +`.github/workflows/build_moonlight.yml`: + +```yaml +name: Build moonlight examples + +on: + push: + branches: [main, "feat/**"] + paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] + pull_request: + paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] + +jobs: + build: + runs-on: ubuntu-latest + container: ghcr.io/qmk/qmk_cli:latest + strategy: + matrix: + example: [voyager_lamp, moonlander_lamp] + steps: + - name: Checkout firmware (firmware25) + run: | + git clone --depth 1 --branch firmware25 \ + https://github.com/zsa/qmk_firmware.git /qmk_firmware + cd /qmk_firmware + qmk config user.qmk_home=/qmk_firmware + qmk git-submodule + + - name: Checkout this repo as modules/zsa + uses: actions/checkout@v4 + with: + path: modules_checkout + + - name: Overlay module checkout + run: | + rm -rf /qmk_firmware/modules/zsa + cp -r "$GITHUB_WORKSPACE/modules_checkout" /qmk_firmware/modules/zsa + + - name: Build example + run: | + cd /qmk_firmware + qmk compile modules/zsa/moonlight/examples/${{ matrix.example }}.json +``` + +- [ ] **Step 2: Push and verify the run** + +```bash +cd ~/Documents/github/qmk_firmware/modules/zsa +git add .github/workflows/build_moonlight.yml +git commit -m "ci: compile moonlight examples against firmware25" +git push outer feat/moonlight +``` + +Then push `feat/moonlight` from `~/Documents/github/qmk_modules` to GitHub (`git push -u origin feat/moonlight`) and check the Actions run. CI environment quirks (container image contents, submodule flags) may need 1-2 fix iterations — that is expected; keep fixes in this task's commit scope with `ci:` messages. + +--- + +### Task 11: Final verification and PR + +**Files:** none (verification + PR). + +- [ ] **Step 1: Spec sweep** — reread `docs/superpowers/specs/2026-07-15-moonlight-module-design.md` top to bottom; for each requirement confirm where it is implemented (file + function). Fix anything missed before proceeding. + +- [ ] **Step 2: Clean rebuild of both examples** + +```bash +cd ~/Documents/github/qmk_firmware +rm -rf .build +qmk compile modules/zsa/moonlight/examples/voyager_lamp.json +qmk compile modules/zsa/moonlight/examples/moonlander_lamp.json +``` +Expected: both `.bin` files produced. + +- [ ] **Step 3: Hardware verification (user-in-the-loop)** — flash a real board and run the README hardware checklist with the user. The wall-charger test is the headline feature; do not skip it. Record results in the PR description's test plan. + +- [ ] **Step 4: Open the PR** — push `feat/moonlight` to `zsa/qmk_modules` origin, then open a PR titled `feat: moonlight — light-control module for upcycled keyboards`, body summarizing: purpose, the NO_USB_STARTUP_CHECK mechanism, keycode table, lamp-only default, and the test plan (compile CI + hardware checklist results, with unchecked boxes for anything not yet run on hardware). Get user approval on the PR body before creating it. diff --git a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md index ef3b5f5..5877f29 100644 --- a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md +++ b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md @@ -131,8 +131,9 @@ Notes: without it only one animation is ever reachable. No toggle keycode: on/off are discrete by design. - Preset colors are defined per keymap in its `config.h`: - `#define MOONLIGHT_PRESET_1 {HSV_CORAL}` (any `{h, s, v}` triple, so warm - white via low saturation is possible). Unset slots get built-in defaults. + `#define MOONLIGHT_PRESET_1_HSV {HSV_CORAL}` (any `{h, s, v}` triple, so + warm white via low saturation is possible; the `_HSV` suffix avoids + colliding with the keycode name). Unset slots get built-in defaults. Pressing a preset always lands in steady mode. - Presets change color only: the module applies the preset's hue and saturation and preserves the current brightness (the `v` in the triple is @@ -197,7 +198,7 @@ override works). | `MOONLIGHT_MIN_BOOT_BRIGHTNESS` | `40` | brightness floor applied at power-up | | `MOONLIGHT_MIN_BRIGHTNESS` | `16` | floor for `MOONLIGHT_DIMMER` (dark ≠ off; use `MNL_OFF`) | | `MOONLIGHT_DEFAULT_ANIMATION` | breathing | animation used by first `MNL_AST` of a session; falls back to the first enabled non-reactive animation if breathing is disabled | -| `MOONLIGHT_PRESET_1`…`_8` | built-in palette | per-keymap preset colors (hue/sat applied, v ignored) | +| `MOONLIGHT_PRESET_1_HSV`…`_8_HSV` | built-in palette | per-keymap preset colors (hue/sat applied, v ignored) | Plus the non-tunable `NO_USB_STARTUP_CHECK`. From 652fff09dfdba648c990430e0feab935dbf2d133 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:43:20 -0400 Subject: [PATCH 04/17] feat: scaffold moonlight module (manifest, config, skeleton) --- moonlight/config.h | 24 +++++++++++++++++++++++ moonlight/examples/voyager_lamp.json | 20 +++++++++++++++++++ moonlight/moonlight.c | 13 +++++++++++++ moonlight/qmk_module.json | 29 ++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 moonlight/config.h create mode 100644 moonlight/examples/voyager_lamp.json create mode 100644 moonlight/moonlight.c create mode 100644 moonlight/qmk_module.json diff --git a/moonlight/config.h b/moonlight/config.h new file mode 100644 index 0000000..12ff4e1 --- /dev/null +++ b/moonlight/config.h @@ -0,0 +1,24 @@ +// Copyright 2026 ZSA Technology Labs, Inc <@zsa> +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +// Keep running when USB never enumerates (dumb charger / power bank). +// Compiles out the suspend trap in tmk_core/protocol/chibios/chibios.c that +// would otherwise park the board in suspend_power_down() forever. +#define NO_USB_STARTUP_CHECK + +// Swallow every non-moonlight keycode so a broken matrix can never type +// into a host. Keymaps for working keyboards may set this to 0. +#ifndef MOONLIGHT_LAMP_ONLY +# define MOONLIGHT_LAMP_ONLY 1 +#endif + +// A lamp never boots dark: brightness floor applied at power-up. +#ifndef MOONLIGHT_MIN_BOOT_BRIGHTNESS +# define MOONLIGHT_MIN_BOOT_BRIGHTNESS 40 +#endif + +// MOONLIGHT_DIMMER floor (dim ≠ off; MOONLIGHT_OFF turns the light off). +#ifndef MOONLIGHT_MIN_BRIGHTNESS +# define MOONLIGHT_MIN_BRIGHTNESS 16 +#endif diff --git a/moonlight/examples/voyager_lamp.json b/moonlight/examples/voyager_lamp.json new file mode 100644 index 0000000..f50d245 --- /dev/null +++ b/moonlight/examples/voyager_lamp.json @@ -0,0 +1,20 @@ +{ + "keyboard": "zsa/voyager", + "keymap": "moonlight_lamp", + "layout": "LAYOUT", + "modules": ["zsa/defaults", "zsa/moonlight"], + "layers": [ + [ + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", + "KC_NO", "KC_NO" + ] + ] +} diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c new file mode 100644 index 0000000..ba8fc66 --- /dev/null +++ b/moonlight/moonlight.c @@ -0,0 +1,13 @@ +// Copyright 2026 ZSA Technology Labs, Inc <@zsa> +// SPDX-License-Identifier: GPL-2.0-or-later +// +// Moonlight: light control for upcycled keyboards. Purely a lamp module — +// see README.md. All behavior is driven by rgb_matrix. + +#include QMK_KEYBOARD_H + +ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); + +#ifndef RGB_MATRIX_ENABLE +# error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." +#endif diff --git a/moonlight/qmk_module.json b/moonlight/qmk_module.json new file mode 100644 index 0000000..4130de1 --- /dev/null +++ b/moonlight/qmk_module.json @@ -0,0 +1,29 @@ +{ + "module_name": "Moonlight", + "maintainer": "ZSA", + "license": "GPL-2.0-or-later", + "features": { + "rgb_matrix": true + }, + "keycodes": [ + { "key": "MOONLIGHT_ON", "aliases": ["MNL_ON"] }, + { "key": "MOONLIGHT_OFF", "aliases": ["MNL_OFF"] }, + { "key": "MOONLIGHT_BRIGHTER", "aliases": ["MNL_BRI"] }, + { "key": "MOONLIGHT_DIMMER", "aliases": ["MNL_DIM"] }, + { "key": "MOONLIGHT_HUE_UP", "aliases": ["MNL_HUU"] }, + { "key": "MOONLIGHT_HUE_DOWN", "aliases": ["MNL_HUD"] }, + { "key": "MOONLIGHT_ANIM_START", "aliases": ["MNL_AST"] }, + { "key": "MOONLIGHT_ANIM_STOP", "aliases": ["MNL_ASP"] }, + { "key": "MOONLIGHT_ANIM_NEXT", "aliases": ["MNL_ANX"] }, + { "key": "MOONLIGHT_ANIM_FASTER", "aliases": ["MNL_FST"] }, + { "key": "MOONLIGHT_ANIM_SLOWER", "aliases": ["MNL_SLW"] }, + { "key": "MOONLIGHT_PRESET_1", "aliases": ["MNL_P1"] }, + { "key": "MOONLIGHT_PRESET_2", "aliases": ["MNL_P2"] }, + { "key": "MOONLIGHT_PRESET_3", "aliases": ["MNL_P3"] }, + { "key": "MOONLIGHT_PRESET_4", "aliases": ["MNL_P4"] }, + { "key": "MOONLIGHT_PRESET_5", "aliases": ["MNL_P5"] }, + { "key": "MOONLIGHT_PRESET_6", "aliases": ["MNL_P6"] }, + { "key": "MOONLIGHT_PRESET_7", "aliases": ["MNL_P7"] }, + { "key": "MOONLIGHT_PRESET_8", "aliases": ["MNL_P8"] } + ] +} From a15ca63cd3425c057f3b71cce656b7f9494bf64d Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:47:42 -0400 Subject: [PATCH 05/17] feat: core light control (on/off, brightness, hue) --- moonlight/moonlight.c | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index ba8fc66..5d615bc 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -11,3 +11,47 @@ ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); #ifndef RGB_MATRIX_ENABLE # error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." #endif + +bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { + if (!process_record_moonlight_kb(keycode, record)) { + return false; + } + + // Moonlight keycodes act on press only; consume the release too. + if (!record->event.pressed) { + switch (keycode) { + case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: + return false; + default: + return true; + } + } + + switch (keycode) { + case MOONLIGHT_ON: + rgb_matrix_enable(); + return false; + case MOONLIGHT_OFF: + rgb_matrix_disable(); + return false; + case MOONLIGHT_BRIGHTER: + rgb_matrix_increase_val(); // clamps at RGB_MATRIX_MAXIMUM_BRIGHTNESS + return false; + case MOONLIGHT_DIMMER: { + HSV hsv = rgb_matrix_get_hsv(); + uint8_t v = hsv.v > MOONLIGHT_MIN_BRIGHTNESS + RGB_MATRIX_VAL_STEP + ? hsv.v - RGB_MATRIX_VAL_STEP + : MOONLIGHT_MIN_BRIGHTNESS; + rgb_matrix_sethsv(hsv.h, hsv.s, v); + return false; + } + case MOONLIGHT_HUE_UP: + rgb_matrix_increase_hue(); + return false; + case MOONLIGHT_HUE_DOWN: + rgb_matrix_decrease_hue(); + return false; + default: + return true; + } +} From 8e41e129e17d85c4edf76225a92a374280e0244a Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:56:11 -0400 Subject: [PATCH 06/17] feat: animation start/stop/next/speed with reactive-effect exclusion --- moonlight/moonlight.c | 114 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index 5d615bc..cecf69f 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -12,6 +12,88 @@ ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); # error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." #endif +// Modes that must never run on a lamp: reactive/keypress-driven effects, +// plus NONE and out-of-range. SOLID_COLOR is excluded from the carousel +// too — it is the "steady" state reached via MOONLIGHT_ANIM_STOP. +static bool moonlight_mode_is_lamp_safe(uint8_t mode) { + switch (mode) { + case RGB_MATRIX_NONE: + case RGB_MATRIX_SOLID_COLOR: +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_SIMPLE + case RGB_MATRIX_SOLID_REACTIVE_SIMPLE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE + case RGB_MATRIX_SOLID_REACTIVE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_WIDE + case RGB_MATRIX_SOLID_REACTIVE_WIDE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE + case RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_CROSS + case RGB_MATRIX_SOLID_REACTIVE_CROSS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTICROSS + case RGB_MATRIX_SOLID_REACTIVE_MULTICROSS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_NEXUS + case RGB_MATRIX_SOLID_REACTIVE_NEXUS: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS + case RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS: +#endif +#ifdef ENABLE_RGB_MATRIX_SPLASH + case RGB_MATRIX_SPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_MULTISPLASH + case RGB_MATRIX_MULTISPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_SPLASH + case RGB_MATRIX_SOLID_SPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_SOLID_MULTISPLASH + case RGB_MATRIX_SOLID_MULTISPLASH: +#endif +#ifdef ENABLE_RGB_MATRIX_TYPING_HEATMAP + case RGB_MATRIX_TYPING_HEATMAP: // framebuffer effect, keypress-driven +#endif + return false; + default: + return mode < RGB_MATRIX_EFFECT_MAX; + } +} + +// Next lamp-safe animation after `from`, wrapping. Falls back to +// SOLID_COLOR if the board somehow has no lamp-safe animations. +static uint8_t moonlight_next_anim(uint8_t from) { + uint8_t mode = from; + for (uint8_t i = 0; i < RGB_MATRIX_EFFECT_MAX; i++) { + mode = (mode + 1 < RGB_MATRIX_EFFECT_MAX) ? mode + 1 : 1; + if (moonlight_mode_is_lamp_safe(mode)) { + return mode; + } + } + return RGB_MATRIX_SOLID_COLOR; +} + +#if !defined(MOONLIGHT_DEFAULT_ANIMATION) && defined(ENABLE_RGB_MATRIX_BREATHING) +# define MOONLIGHT_DEFAULT_ANIMATION RGB_MATRIX_BREATHING +#endif + +static uint8_t moonlight_default_anim(void) { +#ifdef MOONLIGHT_DEFAULT_ANIMATION + if (moonlight_mode_is_lamp_safe(MOONLIGHT_DEFAULT_ANIMATION)) { + return MOONLIGHT_DEFAULT_ANIMATION; + } +#endif + return moonlight_next_anim(RGB_MATRIX_SOLID_COLOR); +} + +// Last animation used this power session (0 = none yet), so +// stop-then-start resumes the same animation. +static uint8_t moonlight_last_anim = 0; + bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { if (!process_record_moonlight_kb(keycode, record)) { return false; @@ -51,6 +133,38 @@ bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { case MOONLIGHT_HUE_DOWN: rgb_matrix_decrease_hue(); return false; + case MOONLIGHT_ANIM_START: { + uint8_t target = moonlight_last_anim ? moonlight_last_anim : moonlight_default_anim(); + rgb_matrix_enable(); + rgb_matrix_mode(target); + moonlight_last_anim = target; + return false; + } + case MOONLIGHT_ANIM_STOP: { + uint8_t cur = rgb_matrix_get_mode(); + if (moonlight_mode_is_lamp_safe(cur)) { + moonlight_last_anim = cur; // resume point for the next START + } + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); + return false; + } + case MOONLIGHT_ANIM_NEXT: { + uint8_t cur = rgb_matrix_get_mode(); + uint8_t base = moonlight_mode_is_lamp_safe(cur) + ? cur + : (moonlight_last_anim ? moonlight_last_anim : RGB_MATRIX_SOLID_COLOR); + uint8_t next = moonlight_next_anim(base); + rgb_matrix_enable(); + rgb_matrix_mode(next); + moonlight_last_anim = next; + return false; + } + case MOONLIGHT_ANIM_FASTER: + rgb_matrix_increase_speed(); + return false; + case MOONLIGHT_ANIM_SLOWER: + rgb_matrix_decrease_speed(); + return false; default: return true; } From b32f4a346ae46826d9a9fe4ed7188d110f904e4f Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:04:31 -0400 Subject: [PATCH 07/17] feat: preset color slots (hue/sat only, brightness preserved) --- moonlight/moonlight.c | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index cecf69f..8b7576d 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -94,6 +94,40 @@ static uint8_t moonlight_default_anim(void) { // stop-then-start resumes the same animation. static uint8_t moonlight_last_anim = 0; +// Preset palette. Keymaps override any slot in their config.h, e.g. +// #define MOONLIGHT_PRESET_1_HSV {HSV_TEAL} +// Only hue and saturation are applied; current brightness is preserved +// (the v component is accepted for HSV_* macro convenience but ignored). +#ifndef MOONLIGHT_PRESET_1_HSV +# define MOONLIGHT_PRESET_1_HSV {HSV_RED} +#endif +#ifndef MOONLIGHT_PRESET_2_HSV +# define MOONLIGHT_PRESET_2_HSV {HSV_CORAL} +#endif +#ifndef MOONLIGHT_PRESET_3_HSV +# define MOONLIGHT_PRESET_3_HSV {HSV_GOLD} +#endif +#ifndef MOONLIGHT_PRESET_4_HSV +# define MOONLIGHT_PRESET_4_HSV {HSV_GREEN} +#endif +#ifndef MOONLIGHT_PRESET_5_HSV +# define MOONLIGHT_PRESET_5_HSV {HSV_AZURE} +#endif +#ifndef MOONLIGHT_PRESET_6_HSV +# define MOONLIGHT_PRESET_6_HSV {HSV_BLUE} +#endif +#ifndef MOONLIGHT_PRESET_7_HSV +# define MOONLIGHT_PRESET_7_HSV {HSV_PURPLE} +#endif +#ifndef MOONLIGHT_PRESET_8_HSV +# define MOONLIGHT_PRESET_8_HSV {HSV_WHITE} +#endif + +static const HSV moonlight_presets[] = { + MOONLIGHT_PRESET_1_HSV, MOONLIGHT_PRESET_2_HSV, MOONLIGHT_PRESET_3_HSV, MOONLIGHT_PRESET_4_HSV, + MOONLIGHT_PRESET_5_HSV, MOONLIGHT_PRESET_6_HSV, MOONLIGHT_PRESET_7_HSV, MOONLIGHT_PRESET_8_HSV, +}; + bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { if (!process_record_moonlight_kb(keycode, record)) { return false; @@ -165,6 +199,12 @@ bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { case MOONLIGHT_ANIM_SLOWER: rgb_matrix_decrease_speed(); return false; + case MOONLIGHT_PRESET_1 ... MOONLIGHT_PRESET_8: { + HSV preset = moonlight_presets[keycode - MOONLIGHT_PRESET_1]; + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); // presets always land steady + rgb_matrix_sethsv(preset.h, preset.s, rgb_matrix_get_hsv().v); + return false; + } default: return true; } From 22bd05def288b605a6d8ccf69bfff96ca7ed265b Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:08:22 -0400 Subject: [PATCH 08/17] feat: power-up always on, brightness floor, reactive-mode snap --- moonlight/moonlight.c | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index 8b7576d..827697c 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -209,3 +209,28 @@ bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { return true; } } + +void keyboard_post_init_moonlight(void) { + keyboard_post_init_moonlight_kb(); + + // A lamp on a wall switch always comes on. + rgb_matrix_enable(); + + // Never boot dark. + HSV hsv = rgb_matrix_get_hsv(); + if (hsv.v < MOONLIGHT_MIN_BOOT_BRIGHTNESS) { + rgb_matrix_sethsv(hsv.h, hsv.s, MOONLIGHT_MIN_BOOT_BRIGHTNESS); + } + + // EEPROM may hold a reactive or out-of-range mode (previous firmware, + // fewer animations, etc.). Snap those to steady; remember valid + // animations as the START resume point. + uint8_t mode = rgb_matrix_get_mode(); + if (mode == RGB_MATRIX_SOLID_COLOR) { + // steady — nothing to do + } else if (moonlight_mode_is_lamp_safe(mode)) { + moonlight_last_anim = mode; // restored mid-animation; keep animating + } else { + rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); + } +} From 9f5bdfe276bae44ae776269d3b02512f7dd66622 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:13:49 -0400 Subject: [PATCH 09/17] feat: lamp-only mode swallows all non-moonlight keycodes --- moonlight/moonlight.c | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index 827697c..e83f324 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -128,6 +128,25 @@ static const HSV moonlight_presets[] = { MOONLIGHT_PRESET_5_HSV, MOONLIGHT_PRESET_6_HSV, MOONLIGHT_PRESET_7_HSV, MOONLIGHT_PRESET_8_HSV, }; +// Lamp-only: nothing but moonlight controls (and QK_BOOT, for flashing) +// gets processed — a broken matrix can never type into a host. +bool pre_process_record_moonlight(uint16_t keycode, keyrecord_t *record) { + if (!pre_process_record_moonlight_kb(keycode, record)) { + return false; + } +#if MOONLIGHT_LAMP_ONLY + switch (keycode) { + case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: + case QK_BOOT: + return true; + default: + return false; + } +#else + return true; +#endif +} + bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { if (!process_record_moonlight_kb(keycode, record)) { return false; From 26f4f951d854b130b8d56602b201c121b4c8da56 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:29:18 -0400 Subject: [PATCH 10/17] feat: reference lamp keymaps for voyager and moonlander --- moonlight/examples/moonlander_lamp.json | 24 ++++++++++++++++++++++++ moonlight/examples/voyager_lamp.json | 16 ++++++++-------- 2 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 moonlight/examples/moonlander_lamp.json diff --git a/moonlight/examples/moonlander_lamp.json b/moonlight/examples/moonlander_lamp.json new file mode 100644 index 0000000..2a1014d --- /dev/null +++ b/moonlight/examples/moonlander_lamp.json @@ -0,0 +1,24 @@ +{ + "keyboard": "zsa/moonlander/revb", + "keymap": "moonlight_lamp", + "layout": "LAYOUT", + "modules": ["zsa/defaults", "zsa/moonlight"], + "layers": [ + [ + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", + "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", + "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO" + ] + ] +} diff --git a/moonlight/examples/voyager_lamp.json b/moonlight/examples/voyager_lamp.json index f50d245..71f44bf 100644 --- a/moonlight/examples/voyager_lamp.json +++ b/moonlight/examples/voyager_lamp.json @@ -5,14 +5,14 @@ "modules": ["zsa/defaults", "zsa/moonlight"], "layers": [ [ - "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", + "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", + "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", + "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", + "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO" ] From 6adfdd6c9380a5239f45d818098ee9ff420f535d Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:35:52 -0400 Subject: [PATCH 11/17] docs: moonlight README and root module listing --- README.md | 2 + moonlight/README.md | 143 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 moonlight/README.md diff --git a/README.md b/README.md index 4ed3bfa..3193783 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,5 @@ Available modules: - `oryx`: Includes the raw hid protocol to connect keyboards to Oryx's live training / Keymapp live view / typ.ing live view - `keycolors`: Includes per-layer key color change +- `moonlight`: Turns a (broken) keyboard into a USB-powered room light — + standalone power support, light-control keycodes, lamp-only by default diff --git a/moonlight/README.md b/moonlight/README.md new file mode 100644 index 0000000..700e6ed --- /dev/null +++ b/moonlight/README.md @@ -0,0 +1,143 @@ +# moonlight + +Moonlight turns a keyboard — typically one whose matrix is too broken to +type on — into a USB-powered room light. That's the whole feature. + +**This is not a keyboard-lighting or typing-feedback module.** It doesn't +react to keypresses, it doesn't animate on your typing, and by default it +never sends a single keystroke to a computer. It just lights up and stays +lit, like a lamp. + +## How it works + +- The LEDs turn on as soon as the board gets USB power — a wall charger, a + power bank, or a computer. No enumeration required, no host needed. +- Color, brightness, and animation state persist across power cycles + (stored in the same EEPROM-backed `rgb_matrix_config` QMK already uses). +- The light always comes on at power-up, even if it was switched off before + the board was last unplugged. A lamp on a wall switch doesn't remember + "off". + +## Keycodes + +All keycodes act on key-down only; the release is swallowed. + +| Keycode | Alias | Behavior | +|---|---|---| +| `MOONLIGHT_ON` | `MNL_ON` | Turn the light on | +| `MOONLIGHT_OFF` | `MNL_OFF` | Turn the light off | +| `MOONLIGHT_BRIGHTER` | `MNL_BRI` | Brightness up one step | +| `MOONLIGHT_DIMMER` | `MNL_DIM` | Brightness down one step, floored (see `MOONLIGHT_MIN_BRIGHTNESS` below — dim is not the same as off, use `MNL_OFF` for that) | +| `MOONLIGHT_HUE_UP` | `MNL_HUU` | Hue wheel forward | +| `MOONLIGHT_HUE_DOWN` | `MNL_HUD` | Hue wheel backward | +| `MOONLIGHT_ANIM_START` | `MNL_AST` | Switch to the last-used animation (default: breathing) | +| `MOONLIGHT_ANIM_STOP` | `MNL_ASP` | Freeze into steady solid color at the current HSV | +| `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | Next animation, skipping reactive/keypress-driven effects | +| `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | Animation speed up | +| `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | Animation speed down | +| `MOONLIGHT_PRESET_1` … `MOONLIGHT_PRESET_8` | `MNL_P1` … `MNL_P8` | Steady mode + jump to a preset hue/saturation | + +Notes: + +- Hue and brightness changes apply whether the light is steady or + animating — they recolor animations too, wherever the effect uses the + base HSV. +- There's no on/off toggle keycode; `MNL_ON` and `MNL_OFF` are discrete by + design. +- Presets change **color only**: pressing a preset applies its hue and + saturation and always lands in steady mode, but keeps whatever brightness + the light is currently at. The `v` (brightness) component in an + `MOONLIGHT_PRESET_n_HSV` definition is accepted for convenience with + QMK's `HSV_*` macros but is otherwise ignored — presets never cause a + brightness jump. + +## Quick start + +From a `qmk_firmware` checkout on branch `firmware25`, with this repo +checked out at `modules/zsa` (i.e. `modules/zsa/moonlight/` is this +directory): + +```sh +qmk compile modules/zsa/moonlight/examples/voyager_lamp.json +``` + +or, for a Moonlander (note the `revb` — bare `zsa/moonlander` isn't a +buildable target): + +```sh +qmk compile modules/zsa/moonlight/examples/moonlander_lamp.json +``` + +Flash the resulting firmware with Keymapp, or directly with +`qmk flash modules/zsa/moonlight/examples/voyager_lamp.json`. + +Both example keymaps lay out the full keycode set on a single layer (see +"Layer-switching keys" below for why it has to be one layer): + +- Row 1: `MNL_ON`, `MNL_OFF`, `MNL_BRI`, `MNL_DIM` +- Row 2: `MNL_HUU`, `MNL_HUD`, plus `QK_BOOT` at the far end of the row so + the board stays reflashable +- Row 3: `MNL_AST`, `MNL_ASP`, `MNL_ANX`, `MNL_FST`, `MNL_SLW` +- Row 4: `MNL_P1` through `MNL_P8` +- Every other position is `KC_NO` + +In `examples/moonlander_lamp.json`, the flat `layers` array's grouping +around indices 54–71 (a run of lines holding a single `KC_NO` each) looks +odd if you're skimming it — that's not a mistake. It mirrors the physical +argument order of the Moonlander's `LAYOUT` macro, where the thumb-cluster +keys interleave with the main rows. Leave the shape alone if you hand-edit +the file; only the values matter. + +## Customization + +Config defines, set in a keymap's own `config.h` (module config is +included first, so keymap-level defines override these): + +| Define | Default | Meaning | +|---|---|---| +| `MOONLIGHT_LAMP_ONLY` | `1` | Swallow every non-moonlight keycode (see below). Set to `0` on a working keyboard to allow normal typing alongside light control. | +| `MOONLIGHT_MIN_BOOT_BRIGHTNESS` | `40` | Brightness floor applied at power-up — the lamp never boots dark. | +| `MOONLIGHT_MIN_BRIGHTNESS` | `16` | Floor for `MOONLIGHT_DIMMER` — dimming stops here rather than going fully dark; use `MNL_OFF` to actually turn the light off. | +| `MOONLIGHT_DEFAULT_ANIMATION` | breathing | Animation used the first time `MNL_AST` runs in a power session. Falls back to the first enabled non-reactive animation if breathing isn't enabled on the board. | +| `MOONLIGHT_PRESET_1_HSV` … `MOONLIGHT_PRESET_8_HSV` | built-in palette (red, coral, gold, green, azure, blue, purple, white) | Per-keymap preset colors, e.g. `#define MOONLIGHT_PRESET_1_HSV {HSV_TEAL}`. Any `{h, s, v}` triple works, including `HSV_*` macros; only hue and saturation are actually applied (see the preset note above) — the `v` is accepted but ignored. Unset slots keep their default. | + +To remap keys, edit the `layers` array in your keymap json (or in +`keymap.c` if you're not using QMK Configurator json format) and place any +of the keycodes/aliases above wherever a physical key still works — you +don't need to fill every position, unused keys can stay `KC_NO`. + +### Layer-switching keys + +In lamp-only mode (the default), **every** non-moonlight keycode is +swallowed — including layer-switching keys like `MO()`, `TT()`, or `DF()`. +A second layer is simply unreachable, so don't design a layout that +depends on one. Put every moonlight keycode you want on a single layer. + +## Caveats + +- **Ignores USB suspend.** When plugged into a computer that goes to + sleep, the lamp stays lit and keeps drawing full LED current instead of + powering down. This is intentional — it's what makes standalone + operation on a dumb charger work at all. +- **Exceeds the USB unconfigured-device power budget by design.** Drawing + LED-level current without ever enumerating goes over the 100 mA budget + a compliant USB device is supposed to observe before configuration. + Chargers and power banks don't enforce that budget, so this is fine in + practice — this is a lamp, not a certified USB peripheral. +- **Reflashing still works normally.** Plugged into a real host, USB + enumerates as usual, so `QK_BOOT` (included in both example keymaps) or + the board's physical reset button both work exactly as they would on + any other build. + +## Hardware test checklist + +After flashing, verify on real hardware: + +1. Boots lit from a dumb USB charger / power bank (no computer attached). +2. Every keycode in the table above behaves as described. +3. Power-cycling restores color, brightness, and animation state, and the + light is ON after power-up even if it was turned off before unplugging. +4. Plugged into a computer: no keystrokes are ever registered (lamp-only + mode), and the light stays on when the computer goes to sleep. +5. Reflashing works, both via the `QK_BOOT` key and via the board's + physical reset button. From 6024788a13e69aa91b22666a104d92d24c88cc97 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:44:45 -0400 Subject: [PATCH 12/17] docs: fix QK_BOOT row placement in README layout description --- moonlight/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/moonlight/README.md b/moonlight/README.md index 700e6ed..1b2d547 100644 --- a/moonlight/README.md +++ b/moonlight/README.md @@ -74,9 +74,9 @@ Flash the resulting firmware with Keymapp, or directly with Both example keymaps lay out the full keycode set on a single layer (see "Layer-switching keys" below for why it has to be one layer): -- Row 1: `MNL_ON`, `MNL_OFF`, `MNL_BRI`, `MNL_DIM` -- Row 2: `MNL_HUU`, `MNL_HUD`, plus `QK_BOOT` at the far end of the row so - the board stays reflashable +- Row 1 (top): `MNL_ON`, `MNL_OFF`, `MNL_BRI`, `MNL_DIM`, with `QK_BOOT` + on the far top-right corner key so the board stays reflashable +- Row 2: `MNL_HUU`, `MNL_HUD` - Row 3: `MNL_AST`, `MNL_ASP`, `MNL_ANX`, `MNL_FST`, `MNL_SLW` - Row 4: `MNL_P1` through `MNL_P8` - Every other position is `KC_NO` From ddf1d39692cab3901ee93ffaed2ef312db56e55a Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:49:07 -0400 Subject: [PATCH 13/17] ci: compile moonlight examples against firmware25 --- .github/workflows/build_moonlight.yml | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/build_moonlight.yml diff --git a/.github/workflows/build_moonlight.yml b/.github/workflows/build_moonlight.yml new file mode 100644 index 0000000..3509b83 --- /dev/null +++ b/.github/workflows/build_moonlight.yml @@ -0,0 +1,39 @@ +name: Build moonlight examples + +on: + push: + branches: [main, "feat/**"] + paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] + pull_request: + paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] + +jobs: + build: + runs-on: ubuntu-latest + container: ghcr.io/qmk/qmk_cli:latest + strategy: + matrix: + example: [voyager_lamp, moonlander_lamp] + steps: + - name: Checkout firmware (firmware25) + run: | + git clone --depth 1 --branch firmware25 \ + https://github.com/zsa/qmk_firmware.git /qmk_firmware + cd /qmk_firmware + qmk config user.qmk_home=/qmk_firmware + qmk git-submodule + + - name: Checkout this repo as modules/zsa + uses: actions/checkout@v4 + with: + path: modules_checkout + + - name: Overlay module checkout + run: | + rm -rf /qmk_firmware/modules/zsa + cp -r "$GITHUB_WORKSPACE/modules_checkout" /qmk_firmware/modules/zsa + + - name: Build example + run: | + cd /qmk_firmware + qmk compile modules/zsa/moonlight/examples/${{ matrix.example }}.json From c2e7937ebb6eb15a2bdb4acb03457d2cb0f7f036 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:15:50 -0400 Subject: [PATCH 14/17] fix: make config overrides work from keymap config.h; presets turn the light on Module config.h is included before keymap config.h in QMK's config chain, so a keymap-level #define MOONLIGHT_LAMP_ONLY 0 collided with the module's own #ifndef default under -Werror. Move the tunable #ifndef blocks (MOONLIGHT_LAMP_ONLY, MOONLIGHT_MIN_BOOT_BRIGHTNESS, MOONLIGHT_MIN_BRIGHTNESS) from config.h into moonlight.c, alongside the other already-there tunables, so a keymap's config.h define wins as intended. config.h now only carries NO_USB_STARTUP_CHECK. Also make presets turn the light on (rgb_matrix_enable() before rgb_matrix_mode()), matching MOONLIGHT_ANIM_START's symmetry: press a color, get that color, even if the light was off. --- moonlight/config.h | 16 ---------------- moonlight/moonlight.c | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/moonlight/config.h b/moonlight/config.h index 12ff4e1..2614ea8 100644 --- a/moonlight/config.h +++ b/moonlight/config.h @@ -6,19 +6,3 @@ // Compiles out the suspend trap in tmk_core/protocol/chibios/chibios.c that // would otherwise park the board in suspend_power_down() forever. #define NO_USB_STARTUP_CHECK - -// Swallow every non-moonlight keycode so a broken matrix can never type -// into a host. Keymaps for working keyboards may set this to 0. -#ifndef MOONLIGHT_LAMP_ONLY -# define MOONLIGHT_LAMP_ONLY 1 -#endif - -// A lamp never boots dark: brightness floor applied at power-up. -#ifndef MOONLIGHT_MIN_BOOT_BRIGHTNESS -# define MOONLIGHT_MIN_BOOT_BRIGHTNESS 40 -#endif - -// MOONLIGHT_DIMMER floor (dim ≠ off; MOONLIGHT_OFF turns the light off). -#ifndef MOONLIGHT_MIN_BRIGHTNESS -# define MOONLIGHT_MIN_BRIGHTNESS 16 -#endif diff --git a/moonlight/moonlight.c b/moonlight/moonlight.c index e83f324..a5d9102 100644 --- a/moonlight/moonlight.c +++ b/moonlight/moonlight.c @@ -12,6 +12,29 @@ ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); # error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." #endif +// Swallow every non-moonlight keycode so a broken matrix can never type +// into a host. Keymaps for working keyboards may set this to 0. +// +// These tunables default here (not in config.h) because a module's +// config.h is included before the keymap's config.h in QMK's config +// chain: an #ifndef default here lets a keymap's #define override it, +// whereas the same #ifndef in config.h would already have "won" by the +// time the keymap's config.h is processed, and a bare #define would +// collide with it under -Werror. +#ifndef MOONLIGHT_LAMP_ONLY +# define MOONLIGHT_LAMP_ONLY 1 +#endif + +// A lamp never boots dark: brightness floor applied at power-up. +#ifndef MOONLIGHT_MIN_BOOT_BRIGHTNESS +# define MOONLIGHT_MIN_BOOT_BRIGHTNESS 40 +#endif + +// MOONLIGHT_DIMMER floor (dim ≠ off; MOONLIGHT_OFF turns the light off). +#ifndef MOONLIGHT_MIN_BRIGHTNESS +# define MOONLIGHT_MIN_BRIGHTNESS 16 +#endif + // Modes that must never run on a lamp: reactive/keypress-driven effects, // plus NONE and out-of-range. SOLID_COLOR is excluded from the carousel // too — it is the "steady" state reached via MOONLIGHT_ANIM_STOP. @@ -220,6 +243,7 @@ bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { return false; case MOONLIGHT_PRESET_1 ... MOONLIGHT_PRESET_8: { HSV preset = moonlight_presets[keycode - MOONLIGHT_PRESET_1]; + rgb_matrix_enable(); // presets turn the light on, like MOONLIGHT_ANIM_START rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); // presets always land steady rgb_matrix_sethsv(preset.h, preset.s, rgb_matrix_get_hsv().v); return false; From caa4476ba6faf497ce72f00cb371578e9114312b Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:18:25 -0400 Subject: [PATCH 15/17] docs+ci: keymap modules note, prose fix, CI hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: note that hand-rolled keymap.json files must keep "modules": ["zsa/defaults", "zsa/moonlight"] (zsa/defaults is required by ZSA board code, not optional). - README: fix the indices 54-71 prose describing moonlander_lamp.json's layers formatting — the lines actually group 5/1/1/5/3/3 entries, not one KC_NO per line. bring both up to date with the config.h -> moonlight.c move: tunables now default in moonlight.c, and presets also turn the light on. - CI: add timeout-minutes: 30 and strategy.fail-fast: false to the build_moonlight workflow so a hung job can't block the run indefinitely and one matrix leg failing doesn't cancel the other. - Spec doc: correct the "keymap config is included after module config" claim (it's the reverse) and describe the actual mechanism — tunables default in moonlight.c under #ifndef — plus the preset-enables-light behavior. --- .github/workflows/build_moonlight.yml | 2 ++ .../2026-07-15-moonlight-module-design.md | 19 ++++++++++---- moonlight/README.md | 25 +++++++++++++------ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build_moonlight.yml b/.github/workflows/build_moonlight.yml index 3509b83..8961f62 100644 --- a/.github/workflows/build_moonlight.yml +++ b/.github/workflows/build_moonlight.yml @@ -11,7 +11,9 @@ jobs: build: runs-on: ubuntu-latest container: ghcr.io/qmk/qmk_cli:latest + timeout-minutes: 30 strategy: + fail-fast: false matrix: example: [voyager_lamp, moonlander_lamp] steps: diff --git a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md index 5877f29..1ae9d0a 100644 --- a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md +++ b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md @@ -121,7 +121,7 @@ animation within a power session). | `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | next animation, skipping reactive effects (runtime skip) | | `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | animation speed up | | `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | animation speed down | -| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | steady mode + jump to preset hue/saturation (current brightness preserved) | +| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | turns the light on + steady mode + jump to preset hue/saturation (current brightness preserved) | Notes: @@ -139,6 +139,9 @@ Notes: saturation and preserves the current brightness (the `v` in the triple is accepted for convenience with `HSV_*` macros but ignored). Changing color never causes a brightness jump. +- Presets also turn the light on (`rgb_matrix_enable()`, mirroring + `MOONLIGHT_ANIM_START`'s symmetry): pressing a preset while the light is + off still lands you in that color, steady and lit. ## Reactive-animation exclusion @@ -187,10 +190,15 @@ Everything else (hue, speed, chosen animation) restores exactly as last set. Controlled by `MOONLIGHT_LAMP_ONLY`, **default on**. A keymap that wants light control on a *working* keyboard can `#define MOONLIGHT_LAMP_ONLY 0` -in its config.h (keymap config is included after module config, so the -override works). +in its config.h. This works because the tunable's default lives in +`moonlight.c` under `#ifndef` rather than in the module's `config.h`: +module `config.h` is included *before* keymap `config.h` in QMK's config +chain, so an `#ifndef`-guarded default placed there would already have +"won" by the time the keymap's `config.h` is processed, and the keymap's +`#define` would collide with it under `-Werror`. Defaulting in the .c +file (compiled after both config.h files) lets the keymap's define win. -## Configuration surface (module `config.h` defaults) +## Configuration surface (`moonlight.c` tunable defaults) | Define | Default | Meaning | |---|---|---| @@ -200,7 +208,8 @@ override works). | `MOONLIGHT_DEFAULT_ANIMATION` | breathing | animation used by first `MNL_AST` of a session; falls back to the first enabled non-reactive animation if breathing is disabled | | `MOONLIGHT_PRESET_1_HSV`…`_8_HSV` | built-in palette | per-keymap preset colors (hue/sat applied, v ignored) | -Plus the non-tunable `NO_USB_STARTUP_CHECK`. +The module's `config.h` carries only the non-tunable `NO_USB_STARTUP_CHECK` +(it must live in config.h because other translation units consume it). ## Error handling / guardrails diff --git a/moonlight/README.md b/moonlight/README.md index 1b2d547..c5ec54e 100644 --- a/moonlight/README.md +++ b/moonlight/README.md @@ -35,7 +35,7 @@ All keycodes act on key-down only; the release is swallowed. | `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | Next animation, skipping reactive/keypress-driven effects | | `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | Animation speed up | | `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | Animation speed down | -| `MOONLIGHT_PRESET_1` … `MOONLIGHT_PRESET_8` | `MNL_P1` … `MNL_P8` | Steady mode + jump to a preset hue/saturation | +| `MOONLIGHT_PRESET_1` … `MOONLIGHT_PRESET_8` | `MNL_P1` … `MNL_P8` | Steady mode + jump to preset hue/sat (turns light on; brightness preserved) | Notes: @@ -50,6 +50,9 @@ Notes: `MOONLIGHT_PRESET_n_HSV` definition is accepted for convenience with QMK's `HSV_*` macros but is otherwise ignored — presets never cause a brightness jump. +- Presets also turn the light on, the same way `MNL_AST` does: pressing a + preset while the light is off still lands you in that color, steady and + lit, rather than leaving the light off. ## Quick start @@ -82,16 +85,18 @@ Both example keymaps lay out the full keycode set on a single layer (see - Every other position is `KC_NO` In `examples/moonlander_lamp.json`, the flat `layers` array's grouping -around indices 54–71 (a run of lines holding a single `KC_NO` each) looks -odd if you're skimming it — that's not a mistake. It mirrors the physical -argument order of the Moonlander's `LAYOUT` macro, where the thumb-cluster -keys interleave with the main rows. Leave the shape alone if you hand-edit -the file; only the values matter. +around indices 54–71 (grouped 5/1/1/5/3/3 entries per line) looks odd if +you're skimming it — that's not a mistake. It mirrors the physical +argument order of the Moonlander's `LAYOUT` macro, including two +single-entry lines for the interleaved thumb keys. Leave the shape alone +if you hand-edit the file; only the values matter. ## Customization -Config defines, set in a keymap's own `config.h` (module config is -included first, so keymap-level defines override these): +Config defines, set in a keymap's own `config.h`. The module's own +`config.h` only carries `NO_USB_STARTUP_CHECK`; these tunables default in +`moonlight.c` behind `#ifndef` guards specifically so that a keymap-level +`config.h` define (processed after the module's) overrides them: | Define | Default | Meaning | |---|---|---| @@ -101,6 +106,10 @@ included first, so keymap-level defines override these): | `MOONLIGHT_DEFAULT_ANIMATION` | breathing | Animation used the first time `MNL_AST` runs in a power session. Falls back to the first enabled non-reactive animation if breathing isn't enabled on the board. | | `MOONLIGHT_PRESET_1_HSV` … `MOONLIGHT_PRESET_8_HSV` | built-in palette (red, coral, gold, green, azure, blue, purple, white) | Per-keymap preset colors, e.g. `#define MOONLIGHT_PRESET_1_HSV {HSV_TEAL}`. Any `{h, s, v}` triple works, including `HSV_*` macros; only hue and saturation are actually applied (see the preset note above) — the `v` is accepted but ignored. Unset slots keep their default. | +If you hand-roll a keymap.json instead of starting from an example, keep +`"modules": ["zsa/defaults", "zsa/moonlight"]` — `zsa/defaults` is +required by ZSA board code and isn't optional, even on a lamp-only build. + To remap keys, edit the `layers` array in your keymap json (or in `keymap.c` if you're not using QMK Configurator json format) and place any of the keycodes/aliases above wherever a physical key still works — you From 0a9dede9e4f7472d5879aa001f1273624cdcfa94 Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:17:33 -0400 Subject: [PATCH 16/17] Delete docs/superpowers/plans/2026-07-15-moonlight-module.md --- .../plans/2026-07-15-moonlight-module.md | 860 ------------------ 1 file changed, 860 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-15-moonlight-module.md diff --git a/docs/superpowers/plans/2026-07-15-moonlight-module.md b/docs/superpowers/plans/2026-07-15-moonlight-module.md deleted file mode 100644 index 7b35695..0000000 --- a/docs/superpowers/plans/2026-07-15-moonlight-module.md +++ /dev/null @@ -1,860 +0,0 @@ -# Moonlight Module Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A `moonlight` QMK community module that turns broken ZSA keyboards into USB-powered room lights: LEDs on from any USB power source (no host needed), a full set of light-control keycodes, lamp-only by default. - -**Architecture:** Pure community module in `zsa/qmk_modules` (no firmware-repo changes), built against `zsa/qmk_firmware@firmware25`. All behavior lives in `moonlight/moonlight.c` via module hooks (`keyboard_post_init_moonlight`, `pre_process_record_moonlight`, `process_record_moonlight`); keycodes are declared in `qmk_module.json`; the standalone-power fix is a single `NO_USB_STARTUP_CHECK` define in the module's `config.h`. - -**Tech Stack:** QMK community modules API (≥ 1.0.0), rgb_matrix API, C, `qmk` CLI, GitHub Actions. - -**Spec:** `docs/superpowers/specs/2026-07-15-moonlight-module-design.md` (same repo — read it first). - -## Global Constraints - -- All work in the `zsa/qmk_modules` repo on branch `feat/moonlight`; tidy, batched commits; one cohesive PR at the end. Avoid overengineering. -- Firmware base: `zsa/qmk_firmware`, branch `firmware25`. Zero changes to that repo. -- Module name `moonlight`; keycodes `MOONLIGHT_*` with `MNL_*` aliases exactly as tabled in the spec. -- Keycode manifest order is load-bearing: `MOONLIGHT_ON` must be first and `MOONLIGHT_PRESET_8` last, with no gaps, so `case MOONLIGHT_ON ... MOONLIGHT_PRESET_8:` range matches work. -- Target boards: `zsa/voyager` (LAYOUT, 52 keys) and `zsa/moonlander` (LAYOUT, 72 keys). -- "Tests" for this project are compile checks (no unit-test rig exists for community modules) — every task ends with a `qmk compile` verification. Hardware verification happens once at the end via the README checklist. - -## Development environment (used by every task) - -The firmware checkout at `~/Documents/github/qmk_firmware` vendors this repo as the submodule `modules/zsa`. **The submodule working tree is the primary dev checkout** — edits there are immediately buildable. Finished commits get pushed back to the canonical local repo (`~/Documents/github/qmk_modules`, remote name `outer`). - -```bash -FW=~/Documents/github/qmk_firmware -MOD=$FW/modules/zsa # dev checkout of qmk_modules (this repo) -OUTER=~/Documents/github/qmk_modules -``` - -Build command used throughout (run from anywhere; `qmk` knows the firmware path after Task 1): - -```bash -qmk compile $MOD/moonlight/examples/voyager_lamp.json -``` - ---- - -### Task 1: Build environment on firmware25 - -**Files:** -- No repo files. Sets up `~/Documents/github/qmk_firmware` on `firmware25` and the module dev checkout. - -**Interfaces:** -- Produces: a working `qmk compile` baseline for `zsa/voyager`; `$MOD` checked out on `feat/moonlight` with remote `outer` → `~/Documents/github/qmk_modules`. - -- [ ] **Step 1: Switch firmware checkout to firmware25 and init submodules** - -```bash -cd ~/Documents/github/qmk_firmware -git status --short # must be clean; stop and report if not -git fetch origin -git checkout firmware25 -qmk git-submodule # inits/updates lib/* and modules/* submodules; takes a while -``` - -- [ ] **Step 2: Point qmk CLI at this checkout** - -```bash -qmk config user.qmk_home=$HOME/Documents/github/qmk_firmware -qmk doctor | tail -5 -``` -Expected: `qmk doctor` ends with "QMK is ready to go" (warnings are fine, errors are not). - -- [ ] **Step 3: Baseline compile (proves toolchain + firmware25 build)** - -```bash -qmk compile -kb zsa/voyager -km default -``` -Expected: ends with `[OK]` lines and produces `zsa_voyager_default.bin`. If this fails, fix the environment before proceeding — nothing else in the plan can work. - -- [ ] **Step 4: Set up the module dev checkout on feat/moonlight** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git remote add outer ~/Documents/github/qmk_modules 2>/dev/null || true -git fetch outer -git checkout -b feat/moonlight outer/feat/moonlight -git log --oneline -2 # expect the two docs: commits (spec) -``` - ---- - -### Task 2: Module scaffold — manifest, config.h, compilable skeleton - -**Files:** -- Create: `moonlight/qmk_module.json` -- Create: `moonlight/config.h` -- Create: `moonlight/moonlight.c` -- Create: `moonlight/examples/voyager_lamp.json` (minimal; finalized in Task 8) - -**Interfaces:** -- Produces: keycodes `MOONLIGHT_ON` … `MOONLIGHT_PRESET_8` (aliases `MNL_ON` … `MNL_P8`) usable in keymaps and in C; module compiles into a build. - -- [ ] **Step 1: Write the manifest** - -`moonlight/qmk_module.json` — keycode order matters (see Global Constraints): - -```json -{ - "module_name": "Moonlight", - "maintainer": "ZSA", - "license": "GPL-2.0-or-later", - "features": { - "rgb_matrix": true - }, - "keycodes": [ - { "key": "MOONLIGHT_ON", "aliases": ["MNL_ON"] }, - { "key": "MOONLIGHT_OFF", "aliases": ["MNL_OFF"] }, - { "key": "MOONLIGHT_BRIGHTER", "aliases": ["MNL_BRI"] }, - { "key": "MOONLIGHT_DIMMER", "aliases": ["MNL_DIM"] }, - { "key": "MOONLIGHT_HUE_UP", "aliases": ["MNL_HUU"] }, - { "key": "MOONLIGHT_HUE_DOWN", "aliases": ["MNL_HUD"] }, - { "key": "MOONLIGHT_ANIM_START", "aliases": ["MNL_AST"] }, - { "key": "MOONLIGHT_ANIM_STOP", "aliases": ["MNL_ASP"] }, - { "key": "MOONLIGHT_ANIM_NEXT", "aliases": ["MNL_ANX"] }, - { "key": "MOONLIGHT_ANIM_FASTER", "aliases": ["MNL_FST"] }, - { "key": "MOONLIGHT_ANIM_SLOWER", "aliases": ["MNL_SLW"] }, - { "key": "MOONLIGHT_PRESET_1", "aliases": ["MNL_P1"] }, - { "key": "MOONLIGHT_PRESET_2", "aliases": ["MNL_P2"] }, - { "key": "MOONLIGHT_PRESET_3", "aliases": ["MNL_P3"] }, - { "key": "MOONLIGHT_PRESET_4", "aliases": ["MNL_P4"] }, - { "key": "MOONLIGHT_PRESET_5", "aliases": ["MNL_P5"] }, - { "key": "MOONLIGHT_PRESET_6", "aliases": ["MNL_P6"] }, - { "key": "MOONLIGHT_PRESET_7", "aliases": ["MNL_P7"] }, - { "key": "MOONLIGHT_PRESET_8", "aliases": ["MNL_P8"] } - ] -} -``` - -- [ ] **Step 2: Write config.h** - -`moonlight/config.h`: - -```c -// Copyright 2026 ZSA Technology Labs, Inc <@zsa> -// SPDX-License-Identifier: GPL-2.0-or-later -#pragma once - -// Keep running when USB never enumerates (dumb charger / power bank). -// Compiles out the suspend trap in tmk_core/protocol/chibios/chibios.c that -// would otherwise park the board in suspend_power_down() forever. -#define NO_USB_STARTUP_CHECK - -// Swallow every non-moonlight keycode so a broken matrix can never type -// into a host. Keymaps for working keyboards may set this to 0. -#ifndef MOONLIGHT_LAMP_ONLY -# define MOONLIGHT_LAMP_ONLY 1 -#endif - -// A lamp never boots dark: brightness floor applied at power-up. -#ifndef MOONLIGHT_MIN_BOOT_BRIGHTNESS -# define MOONLIGHT_MIN_BOOT_BRIGHTNESS 40 -#endif - -// MOONLIGHT_DIMMER floor (dim ≠ off; MOONLIGHT_OFF turns the light off). -#ifndef MOONLIGHT_MIN_BRIGHTNESS -# define MOONLIGHT_MIN_BRIGHTNESS 16 -#endif -``` - -- [ ] **Step 3: Write the skeleton moonlight.c** - -`moonlight/moonlight.c`: - -```c -// Copyright 2026 ZSA Technology Labs, Inc <@zsa> -// SPDX-License-Identifier: GPL-2.0-or-later -// -// Moonlight: light control for upcycled keyboards. Purely a lamp module — -// see README.md. All behavior is driven by rgb_matrix. - -#include QMK_KEYBOARD_H - -ASSERT_COMMUNITY_MODULES_MIN_API_VERSION(1, 0, 0); - -#ifndef RGB_MATRIX_ENABLE -# error "The moonlight module requires an rgb_matrix-enabled keyboard (RGB_MATRIX_ENABLE = yes)." -#endif -``` - -- [ ] **Step 4: Write the minimal example keymap (finalized in Task 8)** - -`moonlight/examples/voyager_lamp.json` — one layer, mostly `KC_NO`, a few moonlight keys to prove keycode generation. Voyager `LAYOUT` takes 52 keys: 4 rows × 6 columns per half (rows interleave left/right), then 2 thumb keys per half. - -```json -{ - "keyboard": "zsa/voyager", - "keymap": "moonlight_lamp", - "layout": "LAYOUT", - "modules": ["zsa/moonlight"], - "layers": [ - [ - "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", - "KC_NO", "KC_NO" - ] - ] -} -``` - -- [ ] **Step 5: Compile to verify the scaffold** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS (`[OK]`, `.bin` produced). Failure modes to fix here: manifest schema errors, module not found (path/name mismatch), keycode generation errors. - -- [ ] **Step 6: Commit and push to outer** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/ -git commit -m "feat: scaffold moonlight module (manifest, config, skeleton)" -git push outer feat/moonlight -``` - ---- - -### Task 3: Core light control — on/off, brightness, hue - -**Files:** -- Modify: `moonlight/moonlight.c` - -**Interfaces:** -- Consumes: keycodes from Task 2; QMK rgb_matrix API (`rgb_matrix_enable/disable`, `rgb_matrix_increase_val`, `rgb_matrix_get_hsv`, `rgb_matrix_sethsv`, `rgb_matrix_increase_hue`, `rgb_matrix_decrease_hue` — all EEPROM-persisting variants). -- Produces: `process_record_moonlight(uint16_t keycode, keyrecord_t *record)` handling the six core keycodes; later tasks extend its `switch`. - -- [ ] **Step 1: Add the handler skeleton and core cases** - -Append to `moonlight/moonlight.c`: - -```c -bool process_record_moonlight(uint16_t keycode, keyrecord_t *record) { - if (!process_record_moonlight_kb(keycode, record)) { - return false; - } - - // Moonlight keycodes act on press only; consume the release too. - if (!record->event.pressed) { - switch (keycode) { - case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: - return false; - default: - return true; - } - } - - switch (keycode) { - case MOONLIGHT_ON: - rgb_matrix_enable(); - return false; - case MOONLIGHT_OFF: - rgb_matrix_disable(); - return false; - case MOONLIGHT_BRIGHTER: - rgb_matrix_increase_val(); // clamps at RGB_MATRIX_MAXIMUM_BRIGHTNESS - return false; - case MOONLIGHT_DIMMER: { - HSV hsv = rgb_matrix_get_hsv(); - uint8_t v = hsv.v > MOONLIGHT_MIN_BRIGHTNESS + RGB_MATRIX_VAL_STEP - ? hsv.v - RGB_MATRIX_VAL_STEP - : MOONLIGHT_MIN_BRIGHTNESS; - rgb_matrix_sethsv(hsv.h, hsv.s, v); - return false; - } - case MOONLIGHT_HUE_UP: - rgb_matrix_increase_hue(); - return false; - case MOONLIGHT_HUE_DOWN: - rgb_matrix_decrease_hue(); - return false; - default: - return true; - } -} -``` - -- [ ] **Step 2: Compile** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS. - -- [ ] **Step 3: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/moonlight.c -git commit -m "feat: core light control (on/off, brightness, hue)" -git push outer feat/moonlight -``` - ---- - -### Task 4: Animation model — reactive skip table, start/stop/next/faster/slower - -**Files:** -- Modify: `moonlight/moonlight.c` - -**Interfaces:** -- Consumes: `process_record_moonlight` switch from Task 3. -- Produces: `moonlight_mode_is_lamp_safe(uint8_t mode)` and `moonlight_next_anim(uint8_t from)` (static helpers used by Task 6); `moonlight_last_anim` (static `uint8_t`, 0 = none yet). - -- [ ] **Step 1: Add helpers ABOVE `process_record_moonlight`** - -```c -// Modes that must never run on a lamp: reactive/keypress-driven effects, -// plus NONE and out-of-range. SOLID_COLOR is excluded from the carousel -// too — it is the "steady" state reached via MOONLIGHT_ANIM_STOP. -static bool moonlight_mode_is_lamp_safe(uint8_t mode) { - switch (mode) { - case RGB_MATRIX_NONE: - case RGB_MATRIX_SOLID_COLOR: -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_SIMPLE - case RGB_MATRIX_SOLID_REACTIVE_SIMPLE: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE - case RGB_MATRIX_SOLID_REACTIVE: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_WIDE - case RGB_MATRIX_SOLID_REACTIVE_WIDE: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE - case RGB_MATRIX_SOLID_REACTIVE_MULTIWIDE: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_CROSS - case RGB_MATRIX_SOLID_REACTIVE_CROSS: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTICROSS - case RGB_MATRIX_SOLID_REACTIVE_MULTICROSS: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_NEXUS - case RGB_MATRIX_SOLID_REACTIVE_NEXUS: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS - case RGB_MATRIX_SOLID_REACTIVE_MULTINEXUS: -#endif -#ifdef ENABLE_RGB_MATRIX_SPLASH - case RGB_MATRIX_SPLASH: -#endif -#ifdef ENABLE_RGB_MATRIX_MULTISPLASH - case RGB_MATRIX_MULTISPLASH: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_SPLASH - case RGB_MATRIX_SOLID_SPLASH: -#endif -#ifdef ENABLE_RGB_MATRIX_SOLID_MULTISPLASH - case RGB_MATRIX_SOLID_MULTISPLASH: -#endif -#ifdef ENABLE_RGB_MATRIX_TYPING_HEATMAP - case RGB_MATRIX_TYPING_HEATMAP: // framebuffer effect, keypress-driven -#endif - return false; - default: - return mode < RGB_MATRIX_EFFECT_MAX; - } -} - -// Next lamp-safe animation after `from`, wrapping. Falls back to -// SOLID_COLOR if the board somehow has no lamp-safe animations. -static uint8_t moonlight_next_anim(uint8_t from) { - uint8_t mode = from; - for (uint8_t i = 0; i < RGB_MATRIX_EFFECT_MAX; i++) { - mode = (mode + 1 < RGB_MATRIX_EFFECT_MAX) ? mode + 1 : 1; - if (moonlight_mode_is_lamp_safe(mode)) { - return mode; - } - } - return RGB_MATRIX_SOLID_COLOR; -} - -#if !defined(MOONLIGHT_DEFAULT_ANIMATION) && defined(ENABLE_RGB_MATRIX_BREATHING) -# define MOONLIGHT_DEFAULT_ANIMATION RGB_MATRIX_BREATHING -#endif - -static uint8_t moonlight_default_anim(void) { -#ifdef MOONLIGHT_DEFAULT_ANIMATION - if (moonlight_mode_is_lamp_safe(MOONLIGHT_DEFAULT_ANIMATION)) { - return MOONLIGHT_DEFAULT_ANIMATION; - } -#endif - return moonlight_next_anim(RGB_MATRIX_SOLID_COLOR); -} - -// Last animation used this power session (0 = none yet), so -// stop-then-start resumes the same animation. -static uint8_t moonlight_last_anim = 0; -``` - -- [ ] **Step 2: Add the animation cases to the `switch` in `process_record_moonlight`** (before `default:`) - -```c - case MOONLIGHT_ANIM_START: { - uint8_t target = moonlight_last_anim ? moonlight_last_anim : moonlight_default_anim(); - rgb_matrix_enable(); - rgb_matrix_mode(target); - moonlight_last_anim = target; - return false; - } - case MOONLIGHT_ANIM_STOP: { - uint8_t cur = rgb_matrix_get_mode(); - if (moonlight_mode_is_lamp_safe(cur)) { - moonlight_last_anim = cur; // resume point for the next START - } - rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); - return false; - } - case MOONLIGHT_ANIM_NEXT: { - uint8_t cur = rgb_matrix_get_mode(); - uint8_t base = moonlight_mode_is_lamp_safe(cur) - ? cur - : (moonlight_last_anim ? moonlight_last_anim : RGB_MATRIX_SOLID_COLOR); - uint8_t next = moonlight_next_anim(base); - rgb_matrix_enable(); - rgb_matrix_mode(next); - moonlight_last_anim = next; - return false; - } - case MOONLIGHT_ANIM_FASTER: - rgb_matrix_increase_speed(); - return false; - case MOONLIGHT_ANIM_SLOWER: - rgb_matrix_decrease_speed(); - return false; -``` - -- [ ] **Step 3: Compile** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS. If a `RGB_MATRIX_*` case constant is undeclared, its `#ifdef ENABLE_...` guard name is wrong for firmware25 — check `quantum/rgb_matrix/rgb_matrix.h` enum and the `ENABLE_RGB_MATRIX_*` names in the generated `info_config.h` under `.build/`. - -- [ ] **Step 4: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/moonlight.c -git commit -m "feat: animation start/stop/next/speed with reactive-effect exclusion" -git push outer feat/moonlight -``` - ---- - -### Task 5: Preset colors - -**Files:** -- Modify: `moonlight/moonlight.c` - -**Interfaces:** -- Consumes: `process_record_moonlight` switch; `HSV` type and `HSV_*` macros from `color.h` (available via `QMK_KEYBOARD_H`). -- Produces: `moonlight_presets[8]` (static `const HSV[]`); presets apply hue+sat only, brightness preserved. - -- [ ] **Step 1: Add preset defaults and table ABOVE `process_record_moonlight`** - -```c -// Preset palette. Keymaps override any slot in their config.h, e.g. -// #define MOONLIGHT_PRESET_1_HSV {HSV_TEAL} -// Only hue and saturation are applied; current brightness is preserved -// (the v component is accepted for HSV_* macro convenience but ignored). -#ifndef MOONLIGHT_PRESET_1_HSV -# define MOONLIGHT_PRESET_1_HSV {HSV_RED} -#endif -#ifndef MOONLIGHT_PRESET_2_HSV -# define MOONLIGHT_PRESET_2_HSV {HSV_CORAL} -#endif -#ifndef MOONLIGHT_PRESET_3_HSV -# define MOONLIGHT_PRESET_3_HSV {HSV_GOLD} -#endif -#ifndef MOONLIGHT_PRESET_4_HSV -# define MOONLIGHT_PRESET_4_HSV {HSV_GREEN} -#endif -#ifndef MOONLIGHT_PRESET_5_HSV -# define MOONLIGHT_PRESET_5_HSV {HSV_AZURE} -#endif -#ifndef MOONLIGHT_PRESET_6_HSV -# define MOONLIGHT_PRESET_6_HSV {HSV_BLUE} -#endif -#ifndef MOONLIGHT_PRESET_7_HSV -# define MOONLIGHT_PRESET_7_HSV {HSV_PURPLE} -#endif -#ifndef MOONLIGHT_PRESET_8_HSV -# define MOONLIGHT_PRESET_8_HSV {HSV_WHITE} -#endif - -static const HSV moonlight_presets[] = { - MOONLIGHT_PRESET_1_HSV, MOONLIGHT_PRESET_2_HSV, MOONLIGHT_PRESET_3_HSV, MOONLIGHT_PRESET_4_HSV, - MOONLIGHT_PRESET_5_HSV, MOONLIGHT_PRESET_6_HSV, MOONLIGHT_PRESET_7_HSV, MOONLIGHT_PRESET_8_HSV, -}; -``` - -Note: the config macros are named `MOONLIGHT_PRESET_n_HSV` (not `MOONLIGHT_PRESET_n`) because the bare names are the keycode identifiers — they cannot also be object-like macros. - -- [ ] **Step 2: Add the preset case to the `switch`** (before `default:`) - -```c - case MOONLIGHT_PRESET_1 ... MOONLIGHT_PRESET_8: { - HSV preset = moonlight_presets[keycode - MOONLIGHT_PRESET_1]; - rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); // presets always land steady - rgb_matrix_sethsv(preset.h, preset.s, rgb_matrix_get_hsv().v); - return false; - } -``` - -- [ ] **Step 3: Compile** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS. - -- [ ] **Step 4: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/moonlight.c -git commit -m "feat: preset color slots (hue/sat only, brightness preserved)" -git push outer feat/moonlight -``` - ---- - -### Task 6: Power-up behavior — always on, never dark, never reactive - -**Files:** -- Modify: `moonlight/moonlight.c` - -**Interfaces:** -- Consumes: `moonlight_mode_is_lamp_safe`, `moonlight_last_anim` (Task 4); `MOONLIGHT_MIN_BOOT_BRIGHTNESS` (Task 2). -- Produces: `keyboard_post_init_moonlight(void)`. - -- [ ] **Step 1: Add the hook at the END of moonlight.c** - -```c -void keyboard_post_init_moonlight(void) { - keyboard_post_init_moonlight_kb(); - - // A lamp on a wall switch always comes on. - rgb_matrix_enable(); - - // Never boot dark. - HSV hsv = rgb_matrix_get_hsv(); - if (hsv.v < MOONLIGHT_MIN_BOOT_BRIGHTNESS) { - rgb_matrix_sethsv(hsv.h, hsv.s, MOONLIGHT_MIN_BOOT_BRIGHTNESS); - } - - // EEPROM may hold a reactive or out-of-range mode (previous firmware, - // fewer animations, etc.). Snap those to steady; remember valid - // animations as the START resume point. - uint8_t mode = rgb_matrix_get_mode(); - if (mode == RGB_MATRIX_SOLID_COLOR) { - // steady — nothing to do - } else if (moonlight_mode_is_lamp_safe(mode)) { - moonlight_last_anim = mode; // restored mid-animation; keep animating - } else { - rgb_matrix_mode(RGB_MATRIX_SOLID_COLOR); - } -} -``` - -- [ ] **Step 2: Compile** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS. - -- [ ] **Step 3: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/moonlight.c -git commit -m "feat: power-up always on, brightness floor, reactive-mode snap" -git push outer feat/moonlight -``` - ---- - -### Task 7: Lamp-only mode - -**Files:** -- Modify: `moonlight/moonlight.c` - -**Interfaces:** -- Consumes: `MOONLIGHT_LAMP_ONLY` (Task 2); keycode range from Task 2. -- Produces: `pre_process_record_moonlight(uint16_t keycode, keyrecord_t *record)`. - -- [ ] **Step 1: Add the hook ABOVE `process_record_moonlight`** - -```c -// Lamp-only: nothing but moonlight controls (and QK_BOOT, for flashing) -// gets processed — a broken matrix can never type into a host. -bool pre_process_record_moonlight(uint16_t keycode, keyrecord_t *record) { - if (!pre_process_record_moonlight_kb(keycode, record)) { - return false; - } -#if MOONLIGHT_LAMP_ONLY - switch (keycode) { - case MOONLIGHT_ON ... MOONLIGHT_PRESET_8: - case QK_BOOT: - return true; - default: - return false; - } -#else - return true; -#endif -} -``` - -- [ ] **Step 2: Compile** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -``` -Expected: PASS. - -- [ ] **Step 3: Verify the opt-out path also compiles** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa/moonlight -sed -i '' 's/# define MOONLIGHT_LAMP_ONLY 1/# define MOONLIGHT_LAMP_ONLY 0/' config.h -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -sed -i '' 's/# define MOONLIGHT_LAMP_ONLY 0/# define MOONLIGHT_LAMP_ONLY 1/' config.h -git diff --stat # must be empty -``` -Expected: both compiles PASS; working tree clean afterwards. - -- [ ] **Step 4: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/moonlight.c -git commit -m "feat: lamp-only mode swallows all non-moonlight keycodes" -git push outer feat/moonlight -``` - ---- - -### Task 8: Example keymaps for both boards - -**Files:** -- Modify: `moonlight/examples/voyager_lamp.json` (full layout) -- Create: `moonlight/examples/moonlander_lamp.json` - -**Interfaces:** -- Consumes: `MNL_*` aliases (Task 2). -- Produces: two ready-to-flash reference keymaps; the canonical key arrangement documented in the README (Task 9). - -Key-order note: a keymap.json layer is a flat array in the same order as the board's `LAYOUT` macro arguments. Before finalizing, open `keyboards/zsa/voyager/keymaps/default/keymap.c` and `keyboards/zsa/moonlander/keymaps/default/keymap.c` in the firmware checkout and confirm the row structure assumed below (Voyager: rows interleave left/right, 6 per half-row, 4 rows, then 2+2 thumbs = 52; Moonlander: half-rows of 7,7,7,6,5 interleaved, then 4+4 thumbs = 72). `qmk compile` hard-fails on a count mismatch, which catches structural errors. - -- [ ] **Step 1: Finalize voyager_lamp.json** - -Replace the layers array of `moonlight/examples/voyager_lamp.json` with: - -```json - "layers": [ - [ - "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", - "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", - "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", - "KC_NO", "KC_NO" - ] - ] -``` - -Layout logic (also goes in the README): left hand = controls (row 1: power/brightness, row 2: hue, row 3: animation), bottom row = presets spilling onto the right hand, `QK_BOOT` on the far top-right corner where it is hard to hit by accident. - -- [ ] **Step 2: Create moonlander_lamp.json** - -`moonlight/examples/moonlander_lamp.json`: - -```json -{ - "keyboard": "zsa/moonlander", - "keymap": "moonlight_lamp", - "layout": "LAYOUT", - "modules": ["zsa/moonlight"], - "layers": [ - [ - "MNL_ON", "MNL_OFF", "MNL_BRI", "MNL_DIM", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "QK_BOOT", - "MNL_HUU", "MNL_HUD", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "MNL_AST", "MNL_ASP", "MNL_ANX", "MNL_FST", "MNL_SLW", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "MNL_P1", "MNL_P2", "MNL_P3", "MNL_P4", "MNL_P5", "MNL_P6", - "MNL_P7", "MNL_P8", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO", - "KC_NO", "KC_NO", "KC_NO", "KC_NO" - ] - ] -} -``` - -- [ ] **Step 3: Compile BOTH** - -```bash -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/voyager_lamp.json -qmk compile ~/Documents/github/qmk_firmware/modules/zsa/moonlight/examples/moonlander_lamp.json -``` -Expected: both PASS. A key-count error means the row structure assumption was wrong — fix against the default keymap.c ordering. - -- [ ] **Step 4: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/examples/ -git commit -m "feat: reference lamp keymaps for voyager and moonlander" -git push outer feat/moonlight -``` - ---- - -### Task 9: Documentation — module README + root README entry - -**Files:** -- Create: `moonlight/README.md` -- Modify: `README.md` (repo root, "Available modules" list) - -**Interfaces:** -- Consumes: everything above (documents it). - -- [ ] **Step 1: Write moonlight/README.md** - -Content requirements (write full prose, not stubs; keycode table can be copied from the spec): - -1. **Opening paragraph, verbatim intent:** Moonlight is purely for light control — it turns a keyboard (typically one too broken to type on) into a USB-powered room light. It is not a keyboard-lighting or typing-feedback feature; by default it never sends a single keystroke to a computer. -2. **How it works:** lights on from any USB power source (wall charger, power bank, computer — no enumeration needed); state persists across power cycles; the light always comes on at power-up. -3. **Keycode table:** all 19 keycodes with aliases and behavior (copy from spec §"Keycodes and behavior model", including the preset hue/sat-only note). -4. **Quick start:** clone firmware25, `qmk compile modules/zsa/moonlight/examples/voyager_lamp.json`, flash with Keymapp or `qmk flash`; diagram/description of the example layout from Task 8. -5. **Customization:** the `MOONLIGHT_*` config defines table (from spec §"Configuration surface", using the `_HSV` names from Task 5), how to remap keys in the json, `MOONLIGHT_LAMP_ONLY 0` for working keyboards. Note that in lamp-only mode layer-switching keys are swallowed too — arrange everything on one layer. -6. **Caveats:** ignores USB suspend (stays lit and drawing current when a host sleeps); exceeds the USB unconfigured-device power budget by design — fine on chargers; reflashing still works normally (`QK_BOOT` key or physical reset button). -7. **Hardware test checklist** (copy the five items from spec §"Testing"). - -- [ ] **Step 2: Add the root README entry** - -In the repo root `README.md`, `Available modules` list, add: - -```markdown -- `moonlight`: Turns a (broken) keyboard into a USB-powered room light — - standalone power support, light-control keycodes, lamp-only by default -``` - -- [ ] **Step 3: Commit and push** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add moonlight/README.md README.md -git commit -m "docs: moonlight README and root module listing" -git push outer feat/moonlight -``` - ---- - -### Task 10: CI — build both examples on GitHub Actions - -**Files:** -- Create: `.github/workflows/build_moonlight.yml` - -**Interfaces:** -- Consumes: example keymaps (Task 8). -- Produces: CI that fails if either example stops compiling against firmware25. - -- [ ] **Step 1: Write the workflow** - -`.github/workflows/build_moonlight.yml`: - -```yaml -name: Build moonlight examples - -on: - push: - branches: [main, "feat/**"] - paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] - pull_request: - paths: ["moonlight/**", ".github/workflows/build_moonlight.yml"] - -jobs: - build: - runs-on: ubuntu-latest - container: ghcr.io/qmk/qmk_cli:latest - strategy: - matrix: - example: [voyager_lamp, moonlander_lamp] - steps: - - name: Checkout firmware (firmware25) - run: | - git clone --depth 1 --branch firmware25 \ - https://github.com/zsa/qmk_firmware.git /qmk_firmware - cd /qmk_firmware - qmk config user.qmk_home=/qmk_firmware - qmk git-submodule - - - name: Checkout this repo as modules/zsa - uses: actions/checkout@v4 - with: - path: modules_checkout - - - name: Overlay module checkout - run: | - rm -rf /qmk_firmware/modules/zsa - cp -r "$GITHUB_WORKSPACE/modules_checkout" /qmk_firmware/modules/zsa - - - name: Build example - run: | - cd /qmk_firmware - qmk compile modules/zsa/moonlight/examples/${{ matrix.example }}.json -``` - -- [ ] **Step 2: Push and verify the run** - -```bash -cd ~/Documents/github/qmk_firmware/modules/zsa -git add .github/workflows/build_moonlight.yml -git commit -m "ci: compile moonlight examples against firmware25" -git push outer feat/moonlight -``` - -Then push `feat/moonlight` from `~/Documents/github/qmk_modules` to GitHub (`git push -u origin feat/moonlight`) and check the Actions run. CI environment quirks (container image contents, submodule flags) may need 1-2 fix iterations — that is expected; keep fixes in this task's commit scope with `ci:` messages. - ---- - -### Task 11: Final verification and PR - -**Files:** none (verification + PR). - -- [ ] **Step 1: Spec sweep** — reread `docs/superpowers/specs/2026-07-15-moonlight-module-design.md` top to bottom; for each requirement confirm where it is implemented (file + function). Fix anything missed before proceeding. - -- [ ] **Step 2: Clean rebuild of both examples** - -```bash -cd ~/Documents/github/qmk_firmware -rm -rf .build -qmk compile modules/zsa/moonlight/examples/voyager_lamp.json -qmk compile modules/zsa/moonlight/examples/moonlander_lamp.json -``` -Expected: both `.bin` files produced. - -- [ ] **Step 3: Hardware verification (user-in-the-loop)** — flash a real board and run the README hardware checklist with the user. The wall-charger test is the headline feature; do not skip it. Record results in the PR description's test plan. - -- [ ] **Step 4: Open the PR** — push `feat/moonlight` to `zsa/qmk_modules` origin, then open a PR titled `feat: moonlight — light-control module for upcycled keyboards`, body summarizing: purpose, the NO_USB_STARTUP_CHECK mechanism, keycode table, lamp-only default, and the test plan (compile CI + hardware checklist results, with unchecked boxes for anything not yet run on hardware). Get user approval on the PR body before creating it. From b99d3af591e3cfbda1299c10e8f06d6f07abf70b Mon Sep 17 00:00:00 2001 From: Erez Zukerman <1092548+ezuk@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:18:05 -0400 Subject: [PATCH 17/17] Delete docs/superpowers/specs/2026-07-15-moonlight-module-design.md --- .../2026-07-15-moonlight-module-design.md | 258 ------------------ 1 file changed, 258 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-15-moonlight-module-design.md diff --git a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md b/docs/superpowers/specs/2026-07-15-moonlight-module-design.md deleted file mode 100644 index 1ae9d0a..0000000 --- a/docs/superpowers/specs/2026-07-15-moonlight-module-design.md +++ /dev/null @@ -1,258 +0,0 @@ -# Moonlight — QMK Community Module for Room Lighting - -**Date:** 2026-07-15 -**Status:** Approved design, pending implementation plan -**Repo:** `zsa/qmk_modules` (this repo), new module `moonlight/` -**Firmware base:** `zsa/qmk_firmware`, branch `firmware25` (latest stable ZSA fork; supports community modules) - -## Purpose - -Upcycle broken ZSA keyboards as USB-powered room lights. A keyboard whose -matrix is too damaged for typing usually still has fully working LEDs and -power circuitry. Moonlight turns it into a lamp: - -1. The LEDs turn on as soon as the board receives USB power — including from - a dumb wall charger or power bank, with no computer and no USB enumeration. -2. A set of assignable keycodes controls the light: on/off, brightness, hue, - preset colors, and animations (start/stop/next/faster/slower). -3. The board is **lamp-only by default**: it never sends keystrokes to a - host, so a shorting matrix can't type garbage into a computer. - -Moonlight is purely for light control. It is not a keyboard-lighting / -typing-feedback feature, and the README must say so up front. - -## Why a community module - -- Community modules (QMK ≥ 25 / ZSA `firmware25`) can declare keycodes, - provide hooks (`keyboard_post_init`, `pre_process_record`, - `process_record`), and ship a `config.h` that participates in the build's - config chain. That covers every requirement with **zero changes to the - firmware repo**. -- The previous plan (a userspace in the `qmk_for_lighting` fork of - `firmware23`) is retired. `firmware23` was a mistake; it predates module - support. The `qmk_for_lighting` repo is no longer needed. - -## Git hygiene - -All work must go in the community modules repo in a branch called `feat/moonlight`. Keep commits tidy and easy to review, batch related changes together. It is important that the whole change ships as a cohesive pull request that makes sense. Avoid overengineering. - -## Target boards - -Moonlander (`keyboards/zsa/moonlander`) and Voyager (`keyboards/zsa/voyager`) -on `firmware25`. The module itself is board-agnostic: it requires only -`RGB_MATRIX_ENABLE` and errors out at compile time without it. Other -rgb-matrix boards (e.g. Planck EZ) should work but are untested/out of scope. - -## Module layout - -``` -moonlight/ -├── qmk_module.json # manifest: module_name "moonlight", -│ # features { rgb_matrix: true }, keycodes list -├── config.h # NO_USB_STARTUP_CHECK + tunable defaults -├── moonlight.c # all behavior: hooks + keycode handlers -├── examples/ -│ ├── voyager_lamp.json # complete keymap.json, "modules": ["moonlight"] -│ └── moonlander_lamp.json -└── README.md -``` - -Users hand-edit a keymap.json (typically starting from an example), keep -`"modules": ["moonlight"]`, arrange the keycodes on whichever physical keys -still work, and build with `qmk compile`. - -The repo root `README.md` also gets a one-line entry for `moonlight` in its -available-modules list. - -## Standalone power (works on a wall charger) - -**Mechanism (verified against `firmware25` sources):** - -- Boot does not wait for enumeration: `WAIT_FOR_USB` is not defined for - these boards, so `protocol_pre_init()` proceeds immediately. -- The only thing that darkens the board without a host is the suspend trap - in `tmk_core/protocol/chibios/chibios.c` (~line 184): the STM32F303 USB - peripheral raises SUSP after ~3 ms of bus idle (always true on a charger), - ChibiOS marks the driver `USB_SUSPENDED`, and the main loop spins in - `suspend_power_down()` forever (remote wakeup is never negotiated without - enumeration). This kills the LEDs and key scanning. -- That entire block is guarded by `#if !defined(NO_USB_STARTUP_CHECK)`. - Moonlight's `config.h` defines **`NO_USB_STARTUP_CHECK`**. Module config.h - files are added to the build's config chain (`build_keyboard.mk`, - `config_h_community_module_appender`), and the define is purely additive. -- With the suspend block compiled out, no other path touches the LEDs: - `usb_event_suspend_handler()` only records device state (no `SLEEP_LED` on - these boards), and Voyager's `"sleep": true` (`RGB_MATRIX_SLEEP`) acts only - through `suspend_power_down_quantum()`, which is now never called. - -**Consequences (accepted, documented in README):** - -- The board ignores USB suspend entirely: plugged into a computer that goes - to sleep, the lamp stays on and keeps drawing full LED current. -- Drawing LED-level current from a port without enumerating exceeds the - USB unconfigured-device budget (100 mA). Chargers don't care; this is a - lamp, not a certified USB device. -- USB still enumerates normally when a real host is present, so reflashing - keeps working. - -## Keycodes and behavior model - -The light is always in one of two shapes: - -- **Steady** — `RGB_MATRIX_SOLID_COLOR` at the current HSV. -- **Animating** — any enabled, non-reactive rgb_matrix animation. - -All persistent state lives in QMK's existing `rgb_matrix_config` -(enable/mode/HSV/speed), saved to EEPROM through the standard -eeprom-persisting API variants. The only module state is one RAM variable: -the last animation mode used (so "stop, then start" resumes the same -animation within a power session). - -| Keycode | Alias | Behavior | -|---|---|---| -| `MOONLIGHT_ON` | `MNL_ON` | `rgb_matrix_enable()` | -| `MOONLIGHT_OFF` | `MNL_OFF` | `rgb_matrix_disable()` | -| `MOONLIGHT_BRIGHTER` | `MNL_BRI` | brightness up one step | -| `MOONLIGHT_DIMMER` | `MNL_DIM` | brightness down one step, floored (see guardrails) | -| `MOONLIGHT_HUE_UP` | `MNL_HUU` | hue wheel forward | -| `MOONLIGHT_HUE_DOWN` | `MNL_HUD` | hue wheel backward | -| `MOONLIGHT_ANIM_START` | `MNL_AST` | switch to last-used animation (default: breathing) | -| `MOONLIGHT_ANIM_STOP` | `MNL_ASP` | freeze into steady solid color at current HSV | -| `MOONLIGHT_ANIM_NEXT` | `MNL_ANX` | next animation, skipping reactive effects (runtime skip) | -| `MOONLIGHT_ANIM_FASTER` | `MNL_FST` | animation speed up | -| `MOONLIGHT_ANIM_SLOWER` | `MNL_SLW` | animation speed down | -| `MOONLIGHT_PRESET_1`…`_8` | `MNL_P1`…`MNL_P8` | turns the light on + steady mode + jump to preset hue/saturation (current brightness preserved) | - -Notes: - -- Hue/brightness changes apply in both shapes (they recolor animations too, - where the effect uses the base HSV). -- `MOONLIGHT_ANIM_NEXT` was added beyond the original request because - without it only one animation is ever reachable. No toggle keycode: - on/off are discrete by design. -- Preset colors are defined per keymap in its `config.h`: - `#define MOONLIGHT_PRESET_1_HSV {HSV_CORAL}` (any `{h, s, v}` triple, so - warm white via low saturation is possible; the `_HSV` suffix avoids - colliding with the keycode name). Unset slots get built-in defaults. - Pressing a preset always lands in steady mode. -- Presets change color only: the module applies the preset's hue and - saturation and preserves the current brightness (the `v` in the triple is - accepted for convenience with `HSV_*` macros but ignored). Changing color - never causes a brightness jump. -- Presets also turn the light on (`rgb_matrix_enable()`, mirroring - `MOONLIGHT_ANIM_START`'s symmetry): pressing a preset while the light is - off still lands you in that color, steady and lit. - -## Reactive-animation exclusion - -Reactive effects make no sense on a lamp and must never be reachable via -`MOONLIGHT_ANIM_NEXT` / `MOONLIGHT_ANIM_START`. Because a module's config.h -is included *before* the keyboard's generated config, the module cannot -compile them out; instead the module skips them at runtime against a -compile-time table of mode IDs, each entry guarded by its `#ifdef` (only -enabled effects have enum values): - -- `SOLID_REACTIVE_SIMPLE`, `SOLID_REACTIVE`, `SOLID_REACTIVE_WIDE`, - `SOLID_REACTIVE_MULTIWIDE`, `SOLID_REACTIVE_CROSS`, - `SOLID_REACTIVE_MULTICROSS`, `SOLID_REACTIVE_NEXUS`, - `SOLID_REACTIVE_MULTINEXUS`, `SPLASH`, `MULTISPLASH`, `SOLID_SPLASH`, - `SOLID_MULTISPLASH` -- `TYPING_HEATMAP` (framebuffer effect, but keypress-driven — reactive for - our purposes) -- `SOLID_COLOR` is also skipped by the cycler: it is the "steady" state, - reached via `MOONLIGHT_ANIM_STOP`, not part of the animation carousel. - -Entries for effects a given board doesn't enable simply compile away via -their `#ifdef` guards, so the table is safe on any configuration. - -## Power-up behavior - -`keyboard_post_init_moonlight()` runs after EEPROM state is restored: - -1. Force `rgb_matrix_enable()` — a lamp on a wall switch always comes on, - even if it was off when unplugged. -2. If restored brightness is below `MOONLIGHT_MIN_BOOT_BRIGHTNESS` - (default 40 of 255), raise it to that floor — never boot dark. -3. If the restored mode is reactive or out of range (e.g. the board was - previously flashed with different firmware), snap to steady solid color. - -Everything else (hue, speed, chosen animation) restores exactly as last set. - -## Lamp-only mode - -`pre_process_record_moonlight()` runs before any other keycode processing: - -- Moonlight keycodes: pass through (handled in `process_record_moonlight()`). -- `QK_BOOT`: pass through, so a keymap can keep a flash key. -- Everything else: consumed (`return false`) — nothing ever reaches the - host. Bootmagic (hold key while plugging in) is unaffected, as it runs - before keymap processing. - -Controlled by `MOONLIGHT_LAMP_ONLY`, **default on**. A keymap that wants -light control on a *working* keyboard can `#define MOONLIGHT_LAMP_ONLY 0` -in its config.h. This works because the tunable's default lives in -`moonlight.c` under `#ifndef` rather than in the module's `config.h`: -module `config.h` is included *before* keymap `config.h` in QMK's config -chain, so an `#ifndef`-guarded default placed there would already have -"won" by the time the keymap's `config.h` is processed, and the keymap's -`#define` would collide with it under `-Werror`. Defaulting in the .c -file (compiled after both config.h files) lets the keymap's define win. - -## Configuration surface (`moonlight.c` tunable defaults) - -| Define | Default | Meaning | -|---|---|---| -| `MOONLIGHT_LAMP_ONLY` | `1` | swallow all non-moonlight keycodes | -| `MOONLIGHT_MIN_BOOT_BRIGHTNESS` | `40` | brightness floor applied at power-up | -| `MOONLIGHT_MIN_BRIGHTNESS` | `16` | floor for `MOONLIGHT_DIMMER` (dark ≠ off; use `MNL_OFF`) | -| `MOONLIGHT_DEFAULT_ANIMATION` | breathing | animation used by first `MNL_AST` of a session; falls back to the first enabled non-reactive animation if breathing is disabled | -| `MOONLIGHT_PRESET_1_HSV`…`_8_HSV` | built-in palette | per-keymap preset colors (hue/sat applied, v ignored) | - -The module's `config.h` carries only the non-tunable `NO_USB_STARTUP_CHECK` -(it must live in config.h because other translation units consume it). - -## Error handling / guardrails - -- `#error` at compile time if `RGB_MATRIX_ENABLE` is not set, with a message - naming the module and the requirement. -- Brightness stepping clamps to `[MOONLIGHT_MIN_BRIGHTNESS, max_brightness]` - (the boards already cap max via `rgb_matrix.max_brightness`). -- Animation cycling wraps modulo the enabled-effect count and re-skips until - it lands on a valid non-reactive mode — this also protects against the - out-of-bounds-mode class of bug previously patched in `firmware23` - (stale EEPROM mode after flashing a build with fewer animations). -- All EEPROM writes go through QMK's debounced eeconfig API (as the existing - RGB keycodes do), so holding a repeat key doesn't thrash flash-backed - EEPROM emulation. - -## Testing - -Honest scope for firmware: no unit-test rig exists for community modules, -so coverage is compile verification plus structured hardware verification. -(This is a deliberate, documented deviation from the user's global 80 % -TDD rule — on-hardware firmware behavior is the wrong place to force it.) - -1. **Build checks (automatable):** compile both `examples/*.json` against - `zsa/qmk_firmware@firmware25`. The qmk_modules repo currently has no CI, - so add a GitHub Action that checks out `firmware25`, clones this repo - into `modules/`, and runs `qmk compile` on both example keymaps. -2. **Hardware checklist (in README):** - - Boots lit from a dumb USB charger / power bank. - - Every keycode behaves per the table above. - - Power-cycle restores color/brightness/animation; light is ON after - power-up even if turned off before unplugging. - - Plugged into a computer: no keystrokes ever registered (lamp-only); - light stays on when the computer sleeps. - - Reflashing works (QK_BOOT key and/or physical reset). - -## Out of scope - -- ErgoDox EZ / Planck EZ support (module should largely work on any - rgb_matrix board, but only Moonlander and Voyager are verified). -- Oryx integration or new Oryx-assignable keycodes. -- Pausing an animation mid-frame (stop = freeze into steady color). -- Any changes to `zsa/qmk_firmware` core. - -## Subagents - -Use subagents to implement this. Use smaller/weaker models as subagent subject to your judgment and review. Exercise autonomy, this spec is a goal. \ No newline at end of file